Skip to content

Add project attention contract + assess_attention (schema v20)#83

Merged
spaceshipmike merged 22 commits into
mainfrom
attention-signals
Jul 14, 2026
Merged

Add project attention contract + assess_attention (schema v20)#83
spaceshipmike merged 22 commits into
mainfrom
attention-signals

Conversation

@spaceshipmike

Copy link
Copy Markdown
Owner

Implements docs/setlist-project-attention-brief.md — Setlist becomes the canonical source for project priority and expected attention, computing an attention-nourishment signal separate from registry health.

Data model & migration

  • Schema v20: five nullable columns on projectspriority_tier (critical/important/normal/someday), attention_mode (active/maintenance/waiting/paused), attention_cadence_days (positive int), next_review_at (ISO), priority_reason. Added via ensureColumns (structural no-op migration step); existing projects migrate with an unconfigured contract and surface as not_configured, never a false warning. Validation (enums, positive integer, ISO timestamp) lives at the write boundary in Registry.updateCore.

Attention formula (deterministic, tested)

attention debt = days since meaningful touch ÷ cadence × priority weight

weights critical 2.0 / important 1.5 / normal 1.0 / someday 0.5; due_soon above a 0.75 ratio, undernourished past 1.0. Cadence decides overdue; priority decides ordering.

Meaningful touch (conservative, source-visible): git commit in a registered path, substantive retained memory (decision/outcome/correction/learning/pattern — not observation), or material registry update (updated_at). The interactions log is deliberately not a source; passive reads, health checks, briefs, and exports never count. Attention-contract writes do not bump updated_at (see MODEL_DECISIONS D-016) so configuring a neglected project can't mask its neglect.

Intentional quiet: waiting/paused → suppressed while next_review_at is future (or unset), → due_for_review once it arrives. Archived/inactive projects excluded from portfolio results.

Changed contracts

  • MCP: new assess_attention (name → single assessment; no name → portfolio ordered by debt) — 63 tools; update_project accepts the five contract fields (null clears); portfolio_brief gains a bounded attention section (≤5 undernourished/due-soon, ≤5 due-for-review, gap names, per-state summary).
  • Core library: AttentionAssessor, contract on ProjectRecord/get_project/ProjectBrief.project.attention; capability introspection updated.
  • Offline export: setlist-project-context.v2 — snapshot carries contract + assessment agreeing with live results at export time; manifest entries carry attention_state/attention_debt/priority_tier; INDEX.md leads with an "Attention concerns" section. Manifest-last atomic write order preserved. Existing launchd schedule unchanged (no new daemon).
  • Desktop: contract editable in the project edit form (unset selects/empty inputs clear to NULL).
  • assess_health untouched — existing consumers keep current semantics.

How Hermes reads/writes

Read: assess_attention (live), portfolio_brief.attention (session start), or the v2 export when offline. Write back natural-language corrections via update_project (e.g. "pause until September" → attention_mode: waiting, next_review_at). No scheduling/notification logic lives in Setlist.

Acceptance scenarios

All 11 brief scenarios covered in packages/core/tests/attention.test.ts, context-export.test.ts (export agreement), and cross-query.test.ts (bounded brief section).

Validation

  • npm test — 57 files, 1062 passed
  • npm run typecheck — clean (main/preload/renderer)
  • npm run build — clean
  • npm run verify:mcp-abi — OK

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi

spaceshipmike and others added 2 commits July 13, 2026 20:36
Setlist becomes the canonical store for project priority and expected
attention, so a downstream assistant (Hermes) can distinguish genuine
neglect from intentional quiet without keeping its own priority list.

- Schema v20: nullable attention-contract columns on projects
  (priority_tier, attention_mode, attention_cadence_days, next_review_at,
  priority_reason). Existing projects migrate as not_configured — a gap,
  never a false warning. Structural no-op migration via ensureColumns.
- Core: new AttentionAssessor (attention.ts) computing a deterministic
  attention debt = days-since-meaningful-touch ÷ cadence × priority weight.
  States: nourished / due_soon / undernourished / due_for_review /
  suppressed / not_configured. Meaningful touch is conservative: git
  commit in a registered path, substantive retained memory
  (decision/outcome/correction/learning/pattern), or material registry
  update — passive reads, health checks, and exports never count, and
  attention-contract writes deliberately do not bump updated_at (D-016).
- waiting/paused projects suppress until next_review_at arrives, then
  flip to due_for_review. Archived/inactive projects are excluded from
  portfolio results.
- MCP: new assess_attention tool (63 tools total); update_project accepts
  the five contract fields with validation; portfolio_brief gains a
  bounded, urgency-ordered attention section (≤5 entries per list).
- Context export v2 (setlist-project-context.v2): snapshots carry the
  contract + live-agreeing assessment, manifest entries carry
  attention_state/debt/tier, INDEX.md leads with attention concerns.
  Atomic manifest-last write order preserved.
- Desktop: attention contract editable in the project edit form.
- assess_health semantics untouched; registry health and attention are
  separate assessments.

Tests: 1062 passing (new attention.test.ts covers the brief's acceptance
scenarios), typecheck clean, verify:mcp-abi OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Found six blocking correctness/security issues and one contract-documentation issue. Runtime validation was environment-limited: the sandbox prevented build output, and dependencies/Vitest were unavailable.

Findings (7)

  • [P1] packages/core/src/context-export.ts:137 — Project names are interpolated directly into output paths. Registry registration and rename accept names containing / or .., so a project such as ../../Areas/reference/foo makes an export overwrite .json/.md files outside the managed directory. Encode names into safe basenames or verify every resolved destination remains under projects/.
  • [P2] packages/core/src/registry.ts:828 — Core identity fields are committed at line 818 before the new attention fields are validated. A combined update such as {description: 'new', attention_cadence_days: 0} throws an error but leaves the description changed, so callers receive a failed-save response after a partial write. Validate first and execute the complete update transactionally.
  • [P2] packages/cli/src/index.ts:496 — The new value-taking flags use positional getFlag, contrary to the trusted CLI surface contract. For example, context install --dir --yes treats --yes as the directory and also as confirmation, creating an export and installing a job for the wrong path; --interval 60junk is accepted by parseInt. Use the strict shared parser and reject missing, flag-shaped, duplicate, and partially numeric values.
  • [P2] packages/core/src/context-export.ts:324 — The offline portfolio index selects attention concerns without requiring status === 'active'. Because exports assess every project individually and AttentionAssessor suppresses only archived, configured idea, draft, paused, or complete projects can appear as undernourished/due concerns even though live portfolio attention excludes inactive projects. This can produce false offline alerts.
  • [P2] packages/cli/src/index.ts:469 — The normal setlist update CLI wrapper was not extended for any of the new attention-contract fields. Core, MCP, and the app can configure canonical priority, but CLI users cannot, violating the trusted core-to-every-wrapper ripple contract and the feature brief's CLI update-surface requirement.
  • [P2] packages/core/src/registry.ts:860new Date(value) checks only JavaScript parseability, not the promised ISO-8601 contract. It accepts locale-dependent strings such as July 13, 2026, numeric strings such as 0, and normalizes impossible dates such as 2026-02-30 to March 2. Those values can silently resurface waiting projects at the wrong time; validate an explicit ISO date/timestamp grammar and calendar validity.
  • [P3] INVARIANTS.md:33 — The authoritative schema invariant still states SCHEMA_VERSION = 19 and describes v19 as current, while this PR bumps the implementation to v20. Update the invariant register with the v20 columns and migration meaning so future migration work is not guided by stale ground truth.

- P1: context export sanitizes project names into safe basenames
  (registry names are not character-validated) with a hash suffix on
  sanitized names to prevent collisions, plus a resolved-path containment
  guard — a name like ../../escape can no longer write outside the
  managed directory.
- P2: updateCore validates all attention-contract fields BEFORE any
  UPDATE executes and runs the core+attention writes in one transaction,
  so a rejected combined update never leaves a partial write.
- P2: next_review_at now enforces a strict ISO 8601 grammar with real
  calendar-date validation (rejects locale strings, bare numbers, and
  impossible dates like 2026-02-30 that Date() silently normalizes).
- P2: CLI `context` flags moved to the strict shared parser
  (`--dir --yes` is now a usage error, `--interval 60junk` rejected).
- P2: CLI `update` moved to the strict parser and extended with the five
  attention-contract flags (`none` clears a field).
- P2: INDEX.md attention concerns and inline flags now require
  status=active, matching live portfolio attention exclusion of
  inactive projects — no false offline alerts.
- P3: INVARIANTS.md schema invariant reconciled to v20.

Regression tests pin each fix; suite 1066 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Three blocking correctness issues remain. The review was source-based; typecheck could not run because the read-only workspace prevented TypeScript from writing build artifacts.

Findings (3)

  • [P2] packages/core/src/registry.ts:63 — The timestamp validator accepts values JavaScript cannot parse, including leap-second timestamps such as 2026-09-01T09:30:60Z and invalid offsets such as +24:00 or +02:60. AttentionAssessor then receives NaN when comparing the review date, so a waiting/paused project remains suppressed forever instead of becoming due for review. Validate offset ranges and reject any accepted value whose normalized timestamp is not finite.
  • [P2] packages/core/src/registry.ts:895 — The new transaction commits core and attention fields before validating or applying area and parent_project, which are handled afterward. The desktop form submits these together, so an invalid area, nonexistent parent, or parent cycle returns a save error after priority/cadence and other edits have already persisted. The user is told the save failed while the project was silently partially modified. Prevalidate and commit every field in the combined update atomically.
  • [P2] packages/core/src/context-export.ts:169projectContextBasename does not guarantee unique output names. For example, unsafe project foo/bar maps to foo-bar-cc5d46bd, which can also be a valid literal project name; names differing only by case also collide on the default case-insensitive macOS filesystem. The exporter then overwrites one project's files and writes a manifest whose two entries point to the same matching-generation snapshot, so readers cannot detect the corruption. Use a collision-proof mapping for every name or detect duplicate resolved basenames before writing.

- P2: next_review_at validator now rejects everything JS Date cannot
  parse — leap seconds (:60) and out-of-range zone offsets (+24:00,
  +02:60) — with a final finite-epoch belt, so a stored review date can
  never suppress a waiting project forever via NaN comparisons.
- P2: updateCore is now fully atomic across ALL fields: area and
  parent_project are prevalidated (existence + cycle walk, via the
  extracted resolveParentIdOrThrow shared with setParentProject) before
  any write and applied inside the same transaction as core + attention
  fields — a combined save with an invalid area or a parent cycle
  persists nothing.
- P2: export basenames are collision-proof per generation:
  computeProjectContextBasenames disambiguates case-insensitive and
  sanitized-vs-literal collisions with per-name hash suffixes and
  refuses the export on any residual collision instead of silently
  overwriting one project's files with another's.

Regression tests pin all three; suite 1068 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Four blocking correctness issues remain. The new attention feature is broadly wired, but payload bounds, CLI discovery, calendar-date handling, and scheduler replacement safety are broken. Tests could not be rerun because this checkout lacks dependencies and network access was unavailable.

Findings (4)

  • [P2] packages/core/src/cross-query.ts:403portfolio_brief returns every unconfigured project instead of applying PORTFOLIO_BRIEF_ATTENTION_LIMIT. Existing projects all migrate unconfigured, so this likely-largest list is unbounded, contradicting the required payload-safe contract and the MCP description promising at most five entries per attention list.
  • [P2] packages/cli/src/commands.ts:94 — The authoritative update command definition still describes only the old identity fields and omits all five attention flags from its usage. Both top-level CLI help and self-registered capability inputs derive from this entry, making the new CLI attention-update surface undiscoverable and breaking cross-surface introspection.
  • [P2] packages/core/src/attention.ts:267 — Bare dates emitted by the desktop <input type="date"> are parsed as UTC midnight. In America/New_York, selecting 2026-09-01 therefore changes to due_for_review at 8 PM on August 31. Date-only contracts need an explicit local-calendar interpretation or must require a timezone-bearing timestamp.
  • [P2] packages/cli/src/context-export-schedule.ts:102 — Schedule replacement writes the new plist and unloads the existing job before attempting launchctl bootstrap, but a bootstrap failure performs no rollback. This can leave a previously working export schedule stopped, with the replacement plist installed and the backup path never returned or reported; restore and reload the prior plist on failure.

- P2: portfolio_brief bounds not_configured to the same 5-entry cap as
  the other attention lists (a freshly-migrated registry has EVERY
  project unconfigured) and adds not_configured_count for honesty.
- P2: the authoritative CLI command registry (commands.ts) now carries
  the five attention flags on `update` (and --json on `context`), so
  help output and self-registered capability inputs match the runtime.
- P2: a bare next_review_at date is interpreted as LOCAL midnight, not
  UTC — pausing "until 2026-09-01" no longer resurfaces the project the
  prior evening in western timezones (reviewEpoch in attention.ts).
- P2: context-schedule install rolls back on launchctl bootstrap
  failure — restores and reloads the previous plist (or removes the new
  one on first install) instead of stranding a stopped schedule;
  launchctl is injectable for tests.

Regression tests pin all four; suite 1072 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

The attention model is internally coherent, but the default export path breaks the end-to-end offline fallback contract. Focused runtime tests could not run because this isolated worktree has no dependencies; the dependency-free CLI introspection tests passed.

Findings (2)

  • [P2] packages/core/src/context-export.ts:24 — The default export directory is ~/.local/share/project-registry/project-context, but the canonical context hub says every harness must use ~/Areas/reference/System/project-context/ when live Setlist is unavailable. Both no-argument export and the launchd schedule therefore refresh a directory that downstream assistants do not read, leaving the documented fallback absent or stale unless the operator always supplies a special --dir. Make the canonical fallback directory the default and align the documentation and tests.
  • [P3] packages/cli/src/index.ts:539 — The command advertises and accepts --json for setlist context, but only the export branch honors it. status, install, and uninstall silently emit human prose even when --json is supplied, so automation following the introspected usage receives output it cannot parse. Either return structured JSON for those branches or reject the flag and narrow the advertised usage.

- P2 (default export directory): resolved by documenting the deliberate
  decision rather than changing the default. The XDG default is
  intentional — automated jobs must never write into cloud-synced
  folders by default (filesystem doctrine); a synced fallback location
  such as ~/Areas/reference/System/project-context/ is an explicit
  --dir opt-in, which is exactly how this machine's launchd job is
  configured. Encoded in context-export.ts and README so the divergence
  between product default and one deployment's documented path can't
  read as an accident.
- P3: `setlist context` now honors --json on install/uninstall/status
  (plan/applied envelopes and a status object), not just export.

Suite 1072 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Four blocking correctness and safety issues remain. Diff hygiene passed, but runtime tests and typechecks could not be rerun because this review worktree has no installed dependencies.

Findings (4)

  • [P2] packages/core/src/attention.ts:309 — The overdue calculation floors elapsed time to whole days before dividing by cadence. A one-day project touched 47 hours ago is still classified as nourished because 47 hours becomes 1 day and the ratio remains 1; every cadence can therefore miss a genuinely overdue project for almost 24 hours.
  • [P2] packages/mcp/src/server.ts:324 — update_project is not atomic when attention/core fields are combined with paths. updateCore commits first, then setProjectPaths validates and writes separately; an invalid path such as "relative/path" makes the tool return an error after the priority, mode, or description has already been saved.
  • [P2] packages/cli/src/context-export-schedule.ts:152 — Uninstall treats every launchctl bootout failure as “already unloaded,” then removes the plist and reports success. A permission or launchd failure can therefore leave the job running while status reports not_installed because the plist is gone, so scheduled writes continue without a managed file to retry against.
  • [P2] packages/core/src/context-export.ts:140 — The export containment check is only lexical and does not reject or resolve symlinks. If the managed projects directory is replaced with a symlink, mkdirSync and atomicWrite follow it, while assertWithinDirectory still sees an apparently in-directory path; an export can consequently overwrite JSON/Markdown files outside the selected target.

- P2: the attention ratio and debt use exact elapsed time instead of
  floored whole days — a 1-day-cadence project touched 47 hours ago now
  reads undernourished instead of hiding up to 24h of overdue-ness.
  days_since_meaningful_touch stays a whole number for readability.
- P2: update_project is atomic including paths — updateCore accepts the
  replace-semantics paths set, validates it up front, and applies it in
  the same transaction as core/attention/area/parent; the MCP handler no
  longer makes a second setProjectPaths call that could fail after the
  fields committed.
- P2: context-schedule uninstall no longer treats every bootout failure
  as "already unloaded": if launchctl print shows the job still loaded,
  the uninstall aborts without removing the plist (no false success
  leaving an unmanaged running job).
- P2: the exporter refuses symlinked managed directories (export root or
  projects/) — the lexical containment check can't see links, so a
  swapped-in symlink could otherwise redirect writes outside the target.

Regression tests pin all four; suite 1077 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking correctness issues remain. Automated tests could not run because dependencies are absent and the review workspace is read-only.

Findings (2)

  • [P2] packages/core/src/attention.ts:350 — The assessor treats every projects.updated_at change as a meaningful touch, but writers bump this timestamp even when nothing actually changes. For example, an attention-contract update that echoes the existing status, an identical paths replacement, or an ignored producer-owned field write resets updated_at and makes a neglected project appear nourished. Only genuine persisted changes should advance the meaningful-touch signal.
  • [P2] packages/cli/src/context-export-schedule.ts:152 — Uninstall catches every launchctl bootout failure as if the job were already unloaded, then deletes the plist and reports uninstalled. If bootout fails for a real operational or permission error, the loaded job remains active and continues exporting until logout or reboot despite the reported success. Distinguish the expected not-loaded case or verify the service is gone before removing the plist.

- P2: no-op writes no longer count as meaningful touches. updateCore
  drops sets that echo the stored value (same status/description/goals/
  display_name/email_account, same area/parent, identical paths
  replacement) so updated_at only moves on genuine persisted change;
  updateFields bumps updated_at only when writeFields actually wrote
  rows (a fully producer-skipped call is not project activity;
  writeFields now returns the written count).
- (The round's second finding — uninstall bootout swallow — was already
  fixed in e78f79d; that review ran against the prior head.)

Regression test pins the echo/skip cases; suite 1078 green, typecheck
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking correctness defects can hide neglected projects or publish internally inconsistent offline context. The authoritative surface map also omits the new export and scheduler contracts.

Findings (3)

  • [P2] packages/core/src/fields.ts:52 — SQLite reports an upsert as changed even when the owning producer writes the exact same value. Registry.updateFields therefore bumps projects.updated_at, which the attention assessor treats as meaningful activity. Periodic reconciliation can make an untouched project appear nourished for another full cadence. Only count writes whose serialized value actually changes.
  • [P2] packages/core/src/context-export.ts:125 — Each project brief and its attention assessment are read separately, without a shared database snapshot. A concurrent app, CLI, or MCP update between those reads can produce one exported file whose brief and attention result describe different project states—for example, an active contract paired with a paused assessment. Building files before writing them does not make the source reads consistent; the generation must derive its database state from one transaction or immutable snapshot.
  • [P3] SURFACES.md:10 — The authoritative ripple map was not updated for the new persistent project-context export format or launchd scheduler. It therefore does not record that project-brief and attention changes must remain synchronized with exported JSON/Markdown, the manifest, CLI scheduling, and installation behavior, making future cross-surface regressions likely.

- P2: writeFields counts only rows whose stored value actually changed
  (upsert WHERE now also requires field_value IS NOT excluded value), so
  periodic reconciliation re-writing identical values no longer bumps
  updated_at and cannot make an untouched project read as nourished.
- P2: the export generation reads from one immutable VACUUM INTO
  database snapshot — briefs and attention assessments can no longer
  interleave with a concurrent writer and publish a project file whose
  brief and attention result describe different states. Side benefit:
  generation reads never touch the live interactions log.
- P3: SURFACES.md gains the offline-context-export ripple edge
  (brief/attention shape ↔ versioned export contract ↔ launchd
  scheduler).

Suite 1078 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

The attention model is broadly wired across all required surfaces, but a no-op enrichment call can falsely mark a neglected project as recently nourished.

Findings (1)

  • [P2] packages/core/src/registry.ts:1535enrichProject unconditionally rewrites the profile columns and sets updated_at = datetime('now'), even when no fields were supplied or unioning the supplied values produces exactly the existing profile. Because assess_attention treats projects.updated_at as a meaningful touch, a valid MCP call such as enrich_project({name}), or periodic reconciliation of unchanged enrichment data, makes an undernourished project appear nourished. Compare the merged values before updating and only advance updated_at when stored profile data genuinely changes.

- P2: enrichProject only writes (and only bumps updated_at) when the
  merged profile genuinely differs from the stored one — an empty
  enrich_project call or a reconciliation re-sending existing values no
  longer counts as a meaningful touch for assess_attention.

Suite 1078 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

One blocking correctness issue remains in the central attention signal. Static review covered all 38 changed files and authoritative contracts; test execution was unavailable because this isolated checkout lacks dependencies.

Findings (1)

  • [P2] packages/core/src/attention.ts:356 — Every projects.updated_at value is treated as a meaningful touch, but existing public writers still advance it for semantic no-ops. For example, setProjectArea and setParentProject update the timestamp when reapplying the current value, setProjectPaths does so for an identical replacement, and enrichProject does so when unioning only existing values. An idempotent reconciliation through any of these surfaces therefore makes a neglected project appear newly nourished, violating D-016’s “material registry update” rule and the PR’s explicit no-op-write contract. Either make all timestamp-writing paths change-aware or record meaningful activity separately.

setProjectArea, setParentProject, and setProjectPaths no longer bump
updated_at when reapplying the current value — the last three public
writers where an idempotent reconciliation could fake a meaningful
touch for assess_attention (D-016). Regression coverage extends the
echo test to the standalone setters.

Suite 1078 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Three blocking correctness gaps remain in the new attention contract. Focused tests could not run because dependencies are absent and network access failed; git diff validation passed.

Findings (3)

  • [P2] packages/core/src/registry.ts:125 — The five new attention fields are structured project columns, but RESERVED_FIELD_NAMES still contains only paths. Consequently, write_fields({priority_tier: "critical"}) reports success while writing an inert project_fields row; assess_attention continues reading projects.priority_tier and returns not_configured. Doctor also uses this set, so it will not detect the dead-letter value. Reserve all five attention field names and direct callers to update_project.
  • [P2] packages/core/src/registry.ts:1748 — batchUpdate unconditionally advances updated_at for every matched project, even when every requested value is already stored. For example, batch_update(status_filter="active", status="active") makes every active project appear freshly touched and can turn genuinely undernourished projects into nourished ones. Compare each row before updating and only advance updated_at for material changes.
  • [P2] packages/core/src/registry.ts:1246 — setProjectType likewise advances updated_at when reapplying the project's existing project_type_id. Because updated_at is now a meaningful-attention signal, an idempotent reconciliation falsely nourishes the project. Skip the update and timestamp bump when the type is unchanged.

- P2: the five attention field names join RESERVED_FIELD_NAMES —
  write_fields({priority_tier: ...}) now errors with a redirect to
  update_project instead of storing an inert project_fields row that
  assess_attention never reads.
- P2: batchUpdate is change-aware per project — a batch re-applying
  stored values (e.g. status_filter='active' + status='active') no
  longer bumps updated_at across the whole portfolio.
- P2: setProjectType skips the write and timestamp bump when reapplying
  the current type.

Regression tests extend the no-op sweep; suite 1079 green, typecheck
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Three blocking correctness problems remain in Git activity detection and the offline export's atomicity guarantees. git diff --check passed, but automated tests could not run because this read-only checkout has no installed dependencies.

Findings (3)

  • [P2] packages/core/src/context-export.ts:364 — The Markdown project files and portfolio index omit generation_id. If a new export replaces these files and crashes before replacing manifest.json, readers see human-readable data from the unfinished generation under the old manifest and cannot perform the documented generation check. Add the generation ID to every Markdown/index artifact and require it to match the manifest.
  • [P2] packages/core/src/context-export.ts:170 — Concurrent exports can interleave because there is no lock or generation-directory swap. For example, export B can replace all project files and commit manifest B, after which the slower export A commits manifest A; the final manifest then references generation A while the files belong to B, even though both commands reported success. Serialize writers or build each generation in an isolated directory and atomically switch a single pointer.
  • [P2] packages/core/src/attention.ts:384 — Git activity is missed for registered folders nested inside a repository or monorepo. latestCommitInPath requires <registered path>/.git, although git -C <path> works from any descendant of a repository. Such projects can therefore be falsely reported as undernourished despite recent commits. Discover the containing worktree with Git and query the latest commit affecting the registered folder, rather than requiring .git directly inside it.

- P2: markdown project files carry generation_id in frontmatter and
  INDEX.md names its generation, so readers can perform the documented
  generation check against manifest.json on every artifact, not just
  the JSON twins.
- P2: exports into the same directory are serialized by an atomic
  mkdir lock (.setlist-context-export-lock) — a concurrent second
  export gets CONTEXT_EXPORT_BUSY instead of interleaving files and
  committing a mismatched manifest. Stale locks (crashed exporter) are
  broken after 10 minutes; assertManagedDirectory ignores the lock.
- P2: latestCommitInPath uses `git -C <path> log -1 -- .` (containing
  worktree + folder-scoped) instead of requiring <path>/.git, so a
  project registered at a monorepo subfolder reports its git activity
  in both health and attention assessments.

Suite 1081 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two P2 correctness issues remain: the export mutex can admit concurrent writers, and attention assessment misses recognized legacy project-memory aliases. Automated tests could not run because dependencies are absent (vitest: command not found); git diff --check passed.

Findings (2)

  • [P2] packages/core/src/context-export.ts:226 — Stale-lock takeover is not atomic, and releasing a lock does not verify ownership. If two exporters observe the same stale lock, both can pass lstat; one can remove and reacquire it, after which the other removes that new lock and also proceeds. A legitimate export lasting over ten minutes has the same problem, and its eventual release can delete a successor's lock. This reopens the concurrent-write path the mutex was added to prevent, allowing project files and manifest.json from different generations to be interleaved. Stale takeover must have one atomic winner, and release must remove only the caller's uniquely identified lock.
  • [P2] packages/core/src/attention.ts:367 — The memory touch query matches only the project's current canonical name. The standing memory model deliberately preserves pre-validation rows stored under case variants or display-name identifiers and resolves those aliases on reads; known legacy rows have this shape. A recent substantive memory under such a recognized legacy identifier is therefore ignored here, producing an incorrect last-touch timestamp and potentially a false undernourished result. Resolve memory project identifiers through the canonical project resolver—with its ambiguity rules—or reuse the established alias-union read behavior.

- P2: the export lock carries an ownership token — release removes the
  lock only while it still holds this exporter's token (a slow run can
  never delete a successor's lock), and stale-lock takeover goes through
  an atomic rename so exactly one of two racing takeover attempts wins.
  assertManagedDirectory ignores takeover remnants.
- P2: the substantive-memory touch query is alias-aware (D-014): legacy
  rows stored under a case variant of the canonical name or under the
  project's display name now count, with each fuzzy alias included only
  when the canonical resolver maps it unambiguously back to this project
  (a shared display name or case-twin never attributes another
  project's memories).

Regression tests pin both; suite 1082 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking correctness issues remain: attention assessments can borrow memories from a different case-variant project, and schema v20 omits constraints explicitly required by the standing model decisions. Focused tests could not run because dependencies are absent and downloading them while executing untrusted PR tests was denied.

Findings (2)

  • [P2] packages/core/src/attention.ts:377 — The alias-safety check fails when two projects differ only by case. With projects casey and CASEY, each lower/upper probe finds an exact match to one spelling, so both assessments add LOWER(project_id) = LOWER(?). The subsequent MAX query therefore considers both projects' memories, allowing a recent decision for one project to make the other appear nourished. This contradicts the stated isolation guarantee and can suppress a genuine attention warning.
  • [P2] packages/core/src/db.ts:815 — The v19→v20 migration is treated as a structural no-op, but trusted MODEL_DECISIONS.md lines 663–665 explicitly require the next schema-version bump to add revived to memory_versions.change_type and a UNIQUE (source_id, target_id, relationship_type) key to memory_edges. Stamping databases as v20 without either migration leaves revival events mislabeled and the known cross-process duplicate-edge race unenforced, directly violating the standing schema decision.

- P2: the case-variant memory alias is gated on an actual case-twin
  count instead of resolver probes — with projects casey/CASEY, the
  resolver's exact-name tier "resolved" each spelling to itself and let
  a LOWER() match union both projects' memories; now any case twin
  disables the fuzzy alias entirely.
- P2: v19→v20 lands the two constraints D-014 explicitly deferred to
  the next schema bump: 'revived' joins the memory_versions.change_type
  CHECK (shape-guarded, crash-re-entrant table rebuild following the
  v8→v9 pattern) and memory_edges gains its natural key as a real
  UNIQUE(source_id, target_id, relationship_type) index, created after
  collapsing existing duplicates to the earliest row. The index lives in
  the migration step, not SCHEMA_SQL, because SCHEMA_SQL runs on every
  open before migrations and would fail over still-duplicated legacy
  rows. MemoryChangeType, CLAUDE.md, and INVARIANTS.md reconciled.

Migration tests cover revived acceptance, duplicate-edge rejection, and
dedupe-on-upgrade; suite 1085 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking contract defects remain: revival history is mislabeled, and offline exports can falsely report stale digests as fresh. The test suite could not be rerun because dependencies are unavailable in this read-only environment; diff whitespace validation passed.

Findings (3)

  • [P2] packages/core/src/memory.ts:192 — Re-retaining an archived memory still writes change_type = 'updated'. This PR adds revived to the v20 schema and public MemoryChangeType, but the actual revival producer never uses it, so history consumers still cannot distinguish a revival from an ordinary edit. Write revived here and add an end-to-end assertion to the existing revive test.
  • [P2] packages/core/src/context-export.ts:381 — The export copies brief.digest.stale, which loadProjectDigest() computes from the optional project_fields.spec_version and defaults to false when that field is absent. Normal CLI digest refreshes only write project_digests, so a subsequent source change can still be exported as digest_stale: false. This contradicts D-011's honest-read contract and can mislead offline agents into trusting obsolete context. Reuse the registered-path-based staleness computation used by getProjectDigest, preserving null when freshness is unknowable.
  • [P3] packages/core/src/context-export.ts:114 — The export lock is acquired before the export root is checked for being a symlink at line 169. Consequently, a symlinked --dir is followed and mutated before the operation refuses it; it can create an out-of-tree lock or rename and recursively delete a stale same-named lock in the symlink target. Check the root before assertManagedDirectory() and acquireExportLock(), and cover a symlinked root—not only a symlinked projects/ directory.

- P2: revive-on-re-retain now records change_type 'revived' (the v20
  enum value this PR added) instead of 'updated' — history consumers
  can distinguish a revival from an ordinary edit; the D-014 revive
  test asserts it end-to-end.
- P2: exported digest_stale uses getProjectDigest's honest path-based
  staleness (null when unknowable) instead of the brief's inline value
  that defaulted to false without a spec_version field — offline agents
  are no longer told an obsolete digest is confidently fresh (D-011).
- P3: the symlinked-root check runs before the managed-directory check
  and the lock, so a symlinked --dir is refused before anything (lock
  included) mutates the link target; covered by a symlinked-root test.

Suite 1086 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking correctness issues can produce false attention and digest signals. The migration also preserves an arbitrary duplicate rather than the documented earliest one.

Findings (3)

  • [P2] packages/core/src/attention.ts:394 — Meaningful-memory activity is ordered by created_at, but re-retaining an existing or archived substantive memory updates only its updated_at. A decision revived or reinforced today therefore still looks as old as its original creation and can leave the project falsely undernourished. Use the retention/update timestamp or record a dedicated retention event.
  • [P2] packages/core/src/context-export.ts:393 — The snapshot embeds the ProjectBrief unchanged even though its nested brief.digest.stale uses the older, unreliable calculation that defaults to false when no project_fields spec_version exists. The same generated JSON can therefore report top-level digest_stale as true/null while brief.digest.stale says false, allowing an offline reader to trust an obsolete digest. Normalize or replace the nested value with the honest getProjectDigest result.
  • [P3] packages/core/src/db.ts:1311 — The v19→v20 deduplication keeps MIN(id), but memory-edge IDs are random UUIDs and do not sort by creation time. The migration therefore retains an arbitrary duplicate—not the documented earliest row—and can discard the earlier row's timestamp or relationship metadata. Select by created_at with a deterministic tiebreaker, or merge the duplicate metadata before deletion.

- P2: the substantive-memory touch orders by per-row
  MAX(created_at, updated_at) — a reinforced or revived decision counts
  as today's activity instead of dating to its original creation.
- P2: brief.digest.stale is now honest at the source — loadProjectDigest
  (feeding get_project_brief and the export's embedded brief) reuses
  getProjectDigest's path-based staleness with the null-when-unknowable
  tri-state, so the export's top-level digest_stale and the embedded
  brief can never disagree. ProjectBriefDigest.stale widened to
  boolean | null (renderer reads are truthiness-based).
- P3: the v19→v20 edge dedupe keeps the earliest row by created_at with
  an id tiebreaker instead of MIN(id) over random UUIDs.

Suite 1087 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

The new context-export work is substantial, but one contract regression remains: unknown digest freshness is exposed as fresh/current in the main human-facing surfaces. I also found one non-blocking mismatch between the migration invariant and the implemented deduplication rule. Automated tests could not run because this read-only checkout has no installed Vitest dependencies.

Findings (2)

  • [P2] packages/core/src/project-brief.ts:16 — This changes digest freshness to the required tri-state, but downstream surfaces were not updated. packages/cli/src/index.ts:149 maps null to fresh; packages/app/src/renderer/components/tabs/OverviewTab.tsx:194 maps it to current and does not show a warning; and packages/app/src/renderer/lib/api.ts:408 plus packages/core/src/models.ts:245 still declare the value as a plain boolean. Whenever freshness cannot be determined—for example, when the project path is missing—the CLI and desktop therefore claim the digest is current. This reverses the honest-read behavior required by D-011. Preserve null through the public types and display it as unknown or unverified.
  • [P3] INVARIANTS.md:33 — The migration invariant still says duplicate memory edges are resolved by keeping MIN(id), but the changed migration keeps the earliest created_at row with id only as a tie-breaker at packages/core/src/db.ts:1312. The implemented rule may be correct, but the authoritative contract now disagrees with database behavior and could cause a later verifier or migration to restore the wrong rule.

- P2: the digest tri-state propagates through every consumer — CLI
  brief prints "freshness unknown" (not "fresh") for null, the desktop
  Overview tag reads "Digest unverified" with a warning (not "current"),
  and the renderer api + core ProjectDigest types declare
  stale: boolean | null.
- P3: INVARIANTS.md dedupe wording reconciled to the implemented rule
  (earliest created_at, id tiebreak).

Suite 1087 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking correctness issues remain in digest freshness reporting and launchd schedule removal.

Findings (2)

  • [P2] packages/core/src/registry.ts:2905 — Project briefs pass the stored project_fields.spec_version as a caller override, bypassing the new path-based tri-state freshness check. A pathless project can therefore report stale: false, and a changed filesystem can still appear fresh when that stored field matches the digest. This contradicts ProjectBriefDigest’s null-when-unverifiable contract and makes context exports disagree with getProjectDigest().
  • [P2] packages/cli/src/context-export-schedule.ts:147 — Uninstall returns not_installed immediately when the plist file is absent without checking launchd. A bootstrapped LaunchAgent remains loaded if its plist is manually deleted, so this path falsely reports success while the exporter continues running; contextExportScheduleStatus() has the same blind spot. Query and boot out the service label even when the plist is missing.

- P2: loadProjectDigest no longer passes the stored
  project_fields.spec_version as a caller override — that bypassed the
  path-based tri-state check and let a pathless project report
  stale:false. Briefs now match getProjectDigest exactly; the two
  project-brief tests that encoded the old field-compare semantics are
  rewritten to the honest tri-state contract, and the unused
  loadCurrentSpecVersion helper is removed.
- P2: schedule uninstall and status probe launchd by label even when the
  plist file is absent — a bootstrapped job whose plist was hand-deleted
  is booted out (uninstall) and reported as loaded (status), never a
  false not_installed while the exporter keeps running.

Suite 1088 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

Two blocking gaps can silently provide incomplete or misleading attention data. Automated tests and type-checking could not run because this checkout lacks the required vitest and TypeScript dependencies.

Findings (2)

  • [P2] packages/core/src/attention.ts:267 — Waiting, paused, and unconfigured projects return before latestMeaningfulTouch is called, so their last_meaningful_touch and days_since_meaningful_touch are always null even when commits, retained memories, or project updates exist. This violates the new assessment contract, and the offline export consequently reports “none recorded” for real activity. Compute the latest touch before these state-specific branches and include it in their results.
  • [P2] packages/core/src/registry.ts:314 — The new reserved-name protection is enforced only by updateFields. The public register and registerExistingWorkspace paths pass opts.fields directly to writeFields, so fields such as priority_tier or attention_mode are accepted as inert extended fields while assess_attention still reports the project as not configured. Apply the reserved-field validation to every writeFields entry point, or add structured attention options to registration.

- P2: the latest meaningful touch is part of the assessment for every
  non-archived state — suppressed, due_for_review, and not_configured
  results now carry last_meaningful_touch and days_since, so an
  assistant (or the offline export) never reports "none recorded" for
  real activity.
- P2: reserved-field validation runs on every writeFields entry point —
  register() (and registerExistingWorkspace via it) now rejects
  attention-contract names and 'paths' in registration fields with the
  same redirect updateFields gives, via a shared
  assertNoReservedFieldNames helper.

Suite 1089 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — REQUEST_CHANGES

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

A failed registration can leave a partially created project in the registry. Independent tests could not run because this checkout has no installed Vitest dependency.

Findings (1)

  • [P2] packages/core/src/registry.ts:337 — Reserved-field validation happens after the project and its paths have already been inserted using autocommit statements. Registering with fields such as attention_mode or paths therefore throws but leaves the project behind; retrying then fails as a duplicate. Validate opts.fields before any writes or make the complete registration sequence transactional.

spaceshipmike and others added 2 commits July 14, 2026 10:36
- P2: reserved-field validation in register() runs before any INSERT —
  a rejected registration no longer strands a half-registered project
  whose retry fails as a duplicate. Test asserts the clean-retry path.

Suite 1090 green, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
…missing)

The clean-retry register IS the stranded-row proof — it would raise
DuplicateProjectError if the rejected attempt had inserted anything.
Suite 1089 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
@spaceshipmike

Copy link
Copy Markdown
Owner Author

🤖 Codex review — APPROVE

Local Codex review (gpt-5.5) of this PR's changes against origin/main.

No blocking correctness, security, migration, or invariant violations found. The implementation is consistent across the main runtime surfaces, and git diff --check passes. The full test suite could not be rerun because this isolated checkout lacks Vitest dependencies. One non-blocking desktop type-contract omission remains.

Findings (1)

  • [P3] packages/app/src/renderer/lib/api.ts:372 — The renderer's ProjectBrief.project type was not updated with the required attention object added to the core ProjectBrief. The runtime returns the field, but desktop consumers cannot access brief.project.attention without contradicting their declared API type. Add the required AttentionContract field so the renderer wrapper mirrors the core projection.

@spaceshipmike
spaceshipmike merged commit 6197802 into main Jul 14, 2026
2 checks passed
@spaceshipmike
spaceshipmike deleted the attention-signals branch July 14, 2026 15:13
spaceshipmike added a commit that referenced this pull request Jul 14, 2026
…d mem-export (#81)

Steward marker advanced dcaf6346197802 with plain-language summary,
registry-sync and cross-project-routing attestations. SURFACES CLI row
gains the #80 mem-export completeness note. .fctry/.gitignore backfill
from the canonical-patterns hook (kept out of the feature PR per loop
norms). Issue #82 closed as resolved by #83's update_project paths
support; #84 remains the only follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant