create-claude-cabinet 0.49.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/package.json +1 -1
- package/templates/CLAUDE.md +58 -25
- package/templates/cabinet/worktree-invocation-contract.md +16 -9
- package/templates/engagement/__tests__/transport-token.test.mjs +103 -0
- package/templates/engagement/engagement-transport.mjs +35 -1
- package/templates/scripts/__tests__/batch-disposition.test.mjs +2 -1
- 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__/routine-dispatch.test.mjs +2 -0
- package/templates/scripts/__tests__/session-ack.test.mjs +61 -0
- package/templates/scripts/watchtower-build-context.mjs +24 -2
- package/templates/scripts/watchtower-queue.mjs +272 -2
- 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/qa-handoff/SKILL.md +112 -48
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -416,28 +416,45 @@ consumes the registry stays in cli.js).
|
|
|
416
416
|
required, and a structural refusal of GATED_CATEGORIES items (today:
|
|
417
417
|
qa-handoff) — gated items never batch, each exits only through its own
|
|
418
418
|
gate; `routine` is dispatched but NOT gated, so stale routines are legal
|
|
419
|
-
batch fodder (
|
|
419
|
+
batch fodder (19-test hermetic suite in `__tests__/batch-disposition.test.mjs`).
|
|
420
420
|
Inbox bulk triage splits sign-off from judgment via `isHighConfidenceSignoff`/
|
|
421
421
|
`partitionForBatchSignoff` — high-confidence pre-drafted extractions
|
|
422
422
|
auto-group into one batch sign-off while judgment items stay per-item
|
|
423
423
|
(act:7f1e4d91). qa-handoff items carry a first-class `evidence.merge_state`
|
|
424
424
|
(act:971570c4): `'merged'` (default — filed after the worktree branch
|
|
425
|
-
merged) or `'merge-pending'` (filed before the merge, when it's gated
|
|
426
|
-
|
|
427
|
-
`createItem` rejects a present-but-illegal token at the boundary
|
|
428
|
-
can't read as `'merged'`) and canonicalizes a legal one (additive —
|
|
429
|
-
stays absent, readers default to `'merged'`). A `createItem`
|
|
425
|
+
merged) or `'merge-pending'` (filed before the merge, ONLY when it's gated
|
|
426
|
+
on something external). `normalizeMergeState` + `QA_MERGE_STATES` are
|
|
427
|
+
exported; `createItem` rejects a present-but-illegal token at the boundary
|
|
428
|
+
(a typo can't read as `'merged'`) and canonicalizes a legal one (additive —
|
|
429
|
+
absent stays absent, readers default to `'merged'`). A `createItem`
|
|
430
430
|
MERGE-COHERENCE guard (act:e859b3a3) also rejects a `'merged'` handoff
|
|
431
431
|
whose `merged_into`/`merged_commit` free-texts a not-yet-merged marker
|
|
432
|
-
(`PENDING`/`unmerged`/…) —
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
`
|
|
440
|
-
`
|
|
432
|
+
(`PENDING`/`unmerged`/…) — pure string check, fenced to qa-handoff. On top
|
|
433
|
+
of it, the MERGED-BY-CONSTRUCTION guards (act:3d1ac2b7) make the merge
|
|
434
|
+
claim verified, not asserted — a two-layer defense with the drain's Step 0
|
|
435
|
+
live re-check: a `'merged'` handoff requires a sha-shaped `merged_commit`
|
|
436
|
+
(omission was the trivial bypass) which `verifyMergedAncestry` checks
|
|
437
|
+
against git (queue-local origin/HEAD→main/master ref resolution, no fetch,
|
|
438
|
+
execFile args, 5s timeout; REJECT only on positive refutation, file with a
|
|
439
|
+
visible `evidence.ancestry_verified: 'unverifiable'` stamp when git can't
|
|
440
|
+
answer); a `'merge-pending'` handoff requires a non-empty
|
|
441
|
+
`evidence.merge_gate` naming the external blocker (rejection messages
|
|
442
|
+
teach the merge-first close-out, self-correcting older skill text); and
|
|
443
|
+
the ONE legal merge-pending→merged flip is `markHandoffMerged` — same
|
|
444
|
+
verification, writes the deferred stage-2 dispatch descriptor directly
|
|
445
|
+
into the mux queue dir (fs-only, desk token shape-guarded, in-flight
|
|
446
|
+
aware), records `evidence.stage2_dispatch` so a failed dispatch retries
|
|
447
|
+
by state — while `annotateItemEvidence` REFUSES `merge_state` patches on
|
|
448
|
+
qa-handoffs (the side door is closed). The recipient gate is unchanged: a
|
|
449
|
+
merge-pending handoff resolves only AFTER `markHandoffMerged` records the
|
|
450
|
+
verified merge, then through a stamped `qa_verdict` cited against the
|
|
451
|
+
post-merge commit. The skill defers stage-2 dispatch and the pickup
|
|
452
|
+
prompt says "merge then QA" when merge-pending (`buildQaPickupPrompt` is
|
|
453
|
+
the library sibling of the skill's merged-path prompt — pared to the gate
|
|
454
|
+
essentials, not byte-parity). 32-test hermetic suite in
|
|
455
|
+
`templates/scripts/__tests__/qa-handoff-gate.test.mjs` + 20-test
|
|
456
|
+
`qa-handoff-merge-state.test.mjs` + 19-test
|
|
457
|
+
`qa-handoff-merged-by-construction.test.mjs` (real temp git repos). Also home of the operator RESOLVE CLI
|
|
441
458
|
(act:00030e1d): `resolve <id> --verdict-file <path.json>` (`parseResolveArgs`
|
|
442
459
|
+ `runResolveCli` + an isMain guard) reads the FULL `resolveItem` params
|
|
443
460
|
object `{resolution, resolution_type, resolution_notes, qa_verdict}` from a
|
|
@@ -508,8 +525,17 @@ consumes the registry stays in cli.js).
|
|
|
508
525
|
(git-dir == git-common-dir; worktree windows stay silent) and built from
|
|
509
526
|
the shared `buildPickupPrompt` so the resolve-on-run wording is
|
|
510
527
|
single-sourced; qa-handoff items are excluded by the `category: 'routine'`
|
|
511
|
-
filter (operator-initiated by design).
|
|
512
|
-
|
|
528
|
+
filter (operator-initiated by design). Also OPENS every injection with a
|
|
529
|
+
session-start acknowledgment directive (act:058d00f0,
|
|
530
|
+
`renderSessionAckSection`, `PRIORITY_NEVER`): a short instruction telling
|
|
531
|
+
the session to begin its first reply with ONE plain-English line
|
|
532
|
+
confirming the context loaded — positive confirmation the hook chain
|
|
533
|
+
works (the injection is invisible in the terminal, so a silent session
|
|
534
|
+
was indistinguishable from a broken hook). Prompt-layer convention only;
|
|
535
|
+
a frontier-model warning still precedes it, resume re-injections skip it;
|
|
536
|
+
tests in `__tests__/session-ack.test.mjs`. Exports
|
|
537
|
+
`renderMissedRoutineSection`/`renderSessionAckSection`/`isMainCheckout`
|
|
538
|
+
for tests.
|
|
513
539
|
- `watchtower-snapshot.mjs` — mid-session awareness polling (act:a8057f87):
|
|
514
540
|
the sibling primitive behind `/catch-up`. The SessionStart hook captures a
|
|
515
541
|
per-session BASELINE snapshot (keyed by the stdin `.session_id`, written to
|
|
@@ -998,7 +1024,9 @@ step — then push it to the desk's main window via `mux qa dispatch`; usually
|
|
|
998
1024
|
filed right after the merge — an unmerged named branch now PROMPTS merge-now
|
|
999
1025
|
(Claude merges → `merged`) vs keep-gated, never a silent `merge-pending`
|
|
1000
1026
|
default (act:350e7373); merge-pending is opt-in, filed BEFORE the merge only
|
|
1001
|
-
when the merge is gated
|
|
1027
|
+
when the merge is gated on a named external blocker (`evidence.merge_gate`,
|
|
1028
|
+
required at the boundary; act:3d1ac2b7), in which case stage-2 dispatch
|
|
1029
|
+
defers until `markHandoffMerged` records the verified flip — see the
|
|
1002
1030
|
watchtower-queue `merge_state` entry above; stages 1–2 of the QA-handoff
|
|
1003
1031
|
protocol, `.claude/plans/qa-handoff-protocol.md`). Stage 3 — the drain — is
|
|
1004
1032
|
enforced by the staff-QA recipient gate in `watchtower-queue.mjs`: qa-handoff
|
|
@@ -1150,10 +1178,15 @@ MAIN checkout for breadcrumbs/report, so worktree invocations land artifacts
|
|
|
1150
1178
|
durably). `checkpoint-protocol.md` documents three CP modes (Interactive,
|
|
1151
1179
|
Advisory, Full) and is the shared contract between `/execute` and
|
|
1152
1180
|
`/execute-group`. `/execute` is worktree-aware (act:1067adee): its Step 0
|
|
1153
|
-
reads the worktree-invocation contract and syncs main *into* the worktree
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1181
|
+
reads the worktree-invocation contract and syncs main *into* the worktree
|
|
1182
|
+
(the merge OUT happens once, at close-out); breadcrumb writes resolve to
|
|
1183
|
+
`$MAIN/.claude/verification/`, closing the lost-on-cleanup gap
|
|
1184
|
+
(act:d4767af6); and its Step 8 worktree close-out is MERGE-FIRST
|
|
1185
|
+
(act:3d1ac2b7): re-sync + re-gate, merge + push via `/qa-handoff`'s
|
|
1186
|
+
merge-now recipe (no prompt — merge-now is the default; every failure HALTs
|
|
1187
|
+
loudly, the action stays open), then file a `merged` qa-handoff that
|
|
1188
|
+
dispatches unconditionally. Done is marked only after merge + filing
|
|
1189
|
+
succeed. `merge-pending` is reserved for an explicitly gated merge
|
|
1190
|
+
(`evidence.merge_gate` required). Without watchtower the close-out still
|
|
1191
|
+
merges, then degrades loudly (never silently) — the no-handoff note names
|
|
1192
|
+
what to QA manually. On the main checkout all of this no-ops.
|
|
@@ -132,15 +132,22 @@ either way). Recovery: `git update-index --no-skip-worktree .mcp.json
|
|
|
132
132
|
&& git checkout -- .mcp.json && git merge main`; the next health run
|
|
133
133
|
re-symlinks and re-hides.
|
|
134
134
|
|
|
135
|
-
##
|
|
135
|
+
## Who merges (universal), and what stays caller-specific
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
139
145
|
|
|
140
146
|
- **`/execute-group`** keeps inline: that its `isolation: worktree`
|
|
141
|
-
agents branch from and merge into MAIN regardless of the invoking cwd
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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;
|
|
@@ -51,7 +51,8 @@ test('GATED_CATEGORIES is exported and contains qa-handoff', () => {
|
|
|
51
51
|
|
|
52
52
|
test('B1: a batch containing a qa-handoff item fails whole — no item mutated', () => {
|
|
53
53
|
const plain = makeItem();
|
|
54
|
-
|
|
54
|
+
// merged-by-construction (act:3d1ac2b7): qa-handoff filings carry the sha.
|
|
55
|
+
const gatedId = makeItem({ category: 'qa-handoff', title: 'QA handoff: merge x', evidence: { merged_commit: 'abc1234' } });
|
|
55
56
|
assert.throws(
|
|
56
57
|
() => q.applyBatch([plain, gatedId], { disposition: 'dismiss', ...TYPED }),
|
|
57
58
|
(err) => err.message.includes(gatedId) && err.message.includes('recipient gate'),
|
|
@@ -50,6 +50,9 @@ function makeItem(category, title, agedDays) {
|
|
|
50
50
|
title,
|
|
51
51
|
summary: 'aging e2e fixture',
|
|
52
52
|
context_anchor: 'git show abc1234',
|
|
53
|
+
// merged-by-construction (act:3d1ac2b7): qa-handoff filings carry the sha
|
|
54
|
+
// (harmless extra evidence for the non-qa control categories).
|
|
55
|
+
evidence: { merged_commit: 'abc1234' },
|
|
53
56
|
});
|
|
54
57
|
if (agedDays > 0) {
|
|
55
58
|
const j = readBack(id);
|
|
@@ -32,7 +32,13 @@ test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
|
32
32
|
|
|
33
33
|
const itemFile = (id) => join(root, 'queue', 'items', `${id}.json`);
|
|
34
34
|
|
|
35
|
+
// The default fixture is a 'merged' handoff, which requires a sha-shaped
|
|
36
|
+
// evidence.merged_commit at the boundary (act:3d1ac2b7 merged-by-construction).
|
|
37
|
+
// /tmp/test-proj is not on disk, so the ancestry check stamps 'unverifiable'
|
|
38
|
+
// and files — the taxonomy itself is exercised against real git repos in
|
|
39
|
+
// qa-handoff-merged-by-construction.test.mjs.
|
|
35
40
|
function makeItem(overrides = {}) {
|
|
41
|
+
const { evidence, ...rest } = overrides;
|
|
36
42
|
return q.createItem({
|
|
37
43
|
project: 'test-proj',
|
|
38
44
|
project_path: '/tmp/test-proj',
|
|
@@ -40,7 +46,8 @@ function makeItem(overrides = {}) {
|
|
|
40
46
|
title: 'QA handoff: test merge',
|
|
41
47
|
summary: 'test',
|
|
42
48
|
context_anchor: 'git show abc1234',
|
|
43
|
-
...
|
|
49
|
+
evidence: { merged_commit: 'abc1234', ...(evidence || {}) },
|
|
50
|
+
...rest,
|
|
44
51
|
});
|
|
45
52
|
}
|
|
46
53
|
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
// qa-handoff merge_state — schema + create-time
|
|
1
|
+
// qa-handoff merge_state — schema + create-time guards on the queue lib.
|
|
2
2
|
// A handoff filed BEFORE the worktree branch merged carries
|
|
3
3
|
// merge_state: "merge-pending"; the default (absent) is "merged". The queue
|
|
4
4
|
// API canonicalizes a legal token, rejects a present-but-illegal one at the
|
|
5
5
|
// boundary (a typo cannot silently read as "merged"), and never loosens the
|
|
6
|
-
// recipient gate.
|
|
7
|
-
//
|
|
6
|
+
// recipient gate. Since act:3d1ac2b7 (merged-by-construction) the boundary
|
|
7
|
+
// also requires: a sha-shaped merged_commit on every 'merged' handoff
|
|
8
|
+
// (ancestry-verified where git can answer — the real-git taxonomy lives in
|
|
9
|
+
// qa-handoff-merged-by-construction.test.mjs), and a named external blocker
|
|
10
|
+
// (evidence.merge_gate) on every 'merge-pending' handoff — the silent
|
|
11
|
+
// merge-pending default is retired. The skill (SKILL.md) owns the
|
|
12
|
+
// dispatch/pickup-prompt behavior; this file pins the data contract.
|
|
8
13
|
//
|
|
9
14
|
// Fixture pattern matches qa-handoff-gate.test.mjs: WATCHTOWER_DIR + MUX_QA_DIR
|
|
10
15
|
// are set BEFORE the dynamic import (the lib reads them into module consts at
|
|
@@ -47,6 +52,9 @@ function validVerdict(overrides = {}) {
|
|
|
47
52
|
};
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
// Every merge-pending filing needs a named external blocker (act:3d1ac2b7).
|
|
56
|
+
const GATE = 'staging deploy dpl_123 in flight — clears on deploy SUCCESS';
|
|
57
|
+
|
|
50
58
|
// --- normalizeMergeState: the single token normalizer ---
|
|
51
59
|
|
|
52
60
|
test('absent (null/undefined) defaults to "merged" — the original contract', () => {
|
|
@@ -78,22 +86,42 @@ test('QA_MERGE_STATES export names exactly the two legal states', () => {
|
|
|
78
86
|
// --- createItem: structural guard, qa-handoff only, additive ---
|
|
79
87
|
|
|
80
88
|
test('legal merge_state is stored canonically on the qa-handoff item', () => {
|
|
81
|
-
const id = makeItem({ evidence: { merge_state: 'merge-pending', pending_branch_head: 'deadbee' } });
|
|
89
|
+
const id = makeItem({ evidence: { merge_state: 'merge-pending', pending_branch_head: 'deadbee', merge_gate: GATE } });
|
|
82
90
|
const item = q.getItem(id);
|
|
83
91
|
assert.equal(item.evidence.merge_state, 'merge-pending');
|
|
84
92
|
assert.equal(item.evidence.pending_branch_head, 'deadbee');
|
|
85
93
|
});
|
|
86
94
|
|
|
87
95
|
test('a drift token is canonicalized at file time', () => {
|
|
88
|
-
const id = makeItem({ evidence: { merge_state: 'merge_pending' } });
|
|
96
|
+
const id = makeItem({ evidence: { merge_state: 'merge_pending', merge_gate: GATE } });
|
|
89
97
|
assert.equal(q.getItem(id).evidence.merge_state, 'merge-pending');
|
|
90
98
|
});
|
|
91
99
|
|
|
92
100
|
test('absent merge_state is NOT injected (additive-field discipline; reader defaults)', () => {
|
|
93
|
-
const id = makeItem({ evidence: { foo: 'bar' } });
|
|
101
|
+
const id = makeItem({ evidence: { foo: 'bar', merged_commit: 'abc1234' } });
|
|
94
102
|
const ev = q.getItem(id).evidence;
|
|
95
103
|
assert.ok(!('merge_state' in ev), 'no merge_state key written when none was passed');
|
|
96
104
|
assert.equal(q.normalizeMergeState(ev.merge_state), 'merged', 'reader treats absent as merged');
|
|
105
|
+
assert.equal(ev.ancestry_verified, 'unverifiable', 'defaulted-merged is still ancestry-stamped');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a 'merged' handoff without merged_commit is rejected (omission was the trivial bypass)", () => {
|
|
109
|
+
assert.throws(
|
|
110
|
+
() => makeItem({ evidence: { merged_into: 'main' } }),
|
|
111
|
+
/requires a sha-shaped evidence\.merged_commit/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('merge-pending without a named merge_gate is rejected (the silent default is retired)', () => {
|
|
116
|
+
assert.throws(
|
|
117
|
+
() => makeItem({ evidence: { merge_state: 'merge-pending', pending_branch_head: 'deadbee' } }),
|
|
118
|
+
/requires evidence\.merge_gate/,
|
|
119
|
+
);
|
|
120
|
+
// The rejection teaches the merge-first close-out, not just the field name.
|
|
121
|
+
assert.throws(
|
|
122
|
+
() => makeItem({ evidence: { merge_state: 'merge-pending' } }),
|
|
123
|
+
/merge-first|merge → push/,
|
|
124
|
+
);
|
|
97
125
|
});
|
|
98
126
|
|
|
99
127
|
test('an illegal merge_state is rejected at create time, naming the field + legal set', () => {
|
|
@@ -124,16 +152,24 @@ test("a 'merged' handoff with merged_commit: 'PENDING' is rejected", () => {
|
|
|
124
152
|
);
|
|
125
153
|
});
|
|
126
154
|
|
|
127
|
-
test("merge-pending is the legitimate
|
|
128
|
-
// The honest way to say 'work done,
|
|
129
|
-
// target 'main'; merge_state carries the not-yet-ness
|
|
130
|
-
|
|
131
|
-
|
|
155
|
+
test("merge-pending with a named gate is the legitimate gated-merge path — not rejected", () => {
|
|
156
|
+
// The honest way to say 'work done, merge genuinely blocked'. merged_into
|
|
157
|
+
// stays the target 'main'; merge_state carries the not-yet-ness; merge_gate
|
|
158
|
+
// names WHY (act:3d1ac2b7 — gateless merge-pending is rejected above).
|
|
159
|
+
const id = makeItem({ evidence: { merge_state: 'merge-pending', merged_into: 'main', merged_commit: null, pending_branch_head: 'deadbee', merge_gate: GATE } });
|
|
160
|
+
const ev = q.getItem(id).evidence;
|
|
161
|
+
assert.equal(ev.merge_state, 'merge-pending');
|
|
162
|
+
assert.equal(ev.merge_gate, GATE);
|
|
132
163
|
});
|
|
133
164
|
|
|
134
|
-
test("a genuine 'merged' handoff (merged_into: 'main', real sha) passes", () => {
|
|
165
|
+
test("a genuine 'merged' handoff (merged_into: 'main', real sha) passes, ancestry-stamped", () => {
|
|
135
166
|
const id = makeItem({ evidence: { merged_into: 'main', merged_commit: 'abc1234' } });
|
|
136
|
-
|
|
167
|
+
const ev = q.getItem(id).evidence;
|
|
168
|
+
assert.equal(ev.merged_commit, 'abc1234');
|
|
169
|
+
// /tmp/test-proj is not a repo — git can't answer, so the stamp says so
|
|
170
|
+
// (the verified/refuted arms run against real repos in the
|
|
171
|
+
// merged-by-construction suite).
|
|
172
|
+
assert.equal(ev.ancestry_verified, 'unverifiable');
|
|
137
173
|
});
|
|
138
174
|
|
|
139
175
|
test('the merge-coherence guard is fenced to qa-handoff', () => {
|
|
@@ -144,19 +180,32 @@ test('the merge-coherence guard is fenced to qa-handoff', () => {
|
|
|
144
180
|
|
|
145
181
|
// --- The merge state never loosens the recipient gate ---
|
|
146
182
|
|
|
147
|
-
test('a merge-pending handoff
|
|
148
|
-
const pendingId = makeItem({ evidence: { merge_state: 'merge-pending' } });
|
|
149
|
-
//
|
|
150
|
-
|
|
183
|
+
test('a merge-pending handoff cannot resolve at all — the flip doorway comes first', () => {
|
|
184
|
+
const pendingId = makeItem({ evidence: { merge_state: 'merge-pending', merge_gate: GATE } });
|
|
185
|
+
// Even a fully valid verdict is rejected while merge-pending: resolution
|
|
186
|
+
// asserts QA of MERGED work, and the merge was never recorded
|
|
187
|
+
// (act:3d1ac2b7 — the widest bypass around markHandoffMerged).
|
|
188
|
+
assert.throws(
|
|
189
|
+
() => q.resolveItem(pendingId, { resolution: 'qa complete', qa_verdict: validVerdict() }),
|
|
190
|
+
/merge-pending.*markHandoffMerged/s,
|
|
191
|
+
);
|
|
151
192
|
assert.equal(q.getItem(pendingId).status, 'pending');
|
|
152
|
-
//
|
|
193
|
+
// After the guarded flip (fake repo → 'unverifiable' stamp, still flips),
|
|
194
|
+
// the same verdict resolves; the record shows the verified-or-stamped merge.
|
|
195
|
+
q.markHandoffMerged(pendingId, { merged_commit: 'abc1234' });
|
|
153
196
|
const out = q.resolveItem(pendingId, { resolution: 'qa complete', qa_verdict: validVerdict() });
|
|
154
197
|
assert.equal(out.status, 'resolved');
|
|
155
|
-
assert.equal(out.evidence.merge_state, '
|
|
198
|
+
assert.equal(out.evidence.merge_state, 'merged');
|
|
156
199
|
assert.equal(out.qa_verdict.verdict, 'runtime-verified');
|
|
157
200
|
});
|
|
158
201
|
|
|
202
|
+
test('dismissal stays legal for a merge-pending handoff (audited escape hatch)', () => {
|
|
203
|
+
const pendingId = makeItem({ evidence: { merge_state: 'merge-pending', merge_gate: GATE } });
|
|
204
|
+
const out = q.dismissItem(pendingId, { notes: 'lane abandoned — work superseded', resolution_type: 'noise' });
|
|
205
|
+
assert.equal(out.status, 'dismissed');
|
|
206
|
+
});
|
|
207
|
+
|
|
159
208
|
test('merge_state survives the disk round-trip', () => {
|
|
160
|
-
const id = makeItem({ evidence: { merge_state: 'merge-pending' } });
|
|
209
|
+
const id = makeItem({ evidence: { merge_state: 'merge-pending', merge_gate: GATE } });
|
|
161
210
|
assert.equal(q.getItem(id).evidence.merge_state, 'merge-pending'); // readItem throws on schema drift
|
|
162
211
|
});
|