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.
@@ -0,0 +1,310 @@
1
+ // qa-handoff merged-by-construction (act:3d1ac2b7) — the boundary guards that
2
+ // make a 'merged' claim verified rather than asserted, exercised against REAL
3
+ // git repos (a local bare "origin" + a clone; no network — the same fixture
4
+ // pattern as ahead-check-origin.test.mjs).
5
+ //
6
+ // The taxonomy under test (verifyMergedAncestry):
7
+ // verified — merged_commit IS an ancestor of the resolved main ref
8
+ // refuted — git answered definitively NOT an ancestor → filing rejected
9
+ // unverifiable — git can't answer (no repo / no main ref / non-sha) → filing
10
+ // proceeds WITH a visible evidence.ancestry_verified stamp
11
+ // Plus markHandoffMerged — the ONE legal merge-pending→merged doorway (same
12
+ // verification, deferred stage-2 dispatch recorded by STATE in evidence) —
13
+ // and the annotateItemEvidence fence that closes the evidence-patch side door.
14
+ //
15
+ // Fixture pattern matches qa-handoff-gate.test.mjs: WATCHTOWER_DIR + MUX_QA_DIR
16
+ // are set BEFORE the dynamic import (the lib reads them into module consts).
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ import { execSync } from 'node:child_process';
24
+
25
+ const root = mkdtempSync(join(tmpdir(), 'qa-mbc-'));
26
+ process.env.WATCHTOWER_DIR = root;
27
+ const muxQaRoot = join(root, 'mux-qa');
28
+ process.env.MUX_QA_DIR = muxQaRoot;
29
+ writeFileSync(join(root, 'config.json'), '{}\n');
30
+
31
+ const q = await import('../watchtower-queue.mjs');
32
+
33
+ test.after(() => rmSync(root, { recursive: true, force: true }));
34
+
35
+ // --- git fixture: bare origin + clone; one merged lane, one unmerged branch ---
36
+
37
+ function git(cwd, cmd) {
38
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
39
+ }
40
+
41
+ function commit(cwd, file, body) {
42
+ writeFileSync(join(cwd, file), body);
43
+ git(cwd, `add ${file}`);
44
+ git(cwd, `-c user.email=t@t -c user.name=t commit -q -m "add ${file}"`);
45
+ return git(cwd, 'rev-parse HEAD');
46
+ }
47
+
48
+ const origin = join(root, 'origin.git');
49
+ const repo = join(root, 'repo');
50
+ execSync(`git init -q --bare -b main "${origin}"`, { stdio: 'ignore' });
51
+ execSync(`git clone -q "${origin}" "${repo}"`, { stdio: 'ignore' });
52
+ commit(repo, 'base.txt', 'base');
53
+ git(repo, 'push -q -u origin main');
54
+
55
+ // An unmerged branch — its tip must be REFUTED as a merged claim.
56
+ git(repo, 'checkout -q -b feature');
57
+ const unmergedSha = commit(repo, 'feat.txt', 'feat');
58
+ git(repo, 'checkout -q main');
59
+
60
+ // A genuinely merged lane — its post-merge sha must VERIFY.
61
+ git(repo, 'checkout -q -b lane');
62
+ commit(repo, 'lane.txt', 'lane');
63
+ git(repo, 'checkout -q main');
64
+ git(repo, 'merge -q --no-edit lane');
65
+ const mergedSha = git(repo, 'rev-parse HEAD');
66
+ git(repo, 'push -q origin main');
67
+
68
+ // A repo git can't answer for: commits exist, but no origin and no main/master.
69
+ const refless = join(root, 'refless');
70
+ execSync(`git init -q -b trunk "${refless}"`, { stdio: 'ignore' });
71
+ commit(refless, 'x.txt', 'x');
72
+
73
+ function makeItem(overrides = {}) {
74
+ const { evidence, ...rest } = overrides;
75
+ return q.createItem({
76
+ project: 'test-proj',
77
+ project_path: repo,
78
+ category: 'qa-handoff',
79
+ title: 'QA handoff: lane merge',
80
+ summary: 'test',
81
+ context_anchor: `git show ${mergedSha.slice(0, 7)}`,
82
+ evidence: { merged_commit: mergedSha, ...(evidence || {}) },
83
+ ...rest,
84
+ });
85
+ }
86
+
87
+ // --- verifyMergedAncestry: the taxonomy ---
88
+
89
+ test('verified: the post-merge sha is an ancestor of origin/main', () => {
90
+ assert.deepEqual(q.verifyMergedAncestry(repo, mergedSha), { state: 'verified', ref: 'origin/main' });
91
+ });
92
+
93
+ test('refuted: an unmerged branch tip is definitively not an ancestor', () => {
94
+ const v = q.verifyMergedAncestry(repo, unmergedSha);
95
+ assert.equal(v.state, 'refuted');
96
+ });
97
+
98
+ test("unverifiable: non-sha input, missing path, and a repo with no main ref — never 'refuted'", () => {
99
+ assert.equal(q.verifyMergedAncestry(repo, 'HEAD').state, 'unverifiable'); // ref name, not a sha
100
+ assert.equal(q.verifyMergedAncestry(repo, '--all').state, 'unverifiable'); // option injection shape
101
+ assert.equal(q.verifyMergedAncestry('/nope/never', mergedSha).state, 'unverifiable');
102
+ assert.equal(q.verifyMergedAncestry(refless, mergedSha).state, 'unverifiable'); // no origin, no main/master
103
+ });
104
+
105
+ // --- createItem: the merged arm ---
106
+
107
+ test("a truly merged filing is accepted and stamped 'verified'", () => {
108
+ const id = makeItem();
109
+ assert.equal(q.getItem(id).evidence.ancestry_verified, 'verified');
110
+ });
111
+
112
+ test('a merged filing whose sha git refutes is REJECTED, teaching the fix', () => {
113
+ assert.throws(
114
+ () => makeItem({ evidence: { merged_commit: unmergedSha } }),
115
+ /NOT an ancestor.*did the push happen\?.*merge-pending.*merge_gate/s,
116
+ );
117
+ });
118
+
119
+ test('a merged filing with a non-sha merged_commit is rejected (option-injection closed)', () => {
120
+ for (const bad of ['HEAD', '--all', 'main', 'abc123g']) {
121
+ assert.throws(() => makeItem({ evidence: { merged_commit: bad } }), /sha-shaped/);
122
+ }
123
+ });
124
+
125
+ // --- markHandoffMerged: the one legal doorway, with deferred dispatch ---
126
+
127
+ function makePending(overrides = {}) {
128
+ return makeItem({
129
+ desk: 'testdesk',
130
+ evidence: {
131
+ merge_state: 'merge-pending',
132
+ merged_commit: null,
133
+ pending_branch_head: unmergedSha,
134
+ merge_gate: 'staging deploy in flight — clears on SUCCESS',
135
+ },
136
+ ...overrides,
137
+ });
138
+ }
139
+
140
+ test('flip to merged: verifies ancestry, stamps evidence, writes the stage-2 descriptor', () => {
141
+ const id = makePending();
142
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
143
+ assert.equal(out.dispatch, 'dispatched');
144
+ const ev = q.getItem(id).evidence;
145
+ assert.equal(ev.merge_state, 'merged');
146
+ assert.equal(ev.merged_commit, mergedSha);
147
+ assert.equal(ev.ancestry_verified, 'verified');
148
+ assert.equal(ev.stage2_dispatch.status, 'dispatched');
149
+ const desc = JSON.parse(readFileSync(join(muxQaRoot, 'testdesk', `${id}.json`), 'utf8'));
150
+ assert.equal(desc.item_id, id);
151
+ assert.equal(desc.project, 'testdesk');
152
+ assert.equal(desc.merged_commit, mergedSha);
153
+ assert.match(desc.pickup_prompt, /Post-merge QA/);
154
+ assert.match(desc.pickup_prompt, new RegExp(id));
155
+ // Second call is state-keyed idempotent: already dispatched → no re-write.
156
+ assert.equal(q.markHandoffMerged(id, { merged_commit: mergedSha }).dispatch, 'dispatched');
157
+ });
158
+
159
+ test('flip with a refuted sha throws and leaves the item unchanged on disk', () => {
160
+ const id = makePending();
161
+ assert.throws(() => q.markHandoffMerged(id, { merged_commit: unmergedSha }), /NOT an ancestor/);
162
+ const ev = q.getItem(id).evidence;
163
+ assert.equal(ev.merge_state, 'merge-pending');
164
+ assert.ok(!('stage2_dispatch' in ev));
165
+ });
166
+
167
+ test('flip requires a sha-shaped merged_commit and a qa-handoff item', () => {
168
+ const id = makePending();
169
+ assert.throws(() => q.markHandoffMerged(id, {}), /sha-shaped/);
170
+ assert.throws(() => q.markHandoffMerged(id, { merged_commit: 'HEAD' }), /sha-shaped/);
171
+ const ke = q.createItem({
172
+ project: 'test-proj', project_path: repo, category: 'knowledge-extraction',
173
+ title: 'ke', summary: 's', context_anchor: 'c',
174
+ });
175
+ assert.throws(() => q.markHandoffMerged(ke, { merged_commit: mergedSha }), /not qa-handoff/);
176
+ });
177
+
178
+ test('no legal desk → flip lands, dispatch recorded no-desk, retried by state on re-call', () => {
179
+ for (const desk of [null, '../evil', 'a/b', '.hidden']) {
180
+ const id = makePending({ desk });
181
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
182
+ assert.equal(out.dispatch, 'no-desk', `desk ${JSON.stringify(desk)} must not route`);
183
+ assert.equal(q.getItem(id).evidence.stage2_dispatch.status, 'no-desk');
184
+ // Re-call retries the dispatch (state-keyed) rather than short-circuiting.
185
+ assert.equal(q.markHandoffMerged(id, { merged_commit: mergedSha }).dispatch, 'no-desk');
186
+ }
187
+ // Traversal check: nothing escaped the mux queue root.
188
+ assert.ok(!existsSync(join(root, 'evil')), 'no dir created outside MUX_QA_DIR');
189
+ const deskDirs = existsSync(muxQaRoot) ? readdirSync(muxQaRoot) : [];
190
+ assert.ok(!deskDirs.some((d) => d.includes('.') || d.includes('evil')), `illegal desk dirs: ${deskDirs}`);
191
+ });
192
+
193
+ test('the already-merged short-circuit refuses a DIFFERENT sha (no silent swallow)', () => {
194
+ const id = makePending();
195
+ q.markHandoffMerged(id, { merged_commit: mergedSha });
196
+ // Same sha → idempotent success; different sha → loud refusal, item intact.
197
+ assert.equal(q.markHandoffMerged(id, { merged_commit: mergedSha }).dispatch, 'dispatched');
198
+ const otherSha = 'a'.repeat(40);
199
+ assert.throws(() => q.markHandoffMerged(id, { merged_commit: otherSha }), /refusing to silently ignore/);
200
+ assert.equal(q.getItem(id).evidence.merged_commit, mergedSha);
201
+ });
202
+
203
+ test('a drained (in-flight) item is not double-offered', () => {
204
+ const id = makePending();
205
+ mkdirSync(join(muxQaRoot, 'testdesk', 'in-flight'), { recursive: true });
206
+ writeFileSync(join(muxQaRoot, 'testdesk', 'in-flight', `${id}.json`), '{}\n');
207
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
208
+ assert.equal(out.dispatch, 'already-in-flight');
209
+ assert.ok(!existsSync(join(muxQaRoot, 'testdesk', `${id}.json`)), 'no queued duplicate beside the in-flight copy');
210
+ });
211
+
212
+ test('createItem stores the trimmed sha it verified (stamp and value never disagree)', () => {
213
+ const id = makeItem({ evidence: { merged_commit: `${mergedSha} ` } });
214
+ const ev = q.getItem(id).evidence;
215
+ assert.equal(ev.merged_commit, mergedSha);
216
+ assert.equal(ev.ancestry_verified, 'verified');
217
+ });
218
+
219
+ test('a failed dispatch is recoverable by state: flip lands, retry succeeds once unblocked', () => {
220
+ const id = makePending({ desk: 'blockeddesk' });
221
+ // Force ENOTDIR: a regular file where the desk DIR must go.
222
+ writeFileSync(join(muxQaRoot, 'blockeddesk'), 'not a dir');
223
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
224
+ assert.match(out.dispatch, /^failed: /);
225
+ assert.equal(q.getItem(id).evidence.merge_state, 'merged', 'the flip itself landed');
226
+ rmSync(join(muxQaRoot, 'blockeddesk'));
227
+ // State-keyed retry: status !== 'dispatched' → the re-call re-dispatches.
228
+ assert.equal(q.markHandoffMerged(id, { merged_commit: mergedSha }).dispatch, 'dispatched');
229
+ assert.ok(existsSync(join(muxQaRoot, 'blockeddesk', `${id}.json`)));
230
+ });
231
+
232
+ test('unverifiable flip through the doorway: flips, stamps, still dispatches', () => {
233
+ const id = makeItem({
234
+ desk: 'testdesk',
235
+ project_path: refless,
236
+ evidence: { merge_state: 'merge-pending', merged_commit: null, merge_gate: 'gated externally' },
237
+ });
238
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
239
+ assert.equal(out.dispatch, 'dispatched');
240
+ const ev = q.getItem(id).evidence;
241
+ assert.equal(ev.merge_state, 'merged');
242
+ assert.equal(ev.ancestry_verified, 'unverifiable', 'git cannot answer in the refless repo — stamped, not blocked');
243
+ });
244
+
245
+ test('the amend path: a filed-merged item passes markHandoffMerged legally and re-dispatches', () => {
246
+ const id = makeItem({ desk: 'testdesk' }); // merged by default, no stage2_dispatch
247
+ const out = q.markHandoffMerged(id, { merged_commit: mergedSha });
248
+ assert.equal(out.dispatch, 'dispatched');
249
+ const ev = q.getItem(id).evidence;
250
+ assert.equal(ev.merge_state, 'merged', 'the defaulted state becomes explicit through the doorway');
251
+ assert.equal(ev.stage2_dispatch.status, 'dispatched');
252
+ });
253
+
254
+ test('the boundary mints the verification record: pre-planted stamps are discarded at createItem', () => {
255
+ // merge-pending arm: neither stamp survives.
256
+ const pendingId = makePending({
257
+ evidence: {
258
+ merge_state: 'merge-pending', merged_commit: null,
259
+ merge_gate: 'gated externally',
260
+ ancestry_verified: 'verified', // spoofed
261
+ stage2_dispatch: { status: 'dispatched' }, // spoofed
262
+ },
263
+ });
264
+ const pev = q.getItem(pendingId).evidence;
265
+ assert.ok(!('ancestry_verified' in pev), 'spoofed ancestry stamp discarded');
266
+ assert.ok(!('stage2_dispatch' in pev), 'spoofed dispatch status discarded');
267
+ // merged arm: the stamp present is the one the boundary just minted, and a
268
+ // pre-planted dispatched status cannot make the doorway short-circuit —
269
+ // markHandoffMerged verifies and dispatches for real.
270
+ const mergedId = makeItem({
271
+ desk: 'testdesk',
272
+ evidence: { merged_commit: mergedSha, stage2_dispatch: { status: 'dispatched' } },
273
+ });
274
+ const mev = q.getItem(mergedId).evidence;
275
+ assert.equal(mev.ancestry_verified, 'verified');
276
+ assert.ok(!('stage2_dispatch' in mev), 'pre-planted dispatch status discarded');
277
+ const out = q.markHandoffMerged(mergedId, { merged_commit: mergedSha });
278
+ assert.equal(out.dispatch, 'dispatched');
279
+ assert.ok(existsSync(join(muxQaRoot, 'testdesk', `${mergedId}.json`)), 'a real descriptor landed');
280
+ });
281
+
282
+ // --- the side door is closed ---
283
+
284
+ test('annotateItemEvidence refuses the whole verification record on a qa-handoff', () => {
285
+ const id = makePending();
286
+ // merge_state, the verified sha, the ancestry stamp, and the dispatch
287
+ // status are all one record — any of them patchable would let a later
288
+ // markHandoffMerged short-circuit or a stamp be falsified.
289
+ for (const patch of [
290
+ { merge_state: 'merged' },
291
+ { merged_commit: mergedSha },
292
+ { ancestry_verified: 'verified' },
293
+ { stage2_dispatch: { status: 'dispatched' } },
294
+ ]) {
295
+ assert.throws(() => q.annotateItemEvidence(id, patch), /markHandoffMerged/);
296
+ }
297
+ assert.equal(q.getItem(id).evidence.merge_state, 'merge-pending');
298
+ // Other evidence keys on the same item still annotate (Ring 2's job).
299
+ const r = q.annotateItemEvidence(id, { freshness: { checked: true } });
300
+ assert.equal(r.changed, true);
301
+ });
302
+
303
+ test('the fence is category-scoped: merge_state annotates freely off qa-handoff', () => {
304
+ const ke = q.createItem({
305
+ project: 'test-proj', project_path: repo, category: 'knowledge-extraction',
306
+ title: 'ke2', summary: 's', context_anchor: 'c',
307
+ });
308
+ const r = q.annotateItemEvidence(ke, { merge_state: 'whatever' });
309
+ assert.equal(r.changed, true);
310
+ });
@@ -49,6 +49,7 @@ function runCli(args) {
49
49
  }
50
50
 
51
51
  function makeQaItem(overrides = {}) {
52
+ const { evidence, ...rest } = overrides;
52
53
  return q.createItem({
53
54
  project: 'test-proj',
54
55
  project_path: '/tmp/test-proj',
@@ -56,7 +57,11 @@ function makeQaItem(overrides = {}) {
56
57
  title: 'QA handoff: test merge',
57
58
  summary: 'test',
58
59
  context_anchor: 'git show abc1234',
59
- ...overrides,
60
+ // merged-by-construction (act:3d1ac2b7): merged filings carry the sha;
61
+ // /tmp/test-proj is not a repo → 'unverifiable' stamp (this suite tests
62
+ // the resolve CLI, not the ancestry taxonomy).
63
+ evidence: { merged_commit: 'abc1234', ...(evidence || {}) },
64
+ ...rest,
60
65
  });
61
66
  }
62
67
 
@@ -305,6 +305,8 @@ test('qa-handoff gate untouched by the generalization (regression)', () => {
305
305
  const id = q.createItem({
306
306
  project: 'proj-a', project_path: projectPath, category: 'qa-handoff',
307
307
  title: 'QA handoff: t', summary: 's', context_anchor: 'git show abc',
308
+ // merged-by-construction (act:3d1ac2b7): qa-handoff filings carry the sha.
309
+ evidence: { merged_commit: 'abc1234' },
308
310
  });
309
311
  assert.throws(() => q.resolveItem(id, { resolution: 'done' }), /qa_verdict/);
310
312
  assert.throws(() => q.expireItem(id), /never expire/);
@@ -0,0 +1,61 @@
1
+ // Hermetic tests for the session-start acknowledgment directive
2
+ // (act:058d00f0): the pure renderer, and an end-to-end subprocess run of the
3
+ // builder asserting the directive actually renders (built-but-never-fired
4
+ // guard) and renders FIRST, above the summary block.
5
+ //
6
+ // The subprocess run is hermetic: WATCHTOWER_DIR and HOME point at temp
7
+ // dirs, so no real state, registry, or ~/.claude.json is read and the
8
+ // advisory pass has nothing to probe.
9
+
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const bctx = await import('../watchtower-build-context.mjs');
19
+ const SCRIPT = fileURLToPath(new URL('../watchtower-build-context.mjs', import.meta.url));
20
+
21
+ test('renderSessionAckSection: plain-English contract, bounded size', () => {
22
+ const out = bctx.renderSessionAckSection();
23
+ assert.match(out, /^--- Session-Start Acknowledgment ---\n/);
24
+ assert.match(out, /first reply/i);
25
+ assert.match(out, /ONE plain-English line/);
26
+ // Ordering contract with the shell hook's prepended frontier warning.
27
+ assert.match(out, /frontier-model warning/i);
28
+ // Re-injection carve-out: no mid-conversation acknowledgment on resume.
29
+ assert.match(out, /resume/i);
30
+ // Never-truncated sections eat the fixed budget for everything else —
31
+ // keep the directive small.
32
+ assert.ok(out.length < 1000, `ack directive too large: ${out.length} chars`);
33
+ });
34
+
35
+ test('builder output opens with the ack directive, then the state summary', () => {
36
+ const wt = mkdtempSync(join(tmpdir(), 'wt-ack-'));
37
+ const home = mkdtempSync(join(tmpdir(), 'wt-ack-home-'));
38
+ const project = mkdtempSync(join(tmpdir(), 'wt-ack-proj-'));
39
+ try {
40
+ mkdirSync(join(wt, 'state'), { recursive: true });
41
+ writeFileSync(join(wt, 'config.json'), JSON.stringify({ projects: {} }));
42
+ writeFileSync(join(wt, 'state', 'summary.md'), '# Watchtower — test\n\nAll quiet.\n');
43
+
44
+ const out = execFileSync(process.execPath, [SCRIPT, '--project-path', project], {
45
+ encoding: 'utf8',
46
+ env: { ...process.env, WATCHTOWER_DIR: wt, HOME: home },
47
+ timeout: 30_000,
48
+ });
49
+
50
+ assert.ok(
51
+ out.startsWith('--- Session-Start Acknowledgment ---'),
52
+ `output does not open with the ack directive:\n${out.slice(0, 200)}`
53
+ );
54
+ const stateIdx = out.indexOf('--- Watchtower State ---');
55
+ assert.ok(stateIdx > 0, 'summary block missing from builder output');
56
+ } finally {
57
+ rmSync(wt, { recursive: true, force: true });
58
+ rmSync(home, { recursive: true, force: true });
59
+ rmSync(project, { recursive: true, force: true });
60
+ }
61
+ });
@@ -31,7 +31,7 @@ const PROJECT_STALENESS_DAYS = 7;
31
31
  // `assembleSections` removes whole sections (never trims within one) in
32
32
  // descending priority order until the output fits MAX_OUTPUT_CHARS;
33
33
  // PRIORITY_NEVER is never dropped.
34
- const PRIORITY_NEVER = 0; // summary + missed-routine directive — always kept
34
+ const PRIORITY_NEVER = 0; // ack directive + summary + missed-routine directive — always kept
35
35
  const PRIORITY_KEEP = 1; // threads, inbox, advisories — try hard to keep
36
36
  const PRIORITY_PROJECT = 2; // per-project state
37
37
  const PRIORITY_DOMAIN = 3; // injected domain files
@@ -377,6 +377,22 @@ function isMainCheckout(projectPath) {
377
377
  // Render the run-first directive for pending routine items, or null when there
378
378
  // are none. qa-handoff items never reach here — the caller filters to
379
379
  // category 'routine'. Caps the inline list at 3; the rest stay in the inbox.
380
+ // --- Session-start acknowledgment directive (act:058d00f0) ---
381
+ //
382
+ // The injection is invisible in the terminal, so a healthy quiet session is
383
+ // indistinguishable from a silently broken hook. This never-truncated
384
+ // directive tells the session to open its first reply with one visible
385
+ // plain-English line composed from the injected state — positive
386
+ // confirmation the context loaded (no-silent-failures), replacing the
387
+ // signal orient's briefing used to give. Prompt-layer convention only:
388
+ // nothing is blocked if the line is skipped.
389
+ function renderSessionAckSection() {
390
+ return [
391
+ '--- Session-Start Acknowledgment ---',
392
+ 'Open your first reply of this session with ONE plain-English line confirming this watchtower context loaded: where the operator left off, what needs attention in this project, and the inbox count — e.g. "Watchtower: picked up where you left off (merged the auth fix to main, 2h ago); 1 flagged action here; inbox 12 pending." If the state below carries a staleness or health warning, lead with that warning instead. A frontier-model warning above this block stays first. One line, then proceed with the operator\'s request. Skip it if the conversation already has replies (context re-injected on resume).',
393
+ ].join('\n');
394
+ }
395
+
380
396
  function renderMissedRoutineSection(pendingItems, projectName, projectPath) {
381
397
  if (!Array.isArray(pendingItems) || pendingItems.length === 0) return null;
382
398
  const n = pendingItems.length;
@@ -425,6 +441,12 @@ function main() {
425
441
 
426
442
  const sections = [];
427
443
 
444
+ // Step 1b: Session-start acknowledgment directive — rendered first so the
445
+ // visible-confirmation contract sits at the top of the injection
446
+ // (act:058d00f0). The shell hook prepends any frontier-model warning above
447
+ // the builder output, so that warning still comes first overall.
448
+ sections.push({ key: 'session-ack', content: renderSessionAckSection(), priority: PRIORITY_NEVER });
449
+
428
450
  // Step 2: Read state/summary.md
429
451
  const summaryPath = join(WATCHTOWER_DIR, 'state', 'summary.md');
430
452
  const summaryContent = safeReadFile(summaryPath);
@@ -618,7 +640,7 @@ function assembleSections(sections) {
618
640
 
619
641
  // Exports for hermetic tests (act:a136b362). Importing this module must NOT
620
642
  // run main() — gate the entry on an argv/url match (matches the ring scripts).
621
- export { reverifyGitAttention, verifyGitFact, renderMissedRoutineSection, isMainCheckout };
643
+ export { reverifyGitAttention, verifyGitFact, renderMissedRoutineSection, renderSessionAckSection, isMainCheckout };
622
644
 
623
645
  const isMain = (() => {
624
646
  try {