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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -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 (18-test hermetic suite in `__tests__/batch-disposition.test.mjs`).
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 on an
426
- in-flight deploy). `normalizeMergeState` + `QA_MERGE_STATES` are exported;
427
- `createItem` rejects a present-but-illegal token at the boundary (a typo
428
- can't read as `'merged'`) and canonicalizes a legal one (additive — absent
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`/…) — unmerged work can't be filed as merged at the
433
- boundary, the honest state is `'merge-pending'`; pure string check, fenced
434
- to qa-handoff, and the drain's Step 0 does the live git-ancestry
435
- verification of the sha. The recipient gate is unchanged: a merge-pending
436
- handoff still resolves only through a stamped `qa_verdict` cited against
437
- the post-merge commit. The skill defers stage-2 dispatch and the pickup
438
- prompt says "merge then QA" when merge-pending. 32-test hermetic suite in
439
- `templates/scripts/__tests__/qa-handoff-gate.test.mjs` + 12-test
440
- `qa-handoff-merge-state.test.mjs`. Also home of the operator RESOLVE CLI
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). Exports `renderMissedRoutineSection`/
512
- `isMainCheckout` for tests.
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, in which case stage-2 dispatch defers — see the
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 (it
1154
- never merges OUT the worktree→main bridge is `/qa-handoff`); breadcrumb
1155
- writes resolve to `$MAIN/.claude/verification/`, closing the lost-on-cleanup
1156
- gap (act:d4767af6); and its Step 8 worktree close-out files a `merge-pending`
1157
- qa-handoff and marks the action done at commit, degrading loudly (never
1158
- silently) when watchtower isn't installed. On the main checkout all of this
1159
- no-ops.
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
- ## What this contract does NOT cover
135
+ ## Who merges (universal), and what stays caller-specific
136
136
 
137
- Caller-specific orchestration stays in the caller's own Step 0, never
138
- here:
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
- and its inline-build escape hatch. These describe the workflow's merge
143
- model and would contradict `/execute`'s no-merge model — they are not
144
- universal.
145
- - **`/execute`** keeps inline: that it does NOT merge (the worktree→main
146
- 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;
@@ -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
- const gatedId = makeItem({ category: 'qa-handoff', title: 'QA handoff: merge x' });
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
- ...overrides,
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 guard on the queue lib.
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. The skill (SKILL.md) owns the dispatch/pickup-prompt
7
- // behavior; this file pins the data contract those decisions key off.
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 filed-before-merge path — not rejected", () => {
128
- // The honest way to say 'work done, not yet merged'. merged_into stays the
129
- // target 'main'; merge_state carries the not-yet-ness.
130
- const id = makeItem({ evidence: { merge_state: 'merge-pending', merged_into: 'main', merged_commit: null, pending_branch_head: 'deadbee' } });
131
- assert.equal(q.getItem(id).evidence.merge_state, 'merge-pending');
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
- assert.equal(q.getItem(id).evidence.merged_commit, 'abc1234');
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 still resolves only through a stamped qa_verdict', () => {
148
- const pendingId = makeItem({ evidence: { merge_state: 'merge-pending' } });
149
- // No verdict still rejected; merge_state grants no bypass.
150
- assert.throws(() => q.resolveItem(pendingId, { resolution: 'done' }), /qa_verdict/);
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
- // With a valid (post-merge) verdictresolves; merge_state is preserved.
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, 'merge-pending');
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
  });