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.
- package/lib/CLAUDE.md +22 -0
- package/lib/cli.js +100 -5
- package/lib/metadata.js +20 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +270 -37
- package/templates/cabinet/watchtower-contracts.md +85 -0
- package/templates/cabinet/worktree-invocation-contract.md +52 -10
- package/templates/engagement/__tests__/transport-token.test.mjs +103 -0
- package/templates/engagement/engagement-transport.mjs +35 -1
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
- package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
- package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
- package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
- package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
- package/templates/mux/bin/mux +44 -28
- package/templates/mux/config/worktree-cleanup.sh +57 -12
- package/templates/mux/config/worktree-dirty-check.sh +72 -2
- package/templates/mux/config/worktree-session-health.sh +401 -17
- package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +21 -1
- package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
- package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
- package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
- package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
- package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
- package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
- package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +3 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +8 -1
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +69 -20
- package/templates/scripts/__tests__/qa-handoff-merged-by-construction.test.mjs +310 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +6 -1
- package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
- package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
- package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
- package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
- package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +2 -0
- package/templates/scripts/__tests__/session-ack.test.mjs +61 -0
- package/templates/scripts/audit-coherence-check.mjs +6 -2
- package/templates/scripts/load-triage-history.js +23 -2
- package/templates/scripts/merge-findings.js +9 -2
- package/templates/scripts/pib-db-lib.mjs +55 -7
- package/templates/scripts/watchtower-build-context.mjs +24 -2
- package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
- package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
- package/templates/scripts/watchtower-lib.mjs +268 -5
- package/templates/scripts/watchtower-queue.mjs +469 -4
- package/templates/scripts/watchtower-ring1.mjs +490 -69
- package/templates/scripts/watchtower-ring2.mjs +175 -3
- package/templates/scripts/watchtower-ring3-close.mjs +519 -69
- package/templates/skills/audit/SKILL.md +6 -3
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
- package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
- package/templates/skills/collab-client/SKILL.md +13 -7
- package/templates/skills/collab-consultant/SKILL.md +9 -4
- package/templates/skills/execute/SKILL.md +60 -33
- package/templates/skills/inbox/SKILL.md +29 -1
- package/templates/skills/qa-handoff/SKILL.md +112 -48
- package/templates/skills/session-handoff/SKILL.md +6 -8
- 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
|
-
##
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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
|
-
|
|
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-
|
|
57
|
-
// Patch (engagement): v1-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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)"
|