create-claude-cabinet 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/lib/CLAUDE.md +22 -0
  2. package/lib/cli.js +100 -5
  3. package/lib/metadata.js +20 -0
  4. package/package.json +1 -1
  5. package/templates/CLAUDE.md +270 -37
  6. package/templates/cabinet/watchtower-contracts.md +85 -0
  7. package/templates/cabinet/worktree-invocation-contract.md +52 -10
  8. package/templates/engagement/__tests__/transport-token.test.mjs +103 -0
  9. package/templates/engagement/engagement-transport.mjs +35 -1
  10. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
  11. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
  12. package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
  13. package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
  14. package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
  15. package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
  16. package/templates/mux/bin/mux +44 -28
  17. package/templates/mux/config/worktree-cleanup.sh +57 -12
  18. package/templates/mux/config/worktree-dirty-check.sh +72 -2
  19. package/templates/mux/config/worktree-session-health.sh +401 -17
  20. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
  21. package/templates/scripts/__tests__/batch-disposition.test.mjs +21 -1
  22. package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
  23. package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
  24. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
  25. package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
  26. package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
  27. package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
  28. package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
  29. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +3 -0
  30. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +8 -1
  31. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +69 -20
  32. package/templates/scripts/__tests__/qa-handoff-merged-by-construction.test.mjs +310 -0
  33. package/templates/scripts/__tests__/resolve-cli.test.mjs +6 -1
  34. package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
  35. package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
  36. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
  37. package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
  38. package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
  39. package/templates/scripts/__tests__/routine-dispatch.test.mjs +2 -0
  40. package/templates/scripts/__tests__/session-ack.test.mjs +61 -0
  41. package/templates/scripts/audit-coherence-check.mjs +6 -2
  42. package/templates/scripts/load-triage-history.js +23 -2
  43. package/templates/scripts/merge-findings.js +9 -2
  44. package/templates/scripts/pib-db-lib.mjs +55 -7
  45. package/templates/scripts/watchtower-build-context.mjs +24 -2
  46. package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
  47. package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
  48. package/templates/scripts/watchtower-lib.mjs +268 -5
  49. package/templates/scripts/watchtower-queue.mjs +469 -4
  50. package/templates/scripts/watchtower-ring1.mjs +490 -69
  51. package/templates/scripts/watchtower-ring2.mjs +175 -3
  52. package/templates/scripts/watchtower-ring3-close.mjs +519 -69
  53. package/templates/skills/audit/SKILL.md +6 -3
  54. package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
  55. package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
  56. package/templates/skills/collab-client/SKILL.md +13 -7
  57. package/templates/skills/collab-consultant/SKILL.md +9 -4
  58. package/templates/skills/execute/SKILL.md +60 -33
  59. package/templates/skills/inbox/SKILL.md +29 -1
  60. package/templates/skills/qa-handoff/SKILL.md +112 -48
  61. package/templates/skills/session-handoff/SKILL.md +6 -8
  62. package/templates/watchtower/config.json.template +2 -1
@@ -336,6 +336,91 @@ each owning its tier. Flag: `defaults.recall_canary` (opt-out).
336
336
  extraction never proposed (M2 under-extraction). The canary states
337
337
  this blindness in its own output.
338
338
 
339
+ ## Detector Symmetry (no detector without a retraction path)
340
+
341
+ The 2026-07-12 inbox drain (893→194 pending) hit the same disease four
342
+ times in four detectors: branch-diverged never retracted (35/39 stale),
343
+ completion-review never reconciled (28/66 moot), worktree-unmerged
344
+ retracted only on branch deletion, and dismissed items refiled on the
345
+ next tick because dedup checks only PENDING items (the `backup/*` loop
346
+ refiled within hours, twice in one day). `autoResolveWorktreeItems`'
347
+ own comment states the principle: dedup suppresses refiling but nothing
348
+ retracts the original — **stale alarms train the operator to ignore
349
+ real ones**. This contract encodes the rule so detector #5 cannot ship
350
+ with the same bug (grp:wt-noise-immunity, act:4d11fd53).
351
+
352
+ **Every detector that FILES an inbox item must declare, at ship time:**
353
+
354
+ 1. **Its retraction condition** — the mechanical, per-tick check that
355
+ the filed condition no longer holds (branch merged/gone, action
356
+ closed, …). Retraction is truth-checking, not judgment: it runs in
357
+ Ring 1's tick beside the other reconcilers.
358
+ 2. **Its filing exclusions** — states that must never file, checked at
359
+ the FILING site (not just retraction) so a human dismissal cannot
360
+ reopen the loop: dedup consults only pending items, so an excluded
361
+ state that files at all refiles after every dismissal, forever.
362
+ 3. **Or an explicit exemption** — a detector with no possible
363
+ mechanical retraction (pure knowledge extraction, advisory
364
+ observations, session-historical facts) declares that explicitly in
365
+ the registry, with the rationale.
366
+
367
+ **The registry and its enforcement:** `INBOX_DETECTOR_REGISTRY` in
368
+ `watchtower-lib.mjs` maps every ring-filed category to
369
+ `{ reconciler }` or `{ exempt }` (exactly one). A structural test
370
+ (`templates/scripts/__tests__/detector-registry.test.mjs`) parses the
371
+ `category:` literals at `createItem` filing sites across ring1, ring2,
372
+ and ring3-close (read-only source scan, house precedent:
373
+ suppression-ledger's wiring assertion) and fails the suite when a
374
+ filing site's category is missing from the registry — a new category
375
+ without a declared reconciler or exemption breaks the build, not the
376
+ operator's trust.
377
+
378
+ **Machine resolutions are machine acts, not engagement.** Every
379
+ auto-retraction resolves through `autoReconcileItem` (watchtower-lib):
380
+ `resolution_type: 'auto-reconciled'` + `evidence.actor: 'ring1'` + a
381
+ note naming the evidence (action fid / branch). The cross-ring reader's
382
+ engagement bucketing and the inbox assessment's `worked_recent` EXCLUDE
383
+ this type — cron sweeps must never read as operator engagement or
384
+ suppress the not-consumed verdict. A semantically-wrong close (e.g. an
385
+ action closed via the ungated `pib_update_action --status done` path)
386
+ stays traceable to its mechanical trigger through the stamp.
387
+
388
+ **Fail-toward-keeping.** A reconciler retracts only on POSITIVE
389
+ evidence that the condition cleared: branch absence is concluded only
390
+ from membership in a successful branch listing (a failed git call
391
+ retracts nothing); an action-fid lookup retracts only on a FOUND,
392
+ non-deleted, closed row — fid-not-found keeps the item, which is
393
+ load-bearing for flow (a readable-but-vestigial pib.db; the real
394
+ tracker is flow.db) and for misattributed items whose fid lives in a
395
+ different project's db. Foreign pib.dbs are opened READ-ONLY behind an
396
+ existsSync guard; reconciler writes land only in the watchtower queue.
397
+
398
+ **Branch exclusion list:** `defaults.long_lived_branches` (watchtower
399
+ config), default `["staging", "production", "backup/*"]`. Entries
400
+ without `*` are EXACT names (`staging-fix` files normally); `*` is a
401
+ glob. **Consumer-default risk, documented:** a consumer whose real work
402
+ branch is literally named `staging` or `production` will never get a
403
+ branch-diverged item for it — the branch stays visible on the attention
404
+ line / git-attention sidecar / summary / Standing Issues (the exclusion
405
+ gates filing ONLY), and the consumer overrides the list in config.
406
+
407
+ **Standing-debt aggregation (coverage-warning first):** a detector
408
+ whose finding is a STANDING condition (recurs while a debt persists)
409
+ files ONE item per (project, debt-class) and APPENDS evidence to the
410
+ pending item (path union + per-path counts + session count), carrying
411
+ `evidence.first_seen` (oldest evidence date) so aging reads the debt's
412
+ true age, with an urgency escalation at a session-count threshold.
413
+ Resolving the item closes the accumulated view; if the debt persists,
414
+ the next tick files FRESH — post-disposition recurrence is a new
415
+ signal, not a refile loop (act:e888dd63; 49 identical coverage-warning
416
+ items for one month-old debt was the live specimen).
417
+
418
+ **Known limitation (named, not hidden):** reconcilers run per
419
+ configured project — items whose project was deleted or de-registered
420
+ are never visited and age pending until a human disposes them (the
421
+ inbox's aging surfaces them). Auto-resolving on "project gone" would
422
+ guess; the honest state is pending.
423
+
339
424
  ## Deferred Schemas
340
425
 
341
426
  The following are specified in project notes but NOT formalized until
@@ -97,15 +97,57 @@ correct in both places — no on-main special case. One rule, both
97
97
  directions: worktree-invisible `.claude/` state is always addressed
98
98
  through `$MAIN`, whether you are writing it (§3) or reading it (here).
99
99
 
100
- ## What this contract does NOT cover
101
-
102
- Caller-specific orchestration stays in the caller's own Step 0, never
103
- here:
100
+ ## 5. Worktree environment lifecycle (mux projects)
101
+
102
+ Mux owns the worktree environment end to end; sessions should not
103
+ hand-repair it. Three verbs, all single-sourced in
104
+ `~/.config/mux/worktree-session-health.sh`:
105
+
106
+ - **Provision** — a project that declares gitignored env files in
107
+ `.mux-worktree-provision` (project root; one path per line) gets them
108
+ copied from main at worktree creation and freshness-checked on later
109
+ health runs. A locally-modified worktree copy is never overwritten.
110
+ - **Preflight** — `mux worktree preflight [project]` verifies main's
111
+ declared env files exist and every declared `check:` command passes.
112
+ Run it BEFORE spawning parallel worktree lanes; non-zero exit means
113
+ do not spawn yet. Probes should verify identity/freshness, not bare
114
+ liveness — a leftover stack answers a bare port probe. Trust note:
115
+ `check:` lines execute as shell from the project root (same trust
116
+ class as npm scripts and CC hooks) — don't run preflight inside a
117
+ clone you don't trust.
118
+ - **Reap** — every worktree removal path stops the lane's docker
119
+ containers (exact compose-project-label match on the worktree dir
120
+ name; containers only, volumes kept). Assumes the lane's compose
121
+ stack was started from the worktree ROOT (compose's default project
122
+ name = directory basename); a stack started with `-p`/
123
+ `COMPOSE_PROJECT_NAME` or from a subdirectory is not reaped. Nothing
124
+ for a session to do; noted so cleanup surprises are recognized as
125
+ reaping, not data loss.
126
+
127
+ One symptom worth recognizing from a worktree: if `git merge main`
128
+ aborts citing "local changes to .mcp.json" while `git status` shows
129
+ clean, that is the mux identity symlink held under skip-worktree (main
130
+ changed the file since the worktree was cut — pre-existing behavior
131
+ either way). Recovery: `git update-index --no-skip-worktree .mcp.json
132
+ && git checkout -- .mcp.json && git merge main`; the next health run
133
+ re-symlinks and re-hides.
134
+
135
+ ## Who merges (universal), and what stays caller-specific
136
+
137
+ The universal rule every caller shares (act:3d1ac2b7): **finished worktree
138
+ work is MERGED by its producer** — a close-out or handoff describes work
139
+ already on main, and `merge_state: "merge-pending"` exists only for a
140
+ merge explicitly gated on something external, named in
141
+ `evidence.merge_gate`.
142
+
143
+ The merge ORCHESTRATION is caller-specific and stays inline in each
144
+ caller's own skill, never here:
104
145
 
105
146
  - **`/execute-group`** keeps inline: that its `isolation: worktree`
106
- agents branch from and merge into MAIN regardless of the invoking cwd,
107
- and its inline-build escape hatch. These describe the workflow's merge
108
- model and would contradict `/execute`'s no-merge model — they are not
109
- universal.
110
- - **`/execute`** keeps inline: that it does NOT merge (the worktree→main
111
- bridge is `/qa-handoff`), and how its close-out files the handoff.
147
+ agents branch from and merge into MAIN regardless of the invoking cwd
148
+ (sequential, baseline-gated merges owned by the workflow), and its
149
+ inline-build escape hatch.
150
+ - **`/execute`** keeps inline: its merge-first close-out — Step 8
151
+ delegates the merge to `/qa-handoff`'s merge-now recipe, then files a
152
+ `merged` handoff that dispatches; every merge failure HALTs, never
153
+ silently downgraded to merge-pending.
@@ -0,0 +1,103 @@
1
+ // Unit tests for the transport token pre-flight (act:8b527625).
2
+ // Run: node --test templates/engagement/__tests__/transport-token.test.mjs
3
+ // Guards the structural fix for the silent-failure-on-missing-token path:
4
+ // checkToken never throws and returns actionable guidance; resolveToken's
5
+ // throw carries the same guidance (the backstop when pre-flight is skipped).
6
+
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import {
10
+ checkToken,
11
+ resolveToken,
12
+ missingTokenGuidance,
13
+ } from '../engagement-transport.mjs';
14
+
15
+ const VAR = 'CC_TEST_ENGAGEMENT_TOKEN_UNSET';
16
+
17
+ test('checkToken: unset env var → ok false with actionable message', () => {
18
+ delete process.env[VAR];
19
+ const r = checkToken({ token: `env:${VAR}` });
20
+ assert.strictEqual(r.ok, false);
21
+ assert.strictEqual(r.varName, VAR);
22
+ assert.match(r.message, new RegExp(`${VAR} is not set`));
23
+ assert.match(r.message, /export CC_TEST_ENGAGEMENT_TOKEN_UNSET=/, 'message names the export line');
24
+ assert.match(r.message, /API Tokens/, 'message says where to get the key');
25
+ assert.match(r.message, /Restart your terminal/, 'message says to restart the shell');
26
+ });
27
+
28
+ test('checkToken: set env var → ok true', () => {
29
+ process.env[VAR] = 'tok-123';
30
+ try {
31
+ assert.deepStrictEqual(checkToken({ token: `env:${VAR}` }), { ok: true });
32
+ } finally {
33
+ delete process.env[VAR];
34
+ }
35
+ });
36
+
37
+ test('checkToken: empty-string env var counts as unset', () => {
38
+ process.env[VAR] = '';
39
+ try {
40
+ const r = checkToken({ token: `env:${VAR}` });
41
+ assert.strictEqual(r.ok, false);
42
+ assert.strictEqual(r.varName, VAR);
43
+ } finally {
44
+ delete process.env[VAR];
45
+ }
46
+ });
47
+
48
+ test('checkToken: literal token → ok true', () => {
49
+ assert.deepStrictEqual(checkToken({ token: 'literal-token' }), { ok: true });
50
+ });
51
+
52
+ test('checkToken: no token configured → ok false, varName null, names transport.token', () => {
53
+ for (const cfg of [{}, { token: '' }, null, undefined]) {
54
+ const r = checkToken(cfg);
55
+ assert.strictEqual(r.ok, false);
56
+ assert.strictEqual(r.varName, null);
57
+ assert.match(r.message, /transport\.token/);
58
+ }
59
+ });
60
+
61
+ test('checkToken never throws', () => {
62
+ for (const cfg of [undefined, null, {}, { token: 42 && 'env:' }, { token: 'env:' }]) {
63
+ assert.doesNotThrow(() => checkToken(cfg));
64
+ }
65
+ });
66
+
67
+ test('resolveToken: unset env var throws WITH the guidance, not the bare error', () => {
68
+ delete process.env[VAR];
69
+ assert.throws(
70
+ () => resolveToken(`env:${VAR}`),
71
+ (err) => {
72
+ assert.match(err.message, new RegExp(`${VAR} is not set`));
73
+ assert.match(err.message, /export CC_TEST_ENGAGEMENT_TOKEN_UNSET=/);
74
+ assert.match(err.message, /API Tokens/);
75
+ return true;
76
+ },
77
+ );
78
+ });
79
+
80
+ test('resolveToken: set env var resolves; literal passes through; falsy → null', () => {
81
+ process.env[VAR] = 'tok-456';
82
+ try {
83
+ assert.strictEqual(resolveToken(`env:${VAR}`), 'tok-456');
84
+ } finally {
85
+ delete process.env[VAR];
86
+ }
87
+ assert.strictEqual(resolveToken('literal'), 'literal');
88
+ assert.strictEqual(resolveToken(''), null);
89
+ assert.strictEqual(resolveToken(null), null);
90
+ });
91
+
92
+ test('missingTokenGuidance is the single source both paths share', () => {
93
+ delete process.env[VAR];
94
+ const fromCheck = checkToken({ token: `env:${VAR}` }).message;
95
+ let fromThrow;
96
+ try {
97
+ resolveToken(`env:${VAR}`);
98
+ } catch (err) {
99
+ fromThrow = err.message;
100
+ }
101
+ assert.strictEqual(fromCheck, missingTokenGuidance(VAR));
102
+ assert.strictEqual(fromThrow, missingTokenGuidance(VAR));
103
+ });
@@ -5,12 +5,46 @@ import { join } from 'node:path';
5
5
  // Token resolution — env:VAR_NAME or literal
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ export function missingTokenGuidance(varName) {
9
+ return (
10
+ `${varName} is not set — the engagement API needs this token to reach the server.\n` +
11
+ `1. Get your API key: it's the same key you use for the admin tools\n` +
12
+ ` (platform admin panel → API Tokens). If you don't have one, ask your\n` +
13
+ ` consultant or admin to create one for you.\n` +
14
+ `2. Add it to your shell profile:\n` +
15
+ ` echo 'export ${varName}=your-key-here' >> ~/.zshenv\n` +
16
+ `3. Restart your terminal (or run: source ~/.zshenv), then try again.`
17
+ );
18
+ }
19
+
20
+ // Never throws — the mechanical pre-flight check for the /collab-* skills.
21
+ // { ok: true } when a token resolves; { ok: false, varName, message } when
22
+ // the env var backing an env: ref is unset; { ok: false, varName: null,
23
+ // message } when no token is configured at all.
24
+ export function checkToken(transportConfig) {
25
+ const tokenRef = transportConfig?.token;
26
+ if (!tokenRef) {
27
+ return {
28
+ ok: false,
29
+ varName: null,
30
+ message: 'No API token configured — set transport.token in the engagement config (e.g. "env:ENGAGEMENT_API_TOKEN").',
31
+ };
32
+ }
33
+ if (tokenRef.startsWith('env:')) {
34
+ const varName = tokenRef.slice(4);
35
+ if (!process.env[varName]) {
36
+ return { ok: false, varName, message: missingTokenGuidance(varName) };
37
+ }
38
+ }
39
+ return { ok: true };
40
+ }
41
+
8
42
  export function resolveToken(tokenRef) {
9
43
  if (!tokenRef) return null;
10
44
  if (tokenRef.startsWith('env:')) {
11
45
  const varName = tokenRef.slice(4);
12
46
  const value = process.env[varName];
13
- if (!value) throw new Error(`Environment variable ${varName} is not set (from token ref "${tokenRef}")`);
47
+ if (!value) throw new Error(missingTokenGuidance(varName));
14
48
  return value;
15
49
  }
16
50
  return tokenRef;
@@ -44,7 +44,11 @@ function today() {
44
44
  // 6 — added projects.tags (symmetric with actions.tags at v2)
45
45
  // 7 — added client-facing copy columns on actions + projects (4 each)
46
46
  // 8 — added engagement_events.visibility (client|internal, default internal)
47
- export const SCHEMA_VERSION = 8;
47
+ // 9 audit finding ids run-prefixed (run_id || '/' || id) — same change
48
+ // as BASE v7 (act:4ec70792); numbered 9 here because the patch owns
49
+ // 7-8. Keep base and patch bumped in the same release
50
+ // (engagement-setup's base-ahead guard enforces it).
51
+ export const SCHEMA_VERSION = 9;
48
52
 
49
53
  // Each entry: { version, sql }. A single version may have multiple SQL
50
54
  // statements (e.g. column add + index). Statements run in array order;
@@ -53,8 +57,8 @@ export const SCHEMA_VERSION = 8;
53
57
  // gate — try/catch is a safety net for pre-pragma DBs.
54
58
  //
55
59
  // NOTE on version numbering (base vs patch):
56
- // Base (work-tracking): v1-v6 (v6 = client columns)
57
- // Patch (engagement): v1-v7 (v5 = engagement_events, v6 = projects.tags, v7 = client columns)
60
+ // Base (work-tracking): v1-v7 (v6 = client columns, v7 = audit id prefix)
61
+ // Patch (engagement): v1-v9 (v5 = engagement_events, v6 = projects.tags, v7 = client columns, v9 = audit id prefix)
58
62
  // The same physical columns appear at different version numbers because
59
63
  // base and patch have different migration histories. The try/catch on
60
64
  // "duplicate column" makes the overlap safe — a DB that already has
@@ -86,6 +90,10 @@ const MIGRATIONS = [
86
90
  { version: 7, sql: "ALTER TABLE projects ADD COLUMN client_generated_at TEXT" },
87
91
  { version: 7, sql: "ALTER TABLE projects ADD COLUMN client_generated_status TEXT" },
88
92
  { version: 8, sql: "ALTER TABLE engagement_events ADD COLUMN visibility TEXT NOT NULL DEFAULT 'internal' CHECK(visibility IN ('client','internal'))" },
93
+ // v9: prefix legacy audit finding ids with their run id (base v7 twin —
94
+ // act:4ec70792). Idempotent via the NOT LIKE guard. skipOn: a db that
95
+ // predates the audit tables has nothing to prefix.
96
+ { version: 9, sql: "UPDATE audit_findings SET id = run_id || '/' || id WHERE run_id IS NOT NULL AND run_id != '' AND id NOT LIKE run_id || '/%'", skipOn: /no such table: audit_findings/i },
89
97
  ];
90
98
 
91
99
  export function migrate(db) {
@@ -102,7 +110,11 @@ export function migrate(db) {
102
110
  if (m.version <= current) continue;
103
111
  try { db.exec(m.sql); applied++; }
104
112
  catch (e) {
105
- if (!/already exists|duplicate column/i.test(e.message || '')) throw e;
113
+ const msg = e.message || '';
114
+ // Per-entry skip condition (explicit, never a general widening):
115
+ // e.g. the v9 audit-id prefix on a db that predates audit tables.
116
+ if (m.skipOn && m.skipOn.test(msg)) continue;
117
+ if (!/already exists|duplicate column/i.test(msg)) throw e;
106
118
  }
107
119
  }
108
120
  db.pragma(`user_version = ${SCHEMA_VERSION}`);
@@ -438,37 +450,69 @@ export function ingestFindings(db, { runDir }) {
438
450
  const timestamp = data.meta?.timestamp || new Date().toISOString();
439
451
  const dateStr = timestamp.slice(0, 10);
440
452
 
453
+ // Same-run re-ingest refreshes the run row; cross-run id collisions are
454
+ // structurally impossible now that run ids carry the date
455
+ // (run-<YYYY-MM-DD>-<HH-MM-SS>, minted by merge-findings).
441
456
  db.prepare(`
442
- INSERT OR REPLACE INTO audit_runs (id, date, timestamp, trigger, finding_count)
457
+ INSERT INTO audit_runs (id, date, timestamp, trigger, finding_count)
443
458
  VALUES (?, ?, ?, ?, ?)
459
+ ON CONFLICT(id) DO UPDATE SET
460
+ date = excluded.date, timestamp = excluded.timestamp,
461
+ trigger = excluded.trigger, finding_count = excluded.finding_count
444
462
  `).run(runId, dateStr, timestamp, data.meta?.trigger || 'manual', data.findings?.length || 0);
445
463
 
446
- // Try the full INSERT with deliberation columns first; fall back to the
464
+ // Stored finding ids are RUN-PREFIXED (<runId>/<member-NNNN>): member ids
465
+ // restart every run, and the old bare-id INSERT OR REPLACE silently
466
+ // destroyed prior runs' rows AND their triage columns on collision
467
+ // (act:4ec70792, demonstrated live 2026-07-13). The upsert updates
468
+ // CONTENT columns only — the four triage columns (triage_status,
469
+ // triage_notes, triaged_at, fix_description) are never in the SET list,
470
+ // so a same-run re-ingest can never reset an operator's dispositions.
471
+ // Try the full shape with deliberation columns first; fall back to the
447
472
  // legacy shape for existing databases that haven't added them yet.
448
473
  let insert;
449
474
  let hasDeliberationCols = true;
450
475
  try {
451
476
  insert = db.prepare(`
452
- INSERT OR REPLACE INTO audit_findings
477
+ INSERT INTO audit_findings
453
478
  (id, run_id, cabinet_member, severity, title, description, assumption,
454
479
  evidence, question, file, line, suggested_fix, auto_fixable, type,
455
480
  status, annotations, rebuttal)
456
481
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
482
+ ON CONFLICT(id) DO UPDATE SET
483
+ run_id = excluded.run_id, cabinet_member = excluded.cabinet_member,
484
+ severity = excluded.severity, title = excluded.title,
485
+ description = excluded.description, assumption = excluded.assumption,
486
+ evidence = excluded.evidence, question = excluded.question,
487
+ file = excluded.file, line = excluded.line,
488
+ suggested_fix = excluded.suggested_fix,
489
+ auto_fixable = excluded.auto_fixable, type = excluded.type,
490
+ status = excluded.status, annotations = excluded.annotations,
491
+ rebuttal = excluded.rebuttal
457
492
  `);
458
493
  } catch {
459
494
  hasDeliberationCols = false;
460
495
  insert = db.prepare(`
461
- INSERT OR REPLACE INTO audit_findings
496
+ INSERT INTO audit_findings
462
497
  (id, run_id, cabinet_member, severity, title, description, assumption,
463
498
  evidence, question, file, line, suggested_fix, auto_fixable, type)
464
499
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
500
+ ON CONFLICT(id) DO UPDATE SET
501
+ run_id = excluded.run_id, cabinet_member = excluded.cabinet_member,
502
+ severity = excluded.severity, title = excluded.title,
503
+ description = excluded.description, assumption = excluded.assumption,
504
+ evidence = excluded.evidence, question = excluded.question,
505
+ file = excluded.file, line = excluded.line,
506
+ suggested_fix = excluded.suggested_fix,
507
+ auto_fixable = excluded.auto_fixable, type = excluded.type
465
508
  `);
466
509
  }
467
510
 
468
511
  let count = 0;
469
512
  for (const f of (data.findings || [])) {
513
+ const storedId = String(f.id).startsWith(`${runId}/`) ? f.id : `${runId}/${f.id}`;
470
514
  const base = [
471
- f.id, runId, f['cabinet-member'], f.severity, f.title,
515
+ storedId, runId, f['cabinet-member'], f.severity, f.title,
472
516
  f.description || null, f.assumption || null, f.evidence || null,
473
517
  f.question || null, f.file || null, f.line || null,
474
518
  f.suggestedFix || null, f.autoFixable ? 1 : 0, f.type || 'finding'
@@ -266,11 +266,10 @@ assert "a worktree window opened" test "$(win_count)" -eq $((before + 1))
266
266
  seedf=$(ls -t "$SEED_DIR"/sess-*.txt 2>/dev/null | head -1)
267
267
  assert "a seed-prompt file was written" test -n "$seedf"
268
268
  if [[ -n "$seedf" ]]; then
269
- assert "auto_orient=1 prepends the orient instruction as line 1" \
270
- bash -c "head -1 '$seedf' | grep -qx 'Run /orient-quick first to load state, then:'"
271
- tail -n +3 "$seedf" > "$SANDBOX/got-body.txt"
272
- assert "multi-line prompt body preserved byte-identical (quotes/\$/backticks intact)" \
273
- diff "$SANDBOX/expected-body.txt" "$SANDBOX/got-body.txt"
269
+ # No orient prefix: /orient is retired the watchtower SessionStart hook
270
+ # loads state; the seed file carries the prompt VERBATIM.
271
+ assert "seed file carries the prompt verbatim, no injected preamble (quotes/\$/backticks intact)" \
272
+ diff "$SANDBOX/expected-body.txt" "$seedf"
274
273
  fi
275
274
 
276
275
  echo "== T12: the prompt is an ARG to claude, never executed as shell (auto-submit safety)"