From 870bff31a2227ef5b8ab177db75efb0b9946dfcd Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 20:36:11 -0400 Subject: [PATCH 01/22] Add offline project context exports --- README.md | 22 +- packages/cli/src/commands.ts | 11 + packages/cli/src/context-export-schedule.ts | 166 +++++++++ packages/cli/src/index.ts | 76 ++++ .../cli/tests/context-export-schedule.test.ts | 77 ++++ .../cli/tests/introspect-commands.test.ts | 7 + packages/core/src/context-export.ts | 336 ++++++++++++++++++ packages/core/src/index.ts | 15 + packages/core/src/introspect-exports.ts | 40 +++ packages/core/tests/context-export.test.ts | 116 ++++++ 10 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/context-export-schedule.ts create mode 100644 packages/cli/tests/context-export-schedule.test.ts create mode 100644 packages/core/src/context-export.ts create mode 100644 packages/core/tests/context-export.test.ts diff --git a/README.md b/README.md index 8cea759..b1f5a1e 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,26 @@ After registration, ask an agent for the Setlist project brief with the MCP `get_project_brief` tool, or use `setlist brief --json` from the CLI. +For an offline, read-only fallback on another machine, export the same briefs +as a generated Markdown/JSON cache into any folder already synchronized by +your chosen file-sync system. Setlist writes `manifest.json` last so a syncing +reader can verify that a project file belongs to the current generation: + +```bash +setlist context export --dir ~/Shared/project-context +setlist context install --dir ~/Shared/project-context # preview only +setlist context install --dir ~/Shared/project-context --yes # RunAtLoad + DB/WAL watch + 2-hour repair +setlist context status +``` + +Without `--dir`, the cache stays local under Setlist's application-data +directory. + +The cache is explicitly derived: live Setlist remains authoritative. The +exporter refuses a non-empty unmanaged target, overwrites only its managed +filenames, preserves unrelated files, and backs up an existing launchd plist +before replacing or removing it. + **Download the desktop app** from [Releases](https://github.com/spaceshipmike/setlist/releases) — `.dmg` for macOS Apple Silicon. Signed, notarized, with in-app auto-update over a stable channel. **As an MCP server**, point Claude Desktop or Codex at `@setlist/mcp`. The CLI can check supported clients and preview the managed Setlist entry before writing: @@ -265,7 +285,7 @@ See the setup demo above and [`docs/mcp-client-compatibility.md`](docs/mcp-clien **As a Node.js library**, `npm install @setlist/core` and import directly — Chorus, Ensemble, and other tools in my ecosystem use this path rather than going through the MCP protocol. -**As a CLI**, `setlist` exposes registry maintenance, migration, digest refresh, bootstrap, recipe, primitive, worker, MCP-client install/remove commands, and `setlist brief [--json]` from the terminal. Run a command without the required arguments to see its usage. +**As a CLI**, `setlist` exposes registry maintenance, migration, digest refresh, project-context export/scheduling, bootstrap, recipe, primitive, worker, MCP-client install/remove commands, and `setlist brief [--json]` from the terminal. Run a command without the required arguments to see its usage. ## How it's built diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index b4ed418..01ab411 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -100,6 +100,17 @@ export const CLI_COMMAND_DEFINITIONS: CliCommandDefinition[] = [ description: 'Print a workspace-agnostic agent brief for one project.', usage: 'setlist brief [--json]', }, + { + name: 'context', + description: 'Export a read-only Markdown/JSON project-context cache and manage its launchd refresh schedule.', + usage: 'setlist context [--dir ] [--interval ] [--yes]', + subcommands: [ + { name: 'export', description: 'Write a full project-context reconciliation; manifest.json is committed last.', usage: 'setlist context export [--dir ] [--json]' }, + { name: 'install', description: 'Preview or install the RunAtLoad, database-watch, and periodic launchd job.', usage: 'setlist context install [--dir ] [--interval ] [--yes]' }, + { name: 'uninstall', description: 'Preview or remove the managed launchd job, preserving a backup.', usage: 'setlist context uninstall [--yes]' }, + { name: 'status', description: 'Report whether the project-context launchd job is loaded.', usage: 'setlist context status' }, + ], + }, { name: 'inspect', description: 'Inspect a registered workspace locally for lightweight code and non-code signals.', diff --git a/packages/cli/src/context-export-schedule.ts b/packages/cli/src/context-export-schedule.ts new file mode 100644 index 0000000..b716fb2 --- /dev/null +++ b/packages/cli/src/context-export-schedule.ts @@ -0,0 +1,166 @@ +import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { defaultProjectContextDirectory, getDbPath } from '@setlist/core'; + +export const CONTEXT_EXPORT_LABEL = 'com.setlist.context-export'; +export const DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS = 7200; + +export interface ContextExportScheduleOptions { + directory?: string; + intervalSeconds?: number; + nodePath?: string; + cliPath?: string; + dbPath?: string; + home?: string; +} + +export interface ContextExportSchedulePlan { + label: string; + plist_path: string; + export_directory: string; + interval_seconds: number; + watch_paths: string[]; + program_arguments: string[]; + existing_plist: boolean; +} + +export interface ContextExportScheduleResult extends ContextExportSchedulePlan { + status: 'installed' | 'uninstalled' | 'not_installed'; + backup_path: string | null; +} + +export function planContextExportSchedule(options: ContextExportScheduleOptions = {}): ContextExportSchedulePlan { + const home = options.home ?? homedir(); + const directory = resolve(options.directory ?? defaultProjectContextDirectory(home)); + const intervalSeconds = options.intervalSeconds ?? DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS; + if (!Number.isInteger(intervalSeconds) || intervalSeconds < 60) { + throw new Error('Context export interval must be an integer of at least 60 seconds.'); + } + const dbPath = resolve(options.dbPath ?? getDbPath()); + const nodePath = resolve(options.nodePath ?? process.execPath); + const cliPath = resolve(options.cliPath ?? process.argv[1]); + const plistPath = join(home, 'Library', 'LaunchAgents', `${CONTEXT_EXPORT_LABEL}.plist`); + return { + label: CONTEXT_EXPORT_LABEL, + plist_path: plistPath, + export_directory: directory, + interval_seconds: intervalSeconds, + watch_paths: [dbPath, `${dbPath}-wal`], + program_arguments: [nodePath, cliPath, 'context', 'export', '--dir', directory], + existing_plist: existsSync(plistPath), + }; +} + +export function generateContextExportPlist(options: ContextExportScheduleOptions = {}): string { + const plan = planContextExportSchedule(options); + const logDir = join(options.home ?? homedir(), '.local', 'share', 'project-registry', 'logs'); + const args = plan.program_arguments.map(value => ` ${xmlEscape(value)}`).join('\n'); + const watchPaths = plan.watch_paths.map(value => ` ${xmlEscape(value)}`).join('\n'); + return ` + + + + Label + ${CONTEXT_EXPORT_LABEL} + ProgramArguments + +${args} + + RunAtLoad + + StartInterval + ${plan.interval_seconds} + WatchPaths + +${watchPaths} + + ProcessType + Background + StandardOutPath + ${xmlEscape(join(logDir, 'context-export.log'))} + StandardErrorPath + ${xmlEscape(join(logDir, 'context-export-error.log'))} + + +`; +} + +export function installContextExportSchedule(options: ContextExportScheduleOptions = {}): ContextExportScheduleResult { + const plan = planContextExportSchedule(options); + const home = options.home ?? homedir(); + const logDir = join(home, '.local', 'share', 'project-registry', 'logs'); + mkdirSync(dirname(plan.plist_path), { recursive: true }); + mkdirSync(logDir, { recursive: true }); + + let backupPath: string | null = null; + if (existsSync(plan.plist_path)) { + backupPath = `${plan.plist_path}.setlist-backup-${timestamp()}`; + copyFileSync(plan.plist_path, backupPath); + } + atomicWrite(plan.plist_path, generateContextExportPlist(options)); + + const domain = `gui/${process.getuid?.() ?? 501}`; + try { + execFileSync('/bin/launchctl', ['bootout', domain, plan.plist_path], { stdio: 'ignore' }); + } catch { + // Not loaded yet. + } + execFileSync('/bin/launchctl', ['bootstrap', domain, plan.plist_path], { stdio: 'pipe' }); + + return { ...plan, status: 'installed', backup_path: backupPath }; +} + +export function uninstallContextExportSchedule(options: ContextExportScheduleOptions = {}): ContextExportScheduleResult { + const plan = planContextExportSchedule(options); + if (!existsSync(plan.plist_path)) { + return { ...plan, status: 'not_installed', backup_path: null }; + } + const domain = `gui/${process.getuid?.() ?? 501}`; + try { + execFileSync('/bin/launchctl', ['bootout', domain, plan.plist_path], { stdio: 'ignore' }); + } catch { + // Already unloaded. + } + const backupPath = `${plan.plist_path}.setlist-backup-${timestamp()}`; + copyFileSync(plan.plist_path, backupPath); + rmSync(plan.plist_path, { force: true }); + return { ...plan, status: 'uninstalled', backup_path: backupPath }; +} + +export function contextExportScheduleStatus(options: ContextExportScheduleOptions = {}): 'loaded' | 'installed_not_loaded' | 'not_installed' { + const plan = planContextExportSchedule(options); + if (!existsSync(plan.plist_path)) return 'not_installed'; + const target = `gui/${process.getuid?.() ?? 501}/${CONTEXT_EXPORT_LABEL}`; + try { + execFileSync('/bin/launchctl', ['print', target], { stdio: 'ignore' }); + return 'loaded'; + } catch { + return 'installed_not_loaded'; + } +} + +function timestamp(): string { + return new Date().toISOString().replace(/[-:.]/g, ''); +} + +function atomicWrite(path: string, content: string): void { + const tmp = `${path}.tmp-${process.pid}`; + try { + writeFileSync(tmp, content, 'utf8'); + renameSync(tmp, path); + } catch (error) { + try { rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ } + throw error; + } +} + +function xmlEscape(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 19f96cb..ce2eaeb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { MCP_CLIENTS, applyMcpClientInstall, applyMcpClientRemove, detectMcpClients, planMcpClientInstall, planMcpClientRemove, getDbPath, createBackup, restoreDatabase, listSnapshots, snapshotLabel, + defaultProjectContextDirectory, exportProjectContext, runDoctor, applyDoctorFixes, type McpClientDefinition, type McpClientDetection, type McpClientInstallPlan, type McpClientInstallResult, type McpClientRecoveryGuidance, @@ -16,6 +17,13 @@ import { runDigestRefresh } from './digest.js'; import { CLI_COMMAND_DEFINITIONS } from './commands.js'; import { runRetain, runRecall, runMemExport, runMemFeedback, runMemCorrect, emitMemoryOutcome } from './memory-commands.js'; import { runMigrateMemories } from './migrate-memories-command.js'; +import { + DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS, + contextExportScheduleStatus, + installContextExportSchedule, + planContextExportSchedule, + uninstallContextExportSchedule, +} from './context-export-schedule.js'; const args = process.argv.slice(2); const command = args[0]; @@ -484,6 +492,74 @@ switch (command) { break; } + case 'context': { + const directory = getFlag('dir') ?? defaultProjectContextDirectory(); + const intervalRaw = getFlag('interval'); + const intervalSeconds = intervalRaw + ? Number.parseInt(intervalRaw, 10) + : DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS; + if (!Number.isInteger(intervalSeconds) || intervalSeconds < 60) { + console.error('Error: --interval must be an integer of at least 60 seconds.'); + process.exit(1); + } + const scheduleOptions = { directory, intervalSeconds }; + switch (subcommand) { + case 'export': { + try { + const result = exportProjectContext({ directory }); + if (hasFlag('json')) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Exported ${result.project_count} project(s) to ${result.directory}.`); + console.log(`Active: ${result.active_project_count}; files written: ${result.files_written}.`); + console.log(`Manifest: ${result.manifest_path}`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + break; + } + case 'install': { + const plan = planContextExportSchedule(scheduleOptions); + if (!hasFlag('yes')) { + console.log('Dry run only. No launchd files were changed.'); + console.log(`Plist: ${plan.plist_path}`); + console.log(`Export directory: ${plan.export_directory}`); + console.log(`Refresh: on load, when the registry DB/WAL changes, and every ${plan.interval_seconds} seconds.`); + console.log(`Existing managed plist: ${plan.existing_plist ? 'yes (will be backed up)' : 'no'}`); + console.log(`Apply with: setlist context install --dir ${JSON.stringify(plan.export_directory)} --interval ${plan.interval_seconds} --yes`); + break; + } + // Prove the export path is safe and usable before installing automation. + const exported = exportProjectContext({ directory }); + const installed = installContextExportSchedule(scheduleOptions); + console.log(`Exported ${exported.project_count} project(s) to ${exported.directory}.`); + console.log(`Context export schedule installed: ${installed.plist_path}`); + if (installed.backup_path) console.log(`Previous plist backed up: ${installed.backup_path}`); + break; + } + case 'uninstall': { + const plan = planContextExportSchedule(scheduleOptions); + if (!hasFlag('yes')) { + console.log(`Dry run only. ${plan.existing_plist ? 'Would unload and remove' : 'No managed plist found at'} ${plan.plist_path}.`); + if (plan.existing_plist) console.log('The plist would be backed up before removal. Apply with: setlist context uninstall --yes'); + break; + } + const result = uninstallContextExportSchedule(scheduleOptions); + console.log(`Context export schedule: ${result.status}.`); + if (result.backup_path) console.log(`Removed plist preserved at: ${result.backup_path}`); + break; + } + case 'status': + console.log(`Context export schedule: ${contextExportScheduleStatus(scheduleOptions)}.`); + break; + default: + console.log('Usage: setlist context [--dir ] [--interval ] [--yes]'); + } + break; + } + case 'inspect': { const name = args[1]; if (!name || name.startsWith('--')) { diff --git a/packages/cli/tests/context-export-schedule.test.ts b/packages/cli/tests/context-export-schedule.test.ts new file mode 100644 index 0000000..1bbc2cc --- /dev/null +++ b/packages/cli/tests/context-export-schedule.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + CONTEXT_EXPORT_LABEL, + DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS, + generateContextExportPlist, + planContextExportSchedule, +} from '../src/context-export-schedule.js'; + +describe('project-context export schedule', () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'setlist-context-schedule-')); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('plans the dedicated launchd job without writing it', () => { + const plan = planContextExportSchedule({ + home, + dbPath: join(home, 'registry.db'), + nodePath: '/opt/node/bin/node', + cliPath: '/opt/setlist/index.js', + }); + + expect(plan.label).toBe(CONTEXT_EXPORT_LABEL); + expect(plan.interval_seconds).toBe(DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS); + expect(plan.watch_paths).toEqual([ + join(home, 'registry.db'), + join(home, 'registry.db-wal'), + ]); + expect(plan.program_arguments.slice(-4)).toEqual([ + 'context', + 'export', + '--dir', + join(home, '.local/share/project-registry/project-context'), + ]); + expect(plan.existing_plist).toBe(false); + }); + + it('reports an existing managed plist in the preview', () => { + const plistDir = join(home, 'Library', 'LaunchAgents'); + mkdirSync(plistDir, { recursive: true }); + writeFileSync(join(plistDir, `${CONTEXT_EXPORT_LABEL}.plist`), ''); + + expect(planContextExportSchedule({ home }).existing_plist).toBe(true); + }); + + it('generates RunAtLoad, periodic, and DB/WAL watch triggers with escaped paths', () => { + const plist = generateContextExportPlist({ + home, + directory: join(home, 'Areas & Context'), + dbPath: join(home, 'registry&state.db'), + nodePath: '/opt/node/bin/node', + cliPath: '/opt/setlist/index.js', + intervalSeconds: 3600, + }); + + expect(plist).toContain(`${CONTEXT_EXPORT_LABEL}`); + expect(plist).toContain('RunAtLoad'); + expect(plist).toContain('StartInterval'); + expect(plist).toContain('3600'); + expect(plist).toContain('WatchPaths'); + expect(plist).toContain('registry&state.db-wal'); + expect(plist).toContain('Areas & Context'); + }); + + it('rejects intervals shorter than one minute', () => { + expect(() => planContextExportSchedule({ home, intervalSeconds: 30 })) + .toThrow(/at least 60 seconds/); + }); +}); diff --git a/packages/cli/tests/introspect-commands.test.ts b/packages/cli/tests/introspect-commands.test.ts index eaa7519..a16fbc8 100644 --- a/packages/cli/tests/introspect-commands.test.ts +++ b/packages/cli/tests/introspect-commands.test.ts @@ -34,6 +34,7 @@ describe('introspectCliCommands (S112)', () => { expect(names.has('mcp-clients')).toBe(true); expect(names.has('update')).toBe(true); expect(names.has('brief')).toBe(true); + expect(names.has('context')).toBe(true); expect(names.has('inspect')).toBe(true); expect(names.has('archive')).toBe(true); expect(names.has('worker')).toBe(true); @@ -82,6 +83,12 @@ describe('introspectCliCommands (S112)', () => { expect(mcpClients!.outputs).toContain('plan'); expect(mcpClients!.outputs).toContain('install'); expect(mcpClients!.outputs).toContain('remove'); + + const context = caps.find(c => c.name === 'context'); + expect(context!.outputs).toContain('export'); + expect(context!.outputs).toContain('install'); + expect(context!.outputs).toContain('uninstall'); + expect(context!.outputs).toContain('status'); }); it('sets invocation metadata for developer CLI use', () => { diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts new file mode 100644 index 0000000..9669991 --- /dev/null +++ b/packages/core/src/context-export.ts @@ -0,0 +1,336 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { RegistryError } from './errors.js'; +import type { ProjectBrief } from './project-brief.js'; +import { Registry } from './registry.js'; + +export const PROJECT_CONTEXT_SCHEMA_VERSION = 'setlist-project-context.v1'; +export const PROJECT_CONTEXT_OWNER_FILE = '.setlist-context-export.json'; +export const DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH = '.local/share/project-registry/project-context'; + +export interface ProjectContextSnapshot { + schema_version: typeof PROJECT_CONTEXT_SCHEMA_VERSION; + canonical: false; + source: 'setlist'; + generation_id: string; + exported_at: string; + project_updated_at: string; + digest_generated_at: string | null; + digest_stale: boolean | null; + brief: ProjectBrief; +} + +export interface ProjectContextManifestEntry { + name: string; + display_name: string; + status: string; + area: string | null; + workspace_kind: string; + project_updated_at: string; + digest_generated_at: string | null; + digest_stale: boolean | null; + markdown_path: string; + json_path: string; +} + +export interface ProjectContextManifest { + schema_version: typeof PROJECT_CONTEXT_SCHEMA_VERSION; + canonical: false; + source: 'setlist'; + generation_id: string; + exported_at: string; + project_count: number; + active_project_count: number; + projects: ProjectContextManifestEntry[]; +} + +export interface ProjectContextExportOptions { + directory?: string; + registry?: Registry; + now?: Date; +} + +export interface ProjectContextExportResult { + directory: string; + generation_id: string; + exported_at: string; + project_count: number; + active_project_count: number; + files_written: number; + manifest_path: string; + index_path: string; +} + +export function defaultProjectContextDirectory(home: string = homedir()): string { + return join(home, DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH); +} + +/** + * Export the complete Setlist project registry as a read-only, derived cache. + * + * The exporter deliberately writes a full reconciliation rather than trying + * to infer which tables changed. That keeps every Setlist surface (CLI, MCP, + * app, and library callers) covered without mutation-specific hooks. Project + * files are written first and manifest.json last; readers can therefore treat + * the manifest generation_id as the completeness boundary. + */ +export function exportProjectContext(options: ProjectContextExportOptions = {}): ProjectContextExportResult { + const directory = resolve(options.directory ?? defaultProjectContextDirectory()); + const registry = options.registry ?? new Registry(); + const exportedAt = (options.now ?? new Date()).toISOString(); + const generationId = exportedAt; + + assertManagedDirectory(directory); + + const projectRows = registry.listProjects({ depth: 'minimal' }) as Array<{ name: string }>; + const briefs = projectRows + .map(row => registry.getProjectBrief(row.name)) + .sort((a, b) => a.project.name.localeCompare(b.project.name)); + + // Build the entire generation before touching the filesystem. A failed + // project inspection therefore leaves the previous exported generation as-is. + const snapshots = briefs.map(brief => buildSnapshot(brief, generationId, exportedAt)); + const manifest = buildManifest(snapshots, generationId, exportedAt); + const renderedProjects = snapshots.map(snapshot => ({ + name: snapshot.brief.project.name, + json: `${JSON.stringify(snapshot, null, 2)}\n`, + markdown: renderProjectContextMarkdown(snapshot), + })); + const readme = renderReadme(); + const index = renderProjectContextIndex(manifest); + + mkdirSync(join(directory, 'projects'), { recursive: true }); + atomicWrite(join(directory, PROJECT_CONTEXT_OWNER_FILE), `${JSON.stringify({ + owner: 'setlist', + schema_version: PROJECT_CONTEXT_SCHEMA_VERSION, + }, null, 2)}\n`); + atomicWrite(join(directory, 'README.md'), readme); + for (const project of renderedProjects) { + atomicWrite(join(directory, 'projects', `${project.name}.json`), project.json); + atomicWrite(join(directory, 'projects', `${project.name}.md`), project.markdown); + } + atomicWrite(join(directory, 'INDEX.md'), index); + // Commit marker for cross-device readers. This is intentionally last. + atomicWrite(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + + return { + directory, + generation_id: generationId, + exported_at: exportedAt, + project_count: manifest.project_count, + active_project_count: manifest.active_project_count, + files_written: renderedProjects.length * 2 + 4, + manifest_path: join(directory, 'manifest.json'), + index_path: join(directory, 'INDEX.md'), + }; +} + +function assertManagedDirectory(directory: string): void { + if (!existsSync(directory)) return; + const entries = readdirSync(directory); + if (entries.length === 0 || existsSync(join(directory, PROJECT_CONTEXT_OWNER_FILE))) return; + throw new RegistryError( + 'CONTEXT_EXPORT_CONFLICT', + `Refusing to write project context into non-empty unmanaged directory ${directory}.`, + `Choose an empty directory, or use the Setlist-managed default at ${defaultProjectContextDirectory()}.`, + ); +} + +function buildSnapshot( + brief: ProjectBrief, + generationId: string, + exportedAt: string, +): ProjectContextSnapshot { + return { + schema_version: PROJECT_CONTEXT_SCHEMA_VERSION, + canonical: false, + source: 'setlist', + generation_id: generationId, + exported_at: exportedAt, + project_updated_at: brief.project.updated_at, + digest_generated_at: brief.digest?.generated_at ?? null, + digest_stale: brief.digest?.stale ?? null, + brief, + }; +} + +function buildManifest( + snapshots: ProjectContextSnapshot[], + generationId: string, + exportedAt: string, +): ProjectContextManifest { + const projects = snapshots.map(snapshot => { + const brief = snapshot.brief; + return { + name: brief.project.name, + display_name: brief.project.display_name, + status: brief.project.status, + area: brief.project.area, + workspace_kind: brief.project.workspace_kind, + project_updated_at: snapshot.project_updated_at, + digest_generated_at: snapshot.digest_generated_at, + digest_stale: snapshot.digest_stale, + markdown_path: `projects/${brief.project.name}.md`, + json_path: `projects/${brief.project.name}.json`, + }; + }); + return { + schema_version: PROJECT_CONTEXT_SCHEMA_VERSION, + canonical: false, + source: 'setlist', + generation_id: generationId, + exported_at: exportedAt, + project_count: projects.length, + active_project_count: projects.filter(project => project.status === 'active').length, + projects, + }; +} + +export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): string { + const brief = snapshot.brief; + const lines: string[] = [ + '', + '---', + `schema_version: ${jsonScalar(snapshot.schema_version)}`, + `project: ${jsonScalar(brief.project.name)}`, + `exported_at: ${jsonScalar(snapshot.exported_at)}`, + `project_updated_at: ${jsonScalar(snapshot.project_updated_at)}`, + `digest_generated_at: ${snapshot.digest_generated_at ? jsonScalar(snapshot.digest_generated_at) : 'null'}`, + `digest_stale: ${snapshot.digest_stale ?? 'null'}`, + 'source: setlist', + 'canonical: false', + '---', + '', + `# ${brief.project.display_name}`, + '', + '> Derived Setlist snapshot for offline orientation. Setlist is authoritative when reachable.', + '', + brief.purpose.summary ?? 'No project summary has been captured yet.', + '', + '## Identity', + '', + `- Registry name: \`${brief.project.name}\``, + `- Status: ${brief.project.status}`, + `- Area: ${brief.project.area ?? 'Unassigned'}`, + `- Workspace kind: ${brief.project.workspace_kind}`, + `- Project type: ${brief.project.project_type ?? 'Unspecified'}`, + `- Parent: ${brief.project.parent_project ?? 'None'}`, + ]; + + appendList(lines, 'Paths', brief.project.paths.map(path => `\`${path}\``)); + appendList(lines, 'Missing paths', (brief.project.missing_paths ?? []).map(path => `\`${path}\``)); + appendSection(lines, 'Digest', brief.digest?.digest_text ?? 'No essence digest has been stored yet.'); + appendList(lines, 'Goals', brief.purpose.goals); + appendList(lines, 'Orientation', brief.orientation_notes); + appendList(lines, 'Tech stack', brief.profile.tech_stack); + appendList(lines, 'Patterns', brief.profile.patterns); + appendList(lines, 'Topics', brief.purpose.topics); + appendList(lines, 'Concerns', brief.purpose.concerns); + + if (brief.capabilities.count > 0) { + lines.push('', '## Capabilities', '', `Total: ${brief.capabilities.count}`, ''); + for (const capability of brief.capabilities.highlights) { + lines.push(`- **${capability.name}** (${capability.type}): ${capability.description}`); + } + } + appendList(lines, 'Enrichment gaps', brief.enrichment_gaps); + + lines.push( + '', + '## Freshness', + '', + `- Snapshot exported: ${snapshot.exported_at}`, + `- Registry record updated: ${snapshot.project_updated_at}`, + `- Digest generated: ${snapshot.digest_generated_at ?? 'none'}`, + `- Digest marked stale: ${snapshot.digest_stale ?? 'unknown'}`, + '', + ); + return `${lines.join('\n')}\n`; +} + +export function renderProjectContextIndex(manifest: ProjectContextManifest): string { + const lines = [ + '', + '# Setlist project context', + '', + '> Derived, read-only fallback. Query live Setlist first; use these files when Setlist is unavailable.', + '', + `Exported: ${manifest.exported_at}`, + '', + 'Freshness guidance: under 6 hours is normal orientation; 6–24 hours is aging; over 24 hours must be described as stale.', + '', + ]; + for (const [label, predicate] of [ + ['Active projects', (entry: ProjectContextManifestEntry) => entry.status === 'active'], + ['Other projects', (entry: ProjectContextManifestEntry) => entry.status !== 'active'], + ] as const) { + const projects = manifest.projects.filter(predicate); + if (projects.length === 0) continue; + lines.push(`## ${label}`, ''); + for (const project of projects) { + const area = project.area ? ` · ${project.area}` : ''; + const stale = project.digest_stale ? ' · digest stale' : ''; + lines.push(`- [${project.display_name}](${project.markdown_path}) — ${project.status}${area}${stale}`); + } + lines.push(''); + } + return `${lines.join('\n')}\n`; +} + +function renderReadme(): string { + return ` +# Setlist project-context cache + +This directory is generated by Setlist for agents that cannot reach the live registry. + +- Setlist remains the authoritative project registry. +- Read \`manifest.json\` first and only trust project files whose \`generation_id\` matches it. +- Prefer the Markdown project file for orientation and its JSON twin for exact structured fields. +- Never edit these generated files or import them wholesale into long-term agent memory. +- Under 6 hours old is normal orientation; 6–24 hours is aging; over 24 hours must be called stale. + +The exporter owns \`README.md\`, \`INDEX.md\`, \`manifest.json\`, and \`projects/.{md,json}\`. +It does not delete unrelated files. +`; +} + +function appendSection(lines: string[], title: string, body: string): void { + lines.push('', `## ${title}`, '', body); +} + +function appendList(lines: string[], title: string, values: string[]): void { + if (values.length === 0) return; + lines.push('', `## ${title}`, ''); + for (const value of values) lines.push(`- ${value}`); +} + +function jsonScalar(value: string): string { + return JSON.stringify(value); +} + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${process.pid}`; + try { + writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o644 }); + renameSync(tmp, path); + } catch (error) { + try { rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ } + throw error; + } +} + +export function readProjectContextManifest(directory: string): ProjectContextManifest | null { + const path = join(resolve(directory), 'manifest.json'); + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, 'utf8')) as ProjectContextManifest; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1face72..a5cd5a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -183,6 +183,21 @@ export { type ProjectBriefDigest, type ProjectBriefWorkspaceKind, } from './project-brief.js'; +export { + DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH, + PROJECT_CONTEXT_OWNER_FILE, + PROJECT_CONTEXT_SCHEMA_VERSION, + defaultProjectContextDirectory, + exportProjectContext, + readProjectContextManifest, + renderProjectContextIndex, + renderProjectContextMarkdown, + type ProjectContextExportOptions, + type ProjectContextExportResult, + type ProjectContextManifest, + type ProjectContextManifestEntry, + type ProjectContextSnapshot, +} from './context-export.js'; export { inspectWorkspacePaths, type InspectWorkspacePathsOptions, diff --git a/packages/core/src/introspect-exports.ts b/packages/core/src/introspect-exports.ts index 2417f02..f66b3ea 100644 --- a/packages/core/src/introspect-exports.ts +++ b/packages/core/src/introspect-exports.ts @@ -247,6 +247,31 @@ const LIBRARY_EXPORTS_MANIFEST: LibraryExportManifestEntry[] = [ kind: 'function', description: 'Build a deterministic project/workspace agent brief from registry identity, fields, digest metadata, capabilities, and ports.', }, + { + name: 'defaultProjectContextDirectory', + kind: 'function', + description: 'Return the default local derived project-context cache directory under the Setlist application-data home. Pass an explicit directory to publish into a synced folder.', + }, + { + name: 'exportProjectContext', + kind: 'function', + description: 'Export a full read-only Setlist project-context reconciliation as Markdown and JSON, writing manifest.json last as the cross-device reader boundary.', + }, + { + name: 'readProjectContextManifest', + kind: 'function', + description: 'Read the structured manifest from a Setlist-managed project-context export directory, or return null when none exists.', + }, + { + name: 'renderProjectContextIndex', + kind: 'function', + description: 'Render the deterministic Markdown index for a project-context export manifest, including freshness guidance and active/non-active grouping.', + }, + { + name: 'renderProjectContextMarkdown', + kind: 'function', + description: 'Render one compact agent-facing Markdown project snapshot from the exact structured JSON snapshot contract.', + }, { name: 'extractNamedTerms', @@ -260,6 +285,21 @@ const LIBRARY_EXPORTS_MANIFEST: LibraryExportManifestEntry[] = [ kind: 'constant', description: 'Current registry schema version integer — bumped on every additive or breaking migration.', }, + { + name: 'DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH', + kind: 'constant', + description: 'Home-relative default location for the generated project-context cache.', + }, + { + name: 'PROJECT_CONTEXT_OWNER_FILE', + kind: 'constant', + description: 'Ownership marker filename that lets repeated exports distinguish a Setlist-managed directory from unmanaged user content.', + }, + { + name: 'PROJECT_CONTEXT_SCHEMA_VERSION', + kind: 'constant', + description: 'Versioned contract identifier shared by project-context JSON, Markdown frontmatter, the owner marker, and manifest.', + }, { name: 'PORT_RANGE_MIN', kind: 'constant', diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts new file mode 100644 index 0000000..351477a --- /dev/null +++ b/packages/core/tests/context-export.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + PROJECT_CONTEXT_OWNER_FILE, + Registry, + exportProjectContext, + readProjectContextManifest, +} from '../src/index.js'; + +describe('project-context export', () => { + let tmpDir: string; + let exportDir: string; + let registry: Registry; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'setlist-context-export-')); + exportDir = join(tmpDir, 'project-context'); + registry = new Registry(join(tmpDir, 'registry.db')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('exports deterministic JSON and Markdown with manifest committed as the reader boundary', () => { + registry.register({ + name: 'alpha', + display_name: 'Alpha Project', + type: 'project', + status: 'active', + description: 'A concise project summary.', + goals: ['Ship the useful slice'], + }); + registry.updateFields('alpha', { tech_stack: ['TypeScript'], patterns: ['library-first'] }, 'test'); + registry.refreshProjectDigest({ + project_name: 'alpha', + digest_text: 'Alpha is the durable project essence.', + spec_version: 'unversioned', + producer: 'test', + }); + + const now = new Date('2026-07-13T23:00:00.000Z'); + const result = exportProjectContext({ directory: exportDir, registry, now }); + + expect(result.project_count).toBe(1); + expect(result.active_project_count).toBe(1); + expect(result.files_written).toBe(6); + expect(existsSync(join(exportDir, PROJECT_CONTEXT_OWNER_FILE))).toBe(true); + expect(existsSync(join(exportDir, 'manifest.json'))).toBe(true); + + const manifest = readProjectContextManifest(exportDir)!; + expect(manifest.generation_id).toBe(now.toISOString()); + expect(manifest.projects).toEqual([expect.objectContaining({ + name: 'alpha', + status: 'active', + markdown_path: 'projects/alpha.md', + json_path: 'projects/alpha.json', + })]); + + const snapshot = JSON.parse(readFileSync(join(exportDir, 'projects', 'alpha.json'), 'utf8')); + expect(snapshot).toEqual(expect.objectContaining({ + canonical: false, + source: 'setlist', + generation_id: manifest.generation_id, + exported_at: now.toISOString(), + })); + expect(snapshot.brief.purpose.summary).toBe('A concise project summary.'); + + const markdown = readFileSync(join(exportDir, 'projects', 'alpha.md'), 'utf8'); + expect(markdown).toContain('Derived Setlist snapshot for offline orientation'); + expect(markdown).toContain('Alpha is the durable project essence.'); + expect(markdown).toContain('Ship the useful slice'); + + const index = readFileSync(join(exportDir, 'INDEX.md'), 'utf8'); + expect(index).toContain('[Alpha Project](projects/alpha.md)'); + expect(index).toContain('over 24 hours must be described as stale'); + }); + + it('includes non-active projects so an old active file cannot masquerade as current', () => { + registry.register({ name: 'active-one', type: 'project', status: 'active', description: 'Active.' }); + registry.register({ name: 'archived-one', type: 'project', status: 'archived', description: 'Archived.' }); + + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T23:00:00Z') }); + const manifest = readProjectContextManifest(exportDir)!; + + expect(manifest.project_count).toBe(2); + expect(manifest.active_project_count).toBe(1); + expect(manifest.projects.find(project => project.name === 'archived-one')?.status).toBe('archived'); + expect(readFileSync(join(exportDir, 'INDEX.md'), 'utf8')).toContain('## Other projects'); + }); + + it('refuses a non-empty unmanaged target instead of overwriting user files', () => { + mkdirSync(exportDir, { recursive: true }); + writeFileSync(join(exportDir, 'notes.md'), 'human-authored\n'); + registry.register({ name: 'alpha', type: 'project', status: 'active' }); + + expect(() => exportProjectContext({ directory: exportDir, registry })) + .toThrow(/CONTEXT_EXPORT_CONFLICT/); + expect(readFileSync(join(exportDir, 'notes.md'), 'utf8')).toBe('human-authored\n'); + }); + + it('reconciles managed output while preserving unrelated files', () => { + registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'Before.' }); + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T23:00:00Z') }); + writeFileSync(join(exportDir, 'operator-note.md'), 'keep me\n'); + registry.updateCore('alpha', { description: 'After.' }); + + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-14T01:00:00Z') }); + + expect(readFileSync(join(exportDir, 'operator-note.md'), 'utf8')).toBe('keep me\n'); + expect(readFileSync(join(exportDir, 'projects', 'alpha.md'), 'utf8')).toContain('After.'); + expect(readProjectContextManifest(exportDir)?.exported_at).toBe('2026-07-14T01:00:00.000Z'); + }); +}); From 0c92080a24f0816a4ac58b4f4860f74c237ee723 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 21:28:53 -0400 Subject: [PATCH 02/22] Add project attention contract + assess_attention (schema v20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- CLAUDE.md | 16 +- MODEL_DECISIONS.md | 48 ++ docs/setlist-project-attention-brief.md | 152 +++++++ packages/app/src/preload/index.ts | 6 + .../renderer/components/EditProjectForm.tsx | 101 ++++- packages/app/src/renderer/lib/api.ts | 20 + .../src/renderer/views/ProjectDetailView.tsx | 8 + packages/core/src/attention.ts | 414 ++++++++++++++++++ packages/core/src/context-export.ts | 85 +++- packages/core/src/cross-query.ts | 55 +++ packages/core/src/db.ts | 30 +- packages/core/src/health.ts | 4 +- packages/core/src/index.ts | 18 + packages/core/src/introspect-exports.ts | 46 ++ packages/core/src/models.ts | 25 ++ packages/core/src/project-brief.ts | 15 + packages/core/src/registry.ts | 75 ++++ packages/core/tests/attention.test.ts | 272 ++++++++++++ packages/core/tests/compatibility.test.ts | 6 +- packages/core/tests/context-export.test.ts | 49 +++ packages/core/tests/cross-query.test.ts | 52 ++- packages/core/tests/db.test.ts | 2 +- packages/core/tests/interactions.test.ts | 4 +- packages/core/tests/recipes-store.test.ts | 6 +- packages/mcp/src/server.ts | 25 +- packages/mcp/tests/introspect-tools.test.ts | 6 +- .../tests/self-register-integration.test.ts | 14 +- packages/mcp/tests/self-register.test.ts | 4 +- packages/mcp/tests/server.test.ts | 6 +- 29 files changed, 1521 insertions(+), 43 deletions(-) create mode 100644 docs/setlist-project-attention-brief.md create mode 100644 packages/core/src/attention.ts create mode 100644 packages/core/tests/attention.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 271ddcf..d06d8db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Setlist is the TypeScript implementation of the Project Registry — the active Spec 0.32 is **shipped through v0.6.1-beta.7**: user-managed areas and project types, user-composable bootstrap recipes, safe MCP client install/remove planning (now with a Take over action for unmanaged-entry conflicts), local workspace inspection (with an Overview tab Signals from disk panel), portable project briefs, per-project essence digests, the optional `email_account` field that drives the `mail-create-mailbox` bootstrap primitive, menu-bar persistence with a system tray plus configurable global project shortcuts, and the quit-prompt update handoff are all live. For origin and port history, see spec §1.5. -Running system: Schema v19 (adds `projects.container` for the `projects/` taxonomy — non-code projects land at `//projects/` — and retargets the Finance/Health seed directories to `~/Areas/home/Finances` and `~/Areas/personal/Health`; v18 added `areas.default_directory` for area-aware bootstrap; v17 added the interactions log; v16 added `projects.email_account` for the `mail-create-mailbox` primitive and builds on v15's digest `named_terms`, v14's `bootstrap_primitives` + `project_type_recipe_steps`, v13's user-managed `project_types`, v12's `project_digests`, v11's structural `area_id`/`parent_project_id`, and v10's unified memory types), 62 MCP tools, desktop UI sharing Chorus's design system with multiselect status filtering and archived project visibility. +Running system: Schema v20 (adds the attention contract on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — driving the `assess_attention` surface; v19 added `projects.container` for the `projects/` taxonomy — non-code projects land at `//projects/` — and retargeted the Finance/Health seed directories to `~/Areas/home/Finances` and `~/Areas/personal/Health`; v18 added `areas.default_directory` for area-aware bootstrap; v17 added the interactions log; v16 added `projects.email_account` for the `mail-create-mailbox` primitive and builds on v15's digest `named_terms`, v14's `bootstrap_primitives` + `project_type_recipe_steps`, v13's user-managed `project_types`, v12's `project_digests`, v11's structural `area_id`/`parent_project_id`, and v10's unified memory types), 63 MCP tools, desktop UI sharing Chorus's design system with multiselect status filtering and archived project visibility. ## Commands @@ -31,7 +31,7 @@ npm run typecheck ``` packages/ ├── core/ # @setlist/core — library (all registry logic) -├── mcp/ # @setlist/mcp — MCP server (62 tools, stdio) +├── mcp/ # @setlist/mcp — MCP server (63 tools, stdio) ├── cli/ # @setlist/cli — CLI commands + async worker ├── app/ # @setlist/app — desktop control panel (Electron + React) └── skills/ # @setlist/skills — bundled agent skills, dual-host (.claude/.codex) manifests, installer @@ -44,13 +44,13 @@ packages/ - **Electron** — Desktop shell for @setlist/app. Main process imports @setlist/core via IPC bridge. - **React + Tailwind CSS 4 + Radix UI** — Renderer stack, shared design system with Chorus. - **ESM-only** — All packages produce ESM output. No CJS. -- **Schema v19** — Current schema includes `projects.container` (the `projects/` taxonomy — non-code placement at `//projects/`), `areas.default_directory` (area-aware bootstrap), the v17 interactions log, optional `projects.email_account` for Mail.app mailbox bootstrap, `project_digests.named_terms` for digest retrieval, `bootstrap_primitives` seeded with five built-ins (`create-folder`, `copy-template`, `git-init`, `update-parent-gitignore`, `mail-create-mailbox`), `project_type_recipe_steps` for per-type recipes, user-managed `project_types` and `areas`, and structural `area_id` / `parent_project_id` columns on projects. Migration history from v8 onward is preserved in spec §5.2. +- **Schema v20** — Current schema includes the attention contract on projects (`priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — all nullable; unconfigured surfaces as a gap, never a warning), `projects.container` (the `projects/` taxonomy — non-code placement at `//projects/`), `areas.default_directory` (area-aware bootstrap), the v17 interactions log, optional `projects.email_account` for Mail.app mailbox bootstrap, `project_digests.named_terms` for digest retrieval, `bootstrap_primitives` seeded with five built-ins (`create-folder`, `copy-template`, `git-init`, `update-parent-gitignore`, `mail-create-mailbox`), `project_type_recipe_steps` for per-type recipes, user-managed `project_types` and `areas`, and structural `area_id` / `parent_project_id` columns on projects. Migration history from v8 onward is preserved in spec §5.2. - **Library-first** — @setlist/core is the primary interface. MCP, CLI, and desktop app are thin wrappers. ### Database Location: `~/.local/share/project-registry/registry.db` -Schema v19, WAL mode, FTS5 for memory search. +Schema v20, WAL mode, FTS5 for memory search. **Migrations & backups.** `upgradeSchema` chains one version at a time (`MIGRATION_STEPS[N]`: N→N+1, stamping after each step) so a multi-version jump — @@ -62,7 +62,7 @@ before each migration (aborts the migration if it can't be written), plus a 24h auto-snapshot and the `setlist backup` / `setlist restore` CLI, both under `/backups/` (retention 14). See `MODEL_DECISIONS.md` → D-012. -### 62 MCP Tools +### 63 MCP Tools **Identity (16):** list_projects, get_project, get_project_brief, switch_project, search_projects, get_registry_stats, register_project, update_project, archive_project, unarchive_project, rename_project, batch_update, write_fields, enrich_project, set_project_area, set_parent_project @@ -80,6 +80,8 @@ auto-snapshot and the `setlist backup` / `setlist restore` CLI, both under **Health (2):** assess_health, doctor +**Attention (1):** assess_attention + **Digests (3):** get_project_digest, get_project_digests, refresh_project_digest **Areas (4):** list_areas, create_area, update_area, delete_area @@ -187,8 +189,8 @@ a feature-history index of what the system does. Preserve during auto-compaction: - Agent-model: INVARIANTS.md, SURFACES.md, MODEL_DECISIONS.md, .fctry/lessons.md (the prototype-driven oracle) - Key constraint: prototype-driven loop — the running prototype + INVARIANTS.md are the oracle; scenario satisfaction is retired (spec-era artifacts archived under .fctry/legacy-spec-era/) -- Key constraint: Running code is on schema v19 (projects.container for the projects/ taxonomy, areas.default_directory, interactions log, email_account, digest named_terms, bootstrap_primitives + project_type_recipe_steps, user-managed project_types/areas, structural area/parent links) -- Key constraint: 62 MCP tools covering identity, capabilities, memory, ports, tasks, bootstrap, health, digests, areas, project types, primitives, recipes, and safe client onboarding +- Key constraint: Running code is on schema v20 (attention contract on projects, projects.container for the projects/ taxonomy, areas.default_directory, interactions log, email_account, digest named_terms, bootstrap_primitives + project_type_recipe_steps, user-managed project_types/areas, structural area/parent links) +- Key constraint: 63 MCP tools covering identity, capabilities, memory, ports, tasks, bootstrap, health, attention, digests, areas, project types, primitives, recipes, and safe client onboarding - Key constraint: Library-first (@setlist/core), ESM-only, better-sqlite3; monorepo packages {core,mcp,cli,app,skills} --> diff --git a/MODEL_DECISIONS.md b/MODEL_DECISIONS.md index 4ed9d86..e54991e 100644 --- a/MODEL_DECISIONS.md +++ b/MODEL_DECISIONS.md @@ -720,6 +720,54 @@ regression pins). --- +## D-016 · Attention contract: explicit user-set priority, conservative meaningful touch, contract writes don't count as touches + +**Decision (2026-07-13, attention-signals).** Schema v20 adds an explicit +attention contract to projects (`priority_tier`, `attention_mode`, +`attention_cadence_days`, `next_review_at`, `priority_reason`) and a separate +`assess_attention` surface (`AttentionAssessor`, `attention.ts`) computing +`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). Four deliberate defaults: + +1. **Priorities are never inferred.** No git-activity, health, or + personal-context heuristics assign a tier; an unconfigured project reports + `not_configured` (a gap, never a warning). Setlist is the canonical + priority store — downstream assistants (Hermes) interpret and deliver but + keep no separate list. +2. **Meaningful touch is conservative and source-visible.** Only a git commit + in a registered path, a substantive retained memory (decision / outcome / + correction / learning / pattern — not observation/context), or a material + registry update (`projects.updated_at`) count. The interactions log is + deliberately NOT a source — it records queries, and passive reads, health + checks, brief generation, and context exports must never nourish a project. +3. **Attention-contract writes do not bump `updated_at`.** `updateCore` writes + the five contract columns in a separate non-touching UPDATE: if configuring + a contract counted as a touch, a neglected project would read "nourished" + the moment its priority was set — hiding exactly the neglect the contract + exists to surface. +4. **Intentional quiet is a first-class state.** waiting/paused suppress + entirely until `next_review_at` arrives, then flip to `due_for_review` + (an explicit resume/extend/archive decision), never silently back to + active. Registry health (`assess_health`) semantics are untouched. + +**Why.** Registry health measures hygiene with fixed 7/30-day thresholds; it +cannot answer "is this project getting the attention its importance deserves?" +A high-priority project is undernourished in days while a maintenance project +is healthy for weeks — only an explicit, user-owned contract separates neglect +from intentional quiet. + +**Revisit trigger.** If contract-setting sessions routinely include real work +the assessor misses (because the contract write masked no other touch), +consider an explicit external-activity signal instead of loosening touch +sources. If Hermes needs more than 5 inlined entries in `portfolio_brief`'s +attention section, raise `PORTFOLIO_BRIEF_ATTENTION_LIMIT` rather than +unbounding it. See `packages/core/src/attention.ts`, +`packages/core/tests/attention.test.ts` (the brief's 11 acceptance scenarios), +and `docs/setlist-project-attention-brief.md` (the originating brief). + +--- + ## Provenance D-001–D-004 are the standing architectural decisions documented in `CLAUDE.md` diff --git a/docs/setlist-project-attention-brief.md b/docs/setlist-project-attention-brief.md new file mode 100644 index 0000000..94dc9db --- /dev/null +++ b/docs/setlist-project-attention-brief.md @@ -0,0 +1,152 @@ +# Brief: Project attention and nourishment signals + +## Objective + +Extend Setlist so an assistant such as Hermes can tell when an important project is not receiving the attention it should, while distinguishing genuine neglect from work that is intentionally quiet, waiting, or paused. + +Setlist must remain the canonical source for project priority and expected attention. Hermes may interpret and deliver the signal, but it must not maintain a separate priority list. + +## Problem + +Setlist's current health assessment measures registry health across activity, completeness, and outcomes. Its activity thresholds are fixed: healthy within 7 days, at risk after 8 days, and stale after 30 days. That is useful for registry maintenance, but it does not answer the strategic question: "Is this project receiving enough attention for how important it is?" + +A high-priority project may be undernourished after a few days. A maintenance project may be healthy after several quiet weeks. A waiting or paused project should not generate a warning at all. + +## Required behavior + +### 1. Add an explicit attention contract to projects + +Persist these fields as canonical Setlist data: + +- `priority_tier`: `critical`, `important`, `normal`, or `someday` +- `attention_mode`: `active`, `maintenance`, `waiting`, or `paused` +- `attention_cadence_days`: positive integer or null +- `next_review_at`: ISO timestamp or null +- `priority_reason`: optional short explanation + +Expose them through the normal project read and update surfaces, including the core library, MCP, CLI where appropriate, desktop project editing, and project briefs. + +Do not infer or silently assign priorities from git activity, personal-context files, or current health. Existing projects should migrate safely with an unconfigured attention contract and appear as configuration gaps rather than false warnings. + +### 2. Compute attention health separately from registry health + +Add a first-class attention assessment. Prefer a dedicated `assess_attention` core/MCP surface, or an equivalently clear additive contract that does not change the meaning of the existing `assess_health.overall` result. + +Return, at minimum: + +- project name +- state: `nourished`, `due_soon`, `undernourished`, `due_for_review`, `suppressed`, or `not_configured` +- priority tier and attention mode +- expected cadence +- latest meaningful touch timestamp and source +- days since meaningful touch +- next review timestamp, when present +- concise reasons suitable for an assistant to quote +- a sortable attention-debt value or equivalent ordering signal + +The basic model should be understandable: + +> attention debt = time since meaningful touch divided by expected cadence, weighted by priority + +Exact weights are an implementation decision, but the result must be deterministic and covered by tests. Priority should affect ordering and notification urgency; cadence should determine whether a project is overdue. + +### 3. Define meaningful attention conservatively + +Use signals that represent substantive movement, such as: + +- a git commit in a registered project path +- a project decision, outcome, or other substantive retained memory +- a material project update +- an explicitly recorded external activity signal, if Setlist already has or adds a bounded way to accept one + +Do not count passive reads, health checks, context exports, brief generation, or other registry queries as meaningful attention. + +Keep the returned source visible so Hermes can explain why Setlist believes a project was or was not touched. Do not add Todoist, calendar, email, or messaging integrations to Setlist; Hermes/Cosa can use those systems to validate a candidate before notifying the user. + +### 4. Respect intentional quiet + +- `waiting` and `paused` projects are suppressed while `next_review_at` is in the future. +- When `next_review_at` arrives, return `due_for_review`; do not pretend the project has become active. +- `maintenance` projects use their longer explicit cadence normally. +- Archived and inactive projects are excluded from portfolio attention results by default. +- Missing priority or cadence returns `not_configured`, not `undernourished`. + +### 5. Surface portfolio-level results + +Add a bounded attention section to `portfolio_brief` containing: + +- the most undernourished configured projects, ordered by urgency +- projects due for review +- attention-configuration gaps +- enough evidence for an assistant to explain each result + +The response must remain payload-safe. Do not dump every healthy project into the default portfolio brief. + +### 6. Include attention data in the offline context export + +The existing Markdown/JSON project-context export must carry: + +- the project's attention contract +- its current attention assessment +- the relevant timestamps and reasons +- export generation time and existing freshness information + +Update the export schema version if the structured JSON contract changes. Preserve atomic reconciliation behavior, including writing `manifest.json` last. Both per-project files and the portfolio index should make undernourished and due-for-review projects easy for Hermes to identify when live Setlist access is unavailable. + +## Hermes boundary + +Setlist owns the facts and computes the signal. Hermes owns cadence, cross-checking, and delivery. + +The intended downstream behavior is: + +- a light daily check that surfaces no more than one urgent concern +- a weekly portfolio review containing the 1-3 strongest nourishment concerns +- standalone notifications only for high-priority projects with hard deadlines or missed commitments +- natural-language corrections such as "pause this until September" or "make this maintenance" written back to Setlist + +Do not implement Hermes scheduling or notification delivery in this Setlist change. + +## Acceptance scenarios + +1. A critical active project with a 7-day cadence and no meaningful touch for 10 days is undernourished and ranks ahead of a normal project with the same ratio. +2. A maintenance project with a 30-day cadence and a meaningful touch 10 days ago is nourished. +3. A waiting project with a future `next_review_at` is suppressed even if its last touch is old. +4. A waiting project whose `next_review_at` has arrived is due for review, not automatically active or undernourished. +5. An unconfigured project is reported as `not_configured` and never generates a false nourishment warning. +6. Passive Setlist reads and context exports do not reset the meaningful-touch timestamp. +7. A new git commit or substantive retained project event updates the meaningful-touch timestamp and assessment. +8. Archived projects do not appear in default portfolio attention results. +9. `portfolio_brief` returns a bounded, urgency-ordered attention summary with clear reasons. +10. Markdown and JSON exports agree with the live attention assessment and retain their freshness and atomic-manifest guarantees. +11. Existing `assess_health` consumers continue to receive the current registry-health semantics without a breaking reinterpretation of `overall`. + +## Implementation constraints + +- Read the current `.fctry/spec.md`, `.fctry/scenarios.md`, repository instructions, and existing health/export implementation before changing code. +- Update the Factory spec and scenarios first if the repository workflow requires it. +- Keep the model deterministic and explainable; no LLM scoring is needed. +- Keep Setlist free of credentials and external-service dependencies. +- Avoid a second scheduler or daemon; the existing context-export schedule is sufficient for offline propagation. +- Preserve backward compatibility or provide an explicit schema migration and versioned export contract. +- Add focused core, MCP, CLI/export, migration, and UI tests where those surfaces change. +- Run the repository's focused tests, full test suite, typecheck, and required validation command before declaring completion. + +## Out of scope + +- Hermes notification implementation +- automatic edits to the user's priority hierarchy +- direct Todoist, calendar, mail, or Messages integrations in Setlist +- predictive or LLM-based priority scoring +- automatic project reprioritization based only on recent activity +- treating registry completeness as evidence that a project is strategically nourished + +## Completion handoff + +When complete, report: + +- the final data model and migration behavior +- the attention formula and meaningful-touch sources +- all changed live and exported contracts +- how Hermes should read and update the new fields +- acceptance-scenario coverage +- exact validation commands and results diff --git a/packages/app/src/preload/index.ts b/packages/app/src/preload/index.ts index 0c1de46..3a369d2 100644 --- a/packages/app/src/preload/index.ts +++ b/packages/app/src/preload/index.ts @@ -65,6 +65,12 @@ const api = { parent_project?: string | null; // spec 0.29 email_account?: string | null; + // v20: attention contract + priority_tier?: string | null; + attention_mode?: string | null; + attention_cadence_days?: number | null; + next_review_at?: string | null; + priority_reason?: string | null; }) => ipcRenderer.invoke('updateCore', name, updates), updateFields: (name: string, fields: Record, producer?: string) => ipcRenderer.invoke('updateFields', name, fields, producer), diff --git a/packages/app/src/renderer/components/EditProjectForm.tsx b/packages/app/src/renderer/components/EditProjectForm.tsx index 20b08a4..575f40a 100644 --- a/packages/app/src/renderer/components/EditProjectForm.tsx +++ b/packages/app/src/renderer/components/EditProjectForm.tsx @@ -1,5 +1,8 @@ import { useState, useEffect } from 'react'; -import api, { type AreaName, type ProjectSummary, AREA_NAMES } from '../lib/api'; +import api, { + type AreaName, type ProjectSummary, type AttentionContract, + AREA_NAMES, PRIORITY_TIER_OPTIONS, ATTENTION_MODE_OPTIONS, +} from '../lib/api'; import { friendlyError } from '../lib/errors'; interface EditProjectFormProps { @@ -14,6 +17,8 @@ interface EditProjectFormProps { parent_project: string | null; // spec 0.29: optional email account driving mail-create-mailbox. email_account: string | null; + // v20: attention contract (all-null = unconfigured). + attention: AttentionContract; }; projectType: string; onSave: () => void; @@ -23,6 +28,7 @@ interface EditProjectFormProps { // spec 0.13: only 'project' type exists. const STATUS_OPTIONS: string[] = ['idea', 'draft', 'active', 'paused', 'complete', 'archived']; const UNASSIGNED = '__unassigned__'; +const UNSET = '__unset__'; export function EditProjectForm({ name, currentValues, onSave, onCancel }: EditProjectFormProps) { const [displayName, setDisplayName] = useState(currentValues.display_name); @@ -34,6 +40,14 @@ export function EditProjectForm({ name, currentValues, onSave, onCancel }: EditP const [parentProject, setParentProject] = useState(currentValues.parent_project ?? ''); // spec 0.29: optional email_account. const [emailAccount, setEmailAccount] = useState(currentValues.email_account ?? ''); + // v20: attention contract. UNSET sentinel for the selects = unconfigured. + const [priorityTier, setPriorityTier] = useState(currentValues.attention.priority_tier ?? UNSET); + const [attentionMode, setAttentionMode] = useState(currentValues.attention.attention_mode ?? UNSET); + const [cadenceDays, setCadenceDays] = useState( + currentValues.attention.attention_cadence_days != null ? String(currentValues.attention.attention_cadence_days) : '' + ); + const [nextReviewAt, setNextReviewAt] = useState(currentValues.attention.next_review_at ?? ''); + const [priorityReason, setPriorityReason] = useState(currentValues.attention.priority_reason ?? ''); const [parentOptions, setParentOptions] = useState([]); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -60,6 +74,14 @@ export function EditProjectForm({ name, currentValues, onSave, onCancel }: EditP const currentEmail = currentValues.email_account ?? null; const newEmail: string | null = emailAccount.trim() || null; + // v20: attention contract diffs. UNSET/empty clears to NULL. + const attention = currentValues.attention; + const newTier: string | null = priorityTier === UNSET ? null : priorityTier; + const newMode: string | null = attentionMode === UNSET ? null : attentionMode; + const newCadence: number | null = cadenceDays.trim() === '' ? null : Number(cadenceDays); + const newReview: string | null = nextReviewAt.trim() || null; + const newReason: string | null = priorityReason.trim() || null; + await api.updateCore(name, { display_name: displayName !== currentValues.display_name ? displayName : undefined, status: status !== currentValues.status ? status : undefined, @@ -68,6 +90,11 @@ export function EditProjectForm({ name, currentValues, onSave, onCancel }: EditP area: newArea !== currentArea ? newArea : undefined, parent_project: newParent !== currentParent ? newParent : undefined, email_account: newEmail !== currentEmail ? newEmail : undefined, + priority_tier: newTier !== attention.priority_tier ? newTier : undefined, + attention_mode: newMode !== attention.attention_mode ? newMode : undefined, + attention_cadence_days: newCadence !== attention.attention_cadence_days ? newCadence : undefined, + next_review_at: newReview !== attention.next_review_at ? newReview : undefined, + priority_reason: newReason !== attention.priority_reason ? newReason : undefined, }); onSave(); } catch (e) { @@ -197,6 +224,78 @@ export function EditProjectForm({ name, currentValues, onSave, onCancel }: EditP /> + {/* v20: attention contract — priority + expected attention. Unset selects + and empty inputs clear to NULL (unconfigured). */} +
+ + +
+ +
+ + +
+ + + {error && (
{error} diff --git a/packages/app/src/renderer/lib/api.ts b/packages/app/src/renderer/lib/api.ts index 4c579e7..bee5a98 100644 --- a/packages/app/src/renderer/lib/api.ts +++ b/packages/app/src/renderer/lib/api.ts @@ -299,8 +299,22 @@ export interface ProjectSummary { email_account?: string | null; // v19 (projects/ taxonomy): entity/property sub-path the project nests under. container?: string | null; + // v20: attention contract (all-null = unconfigured). + attention?: AttentionContract; } +// v20 attention contract as returned by get_project/list_projects. +export interface AttentionContract { + priority_tier: string | null; + attention_mode: string | null; + attention_cadence_days: number | null; + next_review_at: string | null; + priority_reason: string | null; +} + +export const PRIORITY_TIER_OPTIONS = ['critical', 'important', 'normal', 'someday'] as const; +export const ATTENTION_MODE_OPTIONS = ['active', 'maintenance', 'waiting', 'paused'] as const; + export interface PinnedProjectSummary { name: string; display_name: string; @@ -506,6 +520,12 @@ const api = { parent_project?: string | null; // spec 0.29: null clears to NULL on the project row. email_account?: string | null; + // v20: attention contract (null clears each field). + priority_tier?: string | null; + attention_mode?: string | null; + attention_cadence_days?: number | null; + next_review_at?: string | null; + priority_reason?: string | null; }) => window.setlist.updateCore(name, updates), // spec 0.13: areas + sub-projects diff --git a/packages/app/src/renderer/views/ProjectDetailView.tsx b/packages/app/src/renderer/views/ProjectDetailView.tsx index ba6f20c..2535c78 100644 --- a/packages/app/src/renderer/views/ProjectDetailView.tsx +++ b/packages/app/src/renderer/views/ProjectDetailView.tsx @@ -166,6 +166,14 @@ export function ProjectDetailView({ parent_project: project.parent_project ?? null, // spec 0.29: optional email_account (S165). email_account: project.email_account ?? null, + // v20: attention contract (all-null when unconfigured). + attention: project.attention ?? { + priority_tier: null, + attention_mode: null, + attention_cadence_days: null, + next_review_at: null, + priority_reason: null, + }, }} projectType={project.type} onSave={() => { setEditing(false); refresh(); }} diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts new file mode 100644 index 0000000..fd1d233 --- /dev/null +++ b/packages/core/src/attention.ts @@ -0,0 +1,414 @@ +// Attention assessment — "is this project receiving enough attention for how +// important it is?" — computed separately from registry health (health.ts). +// +// The model is deliberately simple and deterministic: +// +// attention debt = (days since meaningful touch / expected cadence) +// × priority weight +// +// Cadence decides whether a project is overdue (the ratio crossing 1.0); +// priority decides ordering and urgency (the weight). Waiting/paused projects +// are suppressed until their next_review_at arrives; unconfigured projects +// report `not_configured`, never a false nourishment warning. +// +// Meaningful touch is conservative: a git commit in a registered path, a +// substantive retained memory, or a material registry update. Passive reads, +// health checks, context exports, and brief generation never count — the +// interactions log (which records queries) is intentionally NOT a source. +import Database from 'better-sqlite3'; +import { connect, getDbPath } from './db.js'; +import { NotFoundError, findClosestMatch } from './errors.js'; +import { latestCommitInPath } from './health.js'; + +export type PriorityTier = 'critical' | 'important' | 'normal' | 'someday'; +export type AttentionMode = 'active' | 'maintenance' | 'waiting' | 'paused'; +export type AttentionState = + | 'nourished' + | 'due_soon' + | 'undernourished' + | 'due_for_review' + | 'suppressed' + | 'not_configured'; + +export const PRIORITY_TIERS = new Set(['critical', 'important', 'normal', 'someday']); +export const ATTENTION_MODES = new Set(['active', 'maintenance', 'waiting', 'paused']); + +/** Priority weights applied to the touch/cadence ratio for ordering. */ +export const PRIORITY_WEIGHTS: Record = { + critical: 2.0, + important: 1.5, + normal: 1.0, + someday: 0.5, +}; + +/** Ratio of days-since-touch to cadence above which a project is "due soon". */ +export const DUE_SOON_RATIO = 0.75; + +/** + * Memory types that count as substantive movement. Observations, context, + * preferences, and dependencies are recorded ambiently and do not prove the + * user worked on the project. + */ +export const SUBSTANTIVE_MEMORY_TYPES = ['decision', 'outcome', 'correction', 'learning', 'pattern'] as const; + +export type MeaningfulTouchSource = 'git-commit' | 'memory' | 'project-update'; + +export interface MeaningfulTouch { + at: string; + source: MeaningfulTouchSource; + detail: string; +} + +export interface AttentionContract { + priority_tier: PriorityTier | null; + attention_mode: AttentionMode | null; + attention_cadence_days: number | null; + next_review_at: string | null; + priority_reason: string | null; +} + +export interface AttentionAssessment { + name: string; + state: AttentionState; + priority_tier: PriorityTier | null; + attention_mode: AttentionMode | null; + attention_cadence_days: number | null; + next_review_at: string | null; + last_meaningful_touch: MeaningfulTouch | null; + days_since_meaningful_touch: number | null; + /** Sortable urgency signal: higher = more urgent. 0 for suppressed/not_configured/nourished-enough ordering still applies within states. */ + attention_debt: number; + reasons: string[]; + computed_at: string; +} + +export interface PortfolioAttention { + /** Configured, non-suppressed projects ordered most-undernourished first. */ + projects: AttentionAssessment[]; + due_for_review: AttentionAssessment[]; + not_configured: string[]; + summary: Record; + computed_at: string; +} + +/** Cache TTL matching the health assessor's "cached briefly" behavior. */ +export const ATTENTION_CACHE_TTL_MS = 120_000; + +interface CacheEntry { + result: AttentionAssessment | PortfolioAttention; + expiresAt: number; +} + +/** Round to 3 decimals so debt values are stable across serialization. */ +function round3(n: number): number { + return Math.round(n * 1000) / 1000; +} + +export function emptyAttentionSummary(): Record { + return { + nourished: 0, + due_soon: 0, + undernourished: 0, + due_for_review: 0, + suppressed: 0, + not_configured: 0, + }; +} + +export class AttentionAssessor { + private _dbPath: string; + private cache = new Map(); + + constructor(dbPath?: string) { + this._dbPath = dbPath ?? getDbPath(); + } + + get dbPath(): string { + return this._dbPath; + } + + /** Clear cached assessments. Primarily for tests. */ + clearCache(): void { + this.cache.clear(); + } + + private getCached(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + return entry.result as T; + } + + private putCached(key: string, result: AttentionAssessment | PortfolioAttention): void { + this.cache.set(key, { result, expiresAt: Date.now() + ATTENTION_CACHE_TTL_MS }); + } + + /** Assess a single project by name. */ + assessProject(name: string, opts?: { noCache?: boolean; now?: Date }): AttentionAssessment { + if (!opts?.noCache && !opts?.now) { + const cached = this.getCached(`p:${name}`); + if (cached) return cached; + } + const db = connect(this._dbPath); + try { + const row = db.prepare('SELECT * FROM projects WHERE name = ?').get(name) as Record | undefined; + if (!row) { + const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; + const closest = findClosestMatch(name, allNames.map(r => r.name)); + throw new NotFoundError(name, closest); + } + const result = this.buildAssessment(db, row, opts?.now ?? new Date()); + if (!opts?.now) this.putCached(`p:${name}`, result); + return result; + } finally { + db.close(); + } + } + + /** + * Portfolio-wide attention snapshot. Only status='active' projects are + * evaluated — archived and otherwise inactive projects are excluded by + * default (idea/draft/paused/complete registry statuses are not "in play"). + */ + assessPortfolio(opts?: { noCache?: boolean; now?: Date }): PortfolioAttention { + if (!opts?.noCache && !opts?.now) { + const cached = this.getCached('portfolio'); + if (cached) return cached; + } + const db = connect(this._dbPath); + try { + const rows = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY name") + .all() as Record[]; + const now = opts?.now ?? new Date(); + const all = rows.map(r => this.buildAssessment(db, r, now)); + + const summary = emptyAttentionSummary(); + for (const a of all) summary[a.state]++; + + const projects = all + .filter(a => a.state === 'nourished' || a.state === 'due_soon' || a.state === 'undernourished') + .sort((a, b) => b.attention_debt - a.attention_debt || a.name.localeCompare(b.name)); + const dueForReview = all + .filter(a => a.state === 'due_for_review') + .sort((a, b) => b.attention_debt - a.attention_debt || a.name.localeCompare(b.name)); + const notConfigured = all.filter(a => a.state === 'not_configured').map(a => a.name); + + const result: PortfolioAttention = { + projects, + due_for_review: dueForReview, + not_configured: notConfigured, + summary, + computed_at: now.toISOString(), + }; + if (!opts?.now) this.putCached('portfolio', result); + return result; + } finally { + db.close(); + } + } + + // ── Assessment builder ───────────────────────────────────────── + + private buildAssessment(db: Database.Database, row: Record, now: Date): AttentionAssessment { + const name = row.name as string; + const status = row.status as string; + const computed_at = now.toISOString(); + + const tierRaw = row.priority_tier as string | null; + const modeRaw = row.attention_mode as string | null; + const tier = tierRaw && PRIORITY_TIERS.has(tierRaw as PriorityTier) ? (tierRaw as PriorityTier) : null; + const mode = modeRaw && ATTENTION_MODES.has(modeRaw as AttentionMode) ? (modeRaw as AttentionMode) : null; + const cadenceRaw = row.attention_cadence_days as number | null; + const cadence = typeof cadenceRaw === 'number' && Number.isInteger(cadenceRaw) && cadenceRaw > 0 ? cadenceRaw : null; + const nextReviewAt = (row.next_review_at as string | null) ?? null; + const contractBase = { + name, + priority_tier: tier, + attention_mode: mode, + attention_cadence_days: cadence, + next_review_at: nextReviewAt, + computed_at, + }; + + if (status === 'archived') { + return { + ...contractBase, + state: 'suppressed', + last_meaningful_touch: null, + days_since_meaningful_touch: null, + attention_debt: 0, + reasons: ['archived project — excluded from attention assessment'], + }; + } + + // Unconfigured contract → configuration gap, never a warning. + if (!tier || !mode || ((mode === 'active' || mode === 'maintenance') && !cadence)) { + const missing: string[] = []; + if (!tier) missing.push('priority_tier'); + if (!mode) missing.push('attention_mode'); + if ((mode === 'active' || mode === 'maintenance') && !cadence) missing.push('attention_cadence_days'); + return { + ...contractBase, + state: 'not_configured', + last_meaningful_touch: null, + days_since_meaningful_touch: null, + attention_debt: 0, + reasons: [`attention contract not configured (missing ${missing.join(', ')})`], + }; + } + + const weight = PRIORITY_WEIGHTS[tier]; + + // Intentional quiet: waiting/paused are suppressed until review time. + if (mode === 'waiting' || mode === 'paused') { + if (nextReviewAt && new Date(nextReviewAt).getTime() <= now.getTime()) { + const daysOverdue = daysBetween(nextReviewAt, now); + return { + ...contractBase, + state: 'due_for_review', + last_meaningful_touch: null, + days_since_meaningful_touch: null, + attention_debt: round3(weight * (1 + daysOverdue / 30)), + reasons: [ + `${mode} project reached its review date ${formatDays(daysOverdue)} ago (${nextReviewAt})`, + 'needs an explicit decision: resume, extend the pause, or archive', + ], + }; + } + const reason = nextReviewAt + ? `intentionally ${mode} until ${nextReviewAt}` + : `intentionally ${mode} with no review date set`; + return { + ...contractBase, + state: 'suppressed', + last_meaningful_touch: null, + days_since_meaningful_touch: null, + attention_debt: 0, + reasons: [reason], + }; + } + + // active / maintenance: measure meaningful touch against cadence. + const touch = this.latestMeaningfulTouch(db, row); + // `updated_at` always exists, so a touch is always found; guard anyway. + if (!touch) { + return { + ...contractBase, + state: 'undernourished', + last_meaningful_touch: null, + days_since_meaningful_touch: null, + attention_debt: round3(weight * 2), + reasons: ['no meaningful-touch signal found in git, memories, or registry updates'], + }; + } + + const daysSince = daysBetween(touch.at, now); + const ratio = daysSince / (cadence as number); + const debt = round3(ratio * weight); + const touchReason = `last meaningful touch ${formatDays(daysSince)} ago via ${touch.detail}`; + const cadenceLabel = `${tier} ${mode} project expects attention every ${cadence} day${cadence === 1 ? '' : 's'}`; + + let state: AttentionState; + const reasons: string[] = []; + if (ratio > 1) { + state = 'undernourished'; + reasons.push(`${cadenceLabel} but has gone ${formatDays(daysSince)} without one`); + } else if (ratio > DUE_SOON_RATIO) { + state = 'due_soon'; + reasons.push(`${cadenceLabel} and is approaching that window`); + } else { + state = 'nourished'; + reasons.push(`${cadenceLabel} and is within that window`); + } + reasons.push(touchReason); + + return { + ...contractBase, + state, + last_meaningful_touch: touch, + days_since_meaningful_touch: daysSince, + attention_debt: debt, + reasons, + }; + } + + // ── Meaningful-touch resolution ──────────────────────────────── + // + // Most recent of: a git commit in any registered path, a substantive + // retained memory (SUBSTANTIVE_MEMORY_TYPES), or a material registry update + // (projects.updated_at — only writes bump it; reads and exports never do). + + private latestMeaningfulTouch(db: Database.Database, row: Record): MeaningfulTouch | null { + const name = row.name as string; + const projectId = row.id as number; + const candidates: MeaningfulTouch[] = []; + + const projectUpdated = row.updated_at as string | null; + if (projectUpdated) { + candidates.push({ + at: projectUpdated, + source: 'project-update', + detail: 'registry project update', + }); + } + + try { + const placeholders = SUBSTANTIVE_MEMORY_TYPES.map(() => '?').join(', '); + const m = db.prepare( + `SELECT type, MAX(created_at) as t FROM memories + WHERE project_id = ? AND status = 'active' AND type IN (${placeholders})` + ).get(name, ...SUBSTANTIVE_MEMORY_TYPES) as { type: string | null; t: string | null } | undefined; + if (m?.t) { + candidates.push({ + at: m.t, + source: 'memory', + detail: `retained ${m.type ?? 'memory'} memory`, + }); + } + } catch { + // memories table unavailable — skip the source, don't fail the assessment + } + + const pathRows = db.prepare('SELECT path FROM project_paths WHERE project_id = ?').all(projectId) as { path: string }[]; + for (const { path } of pathRows) { + const t = latestCommitInPath(path); + if (t) { + candidates.push({ + at: t, + source: 'git-commit', + detail: `git commit in ${path}`, + }); + } + } + + if (candidates.length === 0) return null; + // Compare on epoch time — sources mix ISO offsets (git %cI) with SQLite + // UTC "YYYY-MM-DD HH:MM:SS" strings, so lexicographic order is not safe. + return candidates.reduce((a, b) => (epoch(a.at) >= epoch(b.at) ? a : b)); + } +} + +function epoch(timestamp: string): number { + const t = new Date(normalizeSqliteTimestamp(timestamp)).getTime(); + return Number.isFinite(t) ? t : 0; +} + +/** SQLite datetime('now') yields "YYYY-MM-DD HH:MM:SS" in UTC without a zone marker. */ +function normalizeSqliteTimestamp(timestamp: string): string { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(timestamp)) { + return `${timestamp.replace(' ', 'T')}Z`; + } + return timestamp; +} + +function daysBetween(earlier: string, now: Date): number { + const ms = now.getTime() - epoch(earlier); + return Math.max(0, Math.floor(ms / (1000 * 60 * 60 * 24))); +} + +function formatDays(days: number): string { + return `${days} day${days === 1 ? '' : 's'}`; +} diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index 9669991..c607b68 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -12,8 +12,13 @@ import { dirname, join, resolve } from 'node:path'; import { RegistryError } from './errors.js'; import type { ProjectBrief } from './project-brief.js'; import { Registry } from './registry.js'; +import { AttentionAssessor, type AttentionAssessment } from './attention.js'; -export const PROJECT_CONTEXT_SCHEMA_VERSION = 'setlist-project-context.v1'; +// v2: snapshots carry the project's attention contract (inside the brief) and +// its computed attention assessment; manifest entries carry attention_state / +// attention_debt / priority_tier so offline readers can find undernourished +// and due-for-review projects without parsing every project file. +export const PROJECT_CONTEXT_SCHEMA_VERSION = 'setlist-project-context.v2'; export const PROJECT_CONTEXT_OWNER_FILE = '.setlist-context-export.json'; export const DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH = '.local/share/project-registry/project-context'; @@ -26,6 +31,7 @@ export interface ProjectContextSnapshot { project_updated_at: string; digest_generated_at: string | null; digest_stale: boolean | null; + attention: AttentionAssessment; brief: ProjectBrief; } @@ -38,6 +44,9 @@ export interface ProjectContextManifestEntry { project_updated_at: string; digest_generated_at: string | null; digest_stale: boolean | null; + attention_state: string; + attention_debt: number; + priority_tier: string | null; markdown_path: string; json_path: string; } @@ -96,9 +105,19 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): .map(row => registry.getProjectBrief(row.name)) .sort((a, b) => a.project.name.localeCompare(b.project.name)); + // Attention assessments ride along so offline readers agree with the live + // assess_attention result at export time. Reading/exporting never counts as + // a meaningful touch, so the export itself cannot nourish a project. + const attentionAssessor = new AttentionAssessor(registry.dbPath); + // Build the entire generation before touching the filesystem. A failed // project inspection therefore leaves the previous exported generation as-is. - const snapshots = briefs.map(brief => buildSnapshot(brief, generationId, exportedAt)); + const snapshots = briefs.map(brief => buildSnapshot( + brief, + attentionAssessor.assessProject(brief.project.name, { now: options.now ?? new Date(exportedAt) }), + generationId, + exportedAt, + )); const manifest = buildManifest(snapshots, generationId, exportedAt); const renderedProjects = snapshots.map(snapshot => ({ name: snapshot.brief.project.name, @@ -147,6 +166,7 @@ function assertManagedDirectory(directory: string): void { function buildSnapshot( brief: ProjectBrief, + attention: AttentionAssessment, generationId: string, exportedAt: string, ): ProjectContextSnapshot { @@ -159,6 +179,7 @@ function buildSnapshot( project_updated_at: brief.project.updated_at, digest_generated_at: brief.digest?.generated_at ?? null, digest_stale: brief.digest?.stale ?? null, + attention, brief, }; } @@ -179,6 +200,9 @@ function buildManifest( project_updated_at: snapshot.project_updated_at, digest_generated_at: snapshot.digest_generated_at, digest_stale: snapshot.digest_stale, + attention_state: snapshot.attention.state, + attention_debt: snapshot.attention.attention_debt, + priority_tier: snapshot.attention.priority_tier, markdown_path: `projects/${brief.project.name}.md`, json_path: `projects/${brief.project.name}.json`, }; @@ -198,7 +222,7 @@ function buildManifest( export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): string { const brief = snapshot.brief; const lines: string[] = [ - '', + '', '---', `schema_version: ${jsonScalar(snapshot.schema_version)}`, `project: ${jsonScalar(brief.project.name)}`, @@ -206,6 +230,9 @@ export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): `project_updated_at: ${jsonScalar(snapshot.project_updated_at)}`, `digest_generated_at: ${snapshot.digest_generated_at ? jsonScalar(snapshot.digest_generated_at) : 'null'}`, `digest_stale: ${snapshot.digest_stale ?? 'null'}`, + `attention_state: ${jsonScalar(snapshot.attention.state)}`, + `attention_debt: ${snapshot.attention.attention_debt}`, + `priority_tier: ${snapshot.attention.priority_tier ? jsonScalar(snapshot.attention.priority_tier) : 'null'}`, 'source: setlist', 'canonical: false', '---', @@ -236,6 +263,27 @@ export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): appendList(lines, 'Topics', brief.purpose.topics); appendList(lines, 'Concerns', brief.purpose.concerns); + const attention = snapshot.attention; + lines.push( + '', + '## Attention', + '', + `- State: **${attention.state}**`, + `- Priority tier: ${attention.priority_tier ?? 'not set'}`, + `- Attention mode: ${attention.attention_mode ?? 'not set'}`, + `- Expected cadence: ${attention.attention_cadence_days != null ? `every ${attention.attention_cadence_days} days` : 'not set'}`, + `- Next review: ${attention.next_review_at ?? 'none scheduled'}`, + `- Last meaningful touch: ${attention.last_meaningful_touch ? `${attention.last_meaningful_touch.at} (${attention.last_meaningful_touch.detail})` : 'none recorded'}`, + `- Days since meaningful touch: ${attention.days_since_meaningful_touch ?? 'n/a'}`, + `- Attention debt: ${attention.attention_debt}`, + ); + if (brief.project.attention.priority_reason) { + lines.push(`- Priority reason: ${brief.project.attention.priority_reason}`); + } + for (const reason of attention.reasons) { + lines.push(`- Why: ${reason}`); + } + if (brief.capabilities.count > 0) { lines.push('', '## Capabilities', '', `Total: ${brief.capabilities.count}`, ''); for (const capability of brief.capabilities.highlights) { @@ -259,7 +307,7 @@ export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): export function renderProjectContextIndex(manifest: ProjectContextManifest): string { const lines = [ - '', + '', '# Setlist project context', '', '> Derived, read-only fallback. Query live Setlist first; use these files when Setlist is unavailable.', @@ -269,6 +317,28 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str 'Freshness guidance: under 6 hours is normal orientation; 6–24 hours is aging; over 24 hours must be described as stale.', '', ]; + + // Attention headline: undernourished and due-for-review projects first, so + // an offline assistant can spot nourishment concerns without opening every + // project file. Ordered by attention debt (most urgent first). + const needsAttention = manifest.projects + .filter(entry => entry.attention_state === 'undernourished' || entry.attention_state === 'due_soon') + .sort((a, b) => b.attention_debt - a.attention_debt); + const dueForReview = manifest.projects + .filter(entry => entry.attention_state === 'due_for_review') + .sort((a, b) => b.attention_debt - a.attention_debt); + if (needsAttention.length > 0 || dueForReview.length > 0) { + lines.push('## Attention concerns', ''); + for (const entry of needsAttention) { + const tier = entry.priority_tier ? ` · ${entry.priority_tier}` : ''; + lines.push(`- [${entry.display_name}](${entry.markdown_path}) — ${entry.attention_state}${tier} · debt ${entry.attention_debt}`); + } + for (const entry of dueForReview) { + const tier = entry.priority_tier ? ` · ${entry.priority_tier}` : ''; + lines.push(`- [${entry.display_name}](${entry.markdown_path}) — due for review${tier}`); + } + lines.push(''); + } for (const [label, predicate] of [ ['Active projects', (entry: ProjectContextManifestEntry) => entry.status === 'active'], ['Other projects', (entry: ProjectContextManifestEntry) => entry.status !== 'active'], @@ -279,7 +349,10 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str for (const project of projects) { const area = project.area ? ` · ${project.area}` : ''; const stale = project.digest_stale ? ' · digest stale' : ''; - lines.push(`- [${project.display_name}](${project.markdown_path}) — ${project.status}${area}${stale}`); + const attention = project.attention_state !== 'nourished' && project.attention_state !== 'suppressed' && project.attention_state !== 'not_configured' + ? ` · ${project.attention_state.replace(/_/g, ' ')}` + : ''; + lines.push(`- [${project.display_name}](${project.markdown_path}) — ${project.status}${area}${stale}${attention}`); } lines.push(''); } @@ -287,7 +360,7 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str } function renderReadme(): string { - return ` + return ` # Setlist project-context cache This directory is generated by Setlist for agents that cannot reach the live registry. diff --git a/packages/core/src/cross-query.ts b/packages/core/src/cross-query.ts index cfe5048..a86d3ff 100644 --- a/packages/core/src/cross-query.ts +++ b/packages/core/src/cross-query.ts @@ -5,6 +5,7 @@ import type Database from 'better-sqlite3'; import { connect, getDbPath, initDb } from './db.js'; import { MemoryRetrieval, type RecallResult } from './memory-retrieval.js'; import { logInteraction } from './interactions.js'; +import { AttentionAssessor, type AttentionAssessment } from './attention.js'; interface CrossQueryResult { source: 'registry' | 'memory' | 'cc_memory'; @@ -37,6 +38,36 @@ function truncateWithEllipsis(text: string, maxLen: number): string { return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; } +// Payload bound on the portfolio_brief attention section: only the most +// urgent entries are inlined — never every nourished project. Full results +// remain available via assess_attention. +const PORTFOLIO_BRIEF_ATTENTION_LIMIT = 5; + +/** Compact, quotable attention entry for the portfolio brief. */ +export interface PortfolioBriefAttentionEntry { + project: string; + state: string; + priority_tier: string | null; + attention_mode: string | null; + attention_cadence_days: number | null; + days_since_meaningful_touch: number | null; + attention_debt: number; + reasons: string[]; +} + +function toAttentionEntry(a: AttentionAssessment): PortfolioBriefAttentionEntry { + return { + project: a.name, + state: a.state, + priority_tier: a.priority_tier, + attention_mode: a.attention_mode, + attention_cadence_days: a.attention_cadence_days, + days_since_meaningful_touch: a.days_since_meaningful_touch, + attention_debt: a.attention_debt, + reasons: a.reasons, + }; +} + export class CrossQuery { private _dbPath: string; @@ -226,6 +257,12 @@ export class CrossQuery { pending_observations: RecallResult[]; health_indicators: { project: string; issue: string }[]; enrichment_gaps: { project: string; missing: string[] }[]; + attention: { + undernourished: PortfolioBriefAttentionEntry[]; + due_for_review: PortfolioBriefAttentionEntry[]; + not_configured: string[]; + summary: Record; + }; } { const db = this.open(); try { @@ -350,6 +387,23 @@ export class CrossQuery { } } + // Attention section (v20): bounded and urgency-ordered — the most + // undernourished/due-soon projects and due-for-review holds, never a + // dump of every healthy project. Configuration gaps surface as names + // only so an assistant can prompt for contracts without payload cost. + const attentionPortfolio = new AttentionAssessor(this._dbPath).assessPortfolio(); + const attention = { + undernourished: attentionPortfolio.projects + .filter(a => a.state === 'undernourished' || a.state === 'due_soon') + .slice(0, PORTFOLIO_BRIEF_ATTENTION_LIMIT) + .map(toAttentionEntry), + due_for_review: attentionPortfolio.due_for_review + .slice(0, PORTFOLIO_BRIEF_ATTENTION_LIMIT) + .map(toAttentionEntry), + not_configured: attentionPortfolio.not_configured, + summary: attentionPortfolio.summary as unknown as Record, + }; + return { projects: projects.map(p => ({ name: p.name, @@ -364,6 +418,7 @@ export class CrossQuery { pending_observations: pendingObservations, health_indicators: healthIndicators, enrichment_gaps: enrichmentGaps, + attention, }; } finally { db.close(); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 08a183e..e46c858 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -12,7 +12,7 @@ import { seedBuiltinPrimitives, seedBuiltinRecipes } from './recipes/store.js'; import { normalizeList, normalize } from './vocab.js'; import { RegistryError } from './errors.js'; -export const SCHEMA_VERSION = 19; // v19: projects.container (projects/ taxonomy) + Finance/Health seed retarget +export const SCHEMA_VERSION = 20; // v20: attention contract columns on projects (priority_tier, attention_mode, attention_cadence_days, next_review_at, priority_reason) /** * Legacy alias kept for any callers that still expect the constant name. @@ -260,6 +260,11 @@ CREATE TABLE IF NOT EXISTS projects ( project_type_id INTEGER REFERENCES project_types(id), email_account TEXT, container TEXT, + priority_tier TEXT, + attention_mode TEXT, + attention_cadence_days INTEGER, + next_review_at TEXT, + priority_reason TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -726,6 +731,26 @@ function ensureColumns(db: Database.Database): void { if (!projColNames.has('container')) { db.exec(`ALTER TABLE projects ADD COLUMN container TEXT`); } + // v20 (attention contract): explicit priority + expected-attention fields. + // All nullable — an existing project migrates with an unconfigured contract + // and surfaces as a configuration gap, never a false nourishment warning. + // Enum/positive-integer/ISO validation happens at the write boundary + // (Registry.updateCore), not in the schema, matching email_account/container. + if (!projColNames.has('priority_tier')) { + db.exec(`ALTER TABLE projects ADD COLUMN priority_tier TEXT`); + } + if (!projColNames.has('attention_mode')) { + db.exec(`ALTER TABLE projects ADD COLUMN attention_mode TEXT`); + } + if (!projColNames.has('attention_cadence_days')) { + db.exec(`ALTER TABLE projects ADD COLUMN attention_cadence_days INTEGER`); + } + if (!projColNames.has('next_review_at')) { + db.exec(`ALTER TABLE projects ADD COLUMN next_review_at TEXT`); + } + if (!projColNames.has('priority_reason')) { + db.exec(`ALTER TABLE projects ADD COLUMN priority_reason TEXT`); + } // Indexes that depend on v11/v13 columns must be created after the columns exist // (SCHEMA_SQL can't assume the columns are present when upgrading from v0). db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_area_id ON projects(area_id)`); @@ -787,7 +812,8 @@ const MIGRATION_STEPS: Record = { 13: migrateV13ToV14, // v13 → v14: bootstrap primitives + recipes 16: migrateV16ToV17, // v16 → v17: interactions log + vocab normalization backfill 18: migrateV18ToV19, // v18 → v19: projects.container + Finance/Health retarget - // 0,1,2,4,6,14,15,17 → structural no-ops (SCHEMA_SQL / ensureColumns handle them). + // 0,1,2,4,6,14,15,17,19 → structural no-ops (SCHEMA_SQL / ensureColumns handle them). + // v19 → v20 (attention contract) adds only nullable columns via ensureColumns. }; function upgradeSchema(db: Database.Database): void { diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index 31801d8..77da9b9 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -388,8 +388,10 @@ export class HealthAssessor { } // ── Git touch helper (best-effort, bounded) ─────────────────────── +// Shared with the attention assessor (attention.ts) — a git commit is the +// strongest "meaningful touch" signal both assessments agree on. -function latestCommitInPath(path: string): string | null { +export function latestCommitInPath(path: string): string | null { try { if (!path || !existsSync(path)) return null; // Only run git if a .git directory (or gitfile) exists, to avoid diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5cd5a1..d1ac2da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -175,6 +175,24 @@ export { type WalkOpts, } from './recipes/walk.js'; export { HealthAssessor, HEALTH_CACHE_TTL_MS, type HealthTier, type HealthDimension, type DimensionResult, type HealthAssessment, type PortfolioHealth } from './health.js'; +export { + AttentionAssessor, + ATTENTION_CACHE_TTL_MS, + ATTENTION_MODES, + DUE_SOON_RATIO, + PRIORITY_TIERS, + PRIORITY_WEIGHTS, + SUBSTANTIVE_MEMORY_TYPES, + emptyAttentionSummary, + type AttentionAssessment, + type AttentionContract, + type AttentionMode, + type AttentionState, + type MeaningfulTouch, + type MeaningfulTouchSource, + type PortfolioAttention, + type PriorityTier, +} from './attention.js'; export { computeProjectVersion, listProjectDocuments, type ProjectVersion } from './project-version.js'; export { buildProjectBrief, diff --git a/packages/core/src/introspect-exports.ts b/packages/core/src/introspect-exports.ts index f66b3ea..99bd1e6 100644 --- a/packages/core/src/introspect-exports.ts +++ b/packages/core/src/introspect-exports.ts @@ -75,6 +75,12 @@ const LIBRARY_EXPORTS_MANIFEST: LibraryExportManifestEntry[] = [ description: 'Per-project and portfolio health assessment across activity, completeness, and outcomes dimensions; worst-tier-wins composite.', detail: 'assess, assessPortfolio, invalidateCache', }, + { + name: 'AttentionAssessor', + kind: 'class', + description: 'Attention-nourishment assessment against each project\'s explicit attention contract: state (nourished/due_soon/undernourished/due_for_review/suppressed/not_configured), last meaningful touch, and a sortable attention debt.', + detail: 'assessProject, assessPortfolio, clearCache', + }, // Top-level functions { @@ -315,6 +321,46 @@ const LIBRARY_EXPORTS_MANIFEST: LibraryExportManifestEntry[] = [ kind: 'constant', description: 'TTL in milliseconds for HealthAssessor cached results (so repeated assess calls in one session stay cheap).', }, + { + name: 'ATTENTION_CACHE_TTL_MS', + kind: 'constant', + description: 'TTL in milliseconds for AttentionAssessor cached results.', + }, + { + name: 'PRIORITY_TIERS', + kind: 'constant', + description: 'Allowed priority_tier values for the v20 attention contract: critical, important, normal, someday.', + }, + { + name: 'ATTENTION_MODES', + kind: 'constant', + description: 'Allowed attention_mode values for the v20 attention contract: active, maintenance, waiting, paused.', + }, + { + name: 'PRIORITY_WEIGHTS', + kind: 'constant', + description: 'Priority-tier multipliers applied to the days-since-touch/cadence ratio when computing attention debt.', + }, + { + name: 'DUE_SOON_RATIO', + kind: 'constant', + description: 'Touch/cadence ratio above which a configured project reports due_soon (undernourished starts past 1.0).', + }, + { + name: 'SUBSTANTIVE_MEMORY_TYPES', + kind: 'constant', + description: 'Memory types that count as a meaningful attention touch (decision, outcome, correction, learning, pattern).', + }, + { + name: 'emptyAttentionSummary', + kind: 'function', + description: 'Build a zeroed per-state attention summary record (all six attention states).', + }, + { + name: 'attentionContract', + kind: 'function', + description: 'Extract the v20 attention-contract slice (priority_tier, attention_mode, attention_cadence_days, next_review_at, priority_reason) from a project record.', + }, // Error classes { diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 62d6360..42153da 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -111,6 +111,15 @@ export interface ProjectRecord { // under (e.g. "stray-dog", "Properties/41 Monroe St"). NULL means no // container. Always present in the record; consumers read it as string|null. container: string | null; + // v20 (attention contract): explicit priority + expected-attention fields. + // All nullable — NULL means unconfigured, surfaced by assess_attention as + // `not_configured` rather than a warning. Enum/positive-int/ISO validation + // happens in Registry.updateCore. + priority_tier: string | null; + attention_mode: string | null; + attention_cadence_days: number | null; + next_review_at: string | null; + priority_reason: string | null; created_at: string; updated_at: string; } @@ -274,6 +283,9 @@ export function toSummary(record: ProjectRecord): Record { email_account: record.email_account, // v19 (projects/ taxonomy): container sub-path, always present (null = none). container: record.container, + // v20: attention contract — always present so callers can distinguish + // "unconfigured" (all-null) from "not loaded" (missing key). + attention: attentionContract(record), }; if (record.description) result.description = record.description; if (record.parent_project && record.parent_archived) result.parent_archived = true; @@ -307,6 +319,17 @@ export function toStandard(record: ProjectRecord, templateFields: Set): return result; } +/** The v20 attention contract slice of a project record, as one object. */ +export function attentionContract(record: ProjectRecord): Record { + return { + priority_tier: record.priority_tier, + attention_mode: record.attention_mode, + attention_cadence_days: record.attention_cadence_days, + next_review_at: record.next_review_at, + priority_reason: record.priority_reason, + }; +} + function parseJsonArray(value: string | undefined | null): string[] { if (!value) return []; const trimmed = value.trim(); @@ -345,6 +368,8 @@ export function toFull(record: ProjectRecord): Record { email_account: record.email_account, // v19 (projects/ taxonomy): container sub-path, always present (null = none). container: record.container, + // v20: attention contract — always present (all-null = unconfigured). + attention: attentionContract(record), }; if (record.parent_project && record.parent_archived) result.parent_archived = true; if (record.description) result.description = record.description; diff --git a/packages/core/src/project-brief.ts b/packages/core/src/project-brief.ts index 4497612..49ff529 100644 --- a/packages/core/src/project-brief.ts +++ b/packages/core/src/project-brief.ts @@ -40,6 +40,14 @@ export interface ProjectBrief { parent_project: string | null; children: string[]; updated_at: string; + // v20: attention contract — always present; all-null means unconfigured. + attention: { + priority_tier: string | null; + attention_mode: string | null; + attention_cadence_days: number | null; + next_review_at: string | null; + priority_reason: string | null; + }; }; purpose: { summary: string | null; @@ -108,6 +116,13 @@ export function buildProjectBrief(opts: { parent_project: project.parent_project, children: project.children, updated_at: project.updated_at, + attention: { + priority_tier: project.priority_tier, + attention_mode: project.attention_mode, + attention_cadence_days: project.attention_cadence_days, + next_review_at: project.next_review_at, + priority_reason: project.priority_reason, + }, }, purpose: { summary, diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 271ce73..b5e7ac6 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -32,6 +32,14 @@ import { computeNextSteps, type NextStep, type ProjectEnrichmentSnapshot } from import { buildProjectBrief, type ProjectBrief, type ProjectBriefDigest } from './project-brief.js'; import { inspectWorkspacePaths, type WorkspaceInspectionReport } from './workspace-inspection.js'; import { computeProjectVersion } from './project-version.js'; +import { PRIORITY_TIERS, ATTENTION_MODES, type PriorityTier, type AttentionMode } from './attention.js'; + +/** Trim a nullable string field; null or blank both clear (write NULL). */ +function normalizeNullableString(value: string | null | undefined): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} export const PORT_RANGE_MIN = 3000; export const PORT_RANGE_MAX = 9999; @@ -768,6 +776,12 @@ export class Registry { parent_project?: string | null; // spec 0.29: optional email account. null or "" clears (writes NULL). email_account?: string | null; + // v20: attention contract. null (or "" for strings) clears each field. + priority_tier?: string | null; + attention_mode?: string | null; + attention_cadence_days?: number | null; + next_review_at?: string | null; + priority_reason?: string | null; }): void { const db = this.open(); try { @@ -803,6 +817,62 @@ export class Registry { params.push(row.id); db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...params); } + + // v20 attention contract: validated writes that deliberately do NOT bump + // updated_at. `updated_at` is a meaningful-touch source for + // assess_attention — if configuring the contract counted as a touch, a + // neglected project would read "nourished" the moment its priority was + // set, hiding exactly the neglect the contract exists to surface. + const attentionSets: string[] = []; + const attentionParams: unknown[] = []; + if (updates.priority_tier !== undefined) { + const next = normalizeNullableString(updates.priority_tier); + if (next !== null && !PRIORITY_TIERS.has(next as PriorityTier)) { + throw new InvalidInputError( + `Invalid priority_tier '${next}'. Allowed: ${[...PRIORITY_TIERS].join(', ')} (or null to clear).` + ); + } + attentionSets.push('priority_tier = ?'); + attentionParams.push(next); + } + if (updates.attention_mode !== undefined) { + const next = normalizeNullableString(updates.attention_mode); + if (next !== null && !ATTENTION_MODES.has(next as AttentionMode)) { + throw new InvalidInputError( + `Invalid attention_mode '${next}'. Allowed: ${[...ATTENTION_MODES].join(', ')} (or null to clear).` + ); + } + attentionSets.push('attention_mode = ?'); + attentionParams.push(next); + } + if (updates.attention_cadence_days !== undefined) { + const next = updates.attention_cadence_days; + if (next !== null && (!Number.isInteger(next) || next <= 0)) { + throw new InvalidInputError( + `Invalid attention_cadence_days '${next}'. Must be a positive integer or null to clear.` + ); + } + attentionSets.push('attention_cadence_days = ?'); + attentionParams.push(next); + } + if (updates.next_review_at !== undefined) { + const next = normalizeNullableString(updates.next_review_at); + if (next !== null && !Number.isFinite(new Date(next).getTime())) { + throw new InvalidInputError( + `Invalid next_review_at '${next}'. Must be an ISO 8601 timestamp or null to clear.` + ); + } + attentionSets.push('next_review_at = ?'); + attentionParams.push(next); + } + if (updates.priority_reason !== undefined) { + attentionSets.push('priority_reason = ?'); + attentionParams.push(normalizeNullableString(updates.priority_reason)); + } + if (attentionSets.length > 0) { + attentionParams.push(row.id); + db.prepare(`UPDATE projects SET ${attentionSets.join(', ')} WHERE id = ?`).run(...attentionParams); + } } finally { db.close(); } @@ -2633,6 +2703,11 @@ export class Registry { project_type_name: projectTypeName, email_account: (row.email_account as string | null) ?? null, container: (row.container as string | null) ?? null, + priority_tier: (row.priority_tier as string | null) ?? null, + attention_mode: (row.attention_mode as string | null) ?? null, + attention_cadence_days: (row.attention_cadence_days as number | null) ?? null, + next_review_at: (row.next_review_at as string | null) ?? null, + priority_reason: (row.priority_reason as string | null) ?? null, created_at: row.created_at as string, updated_at: row.updated_at as string, }; diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts new file mode 100644 index 0000000..6953007 --- /dev/null +++ b/packages/core/tests/attention.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Registry, MemoryStore, AttentionAssessor, connect } from '../src/index.js'; + +function daysAgoSqlite(days: number): string { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000) + .toISOString().replace('T', ' ').slice(0, 19); +} + +function futureIso(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +describe('AttentionAssessor', () => { + let tmpDir: string; + let dbPath: string; + let registry: Registry; + let attention: AttentionAssessor; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'setlist-attention-')); + dbPath = join(tmpDir, 'test.db'); + registry = new Registry(dbPath); + attention = new AttentionAssessor(dbPath); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function backdateProject(name: string, iso: string) { + const db = connect(dbPath); + try { + db.prepare('UPDATE projects SET updated_at = ? WHERE name = ?').run(iso, name); + } finally { + db.close(); + } + } + + function registerConfigured(name: string, contract: { + priority_tier?: string | null; + attention_mode?: string | null; + attention_cadence_days?: number | null; + next_review_at?: string | null; + priority_reason?: string | null; + }) { + registry.register({ name, type: 'project', status: 'active', description: 'd', goals: 'g' }); + registry.updateCore(name, contract); + } + + // ── Acceptance scenario 1: priority affects ordering ───────────── + + it('scenario 1: critical project with same overdue ratio outranks a normal one', () => { + registerConfigured('crit', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + registerConfigured('norm', { priority_tier: 'normal', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('crit', daysAgoSqlite(10)); + backdateProject('norm', daysAgoSqlite(10)); + + const crit = attention.assessProject('crit', { noCache: true }); + const norm = attention.assessProject('norm', { noCache: true }); + expect(crit.state).toBe('undernourished'); + expect(norm.state).toBe('undernourished'); + expect(crit.attention_debt).toBeGreaterThan(norm.attention_debt); + + attention.clearCache(); + const portfolio = attention.assessPortfolio({ noCache: true }); + const names = portfolio.projects.map(p => p.name); + expect(names.indexOf('crit')).toBeLessThan(names.indexOf('norm')); + }); + + // ── Acceptance scenario 2: maintenance cadence ──────────────────── + + it('scenario 2: maintenance project with 30-day cadence touched 10 days ago is nourished', () => { + registerConfigured('maint', { priority_tier: 'normal', attention_mode: 'maintenance', attention_cadence_days: 30 }); + backdateProject('maint', daysAgoSqlite(10)); + const r = attention.assessProject('maint', { noCache: true }); + expect(r.state).toBe('nourished'); + expect(r.days_since_meaningful_touch).toBe(10); + }); + + it('crossing the due-soon ratio reports due_soon before undernourished', () => { + registerConfigured('soon', { priority_tier: 'normal', attention_mode: 'active', attention_cadence_days: 10 }); + backdateProject('soon', daysAgoSqlite(8)); + const r = attention.assessProject('soon', { noCache: true }); + expect(r.state).toBe('due_soon'); + }); + + // ── Acceptance scenarios 3 + 4: intentional quiet ───────────────── + + it('scenario 3: waiting project with future next_review_at is suppressed despite an old touch', () => { + registerConfigured('waiting', { + priority_tier: 'important', attention_mode: 'waiting', next_review_at: futureIso(30), + }); + backdateProject('waiting', daysAgoSqlite(90)); + const r = attention.assessProject('waiting', { noCache: true }); + expect(r.state).toBe('suppressed'); + expect(r.attention_debt).toBe(0); + expect(r.reasons[0]).toMatch(/intentionally waiting until/); + }); + + it('scenario 4: waiting project whose next_review_at arrived is due_for_review, not undernourished', () => { + registerConfigured('review-me', { + priority_tier: 'important', attention_mode: 'waiting', + next_review_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + }); + const r = attention.assessProject('review-me', { noCache: true }); + expect(r.state).toBe('due_for_review'); + expect(r.attention_debt).toBeGreaterThan(0); + expect(r.reasons.join(' ')).toMatch(/review/); + }); + + it('paused project with no review date is suppressed with an explanatory reason', () => { + registerConfigured('shelved', { priority_tier: 'someday', attention_mode: 'paused' }); + const r = attention.assessProject('shelved', { noCache: true }); + expect(r.state).toBe('suppressed'); + expect(r.reasons[0]).toMatch(/no review date/); + }); + + // ── Acceptance scenario 5: unconfigured is a gap, not a warning ─── + + it('scenario 5: unconfigured project reports not_configured with the missing fields', () => { + registry.register({ name: 'legacy', type: 'project', status: 'active', description: 'd', goals: 'g' }); + backdateProject('legacy', daysAgoSqlite(120)); + const r = attention.assessProject('legacy', { noCache: true }); + expect(r.state).toBe('not_configured'); + expect(r.attention_debt).toBe(0); + expect(r.reasons[0]).toContain('priority_tier'); + expect(r.reasons[0]).toContain('attention_mode'); + }); + + it('scenario 5: active mode without a cadence is still not_configured', () => { + registerConfigured('half', { priority_tier: 'critical', attention_mode: 'active' }); + const r = attention.assessProject('half', { noCache: true }); + expect(r.state).toBe('not_configured'); + expect(r.reasons[0]).toContain('attention_cadence_days'); + }); + + // ── Acceptance scenario 6: passive reads never reset the touch ──── + + it('scenario 6: reads and attention-contract writes do not reset the meaningful touch', () => { + registerConfigured('neglected', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('neglected', daysAgoSqlite(20)); + + // Passive reads + registry.getProject('neglected'); + registry.getProjectBrief('neglected'); + // Reconfiguring the contract itself must not count as nourishment either. + registry.updateCore('neglected', { priority_reason: 'still critical' }); + + const r = attention.assessProject('neglected', { noCache: true }); + expect(r.state).toBe('undernourished'); + expect(r.days_since_meaningful_touch).toBe(20); + }); + + // ── Acceptance scenario 7: substantive events update the touch ──── + + it('scenario 7: a substantive retained memory updates the meaningful touch', () => { + registerConfigured('worked-on', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('worked-on', daysAgoSqlite(20)); + const store = new MemoryStore(dbPath); + store.retain({ content: 'Chose SQLite over Postgres', type: 'decision', project_id: 'worked-on' }); + + const r = attention.assessProject('worked-on', { noCache: true }); + expect(r.state).toBe('nourished'); + expect(r.last_meaningful_touch?.source).toBe('memory'); + expect(r.last_meaningful_touch?.detail).toContain('decision'); + }); + + it('scenario 7 counterpart: an ambient observation memory does NOT count as a touch', () => { + registerConfigured('observed', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('observed', daysAgoSqlite(20)); + const store = new MemoryStore(dbPath); + store.retain({ content: 'Noticed something in passing', type: 'observation', project_id: 'observed' }); + + const r = attention.assessProject('observed', { noCache: true }); + expect(r.state).toBe('undernourished'); + expect(r.days_since_meaningful_touch).toBe(20); + }); + + it('scenario 7: a material project update counts as a touch', () => { + registerConfigured('updated', { priority_tier: 'normal', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('updated', daysAgoSqlite(20)); + registry.updateCore('updated', { description: 'Rewrote the description with new direction' }); + + const r = attention.assessProject('updated', { noCache: true }); + expect(r.state).toBe('nourished'); + expect(r.last_meaningful_touch?.source).toBe('project-update'); + }); + + // ── Acceptance scenario 8: archived/inactive exclusion ──────────── + + it('scenario 8: archived and non-active projects are excluded from the portfolio', () => { + registerConfigured('live', { priority_tier: 'normal', attention_mode: 'active', attention_cadence_days: 7 }); + registerConfigured('gone', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + registry.archiveProject('gone'); + registry.register({ name: 'someday-idea', type: 'project', status: 'idea', description: 'd', goals: 'g' }); + + const portfolio = attention.assessPortfolio({ noCache: true }); + const all = [ + ...portfolio.projects.map(p => p.name), + ...portfolio.due_for_review.map(p => p.name), + ...portfolio.not_configured, + ]; + expect(all).toContain('live'); + expect(all).not.toContain('gone'); + expect(all).not.toContain('someday-idea'); + }); + + it('archived project assessed directly is suppressed, never a warning', () => { + registerConfigured('bye', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + registry.archiveProject('bye'); + const r = attention.assessProject('bye', { noCache: true }); + expect(r.state).toBe('suppressed'); + expect(r.reasons[0]).toMatch(/archived/); + }); + + // ── Determinism of the debt formula ─────────────────────────────── + + it('attention debt = days/cadence × priority weight, deterministic under a fixed now', () => { + registerConfigured('exact', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 10 }); + const touched = new Date('2026-07-01T00:00:00.000Z'); + backdateProject('exact', touched.toISOString().replace('T', ' ').slice(0, 19)); + const now = new Date('2026-07-21T00:00:00.000Z'); // 20 days later + const r = attention.assessProject('exact', { now }); + // 20 days / 10-day cadence × 2.0 critical weight = 4.0 + expect(r.attention_debt).toBe(4); + expect(r.days_since_meaningful_touch).toBe(20); + const again = attention.assessProject('exact', { now }); + expect(again).toEqual(r); + }); + + // ── Contract validation at the write boundary ───────────────────── + + it('rejects invalid contract values with actionable errors', () => { + registry.register({ name: 'v', type: 'project', status: 'active', description: 'd', goals: 'g' }); + expect(() => registry.updateCore('v', { priority_tier: 'urgent' })).toThrow(/priority_tier/); + expect(() => registry.updateCore('v', { attention_mode: 'sleeping' })).toThrow(/attention_mode/); + expect(() => registry.updateCore('v', { attention_cadence_days: 0 })).toThrow(/positive integer/); + expect(() => registry.updateCore('v', { attention_cadence_days: 2.5 })).toThrow(/positive integer/); + expect(() => registry.updateCore('v', { next_review_at: 'not-a-date' })).toThrow(/ISO 8601/); + }); + + it('persists, exposes, and clears the contract through project reads', () => { + registry.register({ name: 'c', type: 'project', status: 'active', description: 'd', goals: 'g' }); + registry.updateCore('c', { + priority_tier: 'important', + attention_mode: 'maintenance', + attention_cadence_days: 30, + priority_reason: 'keeps the lights on', + }); + let p = registry.getProject('c') as { attention: Record }; + expect(p.attention).toEqual({ + priority_tier: 'important', + attention_mode: 'maintenance', + attention_cadence_days: 30, + next_review_at: null, + priority_reason: 'keeps the lights on', + }); + + const brief = registry.getProjectBrief('c'); + expect(brief.project.attention.priority_tier).toBe('important'); + + registry.updateCore('c', { priority_tier: null, attention_mode: '', attention_cadence_days: null, priority_reason: null }); + p = registry.getProject('c') as { attention: Record }; + expect(p.attention.priority_tier).toBeNull(); + expect(p.attention.attention_mode).toBeNull(); + expect(p.attention.attention_cadence_days).toBeNull(); + expect(p.attention.priority_reason).toBeNull(); + }); +}); diff --git a/packages/core/tests/compatibility.test.ts b/packages/core/tests/compatibility.test.ts index 595a912..da7262a 100644 --- a/packages/core/tests/compatibility.test.ts +++ b/packages/core/tests/compatibility.test.ts @@ -27,7 +27,7 @@ describe('Schema Compatibility (S02)', () => { initDb(dbPath); const db = connect(dbPath); const meta = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get() as { value: string }; - expect(meta.value).toBe('19'); + expect(meta.value).toBe('20'); db.close(); }); @@ -115,7 +115,7 @@ describe('Schema Compatibility (S02)', () => { initDb(dbPath); const db2 = connect(dbPath); const meta = db2.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get() as { value: string }; - expect(meta.value).toBe('19'); + expect(meta.value).toBe('20'); // display_name column should exist now const row = db2.prepare('SELECT display_name FROM projects WHERE name = ?').get('old-project') as { display_name: string }; @@ -183,7 +183,7 @@ describe('Library Import (S22)', () => { expect(MemoryReflection).toBeDefined(); expect(initDb).toBeDefined(); expect(connect).toBeDefined(); - expect(SCHEMA_VERSION).toBe(19); + expect(SCHEMA_VERSION).toBe(20); expect(scanLocations).toBeDefined(); expect(applyProposals).toBeDefined(); expect(discoverPortsInPath).toBeDefined(); diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index 351477a..fcc833e 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { PROJECT_CONTEXT_OWNER_FILE, Registry, + connect, exportProjectContext, readProjectContextManifest, } from '../src/index.js'; @@ -91,6 +92,54 @@ describe('project-context export', () => { expect(readFileSync(join(exportDir, 'INDEX.md'), 'utf8')).toContain('## Other projects'); }); + it('carries the attention contract and assessment, agreeing with live assess_attention (v2)', () => { + registry.register({ + name: 'starved', type: 'project', status: 'active', + description: 'Important but neglected.', goals: ['Ship'], + }); + registry.updateCore('starved', { + priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7, + priority_reason: 'launch depends on it', + }); + registry.register({ name: 'unset', type: 'project', status: 'active', description: 'No contract.' }); + // Backdate the meaningful touch to 21 days before the export timestamp. + const db = connect(join(tmpDir, 'registry.db')); + try { + db.prepare("UPDATE projects SET updated_at = '2026-06-22 00:00:00' WHERE name = 'starved'").run(); + } finally { + db.close(); + } + + const now = new Date('2026-07-13T00:00:00.000Z'); + exportProjectContext({ directory: exportDir, registry, now }); + + const manifest = readProjectContextManifest(exportDir)!; + expect(manifest.schema_version).toBe('setlist-project-context.v2'); + const starvedEntry = manifest.projects.find(p => p.name === 'starved')!; + expect(starvedEntry.attention_state).toBe('undernourished'); + expect(starvedEntry.priority_tier).toBe('critical'); + // 21 days / 7-day cadence × 2.0 critical weight = 6.0 + expect(starvedEntry.attention_debt).toBe(6); + expect(manifest.projects.find(p => p.name === 'unset')!.attention_state).toBe('not_configured'); + + const snapshot = JSON.parse(readFileSync(join(exportDir, 'projects', 'starved.json'), 'utf8')); + expect(snapshot.attention.state).toBe('undernourished'); + expect(snapshot.attention.days_since_meaningful_touch).toBe(21); + expect(snapshot.attention.reasons.length).toBeGreaterThan(0); + expect(snapshot.brief.project.attention.priority_reason).toBe('launch depends on it'); + + const markdown = readFileSync(join(exportDir, 'projects', 'starved.md'), 'utf8'); + expect(markdown).toContain('attention_state: "undernourished"'); + expect(markdown).toContain('## Attention'); + expect(markdown).toContain('State: **undernourished**'); + + const index = readFileSync(join(exportDir, 'INDEX.md'), 'utf8'); + expect(index).toContain('## Attention concerns'); + expect(index).toMatch(/starved.*undernourished/); + // The unconfigured project is a gap, not a concern. + expect(index).not.toMatch(/unset.*undernourished/); + }); + it('refuses a non-empty unmanaged target instead of overwriting user files', () => { mkdirSync(exportDir, { recursive: true }); writeFileSync(join(exportDir, 'notes.md'), 'human-authored\n'); diff --git a/packages/core/tests/cross-query.test.ts b/packages/core/tests/cross-query.test.ts index 263f712..17de4ac 100644 --- a/packages/core/tests/cross-query.test.ts +++ b/packages/core/tests/cross-query.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { Registry, MemoryStore, CrossQuery } from '../src/index.js'; +import { Registry, MemoryStore, CrossQuery, connect } from '../src/index.js'; describe('Cross-Project Queries (S19)', () => { let tmpDir: string; @@ -130,3 +130,53 @@ describe('portfolio_brief — payload caps (D-011)', () => { } }); }); + +// ── Attention section (v20, S: portfolio-level attention) ── + +describe('portfolio_brief — attention section', () => { + let tmpDir: string; + let dbPath: string; + let registry: Registry; + let crossQuery: CrossQuery; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'setlist-cq-att-')); + dbPath = join(tmpDir, 'test.db'); + registry = new Registry(dbPath); + crossQuery = new CrossQuery(dbPath); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns a bounded, urgency-ordered attention summary with quotable reasons (scenario 9)', () => { + // An undernourished critical project… + registry.register({ name: 'starved', type: 'project', status: 'active', description: 'd' }); + registry.updateCore('starved', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + const db = connect(dbPath); + try { + db.prepare("UPDATE projects SET updated_at = datetime('now', '-21 days') WHERE name = 'starved'").run(); + } finally { + db.close(); + } + // …a healthy one (must NOT be dumped into the brief)… + registry.register({ name: 'fine', type: 'project', status: 'active', description: 'd' }); + registry.updateCore('fine', { priority_tier: 'normal', attention_mode: 'maintenance', attention_cadence_days: 60 }); + // …a due-for-review hold, and a configuration gap. + registry.register({ name: 'held', type: 'project', status: 'active', description: 'd' }); + registry.updateCore('held', { priority_tier: 'important', attention_mode: 'waiting', next_review_at: '2026-01-01T00:00:00.000Z' }); + registry.register({ name: 'unset', type: 'project', status: 'active', description: 'd' }); + + const brief = crossQuery.portfolioBrief(); + expect(brief.attention.undernourished.map(e => e.project)).toEqual(['starved']); + expect(brief.attention.undernourished[0].state).toBe('undernourished'); + expect(brief.attention.undernourished[0].reasons.length).toBeGreaterThan(0); + expect(brief.attention.due_for_review.map(e => e.project)).toEqual(['held']); + expect(brief.attention.not_configured).toContain('unset'); + expect(brief.attention.summary.nourished).toBe(1); + // The nourished project appears only in the summary count, never inlined. + const inlined = [...brief.attention.undernourished, ...brief.attention.due_for_review].map(e => e.project); + expect(inlined).not.toContain('fine'); + }); +}); diff --git a/packages/core/tests/db.test.ts b/packages/core/tests/db.test.ts index 8d5e5f2..ce5e014 100644 --- a/packages/core/tests/db.test.ts +++ b/packages/core/tests/db.test.ts @@ -23,7 +23,7 @@ describe('Schema Initialization (S01)', () => { try { const meta = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get() as { value: string }; expect(meta.value).toBe(String(SCHEMA_VERSION)); - expect(SCHEMA_VERSION).toBe(19); + expect(SCHEMA_VERSION).toBe(20); } finally { db.close(); } diff --git a/packages/core/tests/interactions.test.ts b/packages/core/tests/interactions.test.ts index 29ea7b8..1f1db7f 100644 --- a/packages/core/tests/interactions.test.ts +++ b/packages/core/tests/interactions.test.ts @@ -35,8 +35,8 @@ describe('Interactions log — schema v17→19', () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it('bumps SCHEMA_VERSION to 19', () => { - expect(SCHEMA_VERSION).toBe(19); + it('bumps SCHEMA_VERSION to at least 19', () => { + expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(19); }); it('creates the interactions table with the expected columns', () => { diff --git a/packages/core/tests/recipes-store.test.ts b/packages/core/tests/recipes-store.test.ts index ae8af3e..6025c70 100644 --- a/packages/core/tests/recipes-store.test.ts +++ b/packages/core/tests/recipes-store.test.ts @@ -38,8 +38,8 @@ beforeEach(() => { }); describe('Schema v19', () => { - it('reports SCHEMA_VERSION = 19', () => { - expect(SCHEMA_VERSION).toBe(19); + it('reports SCHEMA_VERSION = 20', () => { + expect(SCHEMA_VERSION).toBe(20); }); it('has bootstrap_primitives table', () => { @@ -60,7 +60,7 @@ describe('Schema v19', () => { const row = db .prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`) .get() as { value: string }; - expect(row.value).toBe('19'); + expect(row.value).toBe('20'); }); it('has email_account column on projects (NULL for fresh-init existing-pattern test)', () => { diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index a0a29e5..89d2376 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -8,6 +8,7 @@ import { import { Registry, MemoryStore, MemoryRetrieval, MemoryReflection, CrossQuery, Bootstrap, HealthAssessor, + AttentionAssessor, PrimitivesRegistry, InvalidInputError, assembleVocabResponse, @@ -59,7 +60,7 @@ export const MCP_TOOL_DEFINITIONS: McpToolDefinition[] = [ { name: 'search_projects', description: 'Search projects by keyword across name, description, goals, and extended fields. Optional area_filter narrows to a single canonical area.', inputSchema: { type: 'object' as const, properties: { query: { type: 'string' }, type_filter: { type: 'string' }, status_filter: { type: 'string' }, area_filter: { type: 'string' } }, required: ['query'] } }, { name: 'get_registry_stats', description: 'Return project count, type distribution, status distribution, per-area distribution, and unassigned count.', inputSchema: { type: 'object' as const, properties: {} } }, { name: 'register_project', description: 'Register a new project in the registry. Optional area assigns to one of the 7 canonical areas; optional parent_project links as a sub-project; optional email_account drives the mail-create-mailbox bootstrap primitive (spec 0.29). By default, rejects a non-archived project that already shares a path or case-insensitive display_name (A1.4 duplicate guard) — pass allow_duplicate: true only when a second registration at that path/name is genuinely intentional. Suggestion: use update_project() to modify existing projects.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, display_name: { type: 'string' }, project_type: { type: 'string', enum: ['project'], default: 'project' }, status: { type: 'string', default: 'active' }, description: { type: 'string' }, goals: { type: 'array', items: { type: 'string' }, description: 'List of goal statements (one per array element). Legacy string input is accepted but arrays are canonical.' }, paths: { type: 'string' }, area: { type: 'string', description: 'Canonical area: Work, Family, Home, Health, Finance, Personal, or Infrastructure' }, parent_project: { type: 'string', description: 'Name of the parent project for sub-project linking' }, email_account: { type: ['string', 'null'], description: 'Optional email address that drives the mail-create-mailbox bootstrap primitive\'s {project.email_account} token. Free-text, presence-only validation, NULL or empty string both clear.' }, producer: { type: 'string', default: 'system' }, allow_duplicate: { type: 'boolean', description: 'Set true to bypass the duplicate-path/duplicate-display_name guard (A1.4) when a second registration is intentional.' } }, required: ['name'] } }, - { name: 'update_project', description: 'Update core identity fields on an existing project, including optional area, parent_project, email_account, and paths. Pass null to clear area, parent_project, or email_account. paths uses replace semantics — the array you pass becomes the project\'s complete path set (an empty array removes all paths); it does not merge with existing paths.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, display_name: { type: 'string' }, status: { type: 'string' }, description: { type: 'string' }, goals: { type: 'array', items: { type: 'string' }, description: 'List of goal statements (one per array element).' }, area: { type: ['string', 'null'] }, parent_project: { type: ['string', 'null'] }, email_account: { type: ['string', 'null'], description: 'Spec 0.29: optional email address driving mail-create-mailbox. Pass null or "" to clear.' }, paths: { type: 'array', items: { type: 'string' }, description: 'Replace-semantics: the complete new set of filesystem paths for this project. Absolute or ~-prefixed; an empty array clears all paths.' } }, required: ['name'] } }, + { name: 'update_project', description: 'Update core identity fields on an existing project, including optional area, parent_project, email_account, and paths. Pass null to clear area, parent_project, or email_account. paths uses replace semantics — the array you pass becomes the project\'s complete path set (an empty array removes all paths); it does not merge with existing paths.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, display_name: { type: 'string' }, status: { type: 'string' }, description: { type: 'string' }, goals: { type: 'array', items: { type: 'string' }, description: 'List of goal statements (one per array element).' }, area: { type: ['string', 'null'] }, parent_project: { type: ['string', 'null'] }, email_account: { type: ['string', 'null'], description: 'Spec 0.29: optional email address driving mail-create-mailbox. Pass null or "" to clear.' }, paths: { type: 'array', items: { type: 'string' }, description: 'Replace-semantics: the complete new set of filesystem paths for this project. Absolute or ~-prefixed; an empty array clears all paths.' }, priority_tier: { type: ['string', 'null'], enum: ['critical', 'important', 'normal', 'someday', null], description: 'Attention contract: how important this project is. Pass null to clear. Setlist is the canonical priority store.' }, attention_mode: { type: ['string', 'null'], enum: ['active', 'maintenance', 'waiting', 'paused', null], description: 'Attention contract: expected attention pattern. waiting/paused suppress nourishment warnings until next_review_at.' }, attention_cadence_days: { type: ['number', 'null'], description: 'Attention contract: expected days between meaningful touches (positive integer). Pass null to clear.' }, next_review_at: { type: ['string', 'null'], description: 'Attention contract: ISO timestamp when a waiting/paused project should resurface as due_for_review. Pass null to clear.' }, priority_reason: { type: ['string', 'null'], description: 'Attention contract: optional short explanation of why the project has its priority. Pass null or "" to clear.' } }, required: ['name'] } }, { name: 'set_project_area', description: 'Assign or clear a project\'s canonical area. Pass null to move the project to Unassigned.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, area: { type: ['string', 'null'], description: 'Canonical area name or null to clear' } }, required: ['name'] } }, { name: 'set_parent_project', description: 'Link a child project to a parent project (sub-project relationship). Pass null as parent_name to detach. Rejects self-parenting and cycles.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string', description: 'Child project name' }, parent_name: { type: ['string', 'null'], description: 'Parent project name, or null to detach' } }, required: ['name'] } }, { name: 'archive_project', description: 'Archive a project (releases ports, clears capabilities). Children are NOT archived and remain linked via parent_archived flag. v19: a taxonomy-placed non-code folder (/projects/) is moved to its sibling /projects/archive/ (keeps .git; clobber-guarded so a conflict aborts before the status flip); code projects and any non-taxonomy path are status-flip-only and never moved. Returns folders_moved. Reversible via unarchive_project.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' } }, required: ['name'] } }, @@ -80,7 +81,7 @@ export const MCP_TOOL_DEFINITIONS: McpToolDefinition[] = [ { name: 'recall', description: 'Retrieve relevant memories. Omit query for bootstrap mode. Suggestion: use retain() to capture new knowledge.', inputSchema: { type: 'object' as const, properties: { query: { type: 'string' }, project: { type: 'string' }, token_budget: { type: 'number' } } } }, { name: 'feedback', description: 'Report a build outcome for memory reinforcement.', inputSchema: { type: 'object' as const, properties: { result: { type: 'string', enum: ['success', 'failure'] }, memory_ids: { type: 'array', items: { type: 'string' } } }, required: ['result', 'memory_ids'] } }, { name: 'memory_status', description: 'Memory store health check. Suggestion: use reflect() for maintenance.', inputSchema: { type: 'object' as const, properties: {} } }, - { name: 'portfolio_brief', description: 'Structured portfolio snapshot: active projects, portfolio memories, health indicators, pending observations, and enrichment_gaps annotations flagging registered projects missing description / tech_stack / digest. Use at session start for portfolio-level reasoning. Bounded for payload safety: pending_observations content is truncated to 500 chars (with an ellipsis marker) per entry; all matching projects are still listed, one bounded row each.', inputSchema: { type: 'object' as const, properties: {} } }, + { name: 'portfolio_brief', description: 'Structured portfolio snapshot: active projects, portfolio memories, health indicators, pending observations, enrichment_gaps annotations flagging registered projects missing description / tech_stack / digest, and a bounded attention section (most undernourished configured projects ordered by urgency, due-for-review holds, and attention-configuration gaps — full detail via assess_attention). Use at session start for portfolio-level reasoning. Bounded for payload safety: pending_observations content is truncated to 500 chars (with an ellipsis marker) per entry; the attention section inlines at most 5 entries per list; all matching projects are still listed, one bounded row each.', inputSchema: { type: 'object' as const, properties: {} } }, // Memory Admin (5) { name: 'reflect', description: 'Trigger memory consolidation (admin tool).', inputSchema: { type: 'object' as const, properties: {} } }, { name: 'correct', description: 'Create a correction memory superseding an existing one (admin tool).', inputSchema: { type: 'object' as const, properties: { memory_id: { type: 'string' }, correction: { type: 'string' } }, required: ['memory_id', 'correction'] } }, @@ -101,6 +102,7 @@ export const MCP_TOOL_DEFINITIONS: McpToolDefinition[] = [ { name: 'configure_bootstrap', description: 'Configure bootstrap: set path roots per project type and template directory. Call with no arguments to view current config.', inputSchema: { type: 'object' as const, properties: { path_roots: { type: 'object', description: 'Mapping of project type to default path root (e.g., {"project": "~/Code", "non_code_project": "~/Projects"})' }, template_dir: { type: 'string', description: 'Path to the template directory' }, archive_path_root: { type: 'string', description: 'Deprecated (v19): no longer used. Archive now moves a taxonomy project to its sibling /projects/archive/; code projects are status-flip-only. This key is still stored for backward compatibility but does not affect archiving.' } } } }, // Health (2) { name: 'assess_health', description: 'Assess project health. With a name, returns overall tier, per-dimension tiers (activity/completeness/outcomes), and reasons. Without a name, returns a portfolio-wide snapshot ordered worst-to-best plus summary counts. Cached briefly; pass fresh=true to bypass.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, fresh: { type: 'boolean', default: false } } } }, + { name: 'assess_attention', description: 'Assess attention nourishment against each project\'s explicit attention contract (priority_tier, attention_mode, attention_cadence_days, next_review_at — set via update_project). Separate from assess_health: answers "is this project receiving enough attention for how important it is?", not registry hygiene. With a name, returns state (nourished / due_soon / undernourished / due_for_review / suppressed / not_configured), the last meaningful touch (git commit, substantive retained memory, or material registry update — passive reads and exports never count), days since, a sortable attention_debt (days-since-touch ÷ cadence × priority weight), and quotable reasons. Without a name, returns an active-projects portfolio snapshot: undernourished-first ordering, due-for-review holds, and configuration gaps. waiting/paused projects are suppressed until next_review_at arrives, then flip to due_for_review. Cached briefly; pass fresh=true to bypass.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string' }, fresh: { type: 'boolean', default: false } } } }, { name: 'doctor', description: '#59 registry drift sweep. Detect-only by default (read-only — never writes): reports dead-path projects, duplicate registrations, orphaned memories, stale digests, a dead area directory, dead-letter project_fields rows, and path-less projects, each with a content-bound id (hash of the exact suggested fix baked into the id) and (where safe) a suggested_fix carrying a confidence (\'high\' = safe to auto-apply, \'low\' = requires human/foreman confirmation). Applying a fix requires explicit fix_ids naming exactly which findings (by id) to repair — pass apply: true together with fix_ids from a prior detect call; there is no "apply everything" mode. Apply re-runs detection fresh: an id whose fix content drifted since detect is returned as \'stale-fix\' (never applies unreviewed content); low-confidence fixes are \'refused\' unless confirm_low_confidence=true; multi-candidate path suggestions are always refused (narrow to one path via update_project). Each applied fix dispatches to a real primitive (path delta via setProjectPaths, archive_project\'s own archive path, a memory rehome, or a project_fields delete) and failures are reported per-id without aborting the rest.', inputSchema: { type: 'object' as const, properties: { apply: { type: 'boolean', default: false, description: 'When true, apply the fixes named in fix_ids (requires fix_ids to be non-empty). Omit or false for a read-only detect pass.' }, fix_ids: { type: 'array', items: { type: 'string' }, description: 'Finding ids (from a prior detect-mode report, including the #hash suffix) to apply. Required and must be non-empty when apply=true; ignored in detect mode.' }, confirm_low_confidence: { type: 'boolean', default: false, description: 'Explicit human/foreman confirmation for low-confidence fixes; without it they are refused. Multi-candidate path suggestions are refused regardless.' }, include_archived: { type: 'boolean', default: false, description: 'Widen dead-path / path-less-project detection to archived projects too.' } } } }, // Digests (3) — v12 { name: 'get_project_digest', description: 'Read one project\'s essence digest. Returns { digest_text, spec_version, producer, generated_at, token_count?, stale, stale_reason } or null if no digest exists. Staleness is computed server-side from the project\'s registered path (comparing computeProjectVersion against the stored spec_version) when current_spec_version is omitted; pass current_spec_version to override with a caller-known value. stale is null (not false) when it cannot be determined — no registered path, a dead path, or nothing computable — and stale_reason is one of \'spec-version-changed\' | \'path-missing\' | null.', inputSchema: { type: 'object' as const, properties: { project_name: { type: 'string' }, digest_kind: { type: 'string', default: 'essence' }, current_spec_version: { type: 'string', description: 'Optional: overrides server-side computation. When provided, stale=true iff stored spec_version differs.' } }, required: ['project_name'] } }, @@ -155,6 +157,7 @@ export function createServer(dbPath?: string, options: CreateServerOptions = {}) const memoryReflection = new MemoryReflection(dbPath); const bootstrapManager = new Bootstrap(dbPath); const healthAssessor = new HealthAssessor(dbPath); + const attentionAssessor = new AttentionAssessor(dbPath); const primitivesRegistry = new PrimitivesRegistry(dbPath); // Spec 0.28: in-memory store for pending recipe bootstraps (S144 stop-and- @@ -328,6 +331,12 @@ export function createServer(dbPath?: string, options: CreateServerOptions = {}) parent_project: (a.parent_project === null ? null : a.parent_project as string | undefined), // spec 0.29: allow updating email_account (null or "" clears) email_account: (a.email_account === null ? null : a.email_account as string | undefined), + // v20: attention contract fields (null clears; validated in core) + priority_tier: (a.priority_tier === null ? null : a.priority_tier as string | undefined), + attention_mode: (a.attention_mode === null ? null : a.attention_mode as string | undefined), + attention_cadence_days: (a.attention_cadence_days === null ? null : a.attention_cadence_days as number | undefined), + next_review_at: (a.next_review_at === null ? null : a.next_review_at as string | undefined), + priority_reason: (a.priority_reason === null ? null : a.priority_reason as string | undefined), }); // A1.1: optional replace-semantics paths update, wired to the real // writer (Registry.setProjectPaths) rather than the old dead-letter @@ -817,6 +826,18 @@ export function createServer(dbPath?: string, options: CreateServerOptions = {}) }); break; + // Attention + case 'assess_attention': { + const noCache = Boolean(a.fresh); + const targetName = a.name as string | undefined; + if (targetName) { + result = attentionAssessor.assessProject(targetName, { noCache }); + } else { + result = attentionAssessor.assessPortfolio({ noCache }); + } + break; + } + // Health case 'assess_health': { const noCache = Boolean(a.fresh); diff --git a/packages/mcp/tests/introspect-tools.test.ts b/packages/mcp/tests/introspect-tools.test.ts index 577a30b..559e089 100644 --- a/packages/mcp/tests/introspect-tools.test.ts +++ b/packages/mcp/tests/introspect-tools.test.ts @@ -5,8 +5,8 @@ import { MCP_TOOL_DEFINITIONS } from '../src/server.js'; describe('introspectMcpTools (S112)', () => { const caps = introspectMcpTools(); - it('produces exactly 62 capability declarations — one per MCP tool', () => { - expect(caps).toHaveLength(62); + it('produces exactly 63 capability declarations — one per MCP tool', () => { + expect(caps).toHaveLength(63); expect(caps).toHaveLength(MCP_TOOL_DEFINITIONS.length); }); @@ -41,6 +41,8 @@ describe('introspectMcpTools (S112)', () => { // Health expect(names.has('assess_health')).toBe(true); expect(names.has('doctor')).toBe(true); + // Attention (v20) + expect(names.has('assess_attention')).toBe(true); }); it('names in declarations match names in MCP_TOOL_DEFINITIONS exactly', () => { diff --git a/packages/mcp/tests/self-register-integration.test.ts b/packages/mcp/tests/self-register-integration.test.ts index b7d1c6c..f4ea556 100644 --- a/packages/mcp/tests/self-register-integration.test.ts +++ b/packages/mcp/tests/self-register-integration.test.ts @@ -61,11 +61,11 @@ describe('MCP server startup self-registration (goal gate — S112, S113, S114, // ── S112: Each type filter returns the expected set by surface ── - it('S112: query_capabilities returns exactly 61 tool rows, all CLI commands, and all library exports', async () => { + it('S112: query_capabilities returns exactly 63 tool rows, all CLI commands, and all library exports', async () => { const server = createServer(dbPath); const tools = await callTool(server, 'query_capabilities', { project_name: SELF_REGISTER_PROJECT, type: 'tool' }) as Array>; - expect(tools).toHaveLength(62); + expect(tools).toHaveLength(63); expect(tools.every(r => r.type === 'tool')).toBe(true); const cmds = await callTool(server, 'query_capabilities', { project_name: SELF_REGISTER_PROJECT, type: 'command' }) as Array>; @@ -164,7 +164,7 @@ describe('MCP server startup self-registration (goal gate — S112, S113, S114, const registry = new Registry(dbPath); const before = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'tool' }); const beforeCount = before.length; - expect(beforeCount).toBe(62); + expect(beforeCount).toBe(63); // Simulate a code change: temporarily splice an extra tool into MCP_TOOL_DEFINITIONS, // remove an existing one, then re-create the server. @@ -196,7 +196,7 @@ describe('MCP server startup self-registration (goal gate — S112, S113, S114, // After "fixing" the code (restoring the list), the next boot heals the gap. createServer(dbPath); const healed = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'tool' }); - expect(healed.length).toBe(62); + expect(healed.length).toBe(63); const healedNames = new Set(healed.map(r => r.name)); expect(healedNames.has('memory_status')).toBe(true); expect(healedNames.has('__fake_debug_tool')).toBe(false); @@ -217,8 +217,8 @@ describe('MCP server startup self-registration (goal gate — S112, S113, S114, ]); const allTools = registry.queryCapabilities({ capability_type: 'tool' }); - // setlist 62 + chorus 1 = 63 - expect(allTools.length).toBe(63); + // setlist 63 + chorus 1 = 64 + expect(allTools.length).toBe(64); expect(allTools.some(r => r.project === SELF_REGISTER_PROJECT && r.name === 'list_projects')).toBe(true); expect(allTools.some(r => r.project === 'chorus-app' && r.name === 'chorus_tool')).toBe(true); @@ -325,7 +325,7 @@ describe('MCP server startup self-registration (goal gate — S112, S113, S114, // Other two surfaces landed. const tools = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'tool' }); - expect(tools.length).toBe(62); + expect(tools.length).toBe(63); // The failing surface's prior-good rows are preserved. const cli = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'command' }); diff --git a/packages/mcp/tests/self-register.test.ts b/packages/mcp/tests/self-register.test.ts index 228b2ca..a6c6834 100644 --- a/packages/mcp/tests/self-register.test.ts +++ b/packages/mcp/tests/self-register.test.ts @@ -47,7 +47,7 @@ describe('selfRegisterCapabilities (S117)', () => { } // Spec 0.34: capability_type normalizes at the write boundary. // 'cli-command' surface label aliases to canonical slug 'command'. - expect(byType.tool).toBe(62); + expect(byType.tool).toBe(63); expect(byType.command).toBeGreaterThan(0); expect(byType.library).toBeGreaterThan(0); }); @@ -102,7 +102,7 @@ describe('selfRegisterCapabilities (S117)', () => { // Other two surfaces wrote their rows. const tools = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'tool' }); - expect(tools.length).toBe(62); + expect(tools.length).toBe(63); const libs = registry.queryCapabilities({ project_name: SELF_REGISTER_PROJECT, capability_type: 'library' }); expect(libs.length).toBeGreaterThan(0); diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index b774027..75f76f2 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -52,16 +52,16 @@ describe('MCP Server (S21)', () => { // ── Tool Registration ────────────────────────────────────── - it('registers exactly 62 tools', async () => { + it('registers exactly 63 tools', async () => { const tools = await listTools(server); - expect(tools).toHaveLength(62); + expect(tools).toHaveLength(63); }); it('registers all expected tool names', async () => { const tools = await listTools(server); const names = tools.map(t => t.name).sort(); expect(names).toEqual([ - 'append_recipe_step', 'archive_project', 'assess_health', 'batch_update', 'bootstrap_project', + 'append_recipe_step', 'archive_project', 'assess_attention', 'assess_health', 'batch_update', 'bootstrap_project', 'bootstrap_resolve', 'check_port', 'claim_port', 'configure_bootstrap', 'configure_memory', 'correct', 'create_area', 'create_primitive', 'create_project_type', 'cross_query', 'delete_area', 'delete_primitive', 'delete_project_type', 'discover_ports', 'doctor', From fba0f8f67e2f9c1bfd4936672dd175a5e732a2a1 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 22:20:44 -0400 Subject: [PATCH 03/22] Address Codex review round 1 on PR #83 (6 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- INVARIANTS.md | 4 +- packages/cli/src/index.ts | 93 ++++++++++++++++++---- packages/core/src/context-export.ts | 65 ++++++++++++--- packages/core/src/registry.ts | 56 +++++++++---- packages/core/tests/attention.test.ts | 28 +++++++ packages/core/tests/context-export.test.ts | 40 ++++++++++ 6 files changed, 244 insertions(+), 42 deletions(-) diff --git a/INVARIANTS.md b/INVARIANTS.md index 002b7bb..257f12b 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -30,7 +30,7 @@ ## Data & schema -- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 19` (`db.ts:10`, re-exported from `index.ts`; v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* +- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 20` (`db.ts`, re-exported from `index.ts`; v20 added the five nullable attention-contract columns on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — as a structural step realized entirely by `ensureColumns` with no data-repair block, so existing projects migrate with an unconfigured contract; v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* ## Contracts (the cross-surface edges) @@ -60,7 +60,7 @@ ## Open tensions (NOT invariants — tracked drift to resolve) -- **Schema-version labels reconciled at v19.** The running code is `SCHEMA_VERSION = 19` (`packages/core/src/db.ts:10`) — v19 added `projects.container` (the `projects/` taxonomy) and retargeted the Finance/Health seed dirs; v18 added `areas.default_directory`. `CLAUDE.md` and this file are reconciled to v19. The now-archived spec had penciled v18 for an unrelated, never-built set (`is_builtin` / `self_test_json` / `forked_from` / `decision_history_json`) — never migrated, moot. The code is the oracle. +- **Schema-version labels reconciled at v20.** The running code is `SCHEMA_VERSION = 20` (`packages/core/src/db.ts`) — v20 added the attention-contract columns (`priority_tier` / `attention_mode` / `attention_cadence_days` / `next_review_at` / `priority_reason`); v19 added `projects.container` (the `projects/` taxonomy) and retargeted the Finance/Health seed dirs; v18 added `areas.default_directory`. `CLAUDE.md` and this file are reconciled to v20. The now-archived spec had penciled v18 for an unrelated, never-built set (`is_builtin` / `self_test_json` / `forked_from` / `decision_history_json`) — never migrated, moot. The code is the oracle. - **Archived-spec behaviors the code never implemented.** §5.2's "schema too new → refuse to open" hard-stop **is now implemented at open/init** (`upgradeSchema` throws `RegistryError` `SCHEMA_TOO_NEW` when the on-disk version exceeds `SCHEMA_VERSION`; audit A5 remediation, PR *migration-chaining-backup*) — largely resolved, with a documented residual: the fence is a *construction/init* check (`initDb` → `upgradeSchema`), not a per-write gate, so a process that writes through a bare `connect()` handle held against a future-shaped database is not stopped by it. "Refuse to open" is honoured for the `initDb`/`Registry` path; a lower-level bare-`connect()` writer is out of scope. Still open: §2/§3's "same-project re-claim of its own port is a no-op success" is absent from `claimPort` (it throws "already claimed" for any holder). Moot — the spec is retired and the code is the contract — but recorded so a future reader does not resurrect it as a bug against a dead spec. - **Renderer types are checked nowhere in CI.** `packages/app` builds via electron-vite/esbuild, which strips TypeScript types without checking them; the only renderer typecheck is `tsc --noEmit -p packages/app/tsconfig.renderer.json`, which is **not** in `check.yml` and currently reports 2 pre-existing errors (`RegisterProjectDialog.tsx:161`, `OverviewTab.tsx:55`). Renderer type lies therefore reach production — this is how #53 (a `paths` field typed required that core omits) shipped. Resolve by wiring the renderer `tsc` into the gate and clearing the two errors; that wiring is also the graduation path for the projection-optionality invariant above. - **CI runs only on `pull_request`, never on push to `main`.** `check.yml`'s trigger is `pull_request:` alone, so a direct or fast-forward push to `main` is unvalidated. A `package-lock.json` left out of sync with the `packages/skills` workspace (added in `494f778`) landed via the migration fast-forward and broke `npm ci` on every subsequent PR — invisible until #53 became the first PR to surface it. Resolve with a `push: branches: [main]` trigger or branch protection requiring PRs. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ce2eaeb..21e0728 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,7 +15,7 @@ import { import { runWorker, installWorker, uninstallWorker, workerStatus } from './worker.js'; import { runDigestRefresh } from './digest.js'; import { CLI_COMMAND_DEFINITIONS } from './commands.js'; -import { runRetain, runRecall, runMemExport, runMemFeedback, runMemCorrect, emitMemoryOutcome } from './memory-commands.js'; +import { runRetain, runRecall, runMemExport, runMemFeedback, runMemCorrect, emitMemoryOutcome, parseCliArgs, UsageError, type ParsedArgs } from './memory-commands.js'; import { runMigrateMemories } from './migrate-memories-command.js'; import { DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS, @@ -460,17 +460,60 @@ switch (command) { } case 'update': { + const UPDATE_USAGE = 'Usage: setlist update [--status ] [--description ] [--display-name ] [--goals ] [--priority-tier ] [--attention-mode ] [--attention-cadence-days ] [--next-review-at ] [--priority-reason ]'; const name = args[1]; if (!name || name.startsWith('--')) { - console.error('Usage: setlist update [--status ] [--description ] [--display-name ] [--goals ]'); + console.error(UPDATE_USAGE); process.exit(1); } + // Strict shared parser (SURFACES: new value-taking flags never go through + // positional getFlag): rejects missing/flag-shaped/duplicate values. + let parsed: ParsedArgs; + try { + parsed = parseCliArgs( + args.slice(2), + new Set(['status', 'description', 'display-name', 'goals', 'priority-tier', 'attention-mode', 'attention-cadence-days', 'next-review-at', 'priority-reason']), + new Set(), + ); + if (parsed.positionals.length > 0) { + throw new UsageError(`Unexpected argument '${parsed.positionals[0]}'.`); + } + } catch (e) { + if (e instanceof UsageError) { + console.error(`Error: ${e.message}\n${UPDATE_USAGE}`); + process.exit(1); + } + throw e; + } + const flag = (n: string) => parsed.flags.get(n); + // v20 attention contract flags: the literal value `none` clears to NULL. + const clearable = (n: string): string | null | undefined => { + const v = flag(n); + return v === undefined ? undefined : v === 'none' ? null : v; + }; + const cadenceRaw = flag('attention-cadence-days'); + let cadence: number | null | undefined; + if (cadenceRaw !== undefined) { + if (cadenceRaw === 'none') { + cadence = null; + } else if (/^\d+$/.test(cadenceRaw)) { + cadence = Number(cadenceRaw); + } else { + console.error(`Error: --attention-cadence-days must be a whole number of days or 'none', got '${cadenceRaw}'.`); + process.exit(1); + } + } const registry = new Registry(); registry.updateCore(name, { - status: getFlag('status'), - description: getFlag('description'), - display_name: getFlag('display-name'), - goals: getFlag('goals'), + status: flag('status'), + description: flag('description'), + display_name: flag('display-name'), + goals: flag('goals'), + priority_tier: clearable('priority-tier'), + attention_mode: clearable('attention-mode'), + attention_cadence_days: cadence, + next_review_at: clearable('next-review-at'), + priority_reason: clearable('priority-reason'), }); console.log(`Project '${name}' updated.`); break; @@ -493,21 +536,43 @@ switch (command) { } case 'context': { - const directory = getFlag('dir') ?? defaultProjectContextDirectory(); - const intervalRaw = getFlag('interval'); - const intervalSeconds = intervalRaw - ? Number.parseInt(intervalRaw, 10) + const CONTEXT_USAGE = 'Usage: setlist context [--dir ] [--interval ] [--yes] [--json]'; + // Strict shared parser: `context install --dir --yes` must be a usage + // error (missing value), never a silent export to a directory named + // "--yes"; `--interval 60junk` must be rejected, not parseInt-truncated. + let contextParsed: ParsedArgs; + try { + contextParsed = parseCliArgs(args.slice(2), new Set(['dir', 'interval']), new Set(['yes', 'json'])); + if (contextParsed.positionals.length > 0) { + throw new UsageError(`Unexpected argument '${contextParsed.positionals[0]}'.`); + } + } catch (e) { + if (e instanceof UsageError) { + console.error(`Error: ${e.message}\n${CONTEXT_USAGE}`); + process.exit(1); + } + throw e; + } + const directory = contextParsed.flags.get('dir') ?? defaultProjectContextDirectory(); + const intervalRaw = contextParsed.flags.get('interval'); + if (intervalRaw !== undefined && !/^\d+$/.test(intervalRaw)) { + console.error(`Error: --interval must be a whole number of seconds, got '${intervalRaw}'.`); + process.exit(1); + } + const intervalSeconds = intervalRaw !== undefined + ? Number(intervalRaw) : DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS; if (!Number.isInteger(intervalSeconds) || intervalSeconds < 60) { console.error('Error: --interval must be an integer of at least 60 seconds.'); process.exit(1); } + const contextHasFlag = (n: string) => contextParsed.bools.has(n); const scheduleOptions = { directory, intervalSeconds }; switch (subcommand) { case 'export': { try { const result = exportProjectContext({ directory }); - if (hasFlag('json')) { + if (contextHasFlag('json')) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`Exported ${result.project_count} project(s) to ${result.directory}.`); @@ -522,7 +587,7 @@ switch (command) { } case 'install': { const plan = planContextExportSchedule(scheduleOptions); - if (!hasFlag('yes')) { + if (!contextHasFlag('yes')) { console.log('Dry run only. No launchd files were changed.'); console.log(`Plist: ${plan.plist_path}`); console.log(`Export directory: ${plan.export_directory}`); @@ -541,7 +606,7 @@ switch (command) { } case 'uninstall': { const plan = planContextExportSchedule(scheduleOptions); - if (!hasFlag('yes')) { + if (!contextHasFlag('yes')) { console.log(`Dry run only. ${plan.existing_plist ? 'Would unload and remove' : 'No managed plist found at'} ${plan.plist_path}.`); if (plan.existing_plist) console.log('The plist would be backed up before removal. Apply with: setlist context uninstall --yes'); break; @@ -555,7 +620,7 @@ switch (command) { console.log(`Context export schedule: ${contextExportScheduleStatus(scheduleOptions)}.`); break; default: - console.log('Usage: setlist context [--dir ] [--interval ] [--yes]'); + console.log(CONTEXT_USAGE); } break; } diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index c607b68..ffdfd6a 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -8,7 +8,8 @@ import { writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve, sep } from 'node:path'; import { RegistryError } from './errors.js'; import type { ProjectBrief } from './project-brief.js'; import { Registry } from './registry.js'; @@ -120,7 +121,7 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): )); const manifest = buildManifest(snapshots, generationId, exportedAt); const renderedProjects = snapshots.map(snapshot => ({ - name: snapshot.brief.project.name, + basename: projectContextBasename(snapshot.brief.project.name), json: `${JSON.stringify(snapshot, null, 2)}\n`, markdown: renderProjectContextMarkdown(snapshot), })); @@ -134,8 +135,12 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): }, null, 2)}\n`); atomicWrite(join(directory, 'README.md'), readme); for (const project of renderedProjects) { - atomicWrite(join(directory, 'projects', `${project.name}.json`), project.json); - atomicWrite(join(directory, 'projects', `${project.name}.md`), project.markdown); + const jsonPath = join(directory, 'projects', `${project.basename}.json`); + const markdownPath = join(directory, 'projects', `${project.basename}.md`); + assertWithinDirectory(directory, jsonPath); + assertWithinDirectory(directory, markdownPath); + atomicWrite(jsonPath, project.json); + atomicWrite(markdownPath, project.markdown); } atomicWrite(join(directory, 'INDEX.md'), index); // Commit marker for cross-device readers. This is intentionally last. @@ -153,6 +158,35 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): }; } +/** + * Filesystem-safe basename for a project's export files. Registry names are + * not character-validated (register/rename accept arbitrary strings), so a + * name like `../../evil` must never be interpolated into an output path. A + * name that is already a safe single path segment is used verbatim; anything + * else is sanitized and suffixed with a short hash of the original so two + * hostile-or-odd names cannot collide onto one file. + */ +export function projectContextBasename(name: string): string { + if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) && !name.includes('..')) { + return name; + } + const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^[.-]+/, ''); + const hash = createHash('sha256').update(name).digest('hex').slice(0, 8); + return `${sanitized || 'project'}-${hash}`; +} + +/** Defense in depth: refuse any resolved write target outside the export dir. */ +function assertWithinDirectory(directory: string, target: string): void { + const resolved = resolve(target); + if (resolved !== directory && !resolved.startsWith(`${directory}${sep}`)) { + throw new RegistryError( + 'CONTEXT_EXPORT_UNSAFE_PATH', + `Refusing to write outside the managed export directory: ${target}`, + 'A project name resolved to a path outside the export directory; rename the project to a plain identifier.', + ); + } +} + function assertManagedDirectory(directory: string): void { if (!existsSync(directory)) return; const entries = readdirSync(directory); @@ -191,6 +225,7 @@ function buildManifest( ): ProjectContextManifest { const projects = snapshots.map(snapshot => { const brief = snapshot.brief; + const basename = projectContextBasename(brief.project.name); return { name: brief.project.name, display_name: brief.project.display_name, @@ -203,8 +238,8 @@ function buildManifest( attention_state: snapshot.attention.state, attention_debt: snapshot.attention.attention_debt, priority_tier: snapshot.attention.priority_tier, - markdown_path: `projects/${brief.project.name}.md`, - json_path: `projects/${brief.project.name}.json`, + markdown_path: `projects/${basename}.md`, + json_path: `projects/${basename}.json`, }; }); return { @@ -320,11 +355,15 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str // Attention headline: undernourished and due-for-review projects first, so // an offline assistant can spot nourishment concerns without opening every - // project file. Ordered by attention debt (most urgent first). - const needsAttention = manifest.projects + // project file. Ordered by attention debt (most urgent first). Only + // status='active' projects qualify — live portfolio attention excludes + // inactive projects, and the offline index must agree so an idea/draft/ + // paused project can never raise a false offline alert. + const activeEntries = manifest.projects.filter(entry => entry.status === 'active'); + const needsAttention = activeEntries .filter(entry => entry.attention_state === 'undernourished' || entry.attention_state === 'due_soon') .sort((a, b) => b.attention_debt - a.attention_debt); - const dueForReview = manifest.projects + const dueForReview = activeEntries .filter(entry => entry.attention_state === 'due_for_review') .sort((a, b) => b.attention_debt - a.attention_debt); if (needsAttention.length > 0 || dueForReview.length > 0) { @@ -349,9 +388,11 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str for (const project of projects) { const area = project.area ? ` · ${project.area}` : ''; const stale = project.digest_stale ? ' · digest stale' : ''; - const attention = project.attention_state !== 'nourished' && project.attention_state !== 'suppressed' && project.attention_state !== 'not_configured' - ? ` · ${project.attention_state.replace(/_/g, ' ')}` - : ''; + const attentionFlagged = project.status === 'active' + && project.attention_state !== 'nourished' + && project.attention_state !== 'suppressed' + && project.attention_state !== 'not_configured'; + const attention = attentionFlagged ? ` · ${project.attention_state.replace(/_/g, ' ')}` : ''; lines.push(`- [${project.display_name}](${project.markdown_path}) — ${project.status}${area}${stale}${attention}`); } lines.push(''); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index b5e7ac6..7da37d4 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -41,6 +41,29 @@ function normalizeNullableString(value: string | null | undefined): string | nul return trimmed === '' ? null : trimmed; } +// Strict ISO 8601 grammar for next_review_at: a calendar date with an +// optional time (and optional zone). `new Date(value)` alone is too loose — +// it accepts locale strings ("July 13, 2026"), bare numbers ("0"), and +// normalizes impossible dates ("2026-02-30" → Mar 2), any of which would +// silently resurface a waiting project at the wrong time. +const ISO_TIMESTAMP_RE = + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/; + +function isValidIsoTimestamp(value: string): boolean { + const m = ISO_TIMESTAMP_RE.exec(value); + if (!m) return false; + const [, year, month, day, hour, minute, second] = m; + const monthNum = Number(month); + const dayNum = Number(day); + if (monthNum < 1 || monthNum > 12) return false; + // Real calendar-day check: day N must exist in that month of that year. + const daysInMonth = new Date(Date.UTC(Number(year), monthNum, 0)).getUTCDate(); + if (dayNum < 1 || dayNum > daysInMonth) return false; + if (hour !== undefined && (Number(hour) > 23 || Number(minute) > 59)) return false; + if (second !== undefined && Number(second) > 60) return false; // leap second allowed + return true; +} + export const PORT_RANGE_MIN = 3000; export const PORT_RANGE_MAX = 9999; @@ -810,19 +833,16 @@ export class Registry { params.push(next); } - // area/parent handled below via dedicated setters so we get validation + cycle check - const touchedCore = sets.length > 0; - if (touchedCore) { - sets.push("updated_at = datetime('now')"); - params.push(row.id); - db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...params); - } - // v20 attention contract: validated writes that deliberately do NOT bump // updated_at. `updated_at` is a meaningful-touch source for // assess_attention — if configuring the contract counted as a touch, a // neglected project would read "nourished" the moment its priority was // set, hiding exactly the neglect the contract exists to surface. + // + // ALL validation happens here, before any UPDATE executes, so a rejected + // combined update (e.g. valid description + invalid cadence) never + // leaves a partial write behind; the two UPDATEs then run in one + // transaction for the same reason. const attentionSets: string[] = []; const attentionParams: unknown[] = []; if (updates.priority_tier !== undefined) { @@ -857,9 +877,9 @@ export class Registry { } if (updates.next_review_at !== undefined) { const next = normalizeNullableString(updates.next_review_at); - if (next !== null && !Number.isFinite(new Date(next).getTime())) { + if (next !== null && !isValidIsoTimestamp(next)) { throw new InvalidInputError( - `Invalid next_review_at '${next}'. Must be an ISO 8601 timestamp or null to clear.` + `Invalid next_review_at '${next}'. Must be an ISO 8601 date or timestamp (e.g. 2026-09-01 or 2026-09-01T00:00:00Z) with a real calendar date, or null to clear.` ); } attentionSets.push('next_review_at = ?'); @@ -869,10 +889,18 @@ export class Registry { attentionSets.push('priority_reason = ?'); attentionParams.push(normalizeNullableString(updates.priority_reason)); } - if (attentionSets.length > 0) { - attentionParams.push(row.id); - db.prepare(`UPDATE projects SET ${attentionSets.join(', ')} WHERE id = ?`).run(...attentionParams); - } + + // area/parent handled below via dedicated setters so we get validation + cycle check + const touchedCore = sets.length > 0; + db.transaction(() => { + if (touchedCore) { + const coreSets = [...sets, "updated_at = datetime('now')"]; + db.prepare(`UPDATE projects SET ${coreSets.join(', ')} WHERE id = ?`).run(...params, row.id); + } + if (attentionSets.length > 0) { + db.prepare(`UPDATE projects SET ${attentionSets.join(', ')} WHERE id = ?`).run(...attentionParams, row.id); + } + })(); } finally { db.close(); } diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 6953007..ff83d91 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -242,6 +242,34 @@ describe('AttentionAssessor', () => { expect(() => registry.updateCore('v', { next_review_at: 'not-a-date' })).toThrow(/ISO 8601/); }); + it('enforces a strict ISO grammar with real calendar dates for next_review_at', () => { + registry.register({ name: 'iso', type: 'project', status: 'active', description: 'd', goals: 'g' }); + // JS-parseable but not the promised contract — all rejected. + expect(() => registry.updateCore('iso', { next_review_at: 'July 13, 2026' })).toThrow(/ISO 8601/); + expect(() => registry.updateCore('iso', { next_review_at: '0' })).toThrow(/ISO 8601/); + expect(() => registry.updateCore('iso', { next_review_at: '2026-02-30' })).toThrow(/ISO 8601/); // impossible date + expect(() => registry.updateCore('iso', { next_review_at: '2026-13-01' })).toThrow(/ISO 8601/); + expect(() => registry.updateCore('iso', { next_review_at: '2026-09-01T25:00:00Z' })).toThrow(/ISO 8601/); + // Valid forms accepted: bare date, full timestamp, offset zone, leap day. + registry.updateCore('iso', { next_review_at: '2026-09-01' }); + registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:00Z' }); + registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:00.000+02:00' }); + registry.updateCore('iso', { next_review_at: '2028-02-29' }); + const p = registry.getProject('iso') as { attention: Record }; + expect(p.attention.next_review_at).toBe('2028-02-29'); + }); + + it('a combined update with an invalid attention field writes nothing (no partial save)', () => { + registry.register({ name: 'atomic', type: 'project', status: 'active', description: 'original', goals: 'g' }); + expect(() => registry.updateCore('atomic', { + description: 'new description', + attention_cadence_days: 0, + })).toThrow(/positive integer/); + const p = registry.getProject('atomic') as { description: string; attention: Record }; + expect(p.description).toBe('original'); + expect(p.attention.attention_cadence_days).toBeNull(); + }); + it('persists, exposes, and clears the contract through project reads', () => { registry.register({ name: 'c', type: 'project', status: 'active', description: 'd', goals: 'g' }); registry.updateCore('c', { diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index fcc833e..80a7d35 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -140,6 +140,46 @@ describe('project-context export', () => { expect(index).not.toMatch(/unset.*undernourished/); }); + it('never writes outside the export directory for hostile or odd project names', () => { + // register() does not character-validate names, so the exporter must. + registry.register({ name: '../../escape', type: 'project', status: 'active', description: 'Hostile name.' }); + registry.register({ name: 'nested/slash name', type: 'project', status: 'active', description: 'Odd name.' }); + const canary = join(tmpDir, 'escape.json'); + + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T00:00:00Z') }); + + // Nothing escaped the managed directory… + expect(existsSync(canary)).toBe(false); + expect(existsSync(join(tmpDir, 'escape.md'))).toBe(false); + // …and both projects still landed inside projects/ under safe basenames. + const manifest = readProjectContextManifest(exportDir)!; + for (const entry of manifest.projects) { + expect(entry.json_path).toMatch(/^projects\/[a-zA-Z0-9._-]+\.json$/); + expect(entry.markdown_path).toMatch(/^projects\/[a-zA-Z0-9._-]+\.md$/); + expect(existsSync(join(exportDir, entry.json_path))).toBe(true); + expect(existsSync(join(exportDir, entry.markdown_path))).toBe(true); + } + // The two sanitized names must not collide. + const paths = manifest.projects.map(p => p.json_path); + expect(new Set(paths).size).toBe(paths.length); + }); + + it('keeps inactive projects out of the INDEX attention concerns (offline agrees with live portfolio)', () => { + registry.register({ name: 'shelf', type: 'project', status: 'draft', description: 'Configured but inactive.' }); + registry.updateCore('shelf', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + const db = connect(join(tmpDir, 'registry.db')); + try { + db.prepare("UPDATE projects SET updated_at = '2026-06-01 00:00:00' WHERE name = 'shelf'").run(); + } finally { + db.close(); + } + + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T00:00:00Z') }); + const index = readFileSync(join(exportDir, 'INDEX.md'), 'utf8'); + expect(index).not.toContain('## Attention concerns'); + expect(index).not.toMatch(/shelf.*undernourished/); + }); + it('refuses a non-empty unmanaged target instead of overwriting user files', () => { mkdirSync(exportDir, { recursive: true }); writeFileSync(join(exportDir, 'notes.md'), 'human-authored\n'); From 85eb39bd887bdf9e4e2cdafd92d0a28718b1f3fa Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 22:30:15 -0400 Subject: [PATCH 04/22] Address Codex review round 2 on PR #83 (3 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/context-export.ts | 55 +++++++- packages/core/src/registry.ts | 144 ++++++++++++--------- packages/core/tests/attention.test.ts | 36 ++++++ packages/core/tests/context-export.test.ts | 22 ++++ 4 files changed, 195 insertions(+), 62 deletions(-) diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index ffdfd6a..9ecef1f 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -119,9 +119,10 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): generationId, exportedAt, )); - const manifest = buildManifest(snapshots, generationId, exportedAt); + const basenames = computeProjectContextBasenames(snapshots.map(s => s.brief.project.name)); + const manifest = buildManifest(snapshots, basenames, generationId, exportedAt); const renderedProjects = snapshots.map(snapshot => ({ - basename: projectContextBasename(snapshot.brief.project.name), + basename: basenames.get(snapshot.brief.project.name)!, json: `${JSON.stringify(snapshot, null, 2)}\n`, markdown: renderProjectContextMarkdown(snapshot), })); @@ -171,10 +172,55 @@ export function projectContextBasename(name: string): string { return name; } const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^[.-]+/, ''); - const hash = createHash('sha256').update(name).digest('hex').slice(0, 8); + const hash = nameHash(name); return `${sanitized || 'project'}-${hash}`; } +function nameHash(name: string): string { + return createHash('sha256').update(name).digest('hex').slice(0, 8); +} + +/** + * Collision-proof name→basename mapping for a whole export generation. + * Sanitization alone cannot guarantee uniqueness — a sanitized `foo/bar` + * (`foo-bar-`) can equal a literal project name, and names differing + * only by case collide on the default case-insensitive macOS filesystem. + * Any case-insensitive collision group gets per-name hash suffixes; a + * residual collision (astronomically unlikely) refuses the export rather + * than silently overwriting one project's files with another's. + */ +export function computeProjectContextBasenames(names: string[]): Map { + const preliminary = new Map(); + for (const name of names) preliminary.set(name, projectContextBasename(name)); + + const groups = new Map(); + for (const base of preliminary.values()) { + const key = base.toLowerCase(); + groups.set(key, (groups.get(key) ?? 0) + 1); + } + + const result = new Map(); + for (const [name, base] of preliminary) { + const collides = (groups.get(base.toLowerCase()) ?? 0) > 1; + result.set(name, collides ? `${base}-${nameHash(name)}` : base); + } + + const seen = new Map(); + for (const [name, base] of result) { + const key = base.toLowerCase(); + const prior = seen.get(key); + if (prior !== undefined) { + throw new RegistryError( + 'CONTEXT_EXPORT_NAME_COLLISION', + `Projects '${prior}' and '${name}' resolve to the same export filename '${base}'.`, + 'Rename one of the projects to a distinct plain identifier and re-export.', + ); + } + seen.set(key, name); + } + return result; +} + /** Defense in depth: refuse any resolved write target outside the export dir. */ function assertWithinDirectory(directory: string, target: string): void { const resolved = resolve(target); @@ -220,12 +266,13 @@ function buildSnapshot( function buildManifest( snapshots: ProjectContextSnapshot[], + basenames: Map, generationId: string, exportedAt: string, ): ProjectContextManifest { const projects = snapshots.map(snapshot => { const brief = snapshot.brief; - const basename = projectContextBasename(brief.project.name); + const basename = basenames.get(brief.project.name)!; return { name: brief.project.name, display_name: brief.project.display_name, diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 7da37d4..6e250a6 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -47,12 +47,12 @@ function normalizeNullableString(value: string | null | undefined): string | nul // normalizes impossible dates ("2026-02-30" → Mar 2), any of which would // silently resurface a waiting project at the wrong time. const ISO_TIMESTAMP_RE = - /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/; + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?$/; function isValidIsoTimestamp(value: string): boolean { const m = ISO_TIMESTAMP_RE.exec(value); if (!m) return false; - const [, year, month, day, hour, minute, second] = m; + const [, year, month, day, hour, minute, second, zone] = m; const monthNum = Number(month); const dayNum = Number(day); if (monthNum < 1 || monthNum > 12) return false; @@ -60,8 +60,18 @@ function isValidIsoTimestamp(value: string): boolean { const daysInMonth = new Date(Date.UTC(Number(year), monthNum, 0)).getUTCDate(); if (dayNum < 1 || dayNum > daysInMonth) return false; if (hour !== undefined && (Number(hour) > 23 || Number(minute) > 59)) return false; - if (second !== undefined && Number(second) > 60) return false; // leap second allowed - return true; + // No leap-second allowance: JS Date cannot parse :60, and a stored value + // the assessor cannot parse would suppress a waiting project forever. + if (second !== undefined && Number(second) > 59) return false; + if (zone !== undefined && zone !== 'Z') { + const offsetHours = Number(zone.slice(1, 3)); + const offsetMinutes = Number(zone.slice(4, 6)); + if (offsetHours > 23 || offsetMinutes > 59) return false; + } + // Belt-and-suspenders: whatever passed the grammar must also normalize to + // a finite epoch, since consumers compare via `new Date(value)`. + const normalized = hour === undefined ? `${value}T00:00:00Z` : value; + return Number.isFinite(new Date(normalized).getTime()); } export const PORT_RANGE_MIN = 3000; @@ -890,12 +900,27 @@ export class Registry { attentionParams.push(normalizeNullableString(updates.priority_reason)); } - // area/parent handled below via dedicated setters so we get validation + cycle check - const touchedCore = sets.length > 0; + // area/parent share the setters' validation (area exists, parent + // exists, no cycle) but are resolved HERE, before any write, and + // applied inside the same transaction — a combined update with an + // invalid area or a parent cycle must fail with nothing persisted, + // not after priority/cadence/description already landed. + const areaId: number | null | undefined = updates.area === undefined + ? undefined + : updates.area === null ? null : this.resolveAreaIdOrThrow(db, updates.area); + const parentId: number | null | undefined = updates.parent_project === undefined + ? undefined + : this.resolveParentIdOrThrow(db, row.id, name, updates.parent_project); + + const touchedCore = sets.length > 0 || areaId !== undefined || parentId !== undefined; db.transaction(() => { if (touchedCore) { - const coreSets = [...sets, "updated_at = datetime('now')"]; - db.prepare(`UPDATE projects SET ${coreSets.join(', ')} WHERE id = ?`).run(...params, row.id); + const coreSets = [...sets]; + const coreParams = [...params]; + if (areaId !== undefined) { coreSets.push('area_id = ?'); coreParams.push(areaId); } + if (parentId !== undefined) { coreSets.push('parent_project_id = ?'); coreParams.push(parentId); } + coreSets.push("updated_at = datetime('now')"); + db.prepare(`UPDATE projects SET ${coreSets.join(', ')} WHERE id = ?`).run(...coreParams, row.id); } if (attentionSets.length > 0) { db.prepare(`UPDATE projects SET ${attentionSets.join(', ')} WHERE id = ?`).run(...attentionParams, row.id); @@ -904,15 +929,6 @@ export class Registry { } finally { db.close(); } - - // Delegate area/parent updates to the dedicated setters so validation and - // cycle-prevention logic live in one place. - if (updates.area !== undefined) { - this.setProjectArea(name, updates.area); - } - if (updates.parent_project !== undefined) { - this.setParentProject(name, updates.parent_project); - } } // ── Area / Parent structural operations (spec 0.13) ────────── @@ -1283,49 +1299,10 @@ export class Registry { throw new NotFoundError(childName, findClosestMatch(childName, allNames.map(r => r.name))); } - if (parentName === null) { - db.prepare("UPDATE projects SET parent_project_id = NULL, updated_at = datetime('now') WHERE id = ?").run(child.id); - const record = this.loadRecord(db, { projectId: child.id })!; - return this.formatRecord(db, record, 'standard'); - } - - // Self-parent check - if (parentName === childName) { - throw new InvalidInputError( - `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` - ); - } - - const parent = db.prepare('SELECT id FROM projects WHERE name = ?').get(parentName) as { id: number } | undefined; - if (!parent) { - const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; - throw new NotFoundError(parentName, findClosestMatch(parentName, allNames.map(r => r.name))); - } - - // Cycle walker: walk from proposed parent upward through ancestors. If we - // ever hit the child, the proposed move would create a cycle. - // Bounded by total project count to terminate on any pathological loop. - const maxHops = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c + 1; - let cursor: number | null = parent.id; - let hops = 0; - while (cursor != null) { - if (hops++ > maxHops) { - throw new InvalidInputError( - `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` - ); - } - if (cursor === child.id) { - throw new InvalidInputError( - `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` - ); - } - const next = db.prepare('SELECT parent_project_id FROM projects WHERE id = ?').get(cursor) as { parent_project_id: number | null } | undefined; - cursor = next?.parent_project_id ?? null; - } - + const parentId = this.resolveParentIdOrThrow(db, child.id, childName, parentName); db.prepare( "UPDATE projects SET parent_project_id = ?, updated_at = datetime('now') WHERE id = ?" - ).run(parent.id, child.id); + ).run(parentId, child.id); const record = this.loadRecord(db, { projectId: child.id })!; return this.formatRecord(db, record, 'standard'); @@ -1334,6 +1311,57 @@ export class Registry { } } + /** + * Validate and resolve a proposed parent for `childId`/`childName`: + * null clears; otherwise the parent must exist and must not be the child + * or any of its descendants (cycle walker over the ancestor chain, + * bounded by total project count). Shared by setParentProject and + * updateCore so a combined update prevalidates before any write. + */ + private resolveParentIdOrThrow( + db: Database.Database, + childId: number, + childName: string, + parentName: string | null, + ): number | null { + if (parentName === null) return null; + + // Self-parent check + if (parentName === childName) { + throw new InvalidInputError( + `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` + ); + } + + const parent = db.prepare('SELECT id FROM projects WHERE name = ?').get(parentName) as { id: number } | undefined; + if (!parent) { + const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; + throw new NotFoundError(parentName, findClosestMatch(parentName, allNames.map(r => r.name))); + } + + // Cycle walker: walk from proposed parent upward through ancestors. If we + // ever hit the child, the proposed move would create a cycle. + // Bounded by total project count to terminate on any pathological loop. + const maxHops = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c + 1; + let cursor: number | null = parent.id; + let hops = 0; + while (cursor != null) { + if (hops++ > maxHops) { + throw new InvalidInputError( + `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` + ); + } + if (cursor === childId) { + throw new InvalidInputError( + `Cannot set parent: ${childName} is a descendant of ${parentName}. Moving it would create a cycle.` + ); + } + const next = db.prepare('SELECT parent_project_id FROM projects WHERE id = ?').get(cursor) as { parent_project_id: number | null } | undefined; + cursor = next?.parent_project_id ?? null; + } + return parent.id; + } + archiveProject(name: string): { ports_released: number; capabilities_cleared: number } { const db = this.open(); try { diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index ff83d91..3d05e70 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -250,6 +250,11 @@ describe('AttentionAssessor', () => { expect(() => registry.updateCore('iso', { next_review_at: '2026-02-30' })).toThrow(/ISO 8601/); // impossible date expect(() => registry.updateCore('iso', { next_review_at: '2026-13-01' })).toThrow(/ISO 8601/); expect(() => registry.updateCore('iso', { next_review_at: '2026-09-01T25:00:00Z' })).toThrow(/ISO 8601/); + // JS Date cannot parse these — accepting them would suppress a waiting + // project forever (NaN comparisons are always false). + expect(() => registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:60Z' })).toThrow(/ISO 8601/); // leap second + expect(() => registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:00+24:00' })).toThrow(/ISO 8601/); + expect(() => registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:00+02:60' })).toThrow(/ISO 8601/); // Valid forms accepted: bare date, full timestamp, offset zone, leap day. registry.updateCore('iso', { next_review_at: '2026-09-01' }); registry.updateCore('iso', { next_review_at: '2026-09-01T09:30:00Z' }); @@ -270,6 +275,37 @@ describe('AttentionAssessor', () => { expect(p.attention.attention_cadence_days).toBeNull(); }); + it('a combined update with an invalid area or parent cycle writes nothing (no partial save)', () => { + registry.register({ name: 'combo', type: 'project', status: 'active', description: 'original', goals: 'g' }); + // Invalid area alongside valid contract fields: everything must roll back. + expect(() => registry.updateCore('combo', { + description: 'changed', + priority_tier: 'critical', + area: 'No Such Area', + })).toThrow(); + let p = registry.getProject('combo') as { description: string; attention: Record; area: string | null }; + expect(p.description).toBe('original'); + expect(p.attention.priority_tier).toBeNull(); + expect(p.area).toBeNull(); + + // Parent cycle alongside valid fields: same guarantee. + registry.register({ name: 'combo-child', type: 'project', status: 'active', description: 'd', goals: 'g' }); + registry.setParentProject('combo-child', 'combo'); + expect(() => registry.updateCore('combo', { + description: 'changed again', + parent_project: 'combo-child', + })).toThrow(/cycle/); + p = registry.getProject('combo') as { description: string; attention: Record; area: string | null }; + expect(p.description).toBe('original'); + + // And a valid combined update still lands area + parent + contract together. + registry.register({ name: 'combo-parent', type: 'project', status: 'active', description: 'd', goals: 'g' }); + registry.updateCore('combo', { parent_project: 'combo-parent', priority_tier: 'normal' }); + const done = registry.getProject('combo') as { parent_project: string | null; attention: Record }; + expect(done.parent_project).toBe('combo-parent'); + expect(done.attention.priority_tier).toBe('normal'); + }); + it('persists, exposes, and clears the contract through project reads', () => { registry.register({ name: 'c', type: 'project', status: 'active', description: 'd', goals: 'g' }); registry.updateCore('c', { diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index 80a7d35..bc9760f 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -164,6 +165,27 @@ describe('project-context export', () => { expect(new Set(paths).size).toBe(paths.length); }); + it('disambiguates basenames that collide after sanitization or case-folding', () => { + // A literal name equal to another name's sanitized form… + registry.register({ name: 'foo/bar', type: 'project', status: 'active', description: 'Slash.' }); + const sanitized = 'foo-bar-' + createHash('sha256').update('foo/bar').digest('hex').slice(0, 8); + registry.register({ name: sanitized, type: 'project', status: 'active', description: 'Literal twin.' }); + // …and a pair differing only by case (macOS default FS is case-insensitive). + registry.register({ name: 'casey', display_name: 'Casey Lower', type: 'project', status: 'active', description: 'lower.' }); + registry.register({ name: 'CASEY', display_name: 'Casey Upper', type: 'project', status: 'active', description: 'upper.' }); + + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T00:00:00Z') }); + + const manifest = readProjectContextManifest(exportDir)!; + const lowered = manifest.projects.map(p => p.json_path.toLowerCase()); + expect(new Set(lowered).size).toBe(lowered.length); + // Every entry's files exist and each JSON round-trips to its own project. + for (const entry of manifest.projects) { + const snapshot = JSON.parse(readFileSync(join(exportDir, entry.json_path), 'utf8')); + expect(snapshot.brief.project.name).toBe(entry.name); + } + }); + it('keeps inactive projects out of the INDEX attention concerns (offline agrees with live portfolio)', () => { registry.register({ name: 'shelf', type: 'project', status: 'draft', description: 'Configured but inactive.' }); registry.updateCore('shelf', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); From 3627e5b9de176622186a802fbc5efbc45c923801 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 22:42:48 -0400 Subject: [PATCH 05/22] Address Codex review round 3 on PR #83 (4 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/cli/src/commands.ts | 6 +-- packages/cli/src/context-export-schedule.ts | 37 +++++++++++++++-- .../cli/tests/context-export-schedule.test.ts | 40 ++++++++++++++++++- packages/core/src/attention.ts | 22 +++++++++- packages/core/src/cross-query.ts | 8 +++- packages/core/tests/attention.test.ts | 12 ++++++ packages/core/tests/cross-query.test.ts | 11 +++++ 7 files changed, 126 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 01ab411..35bcdcf 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -92,8 +92,8 @@ export const CLI_COMMAND_DEFINITIONS: CliCommandDefinition[] = [ }, { name: 'update', - description: 'Update a project\'s core identity fields (status, description, display_name, goals).', - usage: 'setlist update [--status ] [--description ] [--display-name ] [--goals ]', + description: 'Update a project\'s core identity fields (status, description, display_name, goals) and its v20 attention contract (priority_tier, attention_mode, attention_cadence_days, next_review_at, priority_reason — pass the literal value `none` to clear a contract field).', + usage: 'setlist update [--status ] [--description ] [--display-name ] [--goals ] [--priority-tier ] [--attention-mode ] [--attention-cadence-days ] [--next-review-at ] [--priority-reason ]', }, { name: 'brief', @@ -103,7 +103,7 @@ export const CLI_COMMAND_DEFINITIONS: CliCommandDefinition[] = [ { name: 'context', description: 'Export a read-only Markdown/JSON project-context cache and manage its launchd refresh schedule.', - usage: 'setlist context [--dir ] [--interval ] [--yes]', + usage: 'setlist context [--dir ] [--interval ] [--yes] [--json]', subcommands: [ { name: 'export', description: 'Write a full project-context reconciliation; manifest.json is committed last.', usage: 'setlist context export [--dir ] [--json]' }, { name: 'install', description: 'Preview or install the RunAtLoad, database-watch, and periodic launchd job.', usage: 'setlist context install [--dir ] [--interval ] [--yes]' }, diff --git a/packages/cli/src/context-export-schedule.ts b/packages/cli/src/context-export-schedule.ts index b716fb2..b140907 100644 --- a/packages/cli/src/context-export-schedule.ts +++ b/packages/cli/src/context-export-schedule.ts @@ -14,6 +14,12 @@ export interface ContextExportScheduleOptions { cliPath?: string; dbPath?: string; home?: string; + /** Injectable launchctl runner (tests). Defaults to /bin/launchctl via execFileSync. */ + launchctl?: (args: string[]) => void; +} + +function defaultLaunchctl(args: string[]): void { + execFileSync('/bin/launchctl', args, { stdio: args[0] === 'bootstrap' ? 'pipe' : 'ignore' }); } export interface ContextExportSchedulePlan { @@ -101,13 +107,37 @@ export function installContextExportSchedule(options: ContextExportScheduleOptio } atomicWrite(plan.plist_path, generateContextExportPlist(options)); + const launchctl = options.launchctl ?? defaultLaunchctl; const domain = `gui/${process.getuid?.() ?? 501}`; try { - execFileSync('/bin/launchctl', ['bootout', domain, plan.plist_path], { stdio: 'ignore' }); + launchctl(['bootout', domain, plan.plist_path]); } catch { // Not loaded yet. } - execFileSync('/bin/launchctl', ['bootstrap', domain, plan.plist_path], { stdio: 'pipe' }); + try { + launchctl(['bootstrap', domain, plan.plist_path]); + } catch (error) { + // The previous job (if any) was just booted out — a bootstrap failure + // must not strand the user with a stopped schedule and a broken plist + // silently installed. Restore the prior plist and reload it; with no + // prior plist, remove the new one so status honestly reads not_installed. + let recovery: string; + if (backupPath) { + copyFileSync(backupPath, plan.plist_path); + try { + launchctl(['bootstrap', domain, plan.plist_path]); + recovery = `The previous schedule was restored and reloaded (backup kept at ${backupPath}).`; + } catch { + recovery = `The previous plist was restored from ${backupPath} but could not be reloaded — run 'setlist context install' again or 'launchctl bootstrap ${domain} ${plan.plist_path}' manually.`; + } + } else { + try { rmSync(plan.plist_path, { force: true }); } catch { /* best-effort */ } + recovery = 'The new plist was removed; nothing is installed.'; + } + throw new Error( + `launchctl bootstrap failed for ${plan.plist_path}: ${error instanceof Error ? error.message : String(error)}. ${recovery}` + ); + } return { ...plan, status: 'installed', backup_path: backupPath }; } @@ -117,9 +147,10 @@ export function uninstallContextExportSchedule(options: ContextExportScheduleOpt if (!existsSync(plan.plist_path)) { return { ...plan, status: 'not_installed', backup_path: null }; } + const launchctl = options.launchctl ?? defaultLaunchctl; const domain = `gui/${process.getuid?.() ?? 501}`; try { - execFileSync('/bin/launchctl', ['bootout', domain, plan.plist_path], { stdio: 'ignore' }); + launchctl(['bootout', domain, plan.plist_path]); } catch { // Already unloaded. } diff --git a/packages/cli/tests/context-export-schedule.test.ts b/packages/cli/tests/context-export-schedule.test.ts index 1bbc2cc..bf01c27 100644 --- a/packages/cli/tests/context-export-schedule.test.ts +++ b/packages/cli/tests/context-export-schedule.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { CONTEXT_EXPORT_LABEL, DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS, generateContextExportPlist, + installContextExportSchedule, planContextExportSchedule, } from '../src/context-export-schedule.js'; @@ -74,4 +75,41 @@ describe('project-context export schedule', () => { expect(() => planContextExportSchedule({ home, intervalSeconds: 30 })) .toThrow(/at least 60 seconds/); }); + + it('restores and reloads the previous schedule when bootstrap of the replacement fails', () => { + const plistDir = join(home, 'Library', 'LaunchAgents'); + const plistPath = join(plistDir, `${CONTEXT_EXPORT_LABEL}.plist`); + mkdirSync(plistDir, { recursive: true }); + writeFileSync(plistPath, 'PREVIOUS-WORKING-PLIST'); + + const calls: string[][] = []; + let bootstraps = 0; + const launchctl = (args: string[]) => { + calls.push(args); + if (args[0] === 'bootstrap') { + bootstraps++; + // First bootstrap (the replacement) fails; the restore reload succeeds. + if (bootstraps === 1) throw new Error('Bootstrap failed: 5: Input/output error'); + } + }; + + expect(() => installContextExportSchedule({ home, launchctl })) + .toThrow(/restored and reloaded/); + + // The prior plist content is back in place… + expect(readFileSync(plistPath, 'utf8')).toBe('PREVIOUS-WORKING-PLIST'); + // …and the restored job was re-bootstrapped after the failure. + expect(calls.filter(c => c[0] === 'bootstrap').length).toBe(2); + }); + + it('removes the new plist when a first-time bootstrap fails (honest not_installed)', () => { + const plistPath = join(home, 'Library', 'LaunchAgents', `${CONTEXT_EXPORT_LABEL}.plist`); + const launchctl = (args: string[]) => { + if (args[0] === 'bootstrap') throw new Error('Bootstrap failed'); + }; + + expect(() => installContextExportSchedule({ home, launchctl })) + .toThrow(/nothing is installed/); + expect(existsSync(plistPath)).toBe(false); + }); }); diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index fd1d233..c4cad63 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -264,8 +264,9 @@ export class AttentionAssessor { // Intentional quiet: waiting/paused are suppressed until review time. if (mode === 'waiting' || mode === 'paused') { - if (nextReviewAt && new Date(nextReviewAt).getTime() <= now.getTime()) { - const daysOverdue = daysBetween(nextReviewAt, now); + const reviewAt = nextReviewAt == null ? null : reviewEpoch(nextReviewAt); + if (reviewAt != null && reviewAt <= now.getTime()) { + const daysOverdue = Math.max(0, Math.floor((now.getTime() - reviewAt) / DAY_MS)); return { ...contractBase, state: 'due_for_review', @@ -391,11 +392,28 @@ export class AttentionAssessor { } } +const DAY_MS = 1000 * 60 * 60 * 24; + function epoch(timestamp: string): number { const t = new Date(normalizeSqliteTimestamp(timestamp)).getTime(); return Number.isFinite(t) ? t : 0; } +/** + * Epoch for a next_review_at value. A bare date (what the desktop's date + * picker stores) is interpreted as LOCAL midnight, not UTC midnight — a user + * who pauses a project "until 2026-09-01" means their own September 1st, and + * UTC interpretation would resurface it the prior evening in western zones. + * Timezone-bearing timestamps are honored exactly. + */ +export function reviewEpoch(value: string): number { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (m) { + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime(); + } + return epoch(value); +} + /** SQLite datetime('now') yields "YYYY-MM-DD HH:MM:SS" in UTC without a zone marker. */ function normalizeSqliteTimestamp(timestamp: string): string { if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(timestamp)) { diff --git a/packages/core/src/cross-query.ts b/packages/core/src/cross-query.ts index a86d3ff..87c88c0 100644 --- a/packages/core/src/cross-query.ts +++ b/packages/core/src/cross-query.ts @@ -261,6 +261,7 @@ export class CrossQuery { undernourished: PortfolioBriefAttentionEntry[]; due_for_review: PortfolioBriefAttentionEntry[]; not_configured: string[]; + not_configured_count: number; summary: Record; }; } { @@ -400,7 +401,12 @@ export class CrossQuery { due_for_review: attentionPortfolio.due_for_review .slice(0, PORTFOLIO_BRIEF_ATTENTION_LIMIT) .map(toAttentionEntry), - not_configured: attentionPortfolio.not_configured, + // Bounded like every other list here: a freshly-migrated registry has + // EVERY project unconfigured, which would otherwise be the largest + // list in the payload. The count keeps the total honest; the full + // list is available via assess_attention. + not_configured: attentionPortfolio.not_configured.slice(0, PORTFOLIO_BRIEF_ATTENTION_LIMIT), + not_configured_count: attentionPortfolio.not_configured.length, summary: attentionPortfolio.summary as unknown as Record, }; diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 3d05e70..70201ba 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -111,6 +111,18 @@ describe('AttentionAssessor', () => { expect(r.reasons.join(' ')).toMatch(/review/); }); + it('a bare next_review_at date means LOCAL midnight, not UTC (desktop date-picker contract)', () => { + registry.register({ name: 'local-date', type: 'project', status: 'active', description: 'd', goals: 'g' }); + registry.updateCore('local-date', { priority_tier: 'important', attention_mode: 'waiting', next_review_at: '2026-09-01' }); + + // One minute before local midnight on Sep 1: still suppressed. + const beforeMidnight = new Date(2026, 8, 0, 23, 59); // Aug 31 23:59 local + expect(attention.assessProject('local-date', { now: beforeMidnight }).state).toBe('suppressed'); + // One minute after local midnight: due for review. + const afterMidnight = new Date(2026, 8, 1, 0, 1); // Sep 1 00:01 local + expect(attention.assessProject('local-date', { now: afterMidnight }).state).toBe('due_for_review'); + }); + it('paused project with no review date is suppressed with an explanatory reason', () => { registerConfigured('shelved', { priority_tier: 'someday', attention_mode: 'paused' }); const r = attention.assessProject('shelved', { noCache: true }); diff --git a/packages/core/tests/cross-query.test.ts b/packages/core/tests/cross-query.test.ts index 17de4ac..8959525 100644 --- a/packages/core/tests/cross-query.test.ts +++ b/packages/core/tests/cross-query.test.ts @@ -174,9 +174,20 @@ describe('portfolio_brief — attention section', () => { expect(brief.attention.undernourished[0].reasons.length).toBeGreaterThan(0); expect(brief.attention.due_for_review.map(e => e.project)).toEqual(['held']); expect(brief.attention.not_configured).toContain('unset'); + expect(brief.attention.not_configured_count).toBe(1); expect(brief.attention.summary.nourished).toBe(1); // The nourished project appears only in the summary count, never inlined. const inlined = [...brief.attention.undernourished, ...brief.attention.due_for_review].map(e => e.project); expect(inlined).not.toContain('fine'); }); + + it('bounds the not_configured list (a freshly-migrated registry has every project unconfigured)', () => { + for (let i = 0; i < 8; i++) { + registry.register({ name: `legacy-${i}`, type: 'project', status: 'active', description: 'd' }); + } + const brief = crossQuery.portfolioBrief(); + expect(brief.attention.not_configured.length).toBeLessThanOrEqual(5); + expect(brief.attention.not_configured_count).toBe(8); + expect(brief.attention.summary.not_configured).toBe(8); + }); }); From 83d645ab3179d0dc210e03a4cb72438289e457d3 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 23:14:43 -0400 Subject: [PATCH 06/22] Address Codex review round 4 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- README.md | 6 +++++- packages/cli/src/index.ts | 26 ++++++++++++++++++++++++-- packages/core/src/context-export.ts | 8 ++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b1f5a1e..ee1dec2 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,11 @@ setlist context status ``` Without `--dir`, the cache stays local under Setlist's application-data -directory. +directory (`~/.local/share/project-registry/project-context`). That default is +deliberate: automated jobs must never write into cloud-synced folders by +default, so pointing the cache at a synced location (e.g. an iCloud- or +NAS-synced fallback directory that other assistants read) is always an +explicit `--dir` opt-in. The cache is explicitly derived: live Setlist remains authoritative. The exporter refuses a non-empty unmanaged target, overwrites only its managed diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 21e0728..e9f4507 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -588,6 +588,10 @@ switch (command) { case 'install': { const plan = planContextExportSchedule(scheduleOptions); if (!contextHasFlag('yes')) { + if (contextHasFlag('json')) { + console.log(JSON.stringify({ mode: 'plan', applied: false, ...plan }, null, 2)); + break; + } console.log('Dry run only. No launchd files were changed.'); console.log(`Plist: ${plan.plist_path}`); console.log(`Export directory: ${plan.export_directory}`); @@ -599,6 +603,10 @@ switch (command) { // Prove the export path is safe and usable before installing automation. const exported = exportProjectContext({ directory }); const installed = installContextExportSchedule(scheduleOptions); + if (contextHasFlag('json')) { + console.log(JSON.stringify({ mode: 'installed', applied: true, export: exported, schedule: installed }, null, 2)); + break; + } console.log(`Exported ${exported.project_count} project(s) to ${exported.directory}.`); console.log(`Context export schedule installed: ${installed.plist_path}`); if (installed.backup_path) console.log(`Previous plist backed up: ${installed.backup_path}`); @@ -607,18 +615,32 @@ switch (command) { case 'uninstall': { const plan = planContextExportSchedule(scheduleOptions); if (!contextHasFlag('yes')) { + if (contextHasFlag('json')) { + console.log(JSON.stringify({ mode: 'plan', applied: false, ...plan }, null, 2)); + break; + } console.log(`Dry run only. ${plan.existing_plist ? 'Would unload and remove' : 'No managed plist found at'} ${plan.plist_path}.`); if (plan.existing_plist) console.log('The plist would be backed up before removal. Apply with: setlist context uninstall --yes'); break; } const result = uninstallContextExportSchedule(scheduleOptions); + if (contextHasFlag('json')) { + console.log(JSON.stringify({ mode: 'uninstalled', applied: true, schedule: result }, null, 2)); + break; + } console.log(`Context export schedule: ${result.status}.`); if (result.backup_path) console.log(`Removed plist preserved at: ${result.backup_path}`); break; } - case 'status': - console.log(`Context export schedule: ${contextExportScheduleStatus(scheduleOptions)}.`); + case 'status': { + const scheduleStatus = contextExportScheduleStatus(scheduleOptions); + if (contextHasFlag('json')) { + console.log(JSON.stringify({ status: scheduleStatus }, null, 2)); + break; + } + console.log(`Context export schedule: ${scheduleStatus}.`); break; + } default: console.log(CONTEXT_USAGE); } diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index 9ecef1f..d9b0670 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -21,6 +21,14 @@ import { AttentionAssessor, type AttentionAssessment } from './attention.js'; // and due-for-review projects without parsing every project file. export const PROJECT_CONTEXT_SCHEMA_VERSION = 'setlist-project-context.v2'; export const PROJECT_CONTEXT_OWNER_FILE = '.setlist-context-export.json'; +// DELIBERATE default: the XDG data dir, NOT a cloud-synced folder. The +// operator's canonical fallback location may live under an iCloud-synced +// tree (e.g. ~/Areas/reference/System/project-context/), but automated jobs +// must never write into cloud-synced folders by default (filesystem doctrine, +// CLAUDE.md "Critical Rules" #7) — syncing a half-written generation is worse +// than a stale one. Deployments that want the cache readable from a synced +// location opt in explicitly via --dir (as this machine's launchd job does); +// the export itself stays atomic either way. export const DEFAULT_PROJECT_CONTEXT_RELATIVE_PATH = '.local/share/project-registry/project-context'; export interface ProjectContextSnapshot { From e78f79dc098618c89fd71a62c921a90d4d6c72e4 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 23:29:34 -0400 Subject: [PATCH 07/22] Address Codex review round 5 on PR #83 (4 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/cli/src/context-export-schedule.ts | 19 +++++++++- .../cli/tests/context-export-schedule.test.ts | 35 +++++++++++++++++++ packages/core/src/attention.ts | 19 +++++----- packages/core/src/context-export.ts | 21 +++++++++++ packages/core/src/registry.ts | 20 ++++++++++- packages/core/tests/attention.test.ts | 28 +++++++++++++++ packages/core/tests/context-export.test.ts | 18 +++++++++- packages/mcp/src/server.ts | 11 +++--- 8 files changed, 153 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/context-export-schedule.ts b/packages/cli/src/context-export-schedule.ts index b140907..7bf8785 100644 --- a/packages/cli/src/context-export-schedule.ts +++ b/packages/cli/src/context-export-schedule.ts @@ -152,7 +152,24 @@ export function uninstallContextExportSchedule(options: ContextExportScheduleOpt try { launchctl(['bootout', domain, plan.plist_path]); } catch { - // Already unloaded. + // bootout fails both when the job was never loaded (fine) and on real + // launchd/permission errors (not fine). Distinguish via `print`: if the + // job is STILL loaded, removing the plist would leave it running while + // status reads not_installed — abort instead of reporting a false + // uninstall. + let stillLoaded = false; + try { + launchctl(['print', `${domain}/${CONTEXT_EXPORT_LABEL}`]); + stillLoaded = true; + } catch { + // Not loaded — the bootout failure was "no such job"; safe to proceed. + } + if (stillLoaded) { + throw new Error( + `launchctl bootout failed for ${plan.plist_path} and the job is still loaded. ` + + `Nothing was removed. Retry, or unload manually with 'launchctl bootout ${domain} ${plan.plist_path}'.` + ); + } } const backupPath = `${plan.plist_path}.setlist-backup-${timestamp()}`; copyFileSync(plan.plist_path, backupPath); diff --git a/packages/cli/tests/context-export-schedule.test.ts b/packages/cli/tests/context-export-schedule.test.ts index bf01c27..fc826c1 100644 --- a/packages/cli/tests/context-export-schedule.test.ts +++ b/packages/cli/tests/context-export-schedule.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_CONTEXT_EXPORT_INTERVAL_SECONDS, generateContextExportPlist, installContextExportSchedule, + uninstallContextExportSchedule, planContextExportSchedule, } from '../src/context-export-schedule.js'; @@ -102,6 +103,40 @@ describe('project-context export schedule', () => { expect(calls.filter(c => c[0] === 'bootstrap').length).toBe(2); }); + it('aborts uninstall when bootout fails and the job is still loaded (no false success)', () => { + const plistDir = join(home, 'Library', 'LaunchAgents'); + const plistPath = join(plistDir, `${CONTEXT_EXPORT_LABEL}.plist`); + mkdirSync(plistDir, { recursive: true }); + writeFileSync(plistPath, 'LOADED-PLIST'); + + const launchctl = (args: string[]) => { + if (args[0] === 'bootout') throw new Error('Operation not permitted'); + // `print` succeeding means the job is still loaded. + if (args[0] === 'print') return; + }; + + expect(() => uninstallContextExportSchedule({ home, launchctl })) + .toThrow(/still loaded/); + // The plist was NOT removed — status stays honest. + expect(readFileSync(plistPath, 'utf8')).toBe('LOADED-PLIST'); + }); + + it('proceeds with uninstall when bootout fails because the job was never loaded', () => { + const plistDir = join(home, 'Library', 'LaunchAgents'); + const plistPath = join(plistDir, `${CONTEXT_EXPORT_LABEL}.plist`); + mkdirSync(plistDir, { recursive: true }); + writeFileSync(plistPath, 'STALE-PLIST'); + + const launchctl = (args: string[]) => { + if (args[0] === 'bootout') throw new Error('Boot-out failed: 5: Input/output error'); + if (args[0] === 'print') throw new Error('Could not find service'); + }; + + const result = uninstallContextExportSchedule({ home, launchctl }); + expect(result.status).toBe('uninstalled'); + expect(existsSync(plistPath)).toBe(false); + }); + it('removes the new plist when a first-time bootstrap fails (honest not_installed)', () => { const plistPath = join(home, 'Library', 'LaunchAgents', `${CONTEXT_EXPORT_LABEL}.plist`); const launchctl = (args: string[]) => { diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index c4cad63..05f6e93 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -266,13 +266,14 @@ export class AttentionAssessor { if (mode === 'waiting' || mode === 'paused') { const reviewAt = nextReviewAt == null ? null : reviewEpoch(nextReviewAt); if (reviewAt != null && reviewAt <= now.getTime()) { - const daysOverdue = Math.max(0, Math.floor((now.getTime() - reviewAt) / DAY_MS)); + const exactDaysOverdue = Math.max(0, (now.getTime() - reviewAt) / DAY_MS); + const daysOverdue = Math.floor(exactDaysOverdue); return { ...contractBase, state: 'due_for_review', last_meaningful_touch: null, days_since_meaningful_touch: null, - attention_debt: round3(weight * (1 + daysOverdue / 30)), + attention_debt: round3(weight * (1 + exactDaysOverdue / 30)), reasons: [ `${mode} project reached its review date ${formatDays(daysOverdue)} ago (${nextReviewAt})`, 'needs an explicit decision: resume, extend the pause, or archive', @@ -306,8 +307,13 @@ export class AttentionAssessor { }; } - const daysSince = daysBetween(touch.at, now); - const ratio = daysSince / (cadence as number); + // The ratio uses EXACT elapsed time — flooring to whole days first would + // hide up to 24h of overdue-ness on short cadences (a 1-day-cadence + // project touched 47h ago must read overdue, not "1 day / 1 day = fine"). + // The reported days_since stays a whole number for readability. + const exactDaysSince = Math.max(0, (now.getTime() - epoch(touch.at)) / DAY_MS); + const daysSince = Math.floor(exactDaysSince); + const ratio = exactDaysSince / (cadence as number); const debt = round3(ratio * weight); const touchReason = `last meaningful touch ${formatDays(daysSince)} ago via ${touch.detail}`; const cadenceLabel = `${tier} ${mode} project expects attention every ${cadence} day${cadence === 1 ? '' : 's'}`; @@ -422,11 +428,6 @@ function normalizeSqliteTimestamp(timestamp: string): string { return timestamp; } -function daysBetween(earlier: string, now: Date): number { - const ms = now.getTime() - epoch(earlier); - return Math.max(0, Math.floor(ms / (1000 * 60 * 60 * 24))); -} - function formatDays(days: number): string { return `${days} day${days === 1 ? '' : 's'}`; } diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index d9b0670..8680c12 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -1,5 +1,6 @@ import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -137,6 +138,8 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): const readme = renderReadme(); const index = renderProjectContextIndex(manifest); + assertNotSymlink(directory); + assertNotSymlink(join(directory, 'projects')); mkdirSync(join(directory, 'projects'), { recursive: true }); atomicWrite(join(directory, PROJECT_CONTEXT_OWNER_FILE), `${JSON.stringify({ owner: 'setlist', @@ -241,6 +244,24 @@ function assertWithinDirectory(directory: string, target: string): void { } } +/** + * The lexical containment check above cannot see symlinks: if the export + * root or its projects/ subdirectory is replaced with a symlink, every + * apparently-in-directory write follows it elsewhere. Refuse symlinked + * managed directories outright — the exporter owns these directories and + * never creates links, so a link here is either an accident or an attack. + */ +function assertNotSymlink(path: string): void { + if (!existsSync(path)) return; + if (lstatSync(path).isSymbolicLink()) { + throw new RegistryError( + 'CONTEXT_EXPORT_UNSAFE_PATH', + `Refusing to export through a symlinked directory: ${path}`, + 'Replace the symlink with a real directory (or choose a different --dir) and re-export.', + ); + } +} + function assertManagedDirectory(directory: string): void { if (!existsSync(directory)) return; const entries = readdirSync(directory); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 6e250a6..37d9da2 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -815,6 +815,9 @@ export class Registry { attention_cadence_days?: number | null; next_review_at?: string | null; priority_reason?: string | null; + // Replace-semantics path set (same contract as setProjectPaths), applied + // inside the same transaction so a combined update is all-or-nothing. + paths?: string[]; }): void { const db = this.open(); try { @@ -911,9 +914,24 @@ export class Registry { const parentId: number | null | undefined = updates.parent_project === undefined ? undefined : this.resolveParentIdOrThrow(db, row.id, name, updates.parent_project); + let normalizedPaths: string[] | undefined; + if (updates.paths !== undefined) { + if (!Array.isArray(updates.paths)) { + throw new InvalidInputError("'paths' must be an array of strings."); + } + normalizedPaths = validateAndNormalizePaths(updates.paths); + } - const touchedCore = sets.length > 0 || areaId !== undefined || parentId !== undefined; + const touchedCore = + sets.length > 0 || areaId !== undefined || parentId !== undefined || normalizedPaths !== undefined; db.transaction(() => { + if (normalizedPaths !== undefined) { + db.prepare('DELETE FROM project_paths WHERE project_id = ?').run(row.id); + const insertPath = db.prepare( + 'INSERT INTO project_paths (project_id, path, added_by) VALUES (?, ?, ?)' + ); + for (const p of normalizedPaths) insertPath.run(row.id, p, 'update'); + } if (touchedCore) { const coreSets = [...sets]; const coreParams = [...params]; diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 70201ba..d1b1351 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -230,6 +230,15 @@ describe('AttentionAssessor', () => { // ── Determinism of the debt formula ─────────────────────────────── + it('short cadences use exact elapsed time — 47 hours against a 1-day cadence is overdue', () => { + registerConfigured('daily', { priority_tier: 'normal', attention_mode: 'active', attention_cadence_days: 1 }); + backdateProject('daily', '2026-07-11 01:00:00'); + const now = new Date('2026-07-13T00:00:00.000Z'); // 47 hours later + const r = attention.assessProject('daily', { now }); + expect(r.state).toBe('undernourished'); // not "1 day / 1 day = fine" + expect(r.attention_debt).toBeGreaterThan(1.9); + }); + it('attention debt = days/cadence × priority weight, deterministic under a fixed now', () => { registerConfigured('exact', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 10 }); const touched = new Date('2026-07-01T00:00:00.000Z'); @@ -318,6 +327,25 @@ describe('AttentionAssessor', () => { expect(done.attention.priority_tier).toBe('normal'); }); + it('a combined update with invalid paths writes nothing (paths are in the same transaction)', () => { + registry.register({ name: 'pathy', type: 'project', status: 'active', description: 'original', goals: 'g' }); + expect(() => registry.updateCore('pathy', { + description: 'changed', + priority_tier: 'critical', + paths: ['relative/path'], + })).toThrow(/absolute/); + const p = registry.getProject('pathy') as { description: string; attention: Record; paths?: string[] }; + expect(p.description).toBe('original'); + expect(p.attention.priority_tier).toBeNull(); + expect(p.paths ?? []).toEqual([]); + + // A valid combined update lands fields + paths together. + registry.updateCore('pathy', { description: 'changed', paths: [join(tmpDir, 'workspace')] }); + const done = registry.getProject('pathy') as { description: string; paths?: string[] }; + expect(done.description).toBe('changed'); + expect(done.paths).toEqual([join(tmpDir, 'workspace')]); + }); + it('persists, exposes, and clears the contract through project reads', () => { registry.register({ name: 'c', type: 'project', status: 'active', description: 'd', goals: 'g' }); registry.updateCore('c', { diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index bc9760f..e096127 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -202,6 +202,22 @@ describe('project-context export', () => { expect(index).not.toMatch(/shelf.*undernourished/); }); + it('refuses to export through a symlinked managed directory', () => { + registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); + // First generation establishes the managed dir, then projects/ is + // swapped for a symlink pointing elsewhere. + exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T00:00:00Z') }); + const elsewhere = join(tmpDir, 'elsewhere'); + mkdirSync(elsewhere, { recursive: true }); + rmSync(join(exportDir, 'projects'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(exportDir, 'projects')); + + expect(() => exportProjectContext({ directory: exportDir, registry, now: new Date('2026-07-13T01:00:00Z') })) + .toThrow(/symlinked directory/); + // Nothing was written through the link. + expect(readFileSync(join(exportDir, 'manifest.json'), 'utf8')).toContain('2026-07-13T00:00:00'); + }); + it('refuses a non-empty unmanaged target instead of overwriting user files', () => { mkdirSync(exportDir, { recursive: true }); writeFileSync(join(exportDir, 'notes.md'), 'human-authored\n'); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 89d2376..1de9239 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -337,13 +337,12 @@ export function createServer(dbPath?: string, options: CreateServerOptions = {}) attention_cadence_days: (a.attention_cadence_days === null ? null : a.attention_cadence_days as number | undefined), next_review_at: (a.next_review_at === null ? null : a.next_review_at as string | undefined), priority_reason: (a.priority_reason === null ? null : a.priority_reason as string | undefined), + // A1.1: optional replace-semantics paths update, applied inside + // updateCore's transaction so a combined update (fields + paths) + // is all-or-nothing — an invalid path can't land after the other + // fields already committed. + paths: a.paths as string[] | undefined, }); - // A1.1: optional replace-semantics paths update, wired to the real - // writer (Registry.setProjectPaths) rather than the old dead-letter - // 'paths' free-text field. - if (a.paths !== undefined) { - registry.setProjectPaths(a.name as string, a.paths as string[]); - } // update_project return — internal post-update read, not a user-initiated // get_project. Suppress the interactions log to keep S204 signal clean. result = registry.getProject(a.name as string, 'summary', { logInteraction: false }); From a90d34ff4076ea096ea0df26040eb62f66e0990d Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 23:40:31 -0400 Subject: [PATCH 08/22] Address Codex review round 6 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/fields.ts | 9 ++++-- packages/core/src/registry.ts | 45 ++++++++++++++++++++------- packages/core/tests/attention.test.ts | 21 +++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/core/src/fields.ts b/packages/core/src/fields.ts index 79f734c..79fdc6c 100644 --- a/packages/core/src/fields.ts +++ b/packages/core/src/fields.ts @@ -26,13 +26,16 @@ export function deserializeFieldValue(value: string): string | string[] { /** * Write extended fields for a project, respecting producer ownership. * Fields owned by a different producer are skipped (not overwritten). + * Returns the number of rows actually written, so callers can distinguish + * a genuine write from an entirely-skipped (producer-owned) call — a + * skipped call must not count as project activity. */ export function writeFields( db: Database.Database, projectId: number, fields: Record, producer: string, -): void { +): number { const upsert = db.prepare(` INSERT INTO project_fields (project_id, field_name, field_value, producer, updated_at) VALUES (?, ?, ?, ?, datetime('now')) @@ -43,8 +46,10 @@ export function writeFields( WHERE project_fields.producer = excluded.producer `); + let written = 0; for (const [name, value] of Object.entries(fields)) { if (value === undefined || value === null) continue; - upsert.run(projectId, name, serializeFieldValue(value), producer); + written += upsert.run(projectId, name, serializeFieldValue(value), producer).changes; } + return written; } diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 37d9da2..8a81f02 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -821,7 +821,7 @@ export class Registry { }): void { const db = this.open(); try { - const row = db.prepare('SELECT id, type FROM projects WHERE name = ?').get(name) as { id: number; type: string } | undefined; + const row = db.prepare('SELECT * FROM projects WHERE name = ?').get(name) as Record & { id: number; type: string } | undefined; if (!row) { const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; const closest = findClosestMatch(name, allNames.map(r => r.name)); @@ -832,18 +832,27 @@ export class Registry { validateStatus(row.type, updates.status); } + // No-op writes are dropped BEFORE they can bump updated_at: updated_at + // is a meaningful-touch source for assess_attention, so an update that + // merely echoes the stored value (same status, identical description, + // identical paths) must not make a neglected project read as touched. const sets: string[] = []; const params: unknown[] = []; - if (updates.display_name !== undefined) { sets.push('display_name = ?'); params.push(updates.display_name); } - if (updates.status !== undefined) { sets.push('status = ?'); params.push(updates.status); } - if (updates.description !== undefined) { sets.push('description = ?'); params.push(updates.description); } - if (updates.goals !== undefined) { sets.push('goals = ?'); params.push(this._serializeGoals(updates.goals)); } + const setIfChanged = (column: string, next: unknown) => { + if (next !== ((row[column] as unknown) ?? null)) { + sets.push(`${column} = ?`); + params.push(next); + } + }; + if (updates.display_name !== undefined) setIfChanged('display_name', updates.display_name); + if (updates.status !== undefined) setIfChanged('status', updates.status); + if (updates.description !== undefined) setIfChanged('description', updates.description); + if (updates.goals !== undefined) setIfChanged('goals', this._serializeGoals(updates.goals)); if (updates.email_account !== undefined) { // spec 0.29: null or empty string clears to NULL (S165, S168). const next = updates.email_account == null || updates.email_account === '' ? null : updates.email_account; - sets.push('email_account = ?'); - params.push(next); + setIfChanged('email_account', next); } // v20 attention contract: validated writes that deliberately do NOT bump @@ -908,18 +917,27 @@ export class Registry { // applied inside the same transaction — a combined update with an // invalid area or a parent cycle must fail with nothing persisted, // not after priority/cadence/description already landed. - const areaId: number | null | undefined = updates.area === undefined + let areaId: number | null | undefined = updates.area === undefined ? undefined : updates.area === null ? null : this.resolveAreaIdOrThrow(db, updates.area); - const parentId: number | null | undefined = updates.parent_project === undefined + if (areaId !== undefined && areaId === ((row.area_id as number | null) ?? null)) areaId = undefined; + let parentId: number | null | undefined = updates.parent_project === undefined ? undefined : this.resolveParentIdOrThrow(db, row.id, name, updates.parent_project); + if (parentId !== undefined && parentId === ((row.parent_project_id as number | null) ?? null)) parentId = undefined; let normalizedPaths: string[] | undefined; if (updates.paths !== undefined) { if (!Array.isArray(updates.paths)) { throw new InvalidInputError("'paths' must be an array of strings."); } normalizedPaths = validateAndNormalizePaths(updates.paths); + // Identical replacement (same set of paths) is a no-op, not a touch. + const current = (db.prepare('SELECT path FROM project_paths WHERE project_id = ? ORDER BY path') + .all(row.id) as { path: string }[]).map(r => r.path); + const nextSorted = [...normalizedPaths].sort(); + if (nextSorted.length === current.length && nextSorted.every((p, i) => p === current[i])) { + normalizedPaths = undefined; + } } const touchedCore = @@ -1580,9 +1598,14 @@ export class Registry { } // Spec 0.34: normalize open-vocabulary fields at the write boundary. - writeFields(db, row.id, normalizeRecord(fields), producer); + const written = writeFields(db, row.id, normalizeRecord(fields), producer); - db.prepare("UPDATE projects SET updated_at = datetime('now') WHERE id = ?").run(row.id); + // updated_at is a meaningful-touch source for assess_attention: bump it + // only when something was actually written — a call whose every field + // was skipped by producer ownership is not project activity. + if (written > 0) { + db.prepare("UPDATE projects SET updated_at = datetime('now') WHERE id = ?").run(row.id); + } } finally { db.close(); } diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index d1b1351..815b46f 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -166,6 +166,27 @@ describe('AttentionAssessor', () => { expect(r.days_since_meaningful_touch).toBe(20); }); + it('scenario 6: no-op writes do not reset the meaningful touch', () => { + registerConfigured('echoed', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); + registry.updateCore('echoed', { description: 'settled description' }); + registry.updateFields('echoed', { tech_stack: 'TypeScript' }, 'owner-a'); + backdateProject('echoed', daysAgoSqlite(20)); + + // Echoing stored values back is not a touch… + registry.updateCore('echoed', { status: 'active', description: 'settled description' }); + // …nor is an identical paths replacement… + registry.updateCore('echoed', { paths: [] }); + // …nor a producer-owned field write that gets skipped entirely. + registry.updateFields('echoed', { tech_stack: 'Rust' }, 'other-producer'); + + const r = attention.assessProject('echoed', { noCache: true }); + expect(r.state).toBe('undernourished'); + expect(r.days_since_meaningful_touch).toBe(20); + // The skipped write really was skipped (producer ownership intact). + const p = registry.getProject('echoed') as { fields: Record }; + expect(String(p.fields.tech_stack)).toContain('typescript'); + }); + // ── Acceptance scenario 7: substantive events update the touch ──── it('scenario 7: a substantive retained memory updates the meaningful touch', () => { From 0225a699944198ac1b7bd8420c15fa79dc1566b7 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Mon, 13 Jul 2026 23:53:58 -0400 Subject: [PATCH 09/22] Address Codex review round 7 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- SURFACES.md | 12 +++ packages/core/src/context-export.ts | 138 +++++++++++++++----------- packages/core/src/fields.ts | 9 +- packages/core/tests/attention.test.ts | 4 +- 4 files changed, 102 insertions(+), 61 deletions(-) diff --git a/SURFACES.md b/SURFACES.md index 40b0abe..b73c383 100644 --- a/SURFACES.md +++ b/SURFACES.md @@ -44,6 +44,18 @@ cross-cutting edge without looking like they do. `window.setlist.*` IPC bridge and import `@setlist/core` *type-only*; the native binding runs in the main process. A value import of core or better-sqlite3 in the renderer breaks the single-canonical-state guarantee. *(INVARIANTS → "State ownership".)* +- **Offline context export ↔ brief/attention shape.** `exportProjectContext` + (`packages/core/src/context-export.ts`) republishes `ProjectBrief` and + `AttentionAssessment` verbatim into per-project JSON/Markdown, `manifest.json` + (written last — the completeness boundary), and `INDEX.md`, under the + versioned `setlist-project-context.v*` contract; a launchd job installed by + `setlist context install` (`packages/cli/src/context-export-schedule.ts`) + refreshes it on DB change + a periodic interval. Changing the brief shape, + the attention assessment shape, or manifest/index fields is a versioned- + contract change: bump `PROJECT_CONTEXT_SCHEMA_VERSION`, keep the export + generation reading from one immutable DB snapshot (VACUUM INTO), and keep + offline results agreeing with live `assess_attention`/`get_project_brief` + (`packages/core/tests/context-export.test.ts` pins the agreement). - **Core projection shape ↔ consumer types.** `toFull` / `toStandard` / `toSummary` (`packages/core/src/models.ts`) drop a field when its value is empty, so a consumer that types a list/optional field as always-present crashes on the omit. The renderer's diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index 8680c12..2a9bfc9 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -2,17 +2,19 @@ import { existsSync, lstatSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, tmpdir as osTmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { dirname, join, resolve, sep } from 'node:path'; import { RegistryError } from './errors.js'; import type { ProjectBrief } from './project-brief.js'; +import { connect } from './db.js'; import { Registry } from './registry.js'; import { AttentionAssessor, type AttentionAssessment } from './attention.js'; @@ -104,70 +106,92 @@ export function defaultProjectContextDirectory(home: string = homedir()): string */ export function exportProjectContext(options: ProjectContextExportOptions = {}): ProjectContextExportResult { const directory = resolve(options.directory ?? defaultProjectContextDirectory()); - const registry = options.registry ?? new Registry(); + const liveRegistry = options.registry ?? new Registry(); const exportedAt = (options.now ?? new Date()).toISOString(); const generationId = exportedAt; assertManagedDirectory(directory); - const projectRows = registry.listProjects({ depth: 'minimal' }) as Array<{ name: string }>; - const briefs = projectRows - .map(row => registry.getProjectBrief(row.name)) - .sort((a, b) => a.project.name.localeCompare(b.project.name)); + // One immutable database snapshot feeds the WHOLE generation. Briefs and + // attention assessments are many separate reads; against the live database + // a concurrent writer (app / CLI / MCP) could land between them and produce + // a project file whose brief and attention result describe different + // states. VACUUM INTO is the registry's WAL-safe snapshot primitive; the + // side benefit is that generation reads never touch the live interactions + // log (an export is not project activity). + const snapshotDir = mkdtempSync(join(osTmpdir(), 'setlist-context-export-src-')); + const snapshotDbPath = join(snapshotDir, 'registry-snapshot.db'); + const sourceDb = connect(liveRegistry.dbPath); + try { + sourceDb.prepare('VACUUM INTO ?').run(snapshotDbPath); + } finally { + sourceDb.close(); + } - // Attention assessments ride along so offline readers agree with the live - // assess_attention result at export time. Reading/exporting never counts as - // a meaningful touch, so the export itself cannot nourish a project. - const attentionAssessor = new AttentionAssessor(registry.dbPath); + try { + const registry = new Registry(snapshotDbPath); + const attentionAssessor = new AttentionAssessor(snapshotDbPath); + + const projectRows = registry.listProjects({ depth: 'minimal' }) as Array<{ name: string }>; + const briefs = projectRows + .map(row => registry.getProjectBrief(row.name)) + .sort((a, b) => a.project.name.localeCompare(b.project.name)); + + // Attention assessments ride along so offline readers agree with the live + // assess_attention result at export time. Reading/exporting never counts + // as a meaningful touch, so the export itself cannot nourish a project. + // Build the entire generation before touching the filesystem. A failed + // project inspection therefore leaves the previous exported generation + // as-is. + const snapshots = briefs.map(brief => buildSnapshot( + brief, + attentionAssessor.assessProject(brief.project.name, { now: options.now ?? new Date(exportedAt) }), + generationId, + exportedAt, + )); + const basenames = computeProjectContextBasenames(snapshots.map(s => s.brief.project.name)); + const manifest = buildManifest(snapshots, basenames, generationId, exportedAt); + const renderedProjects = snapshots.map(snapshot => ({ + basename: basenames.get(snapshot.brief.project.name)!, + json: `${JSON.stringify(snapshot, null, 2)}\n`, + markdown: renderProjectContextMarkdown(snapshot), + })); + const readme = renderReadme(); + const index = renderProjectContextIndex(manifest); + + assertNotSymlink(directory); + assertNotSymlink(join(directory, 'projects')); + mkdirSync(join(directory, 'projects'), { recursive: true }); + atomicWrite(join(directory, PROJECT_CONTEXT_OWNER_FILE), `${JSON.stringify({ + owner: 'setlist', + schema_version: PROJECT_CONTEXT_SCHEMA_VERSION, + }, null, 2)}\n`); + atomicWrite(join(directory, 'README.md'), readme); + for (const project of renderedProjects) { + const jsonPath = join(directory, 'projects', `${project.basename}.json`); + const markdownPath = join(directory, 'projects', `${project.basename}.md`); + assertWithinDirectory(directory, jsonPath); + assertWithinDirectory(directory, markdownPath); + atomicWrite(jsonPath, project.json); + atomicWrite(markdownPath, project.markdown); + } + atomicWrite(join(directory, 'INDEX.md'), index); + // Commit marker for cross-device readers. This is intentionally last. + atomicWrite(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - // Build the entire generation before touching the filesystem. A failed - // project inspection therefore leaves the previous exported generation as-is. - const snapshots = briefs.map(brief => buildSnapshot( - brief, - attentionAssessor.assessProject(brief.project.name, { now: options.now ?? new Date(exportedAt) }), - generationId, - exportedAt, - )); - const basenames = computeProjectContextBasenames(snapshots.map(s => s.brief.project.name)); - const manifest = buildManifest(snapshots, basenames, generationId, exportedAt); - const renderedProjects = snapshots.map(snapshot => ({ - basename: basenames.get(snapshot.brief.project.name)!, - json: `${JSON.stringify(snapshot, null, 2)}\n`, - markdown: renderProjectContextMarkdown(snapshot), - })); - const readme = renderReadme(); - const index = renderProjectContextIndex(manifest); - - assertNotSymlink(directory); - assertNotSymlink(join(directory, 'projects')); - mkdirSync(join(directory, 'projects'), { recursive: true }); - atomicWrite(join(directory, PROJECT_CONTEXT_OWNER_FILE), `${JSON.stringify({ - owner: 'setlist', - schema_version: PROJECT_CONTEXT_SCHEMA_VERSION, - }, null, 2)}\n`); - atomicWrite(join(directory, 'README.md'), readme); - for (const project of renderedProjects) { - const jsonPath = join(directory, 'projects', `${project.basename}.json`); - const markdownPath = join(directory, 'projects', `${project.basename}.md`); - assertWithinDirectory(directory, jsonPath); - assertWithinDirectory(directory, markdownPath); - atomicWrite(jsonPath, project.json); - atomicWrite(markdownPath, project.markdown); + return { + directory, + generation_id: generationId, + exported_at: exportedAt, + project_count: manifest.project_count, + active_project_count: manifest.active_project_count, + files_written: renderedProjects.length * 2 + 4, + manifest_path: join(directory, 'manifest.json'), + index_path: join(directory, 'INDEX.md'), + }; + } finally { + rmSync(snapshotDir, { recursive: true, force: true }); } - atomicWrite(join(directory, 'INDEX.md'), index); - // Commit marker for cross-device readers. This is intentionally last. - atomicWrite(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - - return { - directory, - generation_id: generationId, - exported_at: exportedAt, - project_count: manifest.project_count, - active_project_count: manifest.active_project_count, - files_written: renderedProjects.length * 2 + 4, - manifest_path: join(directory, 'manifest.json'), - index_path: join(directory, 'INDEX.md'), - }; } /** diff --git a/packages/core/src/fields.ts b/packages/core/src/fields.ts index 79fdc6c..5bc15e5 100644 --- a/packages/core/src/fields.ts +++ b/packages/core/src/fields.ts @@ -26,9 +26,11 @@ export function deserializeFieldValue(value: string): string | string[] { /** * Write extended fields for a project, respecting producer ownership. * Fields owned by a different producer are skipped (not overwritten). - * Returns the number of rows actually written, so callers can distinguish - * a genuine write from an entirely-skipped (producer-owned) call — a - * skipped call must not count as project activity. + * Returns the number of rows whose stored value actually CHANGED — a call + * skipped by producer ownership, or one that re-writes the identical value + * (periodic reconciliation), must not count as project activity, because + * callers feed the count into the projects.updated_at meaningful-touch + * signal used by assess_attention. */ export function writeFields( db: Database.Database, @@ -44,6 +46,7 @@ export function writeFields( producer = excluded.producer, updated_at = excluded.updated_at WHERE project_fields.producer = excluded.producer + AND project_fields.field_value IS NOT excluded.field_value `); let written = 0; diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 815b46f..25620d7 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -176,8 +176,10 @@ describe('AttentionAssessor', () => { registry.updateCore('echoed', { status: 'active', description: 'settled description' }); // …nor is an identical paths replacement… registry.updateCore('echoed', { paths: [] }); - // …nor a producer-owned field write that gets skipped entirely. + // …nor a producer-owned field write that gets skipped entirely… registry.updateFields('echoed', { tech_stack: 'Rust' }, 'other-producer'); + // …nor the owner re-writing the identical value (periodic reconciliation). + registry.updateFields('echoed', { tech_stack: 'TypeScript' }, 'owner-a'); const r = attention.assessProject('echoed', { noCache: true }); expect(r.state).toBe('undernourished'); From 6ea0e9ae2cfd92cbfbb233402ae67c725946ada4 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 00:31:10 -0400 Subject: [PATCH 10/22] Address Codex review round 8 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/registry.ts | 33 ++++++++++++++++++--------- packages/core/tests/attention.test.ts | 6 ++++- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 8a81f02..9c12820 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -1532,17 +1532,28 @@ export class Registry { ? normalizeFreeList([...existingConcerns, ...profile.concerns]) : existingConcerns; - // Write back - db.prepare(`UPDATE projects SET - goals = ?, topics = ?, entities = ?, concerns = ?, - updated_at = datetime('now') - WHERE id = ?`).run( - JSON.stringify(mergedGoals), - JSON.stringify(mergedTopics), - JSON.stringify(mergedEntities), - JSON.stringify(mergedConcerns), - row.id, - ); + // Write back — but only when the union actually changed something. + // updated_at feeds assess_attention's meaningful-touch signal, so an + // empty enrich_project call (or periodic reconciliation re-sending + // values already stored) must not make a neglected project read as + // nourished. + const changed = + JSON.stringify(mergedGoals) !== JSON.stringify(existingGoals) || + JSON.stringify(mergedTopics) !== JSON.stringify(existingTopics) || + JSON.stringify(mergedEntities) !== JSON.stringify(existingEntities) || + JSON.stringify(mergedConcerns) !== JSON.stringify(existingConcerns); + if (changed) { + db.prepare(`UPDATE projects SET + goals = ?, topics = ?, entities = ?, concerns = ?, + updated_at = datetime('now') + WHERE id = ?`).run( + JSON.stringify(mergedGoals), + JSON.stringify(mergedTopics), + JSON.stringify(mergedEntities), + JSON.stringify(mergedConcerns), + row.id, + ); + } return { name, goals: mergedGoals, topics: mergedTopics, entities: mergedEntities, concerns: mergedConcerns }; } finally { diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 25620d7..0835121 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -170,6 +170,7 @@ describe('AttentionAssessor', () => { registerConfigured('echoed', { priority_tier: 'critical', attention_mode: 'active', attention_cadence_days: 7 }); registry.updateCore('echoed', { description: 'settled description' }); registry.updateFields('echoed', { tech_stack: 'TypeScript' }, 'owner-a'); + registry.enrichProject('echoed', { topics: ['existing-topic'] }); backdateProject('echoed', daysAgoSqlite(20)); // Echoing stored values back is not a touch… @@ -178,8 +179,11 @@ describe('AttentionAssessor', () => { registry.updateCore('echoed', { paths: [] }); // …nor a producer-owned field write that gets skipped entirely… registry.updateFields('echoed', { tech_stack: 'Rust' }, 'other-producer'); - // …nor the owner re-writing the identical value (periodic reconciliation). + // …nor the owner re-writing the identical value (periodic reconciliation)… registry.updateFields('echoed', { tech_stack: 'TypeScript' }, 'owner-a'); + // …nor an enrichment call that adds nothing new. + registry.enrichProject('echoed', {}); + registry.enrichProject('echoed', { topics: ['existing-topic'] }); // union no-op const r = attention.assessProject('echoed', { noCache: true }); expect(r.state).toBe('undernourished'); From 48586557e9cbedc21e93f8f41bb27b943f117d0a Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 00:48:22 -0400 Subject: [PATCH 11/22] Complete the no-op-write sweep (Codex round 9 on PR #83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/registry.ts | 32 ++++++++++++++++++++------- packages/core/tests/attention.test.ts | 6 ++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 9c12820..3d34a54 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -1301,7 +1301,7 @@ export class Registry { setProjectArea(name: string, area: AreaName | string | null): Record { const db = this.open(); try { - const row = db.prepare('SELECT id FROM projects WHERE name = ?').get(name) as { id: number } | undefined; + const row = db.prepare('SELECT id, area_id FROM projects WHERE name = ?').get(name) as { id: number; area_id: number | null } | undefined; if (!row) { const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; throw new NotFoundError(name, findClosestMatch(name, allNames.map(r => r.name))); @@ -1310,9 +1310,13 @@ export class Registry { let areaId: number | null = null; if (area != null) areaId = this.resolveAreaIdOrThrow(db, area); - db.prepare( - "UPDATE projects SET area_id = ?, updated_at = datetime('now') WHERE id = ?" - ).run(areaId, row.id); + // Reapplying the current area is a semantic no-op — updated_at feeds + // assess_attention's meaningful-touch signal (D-016), so don't bump it. + if (areaId !== (row.area_id ?? null)) { + db.prepare( + "UPDATE projects SET area_id = ?, updated_at = datetime('now') WHERE id = ?" + ).run(areaId, row.id); + } const record = this.loadRecord(db, { projectId: row.id })!; return this.formatRecord(db, record, 'standard'); @@ -1329,16 +1333,19 @@ export class Registry { setParentProject(childName: string, parentName: string | null): Record { const db = this.open(); try { - const child = db.prepare('SELECT id FROM projects WHERE name = ?').get(childName) as { id: number } | undefined; + const child = db.prepare('SELECT id, parent_project_id FROM projects WHERE name = ?').get(childName) as { id: number; parent_project_id: number | null } | undefined; if (!child) { const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; throw new NotFoundError(childName, findClosestMatch(childName, allNames.map(r => r.name))); } const parentId = this.resolveParentIdOrThrow(db, child.id, childName, parentName); - db.prepare( - "UPDATE projects SET parent_project_id = ?, updated_at = datetime('now') WHERE id = ?" - ).run(parentId, child.id); + // Reapplying the current parent is a semantic no-op (D-016): no bump. + if (parentId !== (child.parent_project_id ?? null)) { + db.prepare( + "UPDATE projects SET parent_project_id = ?, updated_at = datetime('now') WHERE id = ?" + ).run(parentId, child.id); + } const record = this.loadRecord(db, { projectId: child.id })!; return this.formatRecord(db, record, 'standard'); @@ -1648,6 +1655,15 @@ export class Registry { throw new NotFoundError(name, findClosestMatch(name, allNames.map(r => r.name))); } + // Identical replacement is a semantic no-op — no rewrite, no + // updated_at bump (D-016: updated_at is a meaningful-touch signal). + const current = (db.prepare('SELECT path FROM project_paths WHERE project_id = ? ORDER BY path') + .all(row.id) as { path: string }[]).map(r => r.path); + const nextSorted = [...normalized].sort(); + if (nextSorted.length === current.length && nextSorted.every((p, i) => p === current[i])) { + return; + } + const doReplace = db.transaction(() => { db.prepare('DELETE FROM project_paths WHERE project_id = ?').run(row.id); diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 0835121..6fb94d9 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -181,9 +181,13 @@ describe('AttentionAssessor', () => { registry.updateFields('echoed', { tech_stack: 'Rust' }, 'other-producer'); // …nor the owner re-writing the identical value (periodic reconciliation)… registry.updateFields('echoed', { tech_stack: 'TypeScript' }, 'owner-a'); - // …nor an enrichment call that adds nothing new. + // …nor an enrichment call that adds nothing new… registry.enrichProject('echoed', {}); registry.enrichProject('echoed', { topics: ['existing-topic'] }); // union no-op + // …nor reapplying the current area/parent/paths through the setters. + registry.setProjectArea('echoed', null); + registry.setParentProject('echoed', null); + registry.setProjectPaths('echoed', []); const r = attention.assessProject('echoed', { noCache: true }); expect(r.state).toBe('undernourished'); From d602259a95817b7e0a0b86f67c0f4d7884872799 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 00:59:20 -0400 Subject: [PATCH 12/22] Address Codex review round 10 on PR #83 (3 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/registry.ts | 57 +++++++++++++++++++-------- packages/core/tests/attention.test.ts | 15 ++++++- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 3d34a54..5d00584 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -122,7 +122,17 @@ export function normalizeProjectPath(p: string): string { // as inert free text nobody read back (the dead-letter trap that ate a real // repair attempt for project `cosa`). Reserved names are rejected up front so a // caller is redirected to the real writer instead of a false "success". -export const RESERVED_FIELD_NAMES = new Set(['paths']); +// v20: the five attention-contract names are structured projects columns — +// writing them via write_fields would store an inert project_fields row that +// assess_attention never reads (a not_configured project would "look" set). +export const RESERVED_FIELD_NAMES = new Set([ + 'paths', + 'priority_tier', + 'attention_mode', + 'attention_cadence_days', + 'next_review_at', + 'priority_reason', +]); /** * Validate and normalize a caller-supplied path list for `setProjectPaths`. @@ -1234,7 +1244,7 @@ export class Registry { setProjectType(name: string, projectTypeId: number): Record { const db = this.open(); try { - const row = db.prepare('SELECT id FROM projects WHERE name = ?').get(name) as { id: number } | undefined; + const row = db.prepare('SELECT id, project_type_id FROM projects WHERE name = ?').get(name) as { id: number; project_type_id: number | null } | undefined; if (!row) { const allNames = db.prepare('SELECT name FROM projects').all() as { name: string }[]; throw new NotFoundError(name, findClosestMatch(name, allNames.map(r => r.name))); @@ -1243,9 +1253,12 @@ export class Registry { const projectType = db.prepare('SELECT id FROM project_types WHERE id = ?').get(projectTypeId) as { id: number } | undefined; if (!projectType) throw new InvalidProjectTypeError(projectTypeId); - db.prepare( - "UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE id = ?" - ).run(projectTypeId, row.id); + // Reapplying the current type is a semantic no-op (D-016): no bump. + if (projectTypeId !== (row.project_type_id ?? null)) { + db.prepare( + "UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE id = ?" + ).run(projectTypeId, row.id); + } const record = this.loadRecord(db, { projectId: row.id })!; return this.formatRecord(db, record, 'standard'); @@ -1599,10 +1612,12 @@ export class Registry { // nothing ever read it back. Point the caller at the real writer. for (const fieldName of Object.keys(fields)) { if (RESERVED_FIELD_NAMES.has(fieldName)) { + const redirect = fieldName === 'paths' + ? `Use update_project's 'paths' parameter (Registry.setProjectPaths) to manage project paths instead.` + : `Use update_project's '${fieldName}' parameter (Registry.updateCore) — the attention contract lives in structured project columns that assess_attention reads.`; throw new InvalidInputError( `'${fieldName}' is a reserved field name and cannot be written via write_fields — it would be stored as ` + - `inert free text in project_fields and never read back. Use update_project's 'paths' parameter ` + - `(Registry.setProjectPaths) to manage project paths instead.` + `inert free text in project_fields and never read back. ${redirect}` ); } } @@ -1705,7 +1720,7 @@ export class Registry { const db = this.open(); try { - let sql = 'SELECT id, name, type FROM projects'; + let sql = 'SELECT id, name, type, display_name, status, description, goals FROM projects'; const conditions: string[] = []; const params: unknown[] = []; @@ -1722,7 +1737,7 @@ export class Registry { } sql += ' WHERE ' + conditions.join(' AND '); - const matched = db.prepare(sql).all(...params) as { id: number; name: string; type: string }[]; + const matched = db.prepare(sql).all(...params) as { id: number; name: string; type: string; display_name: string; status: string; description: string; goals: string }[]; const names = matched.map(m => m.name); if (opts.dry_run) { @@ -1738,17 +1753,25 @@ export class Registry { const updateInTransaction = db.transaction(() => { for (const m of matched) { + // Per-project no-op detection (D-016): updated_at is a + // meaningful-touch signal, so a batch that merely re-applies the + // stored values (e.g. status_filter='active' + status='active') + // must not make every matched project read as freshly touched. const sets: string[] = []; const updateParams: unknown[] = []; - if (opts.display_name !== undefined) { sets.push('display_name = ?'); updateParams.push(opts.display_name); } - if (opts.status !== undefined) { sets.push('status = ?'); updateParams.push(opts.status); } - if (opts.description !== undefined) { sets.push('description = ?'); updateParams.push(opts.description); } - if (opts.goals !== undefined) { sets.push('goals = ?'); updateParams.push(this._serializeGoals(opts.goals)); } - sets.push("updated_at = datetime('now')"); - updateParams.push(m.id); - - db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...updateParams); + if (opts.display_name !== undefined && opts.display_name !== m.display_name) { sets.push('display_name = ?'); updateParams.push(opts.display_name); } + if (opts.status !== undefined && opts.status !== m.status) { sets.push('status = ?'); updateParams.push(opts.status); } + if (opts.description !== undefined && opts.description !== m.description) { sets.push('description = ?'); updateParams.push(opts.description); } + if (opts.goals !== undefined) { + const serialized = this._serializeGoals(opts.goals); + if (serialized !== m.goals) { sets.push('goals = ?'); updateParams.push(serialized); } + } + if (sets.length > 0) { + sets.push("updated_at = datetime('now')"); + updateParams.push(m.id); + db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...updateParams); + } // Archive cleanup if (opts.status === 'archived') { diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 6fb94d9..56ef206 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -184,10 +184,12 @@ describe('AttentionAssessor', () => { // …nor an enrichment call that adds nothing new… registry.enrichProject('echoed', {}); registry.enrichProject('echoed', { topics: ['existing-topic'] }); // union no-op - // …nor reapplying the current area/parent/paths through the setters. + // …nor reapplying the current area/parent/paths/type/status through the + // setters and batch update. registry.setProjectArea('echoed', null); registry.setParentProject('echoed', null); registry.setProjectPaths('echoed', []); + registry.batchUpdate({ status_filter: 'active', status: 'active' }); const r = attention.assessProject('echoed', { noCache: true }); expect(r.state).toBe('undernourished'); @@ -285,6 +287,17 @@ describe('AttentionAssessor', () => { // ── Contract validation at the write boundary ───────────────────── + it('write_fields rejects the attention field names as reserved (no dead-letter contract)', () => { + registry.register({ name: 'deadletter', type: 'project', status: 'active', description: 'd', goals: 'g' }); + expect(() => registry.updateFields('deadletter', { priority_tier: 'critical' }, 'test')) + .toThrow(/reserved field name/); + expect(() => registry.updateFields('deadletter', { attention_cadence_days: '7' }, 'test')) + .toThrow(/reserved field name/); + // The project remains genuinely unconfigured — nothing landed anywhere. + const r = attention.assessProject('deadletter', { noCache: true }); + expect(r.state).toBe('not_configured'); + }); + it('rejects invalid contract values with actionable errors', () => { registry.register({ name: 'v', type: 'project', status: 'active', description: 'd', goals: 'g' }); expect(() => registry.updateCore('v', { priority_tier: 'urgent' })).toThrow(/priority_tier/); From 6d9b5b720fcf7774d65351048cbdf26f5a67169d Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 08:47:40 -0400 Subject: [PATCH 13/22] Address Codex review round 11 on PR #83 (3 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 log -1 -- .` (containing worktree + folder-scoped) instead of requiring /.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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/context-export.ts | 71 +++++++++++++++++++--- packages/core/src/health.ts | 11 ++-- packages/core/tests/context-export.test.ts | 30 ++++++++- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index 2a9bfc9..72d00d9 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -111,6 +111,7 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): const generationId = exportedAt; assertManagedDirectory(directory); + const releaseLock = acquireExportLock(directory); // One immutable database snapshot feeds the WHOLE generation. Briefs and // attention assessments are many separate reads; against the live database @@ -119,16 +120,22 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): // states. VACUUM INTO is the registry's WAL-safe snapshot primitive; the // side benefit is that generation reads never touch the live interactions // log (an export is not project activity). - const snapshotDir = mkdtempSync(join(osTmpdir(), 'setlist-context-export-src-')); - const snapshotDbPath = join(snapshotDir, 'registry-snapshot.db'); - const sourceDb = connect(liveRegistry.dbPath); + let snapshotDir: string; try { - sourceDb.prepare('VACUUM INTO ?').run(snapshotDbPath); - } finally { - sourceDb.close(); + snapshotDir = mkdtempSync(join(osTmpdir(), 'setlist-context-export-src-')); + } catch (error) { + releaseLock(); + throw error; } + const snapshotDbPath = join(snapshotDir, 'registry-snapshot.db'); try { + const sourceDb = connect(liveRegistry.dbPath); + try { + sourceDb.prepare('VACUUM INTO ?').run(snapshotDbPath); + } finally { + sourceDb.close(); + } const registry = new Registry(snapshotDbPath); const attentionAssessor = new AttentionAssessor(snapshotDbPath); @@ -191,9 +198,54 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): }; } finally { rmSync(snapshotDir, { recursive: true, force: true }); + releaseLock(); } } +// ── Export mutex ──────────────────────────────────────────────────── +// Two concurrent exports into the same directory could interleave their +// per-file writes and commit mismatched manifests (both reporting success). +// `mkdir` of a lock directory is the atomic mutex; a stale lock (a crashed +// exporter) is broken after 10 minutes — far beyond any real generation time. + +const EXPORT_LOCK_DIRNAME = '.setlist-context-export-lock'; +const EXPORT_LOCK_STALE_MS = 10 * 60 * 1000; + +function acquireExportLock(directory: string): () => void { + const lockPath = join(directory, EXPORT_LOCK_DIRNAME); + mkdirSync(directory, { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + mkdirSync(lockPath); + return () => { + try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }; + } catch { + // Lock held. Break it only when it is stale (a crashed exporter). + try { + const age = Date.now() - lstatSync(lockPath).mtimeMs; + if (age > EXPORT_LOCK_STALE_MS) { + rmSync(lockPath, { recursive: true, force: true }); + continue; + } + } catch { + // Lock vanished between the failed mkdir and the stat — retry once. + continue; + } + throw new RegistryError( + 'CONTEXT_EXPORT_BUSY', + `Another export is already writing ${directory} (lock: ${lockPath}).`, + 'Wait for the running export to finish and retry. If no export is running, delete the lock directory.', + ); + } + } + throw new RegistryError( + 'CONTEXT_EXPORT_BUSY', + `Could not acquire the export lock at ${join(directory, EXPORT_LOCK_DIRNAME)}.`, + 'Retry; if this persists, delete the lock directory manually.', + ); +} + /** * Filesystem-safe basename for a project's export files. Registry names are * not character-validated (register/rename accept arbitrary strings), so a @@ -288,7 +340,8 @@ function assertNotSymlink(path: string): void { function assertManagedDirectory(directory: string): void { if (!existsSync(directory)) return; - const entries = readdirSync(directory); + // A leftover lock from a crashed export is ours, not user content. + const entries = readdirSync(directory).filter(entry => entry !== EXPORT_LOCK_DIRNAME); if (entries.length === 0 || existsSync(join(directory, PROJECT_CONTEXT_OWNER_FILE))) return; throw new RegistryError( 'CONTEXT_EXPORT_CONFLICT', @@ -361,6 +414,7 @@ export function renderProjectContextMarkdown(snapshot: ProjectContextSnapshot): '---', `schema_version: ${jsonScalar(snapshot.schema_version)}`, `project: ${jsonScalar(brief.project.name)}`, + `generation_id: ${jsonScalar(snapshot.generation_id)}`, `exported_at: ${jsonScalar(snapshot.exported_at)}`, `project_updated_at: ${jsonScalar(snapshot.project_updated_at)}`, `digest_generated_at: ${snapshot.digest_generated_at ? jsonScalar(snapshot.digest_generated_at) : 'null'}`, @@ -448,6 +502,9 @@ export function renderProjectContextIndex(manifest: ProjectContextManifest): str '> Derived, read-only fallback. Query live Setlist first; use these files when Setlist is unavailable.', '', `Exported: ${manifest.exported_at}`, + `Generation: ${manifest.generation_id}`, + '', + 'Only trust project files whose `generation_id` matches `manifest.json` — a mismatch means an interrupted export.', '', 'Freshness guidance: under 6 hours is normal orientation; 6–24 hours is aging; over 24 hours must be described as stale.', '', diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index 77da9b9..720ec48 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -1,7 +1,6 @@ // @fctry: #health-assessment import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import Database from 'better-sqlite3'; import { connect, getDbPath } from './db.js'; import { NotFoundError, findClosestMatch } from './errors.js'; @@ -394,10 +393,12 @@ export class HealthAssessor { export function latestCommitInPath(path: string): string | null { try { if (!path || !existsSync(path)) return null; - // Only run git if a .git directory (or gitfile) exists, to avoid - // expensive failures. - if (!existsSync(join(path, '.git'))) return null; - const out = execFileSync('git', ['-C', path, 'log', '-1', '--format=%cI'], { + // `-- .` scopes the query to commits touching THIS folder, and `git -C` + // resolves the containing worktree from any descendant — so a project + // registered at a monorepo subfolder still reports its git activity + // (requiring `/.git` here missed every nested registration). A + // non-repo path just fails fast into the catch. + const out = execFileSync('git', ['-C', path, 'log', '-1', '--format=%cI', '--', '.'], { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'], diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index e096127..c128d13 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -202,6 +202,34 @@ describe('project-context export', () => { expect(index).not.toMatch(/shelf.*undernourished/); }); + it('markdown and INDEX carry the generation_id so readers can verify against the manifest', () => { + registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); + const now = new Date('2026-07-14T00:00:00.000Z'); + exportProjectContext({ directory: exportDir, registry, now }); + + const generationId = readProjectContextManifest(exportDir)!.generation_id; + expect(readFileSync(join(exportDir, 'projects', 'alpha.md'), 'utf8')) + .toContain(`generation_id: ${JSON.stringify(generationId)}`); + expect(readFileSync(join(exportDir, 'INDEX.md'), 'utf8')) + .toContain(`Generation: ${generationId}`); + }); + + it('refuses a concurrent export into the same directory (lock), and breaks a stale lock', () => { + registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); + // Simulate a running exporter holding the lock. + mkdirSync(join(exportDir, '.setlist-context-export-lock'), { recursive: true }); + expect(() => exportProjectContext({ directory: exportDir, registry })) + .toThrow(/CONTEXT_EXPORT_BUSY/); + + // A stale lock (crashed exporter) is broken and the export proceeds. + const past = new Date(Date.now() - 11 * 60 * 1000); + utimesSync(join(exportDir, '.setlist-context-export-lock'), past, past); + const result = exportProjectContext({ directory: exportDir, registry }); + expect(result.project_count).toBe(1); + // The lock is released after a successful run. + expect(existsSync(join(exportDir, '.setlist-context-export-lock'))).toBe(false); + }); + it('refuses to export through a symlinked managed directory', () => { registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); // First generation establishes the managed dir, then projects/ is From 14d12fdf12e24db68c57457293cf301a9117739f Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 09:08:05 -0400 Subject: [PATCH 14/22] Address Codex review round 12 on PR #83 (2 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/attention.ts | 23 +++++++++++++++++-- packages/core/src/context-export.ts | 32 +++++++++++++++++++-------- packages/core/tests/attention.test.ts | 20 +++++++++++++++++ 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index 05f6e93..4c3c592 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -19,6 +19,7 @@ import Database from 'better-sqlite3'; import { connect, getDbPath } from './db.js'; import { NotFoundError, findClosestMatch } from './errors.js'; import { latestCommitInPath } from './health.js'; +import { resolveProjectName } from './project-resolution.js'; export type PriorityTier = 'critical' | 'important' | 'normal' | 'someday'; export type AttentionMode = 'active' | 'maintenance' | 'waiting' | 'paused'; @@ -363,11 +364,29 @@ export class AttentionAssessor { } try { + // Alias-union read (D-014): legacy memory rows can sit under a case + // variant of the canonical name or under the project's display name — + // the memory model preserves those identifiers and resolves them on + // reads. Each fuzzy alias is included only when the canonical resolver + // maps it unambiguously back to THIS project, so a shared display name + // (or a name differing only by case from another project) never + // attributes someone else's memories as this project's touch. + const displayName = String(row.display_name ?? '').trim(); + const conditions = ['project_id = ?']; + const identifierParams: string[] = [name]; + if (resolveProjectName(db, name.toLowerCase()) === name || resolveProjectName(db, name.toUpperCase()) === name) { + conditions.push('LOWER(project_id) = LOWER(?)'); + identifierParams.push(name); + } + if (displayName && resolveProjectName(db, displayName) === name) { + conditions.push('LOWER(project_id) = LOWER(?)'); + identifierParams.push(displayName); + } const placeholders = SUBSTANTIVE_MEMORY_TYPES.map(() => '?').join(', '); const m = db.prepare( `SELECT type, MAX(created_at) as t FROM memories - WHERE project_id = ? AND status = 'active' AND type IN (${placeholders})` - ).get(name, ...SUBSTANTIVE_MEMORY_TYPES) as { type: string | null; t: string | null } | undefined; + WHERE (${conditions.join(' OR ')}) AND status = 'active' AND type IN (${placeholders})` + ).get(...identifierParams, ...SUBSTANTIVE_MEMORY_TYPES) as { type: string | null; t: string | null } | undefined; if (m?.t) { candidates.push({ at: m.t, diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index 72d00d9..e435aa7 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -10,7 +10,7 @@ import { writeFileSync, } from 'node:fs'; import { homedir, tmpdir as osTmpdir } from 'node:os'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { dirname, join, resolve, sep } from 'node:path'; import { RegistryError } from './errors.js'; import type { ProjectBrief } from './project-brief.js'; @@ -213,23 +213,36 @@ const EXPORT_LOCK_STALE_MS = 10 * 60 * 1000; function acquireExportLock(directory: string): () => void { const lockPath = join(directory, EXPORT_LOCK_DIRNAME); + // Ownership token: release removes the lock ONLY while it still carries + // this token, so a slow exporter (or a stale-lock loser) can never delete + // a successor's lock and silently reopen the concurrent-write path. + const token = randomBytes(8).toString('hex'); mkdirSync(directory, { recursive: true }); - for (let attempt = 0; attempt < 2; attempt++) { + for (let attempt = 0; attempt < 3; attempt++) { try { - mkdirSync(lockPath); + mkdirSync(lockPath); // atomic: exactly one creator wins + writeFileSync(join(lockPath, 'owner'), token, 'utf8'); return () => { - try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + if (readFileSync(join(lockPath, 'owner'), 'utf8') === token) { + rmSync(lockPath, { recursive: true, force: true }); + } + } catch { /* lock already replaced or gone — never remove another owner's lock */ } }; } catch { - // Lock held. Break it only when it is stale (a crashed exporter). + // Lock held. Break it only when it is stale (a crashed exporter), and + // break it ATOMICALLY: rename has exactly one winner, so two exporters + // observing the same stale lock cannot both proceed. try { const age = Date.now() - lstatSync(lockPath).mtimeMs; if (age > EXPORT_LOCK_STALE_MS) { - rmSync(lockPath, { recursive: true, force: true }); + const trash = `${lockPath}.stale-${token}`; + renameSync(lockPath, trash); // loser of this rename throws and re-loops + rmSync(trash, { recursive: true, force: true }); continue; } } catch { - // Lock vanished between the failed mkdir and the stat — retry once. + // Lock vanished or takeover lost — re-loop and try to acquire fresh. continue; } throw new RegistryError( @@ -340,8 +353,9 @@ function assertNotSymlink(path: string): void { function assertManagedDirectory(directory: string): void { if (!existsSync(directory)) return; - // A leftover lock from a crashed export is ours, not user content. - const entries = readdirSync(directory).filter(entry => entry !== EXPORT_LOCK_DIRNAME); + // Leftover locks (or stale-takeover remnants) from a crashed export are + // ours, not user content. + const entries = readdirSync(directory).filter(entry => !entry.startsWith(EXPORT_LOCK_DIRNAME)); if (entries.length === 0 || existsSync(join(directory, PROJECT_CONTEXT_OWNER_FILE))) return; throw new RegistryError( 'CONTEXT_EXPORT_CONFLICT', diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 56ef206..6845086 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -213,6 +213,26 @@ describe('AttentionAssessor', () => { expect(r.last_meaningful_touch?.detail).toContain('decision'); }); + it('scenario 7: a substantive memory stored under a legacy alias (display name / case variant) counts', () => { + registry.register({ + name: 'aliased', display_name: 'Apogee', type: 'project', status: 'active', description: 'd', goals: 'g', + }); + registry.updateCore('aliased', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); + backdateProject('aliased', daysAgoSqlite(20)); + // Legacy rows: one under the display name, one under a case variant. + const db = connect(dbPath); + try { + db.prepare(`INSERT INTO memories (id, content, content_hash, type, scope, project_id, status, created_at, updated_at) + VALUES ('m-alias-1', 'Decision under display name', 'alias-hash-1', 'decision', 'project', 'Apogee', 'active', datetime('now'), datetime('now'))`).run(); + } finally { + db.close(); + } + + const r = attention.assessProject('aliased', { noCache: true }); + expect(r.state).toBe('nourished'); + expect(r.last_meaningful_touch?.source).toBe('memory'); + }); + it('scenario 7 counterpart: an ambient observation memory does NOT count as a touch', () => { registerConfigured('observed', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); backdateProject('observed', daysAgoSqlite(20)); From 4ea4dac3bb80b559edb8f55f392d53112a4c9a82 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 09:20:04 -0400 Subject: [PATCH 15/22] Address Codex review round 13 on PR #83 (2 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- CLAUDE.md | 2 +- INVARIANTS.md | 4 +- packages/core/src/attention.ts | 9 ++- packages/core/src/db.ts | 70 +++++++++++++++- packages/core/src/models.ts | 4 +- .../core/tests/migration-chaining.test.ts | 81 +++++++++++++++++++ 6 files changed, 161 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d06d8db..441f629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Setlist is the TypeScript implementation of the Project Registry — the active Spec 0.32 is **shipped through v0.6.1-beta.7**: user-managed areas and project types, user-composable bootstrap recipes, safe MCP client install/remove planning (now with a Take over action for unmanaged-entry conflicts), local workspace inspection (with an Overview tab Signals from disk panel), portable project briefs, per-project essence digests, the optional `email_account` field that drives the `mail-create-mailbox` bootstrap primitive, menu-bar persistence with a system tray plus configurable global project shortcuts, and the quit-prompt update handoff are all live. For origin and port history, see spec §1.5. -Running system: Schema v20 (adds the attention contract on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — driving the `assess_attention` surface; v19 added `projects.container` for the `projects/` taxonomy — non-code projects land at `//projects/` — and retargeted the Finance/Health seed directories to `~/Areas/home/Finances` and `~/Areas/personal/Health`; v18 added `areas.default_directory` for area-aware bootstrap; v17 added the interactions log; v16 added `projects.email_account` for the `mail-create-mailbox` primitive and builds on v15's digest `named_terms`, v14's `bootstrap_primitives` + `project_type_recipe_steps`, v13's user-managed `project_types`, v12's `project_digests`, v11's structural `area_id`/`parent_project_id`, and v10's unified memory types), 63 MCP tools, desktop UI sharing Chorus's design system with multiselect status filtering and archived project visibility. +Running system: Schema v20 (adds the attention contract on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — driving the `assess_attention` surface, plus the D-014-deferred memory constraints: `'revived'` in `memory_versions.change_type` and the `memory_edges` UNIQUE natural key; v19 added `projects.container` for the `projects/` taxonomy — non-code projects land at `//projects/` — and retargeted the Finance/Health seed directories to `~/Areas/home/Finances` and `~/Areas/personal/Health`; v18 added `areas.default_directory` for area-aware bootstrap; v17 added the interactions log; v16 added `projects.email_account` for the `mail-create-mailbox` primitive and builds on v15's digest `named_terms`, v14's `bootstrap_primitives` + `project_type_recipe_steps`, v13's user-managed `project_types`, v12's `project_digests`, v11's structural `area_id`/`parent_project_id`, and v10's unified memory types), 63 MCP tools, desktop UI sharing Chorus's design system with multiselect status filtering and archived project visibility. ## Commands diff --git a/INVARIANTS.md b/INVARIANTS.md index 257f12b..ecc4d16 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -30,7 +30,7 @@ ## Data & schema -- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 20` (`db.ts`, re-exported from `index.ts`; v20 added the five nullable attention-contract columns on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason` — as a structural step realized entirely by `ensureColumns` with no data-repair block, so existing projects migrate with an unconfigured contract; v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* +- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 20` (`db.ts`, re-exported from `index.ts`; v20 added the five nullable attention-contract columns on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason`, via `ensureColumns`, so existing projects migrate with an unconfigured contract — plus, via a v19→v20 block, the two constraints D-014 deferred to the next bump: `'revived'` in the `memory_versions.change_type` CHECK (shape-guarded table rebuild) and the `memory_edges` natural key as a real `UNIQUE(source_id, target_id, relationship_type)` index, created after a MIN(id)-keeps dedupe; v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* ## Contracts (the cross-surface edges) @@ -60,7 +60,7 @@ ## Open tensions (NOT invariants — tracked drift to resolve) -- **Schema-version labels reconciled at v20.** The running code is `SCHEMA_VERSION = 20` (`packages/core/src/db.ts`) — v20 added the attention-contract columns (`priority_tier` / `attention_mode` / `attention_cadence_days` / `next_review_at` / `priority_reason`); v19 added `projects.container` (the `projects/` taxonomy) and retargeted the Finance/Health seed dirs; v18 added `areas.default_directory`. `CLAUDE.md` and this file are reconciled to v20. The now-archived spec had penciled v18 for an unrelated, never-built set (`is_builtin` / `self_test_json` / `forked_from` / `decision_history_json`) — never migrated, moot. The code is the oracle. +- **Schema-version labels reconciled at v20.** The running code is `SCHEMA_VERSION = 20` (`packages/core/src/db.ts`) — v20 added the attention-contract columns (`priority_tier` / `attention_mode` / `attention_cadence_days` / `next_review_at` / `priority_reason`) and landed the D-014-deferred constraints (`'revived'` in `memory_versions.change_type`, UNIQUE `memory_edges` natural key); v19 added `projects.container` (the `projects/` taxonomy) and retargeted the Finance/Health seed dirs; v18 added `areas.default_directory`. `CLAUDE.md` and this file are reconciled to v20. The now-archived spec had penciled v18 for an unrelated, never-built set (`is_builtin` / `self_test_json` / `forked_from` / `decision_history_json`) — never migrated, moot. The code is the oracle. - **Archived-spec behaviors the code never implemented.** §5.2's "schema too new → refuse to open" hard-stop **is now implemented at open/init** (`upgradeSchema` throws `RegistryError` `SCHEMA_TOO_NEW` when the on-disk version exceeds `SCHEMA_VERSION`; audit A5 remediation, PR *migration-chaining-backup*) — largely resolved, with a documented residual: the fence is a *construction/init* check (`initDb` → `upgradeSchema`), not a per-write gate, so a process that writes through a bare `connect()` handle held against a future-shaped database is not stopped by it. "Refuse to open" is honoured for the `initDb`/`Registry` path; a lower-level bare-`connect()` writer is out of scope. Still open: §2/§3's "same-project re-claim of its own port is a no-op success" is absent from `claimPort` (it throws "already claimed" for any holder). Moot — the spec is retired and the code is the contract — but recorded so a future reader does not resurrect it as a bug against a dead spec. - **Renderer types are checked nowhere in CI.** `packages/app` builds via electron-vite/esbuild, which strips TypeScript types without checking them; the only renderer typecheck is `tsc --noEmit -p packages/app/tsconfig.renderer.json`, which is **not** in `check.yml` and currently reports 2 pre-existing errors (`RegisterProjectDialog.tsx:161`, `OverviewTab.tsx:55`). Renderer type lies therefore reach production — this is how #53 (a `paths` field typed required that core omits) shipped. Resolve by wiring the renderer `tsc` into the gate and clearing the two errors; that wiring is also the graduation path for the projection-optionality invariant above. - **CI runs only on `pull_request`, never on push to `main`.** `check.yml`'s trigger is `pull_request:` alone, so a direct or fast-forward push to `main` is unvalidated. A `package-lock.json` left out of sync with the `packages/skills` workspace (added in `494f778`) landed via the migration fast-forward and broke `npm ci` on every subsequent PR — invisible until #53 became the first PR to surface it. Resolve with a `push: branches: [main]` trigger or branch protection requiring PRs. diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index 4c3c592..ba40619 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -374,7 +374,14 @@ export class AttentionAssessor { const displayName = String(row.display_name ?? '').trim(); const conditions = ['project_id = ?']; const identifierParams: string[] = [name]; - if (resolveProjectName(db, name.toLowerCase()) === name || resolveProjectName(db, name.toUpperCase()) === name) { + // Case-variant alias: safe only when NO other project shares this name + // case-insensitively (with a casey/CASEY twin, a LOWER() match would + // union both projects' memories and let one project's decision nourish + // the other). Counting twins directly avoids the resolver's exact-name + // tier, which would "resolve" each spelling to itself and miss the twin. + const caseTwins = (db.prepare('SELECT COUNT(*) AS c FROM projects WHERE LOWER(name) = LOWER(?)') + .get(name) as { c: number }).c; + if (caseTwins <= 1) { conditions.push('LOWER(project_id) = LOWER(?)'); identifierParams.push(name); } diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index e46c858..204fd0e 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -12,7 +12,7 @@ import { seedBuiltinPrimitives, seedBuiltinRecipes } from './recipes/store.js'; import { normalizeList, normalize } from './vocab.js'; import { RegistryError } from './errors.js'; -export const SCHEMA_VERSION = 20; // v20: attention contract columns on projects (priority_tier, attention_mode, attention_cadence_days, next_review_at, priority_reason) +export const SCHEMA_VERSION = 20; // v20: attention contract columns on projects (priority_tier, attention_mode, attention_cadence_days, next_review_at, priority_reason) + deferred D-014 constraints ('revived' in memory_versions.change_type; UNIQUE memory_edges natural key) /** * Legacy alias kept for any callers that still expect the constant name. @@ -396,7 +396,7 @@ CREATE TABLE IF NOT EXISTS memory_versions ( memory_id TEXT NOT NULL REFERENCES memories(id), previous_content TEXT, author TEXT NOT NULL CHECK (author IN ('agent', 'user', 'system')), - change_type TEXT NOT NULL CHECK (change_type IN ('created', 'updated', 'corrected', 'archived', 'superseded')), + change_type TEXT NOT NULL CHECK (change_type IN ('created', 'updated', 'corrected', 'archived', 'superseded', 'revived')), timestamp TEXT NOT NULL ); @@ -812,8 +812,8 @@ const MIGRATION_STEPS: Record = { 13: migrateV13ToV14, // v13 → v14: bootstrap primitives + recipes 16: migrateV16ToV17, // v16 → v17: interactions log + vocab normalization backfill 18: migrateV18ToV19, // v18 → v19: projects.container + Finance/Health retarget - // 0,1,2,4,6,14,15,17,19 → structural no-ops (SCHEMA_SQL / ensureColumns handle them). - // v19 → v20 (attention contract) adds only nullable columns via ensureColumns. + 19: migrateV19ToV20, // v19 → v20: attention columns (ensureColumns) + deferred D-014 constraints + // 0,1,2,4,6,14,15,17 → structural no-ops (SCHEMA_SQL / ensureColumns handle them). }; function upgradeSchema(db: Database.Database): void { @@ -1261,6 +1261,68 @@ function migrateV18ToV19(db: Database.Database): void { retarget.run('~/Areas/personal/Health', 'Health', '~/Areas/health'); } +function migrateV19ToV20(db: Database.Database): void { + // v19 → v20. The five attention-contract columns are added by + // ensureColumns; this step lands the two constraints D-014 deferred to + // "the next schema-version bump": + // + // (a) 'revived' joins the memory_versions.change_type CHECK, so a + // revive-on-re-retain event is recorded as what it is instead of + // being mislabeled 'updated'. SQLite cannot ALTER a CHECK — the + // table is rebuilt, following the migrateV8ToV9 shape-guard + + // crash-re-entry pattern. + // (b) memory_edges gains its natural key as a real + // UNIQUE(source_id, target_id, relationship_type) index, closing + // the cross-process duplicate-edge race the reflection transaction + // alone could not. Existing duplicates are collapsed to the + // earliest row (MIN(id) tiebreak) before the index is created. + + const alreadyV20 = tableSql(db, 'memory_versions').includes("'revived'"); + if (alreadyV20) { + if (tableExists(db, 'memory_versions_v20')) db.exec(`DROP TABLE IF EXISTS memory_versions_v20`); + } else { + db.pragma('foreign_keys = OFF'); + try { + db.transaction(() => { + if (tableExists(db, 'memory_versions_v20') && !tableExists(db, 'memory_versions')) { + db.exec(`ALTER TABLE memory_versions_v20 RENAME TO memory_versions`); + } else { + db.exec(`DROP TABLE IF EXISTS memory_versions_v20`); + db.exec(` + CREATE TABLE memory_versions_v20 ( + id TEXT PRIMARY KEY, + memory_id TEXT NOT NULL REFERENCES memories(id), + previous_content TEXT, + author TEXT NOT NULL CHECK (author IN ('agent', 'user', 'system')), + change_type TEXT NOT NULL CHECK (change_type IN ('created', 'updated', 'corrected', 'archived', 'superseded', 'revived')), + timestamp TEXT NOT NULL + ) + `); + db.exec(`INSERT INTO memory_versions_v20 SELECT id, memory_id, previous_content, author, change_type, timestamp FROM memory_versions`); + db.exec(`DROP TABLE memory_versions`); + db.exec(`ALTER TABLE memory_versions_v20 RENAME TO memory_versions`); + } + db.exec(`CREATE INDEX IF NOT EXISTS idx_versions_memory ON memory_versions(memory_id)`); + })(); + } finally { + db.pragma('foreign_keys = ON'); + } + } + + db.transaction(() => { + db.exec(` + DELETE FROM memory_edges WHERE id NOT IN ( + SELECT MIN(id) FROM memory_edges + GROUP BY source_id, target_id, relationship_type + ) + `); + // Created here (post-dedupe), NOT in SCHEMA_SQL: SCHEMA_SQL runs on every + // open before migrations, and a unique index over still-duplicated legacy + // rows would fail the open. + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_natural_key ON memory_edges(source_id, target_id, relationship_type)`); + })(); +} + /** * v10 → v11 migration. Runs inside a transaction on the live database. * Steps: diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 42153da..ed4ab2b 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -47,7 +47,9 @@ export type MemoryBelief = 'fact' | 'opinion' | 'hypothesis'; export type MemoryScope = 'project' | 'area' | 'portfolio' | 'global'; export type MemoryStatus = 'active' | 'consolidating' | 'archived' | 'superseded'; export type MemoryEdgeType = 'updates' | 'extends' | 'derives' | 'contradicts' | 'caused_by' | 'related_to'; -export type MemoryChangeType = 'created' | 'updated' | 'corrected' | 'archived' | 'superseded'; +// v20: 'revived' records re-activation of an archived memory (D-014 +// revive-on-re-retain) instead of mislabeling it as a plain update. +export type MemoryChangeType = 'created' | 'updated' | 'corrected' | 'archived' | 'superseded' | 'revived'; export type TaskSchedule = 'now' | 'tonight' | 'weekly'; export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed'; diff --git a/packages/core/tests/migration-chaining.test.ts b/packages/core/tests/migration-chaining.test.ts index 7e87ede..bbacae2 100644 --- a/packages/core/tests/migration-chaining.test.ts +++ b/packages/core/tests/migration-chaining.test.ts @@ -313,3 +313,84 @@ describe('Migration chaining, fence, and backup abort (audit A5)', () => { } }); }); + +// v19 → v20 (D-014 deferred constraints landed with the attention contract) +describe('v20 constraints: memory_versions revived + memory_edges natural key', () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'setlist-v20-')); + dbPath = join(tmpDir, 'test.db'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function seedMemory(db: ReturnType, id: string) { + db.prepare(`INSERT INTO memories (id, content, content_hash, type, scope, status, created_at, updated_at) + VALUES (?, ?, ?, 'decision', 'project', 'active', datetime('now'), datetime('now'))`) + .run(id, `content-${id}`, `hash-${id}`); + } + + it("memory_versions accepts change_type 'revived' after init", () => { + initDb(dbPath); + const db = connect(dbPath); + try { + seedMemory(db, 'm1'); + db.prepare(`INSERT INTO memory_versions (id, memory_id, author, change_type, timestamp) + VALUES ('v1', 'm1', 'system', 'revived', datetime('now'))`).run(); + const row = db.prepare("SELECT change_type FROM memory_versions WHERE id = 'v1'").get() as { change_type: string }; + expect(row.change_type).toBe('revived'); + } finally { + db.close(); + } + }); + + it('memory_edges rejects a duplicate (source, target, relationship) after init', () => { + initDb(dbPath); + const db = connect(dbPath); + try { + seedMemory(db, 'a'); + seedMemory(db, 'b'); + const insert = db.prepare(`INSERT INTO memory_edges (id, source_id, target_id, relationship_type, created_at) + VALUES (?, 'a', 'b', 'related_to', datetime('now'))`); + insert.run('e1'); + expect(() => insert.run('e2')).toThrow(/UNIQUE/); + } finally { + db.close(); + } + }); + + it('collapses pre-existing duplicate edges to the earliest row during the v19→v20 step', () => { + initDb(dbPath); + const db = connect(dbPath); + try { + // Simulate a pre-v20 database: drop the index, plant duplicates, restamp v19. + db.exec('DROP INDEX IF EXISTS idx_edges_natural_key'); + seedMemory(db, 'a'); + seedMemory(db, 'b'); + const insert = db.prepare(`INSERT INTO memory_edges (id, source_id, target_id, relationship_type, created_at) + VALUES (?, 'a', 'b', 'related_to', datetime('now'))`); + insert.run('e1'); + insert.run('e2'); + insert.run('e3'); + db.prepare("UPDATE schema_meta SET value = '19' WHERE key = 'schema_version'").run(); + } finally { + db.close(); + } + + initDb(dbPath); // re-runs the v19→v20 step: dedupe + unique index + + const verify = connect(dbPath); + try { + const rows = verify.prepare("SELECT id FROM memory_edges ORDER BY id").all() as { id: string }[]; + expect(rows.map(r => r.id)).toEqual(['e1']); + const idx = verify.prepare("PRAGMA index_list('memory_edges')").all() as { name: string; unique: number }[]; + expect(idx.some(i => i.name === 'idx_edges_natural_key' && i.unique === 1)).toBe(true); + } finally { + verify.close(); + } + }); +}); From 3976f3561b0e3bbd3a13799a08ca01b5acd9a331 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 09:35:00 -0400 Subject: [PATCH 16/22] Address Codex review round 14 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/context-export.ts | 12 +++++++++++- packages/core/src/memory.ts | 4 +++- packages/core/tests/context-export.test.ts | 14 +++++++++++++- packages/core/tests/memory.test.ts | 12 ++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index e435aa7..ab500fb 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -110,6 +110,10 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): const exportedAt = (options.now ?? new Date()).toISOString(); const generationId = exportedAt; + // Refuse a symlinked export root BEFORE anything (lock included) touches + // it — otherwise the lock itself (or a stale-lock takeover) would follow + // the link and mutate the out-of-tree target the check exists to protect. + assertNotSymlink(directory); assertManagedDirectory(directory); const releaseLock = acquireExportLock(directory); @@ -153,6 +157,11 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): const snapshots = briefs.map(brief => buildSnapshot( brief, attentionAssessor.assessProject(brief.project.name, { now: options.now ?? new Date(exportedAt) }), + // Honest staleness (D-011): getProjectDigest computes path-based + // staleness with `null` for "unknowable" — the brief's inline digest + // defaults to `false` when no spec_version field exists, which would + // export an obsolete digest as confidently fresh. + registry.getProjectDigest(brief.project.name)?.stale ?? null, generationId, exportedAt, )); @@ -367,6 +376,7 @@ function assertManagedDirectory(directory: string): void { function buildSnapshot( brief: ProjectBrief, attention: AttentionAssessment, + digestStale: boolean | null, generationId: string, exportedAt: string, ): ProjectContextSnapshot { @@ -378,7 +388,7 @@ function buildSnapshot( exported_at: exportedAt, project_updated_at: brief.project.updated_at, digest_generated_at: brief.digest?.generated_at ?? null, - digest_stale: brief.digest?.stale ?? null, + digest_stale: digestStale, attention, brief, }; diff --git a/packages/core/src/memory.ts b/packages/core/src/memory.ts index 359c772..ab01bea 100644 --- a/packages/core/src/memory.ts +++ b/packages/core/src/memory.ts @@ -186,10 +186,12 @@ export class MemoryStore { `UPDATE memories SET status = 'active', importance = ?, reinforcement_count = ?, forget_reason = NULL, forget_after = NULL, valid_until = NULL, updated_at = ? WHERE id = ?` ).run(revivedImportance, newCount, now, existing.id); + // v20: 'revived' is a first-class change_type so history consumers + // can distinguish a revival from an ordinary edit. const reviveVersionId = newId(); db.prepare(` INSERT INTO memory_versions (id, memory_id, previous_content, author, change_type, timestamp) - VALUES (?, ?, ?, 'system', 'updated', ?) + VALUES (?, ?, ?, 'system', 'revived', ?) `).run(reviveVersionId, existing.id, `revived from status '${existing.status}'`, now); return { memory_id: existing.id, is_new: false, reinforcement_count: newCount, revived: true }; diff --git a/packages/core/tests/context-export.test.ts b/packages/core/tests/context-export.test.ts index c128d13..225a99f 100644 --- a/packages/core/tests/context-export.test.ts +++ b/packages/core/tests/context-export.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -230,6 +230,18 @@ describe('project-context export', () => { expect(existsSync(join(exportDir, '.setlist-context-export-lock'))).toBe(false); }); + it('refuses a symlinked export ROOT before locking or writing anything through it', () => { + registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); + const realTarget = join(tmpDir, 'real-target'); + mkdirSync(realTarget, { recursive: true }); + symlinkSync(realTarget, exportDir); + + expect(() => exportProjectContext({ directory: exportDir, registry })) + .toThrow(/symlinked directory/); + // Nothing (not even a lock) was created through the link. + expect(readdirSync(realTarget)).toEqual([]); + }); + it('refuses to export through a symlinked managed directory', () => { registry.register({ name: 'alpha', type: 'project', status: 'active', description: 'A.' }); // First generation establishes the managed dir, then projects/ is diff --git a/packages/core/tests/memory.test.ts b/packages/core/tests/memory.test.ts index 8022520..c27db4b 100644 --- a/packages/core/tests/memory.test.ts +++ b/packages/core/tests/memory.test.ts @@ -497,6 +497,18 @@ describe('Memory', () => { const details = store.inspectMemory(revived.memory_id); expect((details.memory as Record).status).toBe('active'); expect((details.memory as Record).forget_reason).toBeNull(); + + // v20: the revival is recorded as change_type 'revived', not 'updated', + // so history consumers can tell a revival from an ordinary edit. + const db = connect(dbPath); + try { + const version = db.prepare( + "SELECT change_type FROM memory_versions WHERE memory_id = ? ORDER BY timestamp DESC, rowid DESC LIMIT 1" + ).get(revived.memory_id) as { change_type: string }; + expect(version.change_type).toBe('revived'); + } finally { + db.close(); + } }); it('revival takes the higher of the existing and incoming importance', () => { From 8eec7e64ca786c07cce691c7ef265fdffdc54033 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 09:48:32 -0400 Subject: [PATCH 17/22] Address Codex review round 15 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/attention.ts | 6 +++++- packages/core/src/context-export.ts | 11 ++++------- packages/core/src/db.ts | 12 ++++++++++-- packages/core/src/project-brief.ts | 4 +++- packages/core/src/registry.ts | 7 ++++++- packages/core/tests/attention.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index ba40619..8743219 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -390,8 +390,12 @@ export class AttentionAssessor { identifierParams.push(displayName); } const placeholders = SUBSTANTIVE_MEMORY_TYPES.map(() => '?').join(', '); + // Per-row MAX(created_at, updated_at): a re-retained (reinforced) or + // revived memory bumps only updated_at, and that reinforcement is + // today's substantive activity — ordering by created_at alone would + // date it to its original creation. const m = db.prepare( - `SELECT type, MAX(created_at) as t FROM memories + `SELECT type, MAX(MAX(created_at, COALESCE(updated_at, created_at))) as t FROM memories WHERE (${conditions.join(' OR ')}) AND status = 'active' AND type IN (${placeholders})` ).get(...identifierParams, ...SUBSTANTIVE_MEMORY_TYPES) as { type: string | null; t: string | null } | undefined; if (m?.t) { diff --git a/packages/core/src/context-export.ts b/packages/core/src/context-export.ts index ab500fb..5273ba7 100644 --- a/packages/core/src/context-export.ts +++ b/packages/core/src/context-export.ts @@ -154,14 +154,12 @@ export function exportProjectContext(options: ProjectContextExportOptions = {}): // Build the entire generation before touching the filesystem. A failed // project inspection therefore leaves the previous exported generation // as-is. + // brief.digest.stale is the honest tri-state (loadProjectDigest shares + // getProjectDigest's path-based computation), so the top-level + // digest_stale and the embedded brief can never disagree. const snapshots = briefs.map(brief => buildSnapshot( brief, attentionAssessor.assessProject(brief.project.name, { now: options.now ?? new Date(exportedAt) }), - // Honest staleness (D-011): getProjectDigest computes path-based - // staleness with `null` for "unknowable" — the brief's inline digest - // defaults to `false` when no spec_version field exists, which would - // export an obsolete digest as confidently fresh. - registry.getProjectDigest(brief.project.name)?.stale ?? null, generationId, exportedAt, )); @@ -376,7 +374,6 @@ function assertManagedDirectory(directory: string): void { function buildSnapshot( brief: ProjectBrief, attention: AttentionAssessment, - digestStale: boolean | null, generationId: string, exportedAt: string, ): ProjectContextSnapshot { @@ -388,7 +385,7 @@ function buildSnapshot( exported_at: exportedAt, project_updated_at: brief.project.updated_at, digest_generated_at: brief.digest?.generated_at ?? null, - digest_stale: digestStale, + digest_stale: brief.digest?.stale ?? null, attention, brief, }; diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 204fd0e..5e8d898 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -1310,10 +1310,18 @@ function migrateV19ToV20(db: Database.Database): void { } db.transaction(() => { + // Keep the EARLIEST row per natural key (ids are random UUIDs, so + // MIN(id) would keep an arbitrary duplicate); id ASC is a deterministic + // tiebreaker for same-timestamp duplicates. db.exec(` DELETE FROM memory_edges WHERE id NOT IN ( - SELECT MIN(id) FROM memory_edges - GROUP BY source_id, target_id, relationship_type + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY source_id, target_id, relationship_type + ORDER BY created_at ASC, id ASC + ) AS rn + FROM memory_edges + ) WHERE rn = 1 ) `); // Created here (post-dedupe), NOT in SCHEMA_SQL: SCHEMA_SQL runs on every diff --git a/packages/core/src/project-brief.ts b/packages/core/src/project-brief.ts index 49ff529..ee3b598 100644 --- a/packages/core/src/project-brief.ts +++ b/packages/core/src/project-brief.ts @@ -11,7 +11,9 @@ export interface ProjectBriefDigest { generated_at: string; token_count: number | null; named_terms: string[]; - stale: boolean; + // Tri-state (D-011): true/false when computable from the registered path's + // version signal, null when genuinely unknowable (no path, no signal). + stale: boolean | null; } export interface ProjectBriefCapabilitySummary { diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 5d00584..71e7e64 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -2897,7 +2897,12 @@ export class Registry { | { digest_kind: string; digest_text: string; spec_version: string; producer: string; generated_at: string; token_count: number | null; named_terms: string | null } | undefined; if (!row) return null; + // Honest reads (D-011): reuse the same path-based staleness computation + // as getProjectDigest — the old spec_version-field compare defaulted to + // `false` when no project_fields spec_version existed, presenting an + // obsolete digest as confidently fresh in briefs and exports. const currentSpecVersion = this.loadCurrentSpecVersion(db, projectId); + const { stale } = this.computeDigestStaleness(db, projectId, row.spec_version, currentSpecVersion ?? undefined); return { digest_kind: row.digest_kind, digest_text: row.digest_text, @@ -2906,7 +2911,7 @@ export class Registry { generated_at: row.generated_at, token_count: row.token_count, named_terms: parseTermsField(row.named_terms), - stale: currentSpecVersion != null ? row.spec_version !== currentSpecVersion : false, + stale, }; } diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 6845086..7b1abfc 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -233,6 +233,29 @@ describe('AttentionAssessor', () => { expect(r.last_meaningful_touch?.source).toBe('memory'); }); + it('scenario 7: re-retaining (reinforcing) an old substantive memory counts as fresh activity', () => { + registerConfigured('reinforced', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); + const store = new MemoryStore(dbPath); + const first = store.retain({ content: 'Standing decision', type: 'decision', project_id: 'reinforced' }); + // Age the project AND the memory's original creation far past the cadence. + backdateProject('reinforced', daysAgoSqlite(30)); + const db = connect(dbPath); + try { + db.prepare('UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?') + .run(daysAgoSqlite(30), daysAgoSqlite(30), first.memory_id); + } finally { + db.close(); + } + expect(attention.assessProject('reinforced', { noCache: true }).state).toBe('undernourished'); + + // Re-asserting the same decision today bumps updated_at — that + // reinforcement is today's substantive activity. + store.retain({ content: 'Standing decision', type: 'decision', project_id: 'reinforced' }); + const r = attention.assessProject('reinforced', { noCache: true }); + expect(r.state).toBe('nourished'); + expect(r.last_meaningful_touch?.source).toBe('memory'); + }); + it('scenario 7 counterpart: an ambient observation memory does NOT count as a touch', () => { registerConfigured('observed', { priority_tier: 'important', attention_mode: 'active', attention_cadence_days: 7 }); backdateProject('observed', daysAgoSqlite(20)); From ccf535228c3a79999861ee7091431261a8015e21 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 09:59:54 -0400 Subject: [PATCH 18/22] Address Codex review round 16 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- INVARIANTS.md | 2 +- packages/app/src/renderer/components/tabs/OverviewTab.tsx | 6 ++++-- packages/app/src/renderer/lib/api.ts | 3 ++- packages/cli/src/index.ts | 3 ++- packages/core/src/models.ts | 3 ++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/INVARIANTS.md b/INVARIANTS.md index ecc4d16..c849528 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -30,7 +30,7 @@ ## Data & schema -- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 20` (`db.ts`, re-exported from `index.ts`; v20 added the five nullable attention-contract columns on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason`, via `ensureColumns`, so existing projects migrate with an unconfigured contract — plus, via a v19→v20 block, the two constraints D-014 deferred to the next bump: `'revived'` in the `memory_versions.change_type` CHECK (shape-guarded table rebuild) and the `memory_edges` natural key as a real `UNIQUE(source_id, target_id, relationship_type)` index, created after a MIN(id)-keeps dedupe; v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* +- **The SQLite schema version lives in the `schema_meta` table under key `schema_version`, and migrations are forward-only and idempotent: the `upgradeSchema` migration code in `packages/core/src/db.ts` is the sole contract for what each version means.** The version constant is `export const SCHEMA_VERSION = 20` (`db.ts`, re-exported from `index.ts`; v20 added the five nullable attention-contract columns on projects — `priority_tier`, `attention_mode`, `attention_cadence_days`, `next_review_at`, `priority_reason`, via `ensureColumns`, so existing projects migrate with an unconfigured contract — plus, via a v19→v20 block, the two constraints D-014 deferred to the next bump: `'revived'` in the `memory_versions.change_type` CHECK (shape-guarded table rebuild) and the `memory_edges` natural key as a real `UNIQUE(source_id, target_id, relationship_type)` index, created after a dedupe that keeps the earliest row per key (`created_at` ASC, `id` ASC tiebreak); v19 added the nullable `projects.container` column for the `projects/` taxonomy and, via a v18→v19 block, retargeted the exact-default Finance/Health seed directories to their nested homes while preserving user edits; v18 added the nullable `areas.default_directory` column for area-aware bootstrap). On open, `upgradeSchema(db)` reads the stored version from `schema_meta`, then **hard-refuses** (throws `RegistryError` `SCHEMA_TOO_NEW`) when the on-disk version is *newer* than this build's `SCHEMA_VERSION`. Scope of the fence, stated precisely: it fires only where `upgradeSchema` runs — inside `initDb()`, and therefore at `Registry` *construction* time (the constructor calls `initDb`) — **not** on a bare `connect()`. A long-lived process that already holds (or later opens) a bare `connect()` handle can still issue writes against a future-shaped database without re-tripping the fence, so the fence narrows but does not eliminate the four-tenant mixed-version hazard. And "before any `ALTER`" is exact but not "before any DDL whatsoever": `initDb` runs `SCHEMA_SQL` + `createFtsTriggers` — both `CREATE … IF NOT EXISTS` only, hence no-ops against an already-existing/future-shaped database — *before* it calls `upgradeSchema`; the refusal precedes every `ensureColumns` `ALTER` and every migration step, but those IF-NOT-EXISTS creates have already executed (harmlessly) by the time it throws. Otherwise it takes a one-time WAL-safe `.v.bak` snapshot via `VACUUM INTO` (which captures committed WAL data a raw file copy would miss, and **aborts** the whole migration if the snapshot cannot be written, rather than proceeding unprotected), *always* runs `ensureColumns(db)` (whose every `ALTER TABLE` is guarded by a `PRAGMA table_info` presence check so re-runs are no-ops), then runs a **per-version migration chain** — `MIGRATION_STEPS[N]` transforms version N→N+1, stamping `schema_meta` after each step and re-running `ensureColumns` between steps, so a database that jumps several versions (a restored `.bak`, a lagging machine) runs *every* intermediate data repair instead of flying past the ones whose exact from-version it skipped (seeds use `INSERT OR IGNORE`). Fresh installs still reach the current schema because `SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS` and the historical table-rebuild steps (v8→v9 / v9→v10 memories, v10→v11 projects) are shape-guarded to no-op on already-current tables and finish an interrupted rename on crash re-entry. A matching `currentVersion === SCHEMA_VERSION` is a silent no-op early-return (correct — nothing to migrate); the hard-refusal is reserved for a strictly *newer* on-disk version. If a released migration block is edited rather than appended, or the constant is bumped without a matching block, existing databases silently diverge from what their stamped version claims to contain. — **PROSE** (archived spec §5.2 *Schema Compatibility* at `.fctry/legacy-spec-era/spec.md` L1929 ["incremental and non-destructive… data must never be lost"], L2043-2056; embodied by `packages/core/src/db.ts` — `SCHEMA_VERSION`, `upgradeSchema`, `ensureColumns`). *Graduate to ENFORCED: a test that (a) asserts `SCHEMA_VERSION` equals the highest version referenced by any migration guard / `ensureColumns` block; (b) opens a fresh in-memory db via `initDb` and asserts `schema_meta.schema_version === SCHEMA_VERSION`; (c) runs `initDb` twice against the same file and asserts no error and unchanged schema (idempotency); (d) migrates a checked-in v8 fixture db forward and asserts every current table/column is present with no data loss; (e) a DDL-snapshot test that fails if a migration block for an already-released version changes (append-only enforcement).* ## Contracts (the cross-surface edges) diff --git a/packages/app/src/renderer/components/tabs/OverviewTab.tsx b/packages/app/src/renderer/components/tabs/OverviewTab.tsx index b506be8..caa2650 100644 --- a/packages/app/src/renderer/components/tabs/OverviewTab.tsx +++ b/packages/app/src/renderer/components/tabs/OverviewTab.tsx @@ -190,8 +190,10 @@ export function OverviewTab({ project, brief, onNavigate, healthRefreshNonce = 0 } function AgentBriefSection({ brief }: { brief: ProjectBrief }) { + // Tri-state (D-011): stale === null means freshness could not be verified + // (e.g. missing project path) — shown as unverified, never as current. const digestLabel = brief.digest - ? `Digest ${brief.digest.stale ? 'stale' : 'current'}` + ? `Digest ${brief.digest.stale === null ? 'unverified' : brief.digest.stale ? 'stale' : 'current'}` : 'No digest'; const mcpQuery = `get_project_brief({ name: "${brief.project.name}" })`; const cliQuery = `setlist brief ${brief.project.name} --json`; @@ -209,7 +211,7 @@ function AgentBriefSection({ brief }: { brief: ProjectBrief }) {
- + {brief.capabilities.count > 0 && } {brief.enrichment_gaps.length > 0 && }
diff --git a/packages/app/src/renderer/lib/api.ts b/packages/app/src/renderer/lib/api.ts index bee5a98..b532e46 100644 --- a/packages/app/src/renderer/lib/api.ts +++ b/packages/app/src/renderer/lib/api.ts @@ -405,7 +405,8 @@ export interface ProjectBrief { generated_at: string; token_count: number | null; named_terms: string[]; - stale: boolean; + // Tri-state (D-011): null = freshness unknowable, never "current". + stale: boolean | null; } | null; capabilities: { count: number; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e9f4507..2b946d0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -146,7 +146,8 @@ function printProjectBrief(brief: ProjectBrief): void { console.log(''); console.log(brief.purpose.summary ?? 'No project summary has been captured yet.'); if (brief.digest) { - const stale = brief.digest.stale ? 'stale' : 'fresh'; + // Tri-state (D-011): null means freshness is unknowable — never "fresh". + const stale = brief.digest.stale === null ? 'freshness unknown' : brief.digest.stale ? 'stale' : 'fresh'; console.log(`\nDigest: ${stale}, ${brief.digest.producer}, ${brief.digest.generated_at}`); console.log(brief.digest.digest_text); } else { diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index ed4ab2b..fc4051e 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -242,7 +242,8 @@ export interface ProjectDigest { producer: string; generated_at: string; token_count: number | null; - stale: boolean; + // Tri-state (D-011): null = freshness unknowable (no path / no signal). + stale: boolean | null; } /** Per-digest-kind size config. Keys are DigestKind values. */ From b75749758ae69579062b1cc663401374a9aca4c7 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 10:13:25 -0400 Subject: [PATCH 19/22] Address Codex review round 17 on PR #83 (2 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/cli/src/context-export-schedule.ts | 28 +++++++++++++++---- .../cli/tests/context-export-schedule.test.ts | 22 +++++++++++++++ packages/core/src/registry.ts | 21 +++++--------- packages/core/tests/project-brief.test.ts | 17 ++++++++--- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/context-export-schedule.ts b/packages/cli/src/context-export-schedule.ts index 7bf8785..25a7e6b 100644 --- a/packages/cli/src/context-export-schedule.ts +++ b/packages/cli/src/context-export-schedule.ts @@ -144,11 +144,27 @@ export function installContextExportSchedule(options: ContextExportScheduleOptio export function uninstallContextExportSchedule(options: ContextExportScheduleOptions = {}): ContextExportScheduleResult { const plan = planContextExportSchedule(options); + const launchctl = options.launchctl ?? defaultLaunchctl; + const domain = `gui/${process.getuid?.() ?? 501}`; if (!existsSync(plan.plist_path)) { + // A bootstrapped LaunchAgent survives its plist being deleted by hand — + // "no file" must not report not_installed while the exporter keeps + // running. Probe launchd by label and boot the orphan out. + let stillLoaded = false; + try { + launchctl(['print', `${domain}/${CONTEXT_EXPORT_LABEL}`]); + stillLoaded = true; + } catch { + // Not loaded — genuinely not installed. + } + if (stillLoaded) { + // Throws on failure — an orphaned job we cannot unload is an error, + // never a silent "not_installed". + launchctl(['bootout', `${domain}/${CONTEXT_EXPORT_LABEL}`]); + return { ...plan, status: 'uninstalled', backup_path: null }; + } return { ...plan, status: 'not_installed', backup_path: null }; } - const launchctl = options.launchctl ?? defaultLaunchctl; - const domain = `gui/${process.getuid?.() ?? 501}`; try { launchctl(['bootout', domain, plan.plist_path]); } catch { @@ -179,13 +195,15 @@ export function uninstallContextExportSchedule(options: ContextExportScheduleOpt export function contextExportScheduleStatus(options: ContextExportScheduleOptions = {}): 'loaded' | 'installed_not_loaded' | 'not_installed' { const plan = planContextExportSchedule(options); - if (!existsSync(plan.plist_path)) return 'not_installed'; + const launchctl = options.launchctl ?? defaultLaunchctl; const target = `gui/${process.getuid?.() ?? 501}/${CONTEXT_EXPORT_LABEL}`; + // Probe launchd FIRST: a bootstrapped job whose plist was hand-deleted is + // still running and must report 'loaded', not 'not_installed'. try { - execFileSync('/bin/launchctl', ['print', target], { stdio: 'ignore' }); + launchctl(['print', target]); return 'loaded'; } catch { - return 'installed_not_loaded'; + return existsSync(plan.plist_path) ? 'installed_not_loaded' : 'not_installed'; } } diff --git a/packages/cli/tests/context-export-schedule.test.ts b/packages/cli/tests/context-export-schedule.test.ts index fc826c1..16a202b 100644 --- a/packages/cli/tests/context-export-schedule.test.ts +++ b/packages/cli/tests/context-export-schedule.test.ts @@ -8,6 +8,7 @@ import { generateContextExportPlist, installContextExportSchedule, uninstallContextExportSchedule, + contextExportScheduleStatus, planContextExportSchedule, } from '../src/context-export-schedule.js'; @@ -137,6 +138,27 @@ describe('project-context export schedule', () => { expect(existsSync(plistPath)).toBe(false); }); + it('boots out an orphaned loaded job whose plist was hand-deleted (uninstall + status)', () => { + // No plist on disk, but launchd still has the job bootstrapped. + const calls: string[][] = []; + const launchctl = (args: string[]) => { + calls.push(args); + // `print` succeeds (job loaded); `bootout` succeeds. + }; + + expect(contextExportScheduleStatus({ home, launchctl })).toBe('loaded'); + const result = uninstallContextExportSchedule({ home, launchctl }); + expect(result.status).toBe('uninstalled'); + expect(calls.some(c => c[0] === 'bootout' && c[1].endsWith(`/${CONTEXT_EXPORT_LABEL}`))).toBe(true); + + // And when launchd genuinely has nothing, it is not_installed. + const notLoaded = (args: string[]) => { + if (args[0] === 'print') throw new Error('Could not find service'); + }; + expect(contextExportScheduleStatus({ home, launchctl: notLoaded })).toBe('not_installed'); + expect(uninstallContextExportSchedule({ home, launchctl: notLoaded }).status).toBe('not_installed'); + }); + it('removes the new plist when a first-time bootstrap fails (honest not_installed)', () => { const plistPath = join(home, 'Library', 'LaunchAgents', `${CONTEXT_EXPORT_LABEL}.plist`); const launchctl = (args: string[]) => { diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 71e7e64..2bdea56 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -2897,12 +2897,13 @@ export class Registry { | { digest_kind: string; digest_text: string; spec_version: string; producer: string; generated_at: string; token_count: number | null; named_terms: string | null } | undefined; if (!row) return null; - // Honest reads (D-011): reuse the same path-based staleness computation - // as getProjectDigest — the old spec_version-field compare defaulted to - // `false` when no project_fields spec_version existed, presenting an - // obsolete digest as confidently fresh in briefs and exports. - const currentSpecVersion = this.loadCurrentSpecVersion(db, projectId); - const { stale } = this.computeDigestStaleness(db, projectId, row.spec_version, currentSpecVersion ?? undefined); + // Honest reads (D-011): identical semantics to getProjectDigest with no + // caller override — the stored project_fields.spec_version is NOT a + // caller-supplied version, and passing it here would bypass the + // path-based tri-state check (a pathless project would report + // stale:false, and a changed filesystem could still read as fresh + // whenever the stored field happens to match the digest). + const { stale } = this.computeDigestStaleness(db, projectId, row.spec_version, undefined); return { digest_kind: row.digest_kind, digest_text: row.digest_text, @@ -2915,14 +2916,6 @@ export class Registry { }; } - private loadCurrentSpecVersion(db: Database.Database, projectId: number): string | null { - const row = db.prepare(` - SELECT field_value FROM project_fields - WHERE project_id = ? AND field_name = 'spec_version' - `).get(projectId) as { field_value: string } | undefined; - return row?.field_value || null; - } - private loadProjectTypeGitInit(db: Database.Database, projectTypeId: number | null): boolean | null { if (projectTypeId == null) return null; const row = db.prepare('SELECT git_init FROM project_types WHERE id = ?').get(projectTypeId) as { git_init: number } | undefined; diff --git a/packages/core/tests/project-brief.test.ts b/packages/core/tests/project-brief.test.ts index fc91f6c..c9777e9 100644 --- a/packages/core/tests/project-brief.test.ts +++ b/packages/core/tests/project-brief.test.ts @@ -53,7 +53,9 @@ describe('project agent brief', () => { // 'TypeScript' → 'typescript', 'SQLite' → 'sqlite', 'cli-command' → 'command'. expect(brief.profile.tech_stack).toEqual(['typescript', 'sqlite']); expect(brief.digest?.digest_text).toBe('Code project essence.'); - expect(brief.digest?.stale).toBe(false); + // Tri-state (D-011): /tmp/code-proj doesn't exist, so freshness is + // unknowable — null, never a confident false. + expect(brief.digest?.stale).toBeNull(); expect(brief.capabilities.count).toBe(2); expect(brief.capabilities.by_type).toEqual({ command: 1, tool: 1 }); expect(brief.operations.ports.map(p => p.port)).toEqual([3777]); @@ -160,7 +162,12 @@ describe('project agent brief', () => { expect(brief.enrichment_gaps).toEqual(['digest']); }); - it('marks a digest stale when the stored spec version differs from the registry field', () => { + it('reports digest freshness as unknowable (null) for a pathless project — honest tri-state', () => { + // Pre-D-011-fix behavior compared against the registry's spec_version + // FIELD and defaulted to false when absent — an obsolete digest read as + // confidently fresh. Freshness now shares getProjectDigest's path-based + // computation: with no registered path there is nothing to verify + // against, so the honest answer is null (unverified), not true or false. registry.register({ name: 'drift-proj', type: 'project', status: 'active', description: 'Has a digest.' }); registry.updateFields('drift-proj', { spec_version: '0.2.0' }, 'test'); registry.refreshProjectDigest({ @@ -172,7 +179,9 @@ describe('project agent brief', () => { const brief = registry.getProjectBrief('drift-proj'); - expect(brief.digest?.stale).toBe(true); - expect(brief.orientation_notes).toContain('Essence digest exists but is marked stale.'); + expect(brief.digest?.stale).toBeNull(); + // Matches getProjectDigest exactly — briefs and exports can't disagree. + expect(registry.getProjectDigest('drift-proj')?.stale).toBeNull(); + expect(brief.orientation_notes).toContain('Essence digest is available for agent orientation.'); }); }); From 14820bd6ea04af19fc7b2868d6190dc0bd5bb563 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 10:28:12 -0400 Subject: [PATCH 20/22] Address Codex review round 18 on PR #83 (2 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/attention.ts | 30 ++++++++++++++--------- packages/core/src/registry.ts | 35 ++++++++++++++++++--------- packages/core/tests/attention.test.ts | 35 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index 8743219..8935418 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -245,6 +245,15 @@ export class AttentionAssessor { }; } + // The latest meaningful touch is part of the assessment contract for + // EVERY non-archived state — a suppressed or unconfigured project still + // reports when it was last genuinely touched (and via what), so an + // assistant (or the offline export) never claims "none recorded" for + // real activity. + const touch = this.latestMeaningfulTouch(db, row); + const exactDaysSinceTouch = touch == null ? null : Math.max(0, (now.getTime() - epoch(touch.at)) / DAY_MS); + const daysSince = exactDaysSinceTouch == null ? null : Math.floor(exactDaysSinceTouch); + // Unconfigured contract → configuration gap, never a warning. if (!tier || !mode || ((mode === 'active' || mode === 'maintenance') && !cadence)) { const missing: string[] = []; @@ -254,8 +263,8 @@ export class AttentionAssessor { return { ...contractBase, state: 'not_configured', - last_meaningful_touch: null, - days_since_meaningful_touch: null, + last_meaningful_touch: touch, + days_since_meaningful_touch: daysSince, attention_debt: 0, reasons: [`attention contract not configured (missing ${missing.join(', ')})`], }; @@ -272,8 +281,8 @@ export class AttentionAssessor { return { ...contractBase, state: 'due_for_review', - last_meaningful_touch: null, - days_since_meaningful_touch: null, + last_meaningful_touch: touch, + days_since_meaningful_touch: daysSince, attention_debt: round3(weight * (1 + exactDaysOverdue / 30)), reasons: [ `${mode} project reached its review date ${formatDays(daysOverdue)} ago (${nextReviewAt})`, @@ -287,15 +296,14 @@ export class AttentionAssessor { return { ...contractBase, state: 'suppressed', - last_meaningful_touch: null, - days_since_meaningful_touch: null, + last_meaningful_touch: touch, + days_since_meaningful_touch: daysSince, attention_debt: 0, reasons: [reason], }; } // active / maintenance: measure meaningful touch against cadence. - const touch = this.latestMeaningfulTouch(db, row); // `updated_at` always exists, so a touch is always found; guard anyway. if (!touch) { return { @@ -312,18 +320,16 @@ export class AttentionAssessor { // hide up to 24h of overdue-ness on short cadences (a 1-day-cadence // project touched 47h ago must read overdue, not "1 day / 1 day = fine"). // The reported days_since stays a whole number for readability. - const exactDaysSince = Math.max(0, (now.getTime() - epoch(touch.at)) / DAY_MS); - const daysSince = Math.floor(exactDaysSince); - const ratio = exactDaysSince / (cadence as number); + const ratio = (exactDaysSinceTouch as number) / (cadence as number); const debt = round3(ratio * weight); - const touchReason = `last meaningful touch ${formatDays(daysSince)} ago via ${touch.detail}`; + const touchReason = `last meaningful touch ${formatDays(daysSince as number)} ago via ${touch.detail}`; const cadenceLabel = `${tier} ${mode} project expects attention every ${cadence} day${cadence === 1 ? '' : 's'}`; let state: AttentionState; const reasons: string[] = []; if (ratio > 1) { state = 'undernourished'; - reasons.push(`${cadenceLabel} but has gone ${formatDays(daysSince)} without one`); + reasons.push(`${cadenceLabel} but has gone ${formatDays(daysSince as number)} without one`); } else if (ratio > DUE_SOON_RATIO) { state = 'due_soon'; reasons.push(`${cadenceLabel} and is approaching that window`); diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 2bdea56..6ef0b92 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -34,6 +34,28 @@ import { inspectWorkspacePaths, type WorkspaceInspectionReport } from './workspa import { computeProjectVersion } from './project-version.js'; import { PRIORITY_TIERS, ATTENTION_MODES, type PriorityTier, type AttentionMode } from './attention.js'; +/** + * Reject reserved field names on EVERY writeFields entry point (register, + * updateFields) — a reserved name written as an extended field would be an + * inert dead-letter row: 'paths' has a real writer (setProjectPaths), and the + * five attention-contract names are structured projects columns that + * assess_attention reads (an "accepted" write here would leave the project + * reading as not_configured). + */ +function assertNoReservedFieldNames(fields: Record): void { + for (const fieldName of Object.keys(fields)) { + if (RESERVED_FIELD_NAMES.has(fieldName)) { + const redirect = fieldName === 'paths' + ? `Use update_project's 'paths' parameter (Registry.setProjectPaths) to manage project paths instead.` + : `Use update_project's '${fieldName}' parameter (Registry.updateCore) — the attention contract lives in structured project columns that assess_attention reads.`; + throw new InvalidInputError( + `'${fieldName}' is a reserved field name and cannot be written as an extended field — it would be stored as ` + + `inert free text in project_fields and never read back. ${redirect}` + ); + } + } +} + /** Trim a nullable string field; null or blank both clear (write NULL). */ function normalizeNullableString(value: string | null | undefined): string | null { if (value == null) return null; @@ -312,6 +334,7 @@ export class Registry { } if (opts.fields) { + assertNoReservedFieldNames(opts.fields); // Spec 0.34 (#capability-declarations 2.11): normalize open-vocabulary // fields once at the write boundary. Reads return whatever was stored. writeFields(db, projectId, normalizeRecord(opts.fields), producer); @@ -1610,17 +1633,7 @@ export class Registry { // A1.1: reject reserved field names before touching the DB — 'paths' // previously wrote silently to project_fields as inert free text and // nothing ever read it back. Point the caller at the real writer. - for (const fieldName of Object.keys(fields)) { - if (RESERVED_FIELD_NAMES.has(fieldName)) { - const redirect = fieldName === 'paths' - ? `Use update_project's 'paths' parameter (Registry.setProjectPaths) to manage project paths instead.` - : `Use update_project's '${fieldName}' parameter (Registry.updateCore) — the attention contract lives in structured project columns that assess_attention reads.`; - throw new InvalidInputError( - `'${fieldName}' is a reserved field name and cannot be written via write_fields — it would be stored as ` + - `inert free text in project_fields and never read back. ${redirect}` - ); - } - } + assertNoReservedFieldNames(fields); const db = this.open(); try { diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 7b1abfc..6b4e539 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -336,11 +336,46 @@ describe('AttentionAssessor', () => { .toThrow(/reserved field name/); expect(() => registry.updateFields('deadletter', { attention_cadence_days: '7' }, 'test')) .toThrow(/reserved field name/); + // Registration-time fields go through the same guard — every writeFields + // entry point rejects the reserved names, not just write_fields. + expect(() => registry.register({ + name: 'deadletter-2', type: 'project', status: 'active', description: 'd', + fields: { attention_mode: 'active' }, + })).toThrow(/reserved field name/); // The project remains genuinely unconfigured — nothing landed anywhere. const r = attention.assessProject('deadletter', { noCache: true }); expect(r.state).toBe('not_configured'); }); + it('suppressed, due-for-review, and unconfigured states still report the last meaningful touch', () => { + // Unconfigured project with real activity: touch is reported. + registry.register({ name: 'quiet-unset', type: 'project', status: 'active', description: 'd', goals: 'g' }); + backdateProject('quiet-unset', daysAgoSqlite(12)); + const unset = attention.assessProject('quiet-unset', { noCache: true }); + expect(unset.state).toBe('not_configured'); + expect(unset.last_meaningful_touch?.source).toBe('project-update'); + expect(unset.days_since_meaningful_touch).toBe(12); + + // Waiting project: suppressed, but the touch is still visible. + registerConfigured('quiet-waiting', { + priority_tier: 'normal', attention_mode: 'waiting', next_review_at: futureIso(30), + }); + backdateProject('quiet-waiting', daysAgoSqlite(40)); + const waiting = attention.assessProject('quiet-waiting', { noCache: true }); + expect(waiting.state).toBe('suppressed'); + expect(waiting.days_since_meaningful_touch).toBe(40); + + // Due-for-review: same. + registerConfigured('quiet-due', { + priority_tier: 'normal', attention_mode: 'paused', + next_review_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }); + backdateProject('quiet-due', daysAgoSqlite(50)); + const due = attention.assessProject('quiet-due', { noCache: true }); + expect(due.state).toBe('due_for_review'); + expect(due.days_since_meaningful_touch).toBe(50); + }); + it('rejects invalid contract values with actionable errors', () => { registry.register({ name: 'v', type: 'project', status: 'active', description: 'd', goals: 'g' }); expect(() => registry.updateCore('v', { priority_tier: 'urgent' })).toThrow(/priority_tier/); From 2b67dae058bec2a314990a1993c5ee8a33098721 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 10:36:54 -0400 Subject: [PATCH 21/22] Address Codex review round 19 on PR #83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/src/registry.ts | 6 +++++- packages/core/tests/attention.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 6ef0b92..319347f 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -252,6 +252,10 @@ export class Registry { ); } validateStatus(type, opts.status); + // Validate fields BEFORE any INSERT: rejecting a reserved field name + // after the project row and paths have landed would strand a + // half-registered project (and make the retry fail as a duplicate). + if (opts.fields) assertNoReservedFieldNames(opts.fields); const displayName = opts.display_name || opts.name; const producer = opts.producer ?? 'system'; @@ -334,7 +338,7 @@ export class Registry { } if (opts.fields) { - assertNoReservedFieldNames(opts.fields); + // Reserved names were rejected before any INSERT (top of register). // Spec 0.34 (#capability-declarations 2.11): normalize open-vocabulary // fields once at the write boundary. Reads return whatever was stored. writeFields(db, projectId, normalizeRecord(opts.fields), producer); diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 6b4e539..24536cc 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -342,6 +342,11 @@ describe('AttentionAssessor', () => { name: 'deadletter-2', type: 'project', status: 'active', description: 'd', fields: { attention_mode: 'active' }, })).toThrow(/reserved field name/); + // …and the rejection happens BEFORE any insert: no half-registered + // project is left behind, so a corrected retry succeeds. + expect(() => registry.getProject('deadletter-2')).toThrow(); + registry.register({ name: 'deadletter-2', type: 'project', status: 'active', description: 'd' }); + registry.updateCore('deadletter-2', { attention_mode: 'active' }); // The project remains genuinely unconfigured — nothing landed anywhere. const r = attention.assessProject('deadletter', { noCache: true }); expect(r.state).toBe('not_configured'); From 996c71a9be8bcd2267d3ea482d94202eca849d42 Mon Sep 17 00:00:00 2001 From: Michael Lebowitz Date: Tue, 14 Jul 2026 10:38:24 -0400 Subject: [PATCH 22/22] Fix round-19 regression test assertion (getProject does not throw on missing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Rcn8wbbJG5NELgFEskFdhi --- packages/core/tests/attention.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/tests/attention.test.ts b/packages/core/tests/attention.test.ts index 24536cc..df67ca5 100644 --- a/packages/core/tests/attention.test.ts +++ b/packages/core/tests/attention.test.ts @@ -343,8 +343,8 @@ describe('AttentionAssessor', () => { fields: { attention_mode: 'active' }, })).toThrow(/reserved field name/); // …and the rejection happens BEFORE any insert: no half-registered - // project is left behind, so a corrected retry succeeds. - expect(() => registry.getProject('deadletter-2')).toThrow(); + // project is left behind, so a corrected retry succeeds (it would throw + // DuplicateProjectError if the failed attempt had stranded a row). registry.register({ name: 'deadletter-2', type: 'project', status: 'active', description: 'd' }); registry.updateCore('deadletter-2', { attention_mode: 'active' }); // The project remains genuinely unconfigured — nothing landed anywhere.