@sabaiway/agent-workflow-kit 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +7 -1
  7. package/references/modes/doc-parity.md +1 -1
  8. package/references/modes/gates.md +20 -4
  9. package/references/modes/procedures.md +2 -0
  10. package/references/modes/recommendations.md +4 -1
  11. package/references/modes/review-state.md +1 -1
  12. package/references/modes/setup.md +18 -2
  13. package/references/modes/upgrade.md +38 -18
  14. package/references/modes/velocity.md +1 -0
  15. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  16. package/references/scripts/migrate-gates.mjs +295 -60
  17. package/references/scripts/migrate-gates.test.mjs +206 -14
  18. package/references/shared/deploy-tail.md +1 -1
  19. package/references/templates/gates.json +1 -1
  20. package/tools/ack-write.mjs +20 -11
  21. package/tools/atomic-write.mjs +71 -18
  22. package/tools/checker-claim.mjs +100 -0
  23. package/tools/coverage-producer.mjs +43 -6
  24. package/tools/direct-run.mjs +76 -0
  25. package/tools/doc-parity.mjs +34 -3
  26. package/tools/engine-source.mjs +12 -8
  27. package/tools/ensure-configs.mjs +141 -0
  28. package/tools/ensure-ops.mjs +284 -0
  29. package/tools/ensure-vocabulary.mjs +71 -0
  30. package/tools/flow-check-cores.mjs +253 -0
  31. package/tools/flow-check-git-lane.mjs +56 -0
  32. package/tools/flow-check-rungs.mjs +330 -0
  33. package/tools/flow-check.mjs +23 -611
  34. package/tools/gates-declaration.mjs +36 -11
  35. package/tools/gates-init.mjs +140 -25
  36. package/tools/hide-footprint.mjs +21 -3
  37. package/tools/lens-region.mjs +74 -23
  38. package/tools/orchestration-config.mjs +5 -3
  39. package/tools/orchestration-write.mjs +7 -0
  40. package/tools/procedures.mjs +64 -5
  41. package/tools/recommendations.mjs +384 -34
  42. package/tools/refresh-parity.mjs +263 -0
  43. package/tools/run-gates.mjs +8 -5
  44. package/tools/setup-backends.mjs +88 -77
  45. package/tools/source-size-check.mjs +310 -0
  46. package/tools/source-size-config.mjs +244 -0
  47. package/tools/source-size-core.mjs +59 -0
  48. package/tools/source-size-gate-cmd.mjs +27 -0
  49. package/tools/source-size-judge.mjs +114 -0
  50. package/tools/source-size-refusal.mjs +70 -0
  51. package/tools/source-size-report.mjs +254 -0
  52. package/tools/source-size-scope.mjs +145 -0
  53. package/tools/tracked-tree-census.mjs +102 -0
  54. package/tools/upgrade-runlist.mjs +92 -0
  55. package/tools/velocity-profile.mjs +24 -3
@@ -12,6 +12,7 @@ import { spawnSync } from 'node:child_process';
12
12
  import {
13
13
  LEGACY_FORMS,
14
14
  UNIT_TESTS_COVERAGE_FLAGS,
15
+ KNOWN_COVERAGE_FLAG_SETS,
15
16
  COVERAGE_PRODUCER_BODY,
16
17
  RETIRED_STORE_BASENAMES,
17
18
  findRetiredStores,
@@ -21,8 +22,17 @@ import {
21
22
  main,
22
23
  } from './migrate-gates.mjs';
23
24
 
25
+ // An INSTALLED kit tools dir carries both core checks as real files. That is a fixture
26
+ // requirement, not decoration: canonicity is a realpath anchor, so a core check whose file is not
27
+ // there resolves to nothing and is no claim at all — the same fail-closed answer run-gates gives.
24
28
  const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-kit-'));
25
29
  writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
30
+ writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
31
+ // The project root the plan builder resolves declared RELATIVE tokens against — the same anchor
32
+ // run-gates uses. Empty on purpose for the pure-plan cases: a relative lookalike resolves to
33
+ // nothing there, which is exactly the "no claim can be made" outcome those rows assert. The
34
+ // vendored rows below build their own project and pass it explicitly.
35
+ const PROJECT = mkdtempSync(join(tmpdir(), 'migrate-gates-project-'));
26
36
 
27
37
  const mkProject = (gates) => {
28
38
  const root = mkdtempSync(join(tmpdir(), 'migrate-gates-'));
@@ -37,6 +47,21 @@ const quiet = () => {
37
47
  return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
38
48
  };
39
49
 
50
+ // The FIRST flag set the kit ever emitted, frozen here as literal bytes — never read back out of
51
+ // KNOWN_COVERAGE_FLAG_SETS. Deployed declarations on disk carry exactly these bytes, so the
52
+ // append-only promise needs a checker that goes red if they are edited or dropped; deriving the
53
+ // "prior" from the set under test would keep this green while real deployments broke.
54
+ const PRIOR_FLAG_SET_V1 =
55
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
56
+
57
+ // A VENDORED deployment: the SAME tools, from a copy `--kit-tools` does not name. Recognition is a
58
+ // realpath anchor, so these are real files — a fake path would be the unresolvable case instead.
59
+ const VENDORED_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-vendored-'));
60
+ writeFileSync(join(VENDORED_TOOLS, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
61
+ writeFileSync(join(VENDORED_TOOLS, 'review-state.mjs'), '// a vendored copy of the review-state check\n');
62
+ const vendoredCmd = (name) => `node "${join(VENDORED_TOOLS, `${name}.mjs`)}" --check`;
63
+ const installedCmd = (name) => `node "${join(KIT_TOOLS, `${name}.mjs`)}" --check`;
64
+
40
65
  const LEGACY_LEDGER = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
41
66
  const LEGACY_FOLD = { id: 'fold-completeness', title: 'F', cmd: 'node /kit/tools/fold-completeness.mjs --check' };
42
67
  const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
@@ -45,12 +70,12 @@ const CUSTOM = { id: 'my-ledger-wrap', title: 'C', cmd: 'node scripts/wrap.mjs &
45
70
  describe('migrate-gates — the pure migration plan', () => {
46
71
  it('matches BOTH documented legacy forms (quoted and bare paths) and removes them', () => {
47
72
  for (const form of LEGACY_FORMS) assert.ok(form.re instanceof RegExp);
48
- const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS);
73
+ const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS, PROJECT);
49
74
  assert.deepEqual(plan.filter((r) => r.action === 'remove').map((r) => r.entry.id), ['review-ledger', 'fold-completeness']);
50
75
  });
51
76
 
52
77
  it('extends the canonical unit-tests cmd with the lcov reporters (flags inserted after `node --test`)', () => {
53
- const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS);
78
+ const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
54
79
  assert.ok(unitTestsExtended);
55
80
  const extended = plan.find((r) => r.action === 'extend').entry;
56
81
  assert.equal(extended.cmd, `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs`);
@@ -58,23 +83,60 @@ describe('migrate-gates — the pure migration plan', () => {
58
83
 
59
84
  it('an already-extended unit-tests cmd is left alone (idempotent)', () => {
60
85
  const done = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
61
- const { plan } = buildMigrationPlan([done], KIT_TOOLS);
86
+ const { plan } = buildMigrationPlan([done], KIT_TOOLS, PROJECT);
62
87
  assert.equal(plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
63
88
  });
64
89
 
90
+ it('a declaration carrying a PRIOR emitted flag set reads as already-configured — keep, zero diff, no warning', () => {
91
+ // The canonical flag set moved (the destination became a required-parameter expansion). A
92
+ // deployment written by the earlier kit must not suddenly read as customized: that would send
93
+ // the maintainer to hand-fix a gate which already produces the lcov the checker reads.
94
+ assert.notEqual(PRIOR_FLAG_SET_V1, UNIT_TESTS_COVERAGE_FLAGS, 'the v1 bytes are a form the kit no longer emits');
95
+ assert.ok(KNOWN_COVERAGE_FLAG_SETS.includes(PRIOR_FLAG_SET_V1), 'and the append-only set still carries them');
96
+ const deployed = [
97
+ { id: 'unit-tests', title: 'U', cmd: `node --test ${PRIOR_FLAG_SET_V1} tools/*.test.mjs` },
98
+ { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` },
99
+ { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` },
100
+ ];
101
+ const analysis = buildMigrationPlan(deployed, KIT_TOOLS, PROJECT);
102
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
103
+ assert.deepEqual(analysis.customized, [], 'a prior emitted form is never reported customized');
104
+ assert.equal(analysis.hasProducer, true, 'it still counts as the producer the checker reads');
105
+ assert.equal(analysis.checkerInert, false);
106
+ assert.equal(analysis.finalCapable, true);
107
+ assert.deepEqual(resultingGates(analysis.plan), deployed, 'zero diff — nothing is rewritten');
108
+ const preview = formatPreview(analysis, 'APPLY');
109
+ assert.match(preview, /nothing to migrate/, 'the preview says there is nothing to do');
110
+ assert.doesNotMatch(preview, /CUSTOMIZED|INERT|WARNING/, 'and warns about nothing');
111
+ });
112
+
113
+ it('a unit-tests entry that merely CONTAINS the prior bytes is CUSTOMIZED, never a silent keep', () => {
114
+ // The already-configured decision runs through the closed producer predicate. A substring probe
115
+ // would call `echo <prior flags>` already configured and say nothing, leaving the maintainer
116
+ // with an entry the tool cannot verify and no recovery line.
117
+ const nearMiss = { id: 'unit-tests', title: 'U', cmd: `echo ${PRIOR_FLAG_SET_V1}` };
118
+ const analysis = buildMigrationPlan([nearMiss], KIT_TOOLS, PROJECT);
119
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep', 'nothing is rewritten');
120
+ assert.deepEqual(analysis.customized.map((g) => g.id), ['unit-tests'], 'but it IS reported customized');
121
+ assert.equal(analysis.hasProducer, false, 'and it never counts as the producer the checker would read');
122
+ const preview = formatPreview(analysis, 'APPLY');
123
+ assert.match(preview, /CUSTOMIZED \(untouched\): unit-tests/);
124
+ assert.match(preview, /declare the canonical suite gate by hand/, 'the paste-ready recovery rides along');
125
+ });
126
+
65
127
  it('adds the coverage-check gate LAST with the RESOLVED quoted path; never a second one', () => {
66
- const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS);
128
+ const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
67
129
  const result = resultingGates(plan);
68
130
  const last = result[result.length - 1];
69
131
  assert.equal(last.id, 'coverage-check');
70
132
  assert.equal(last.cmd, `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check`);
71
- const again = buildMigrationPlan(result, KIT_TOOLS);
133
+ const again = buildMigrationPlan(result, KIT_TOOLS, PROJECT);
72
134
  assert.ok(!again.plan.some((r) => r.action === 'add'), 'a declaration already carrying the checker gains no duplicate');
73
135
  });
74
136
 
75
137
  it('a declaration with NO producer never GAINS the checker — the pair is declared together or not at all', () => {
76
138
  const npmSuite = { id: 'suite', title: 'S', cmd: 'npm test' };
77
- const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS);
139
+ const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS, PROJECT);
78
140
  assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'no checker is added over a declaration that produces no lcov');
79
141
  assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['suite'], 'the legacy entry still goes, nothing dead arrives');
80
142
  assert.equal(analysis.finalCapable, false, 'a declaration with no checker is not final-run-capable');
@@ -86,7 +148,7 @@ describe('migrate-gates — the pure migration plan', () => {
86
148
  it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
87
149
  const checker = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
88
150
  const reviewState = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
89
- const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS);
151
+ const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS, PROJECT);
90
152
  assert.equal(analysis.finalCapable, false, 'a review-state present must NOT make an inert pair read as final-run-capable');
91
153
  assert.ok(resultingGates(analysis.plan).some((g) => g.id === 'coverage-check'), 'the declared checker is never removed');
92
154
  const preview = formatPreview(analysis, 'APPLY');
@@ -101,7 +163,7 @@ describe('migrate-gates — the pure migration plan', () => {
101
163
  title: 'T',
102
164
  cmd: `COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ${COVERAGE_PRODUCER_BODY}`,
103
165
  };
104
- const analysis = buildMigrationPlan([offered], KIT_TOOLS);
166
+ const analysis = buildMigrationPlan([offered], KIT_TOOLS, PROJECT);
105
167
  assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['test', 'coverage-check']);
106
168
  // The `no canonical unit-tests entry` advice is keyed on the ID, but a producer is recognized
107
169
  // under ANY id — repeating the advice over a working producer sends the user to fix nothing.
@@ -109,7 +171,7 @@ describe('migrate-gates — the pure migration plan', () => {
109
171
  });
110
172
 
111
173
  it('a CUSTOMIZED dead-tool reference (compound form) is kept untouched and reported', () => {
112
- const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS);
174
+ const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS, PROJECT);
113
175
  assert.equal(analysis.plan.find((r) => r.entry.id === 'my-ledger-wrap').action, 'keep');
114
176
  assert.deepEqual(analysis.customized.map((g) => g.id), ['my-ledger-wrap']);
115
177
  const preview = formatPreview(analysis, 'APPLY');
@@ -121,7 +183,7 @@ describe('migrate-gates — the pure migration plan', () => {
121
183
  describe('migrate-gates — the canonical anchor + final-capability validation (round-1 folds)', () => {
122
184
  it('a canonical checker NOT in the last position is MOVED last (never left mid-list)', () => {
123
185
  const canonical = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
124
- const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS);
186
+ const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS, PROJECT);
125
187
  const result = resultingGates(plan);
126
188
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the canonical checker ends up LAST');
127
189
  assert.ok(plan.some((r) => r.action === 'move' && r.entry.id === 'coverage-check'), 'the reorder is an explicit move action');
@@ -130,14 +192,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
130
192
 
131
193
  it('a LOOKALIKE checker cmd is CUSTOMIZED (never counted canonical) and the canonical one is still added', () => {
132
194
  const lookalike = { id: 'cov', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
133
- const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS);
195
+ const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS, PROJECT);
134
196
  assert.ok(analysis.customized.some((g) => g.id === 'cov'), 'the lookalike is reported customized');
135
197
  const result = resultingGates(analysis.plan);
136
198
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the REAL canonical checker is added last');
137
199
  });
138
200
 
139
201
  it('the result is judged final-capable ONLY with a canonical review-state present; missing → a LOUD warning with the candidate line, never "final-run-capable"', () => {
140
- const analysis = buildMigrationPlan([UNIT], KIT_TOOLS);
202
+ const analysis = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
141
203
  assert.equal(analysis.finalCapable, false, 'no review-state → not final-capable');
142
204
  const preview = formatPreview(analysis, 'APPLY');
143
205
  assert.match(preview, /review-state/, 'the warning names the missing core check');
@@ -146,13 +208,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
146
208
  const withRs = buildMigrationPlan(
147
209
  [UNIT, { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` }],
148
210
  KIT_TOOLS,
211
+ PROJECT,
149
212
  );
150
213
  assert.equal(withRs.finalCapable, true);
151
214
  });
152
215
 
153
216
  it('a NON-canonical unit-tests cmd (npm test / wrapper) is CUSTOMIZED with the full flag set as the recovery', () => {
154
217
  const npmTest = { id: 'unit-tests', title: 'U', cmd: 'npm test' };
155
- const analysis = buildMigrationPlan([npmTest], KIT_TOOLS);
218
+ const analysis = buildMigrationPlan([npmTest], KIT_TOOLS, PROJECT);
156
219
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
157
220
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'a non-canonical suite cmd is customized');
158
221
  const preview = formatPreview(analysis, 'APPLY');
@@ -161,7 +224,7 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
161
224
 
162
225
  it('a PARTIALLY-flagged unit-tests cmd is CUSTOMIZED (a lone coverage flag never reads as configured)', () => {
163
226
  const partial = { id: 'unit-tests', title: 'U', cmd: 'node --test --experimental-test-coverage tools/*.test.mjs' };
164
- const analysis = buildMigrationPlan([partial], KIT_TOOLS);
227
+ const analysis = buildMigrationPlan([partial], KIT_TOOLS, PROJECT);
165
228
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
166
229
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'the half-wired cmd is customized, never silently left');
167
230
  });
@@ -203,6 +266,135 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
203
266
  });
204
267
  });
205
268
 
269
+ describe('migrate-gates — a VENDORED core check is the tool, from a copy --kit-tools does not name (D6)', () => {
270
+ const UNIT_DONE = { id: 'unit-tests', title: 'U', cmd: `${COVERAGE_PRODUCER_BODY} tools/*.test.mjs` };
271
+ const INSTALLED_REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: installedCmd('review-state') };
272
+
273
+ it('a vendored coverage-check is PRESERVED exactly as declared — declared, never added over, never a collision', () => {
274
+ // Before the split this entry was a LOOKALIKE holding the checker's id, which made the whole
275
+ // upgrade a hard STOP: every preview and every apply over a vendored deployment failed.
276
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
277
+ const declaration = [UNIT_DONE, INSTALLED_REVIEW_STATE, vendored];
278
+ const analysis = buildMigrationPlan(declaration, KIT_TOOLS, PROJECT);
279
+ assert.equal(analysis.collision, null, 'a vendored copy carries the tool\'s own id legitimately — it is no squatter');
280
+ assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'the checker IS declared, so a second one is never added');
281
+ assert.deepEqual(analysis.plan.map((r) => r.action), ['keep', 'keep', 'keep'], 'nothing is rewritten or reordered');
282
+ assert.deepEqual(resultingGates(analysis.plan), declaration, 'the declaration comes out byte-for-byte as it went in');
283
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['coverage-check']);
284
+ assert.deepEqual(analysis.customized, [], 'a real copy of the tool is not an entry the tool cannot verify');
285
+ assert.equal(analysis.finalCapable, false, '--final anchors on the INSTALLED copy, so the capability claim is withheld');
286
+ const preview = formatPreview(analysis, 'APPLY');
287
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/, 'the outcome is NAMED');
288
+ assert.match(preview, /DIFFERENT copy of the tool/, 'and says what it actually found');
289
+ assert.match(preview, /NOT final-run-capable/);
290
+ assert.doesNotMatch(preview, /already final-run-capable/);
291
+ });
292
+
293
+ it('a vendored review-state is preserved too — and is never advised to "add it" on top of itself', () => {
294
+ const vendored = { id: 'review-state', title: 'RS', cmd: vendoredCmd('review-state') };
295
+ const analysis = buildMigrationPlan([UNIT_DONE, vendored], KIT_TOOLS, PROJECT);
296
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['review-state']);
297
+ assert.equal(analysis.hasReviewState, false, 'the INSTALLED review-state is still not declared');
298
+ assert.equal(analysis.finalCapable, false);
299
+ const preview = formatPreview(analysis, 'APPLY');
300
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): review-state/);
301
+ assert.doesNotMatch(preview, /Add it \(paste-ready\)/, 'a second review-state entry is the ambiguity, not the remedy');
302
+ });
303
+
304
+ it('a core check naming a path nothing resolves is NO claim — fail-closed exactly where --final is', () => {
305
+ // A lexical path compare called this canonical: the file need not exist to compare equal after
306
+ // resolve(). The migration then promised final-run-capability over a cmd --final cannot run.
307
+ const ghost = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'nowhere', 'review-state.mjs')}" --check` };
308
+ const analysis = buildMigrationPlan([UNIT_DONE, ghost], KIT_TOOLS, PROJECT);
309
+ assert.equal(analysis.hasReviewState, false, 'an unresolvable path is never the installed tool');
310
+ assert.deepEqual(analysis.externalCoreChecks, [], 'nor evidence that the tool lives somewhere else');
311
+ assert.ok(analysis.customized.some((g) => g.id === 'review-state'), 'it is reported as an entry the tool cannot verify');
312
+ assert.equal(analysis.finalCapable, false);
313
+ });
314
+
315
+ it('a vendored checker never produces the lcov it reads — the declared pair stays INERT', () => {
316
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check'), lcovProducer: true };
317
+ const analysis = buildMigrationPlan(
318
+ [{ id: 'lint', title: 'L', cmd: 'eslint .' }, INSTALLED_REVIEW_STATE, vendored],
319
+ KIT_TOOLS,
320
+ PROJECT,
321
+ );
322
+ assert.equal(analysis.hasProducer, false, 'a marker on the checker itself never self-pairs, whichever copy it is');
323
+ assert.equal(analysis.checkerInert, true, 'a checker over nothing that writes the lcov passes verifying nothing');
324
+ assert.match(formatPreview(analysis, 'APPLY'), /INERT/);
325
+ });
326
+
327
+ it('producer-after-vendored-checker: a producer declared AFTER a vendored checker never covers it', () => {
328
+ // A canonical checker is always MOVED last, so "a producer exists" answers "a producer runs
329
+ // first" for it. A vendored checker is left where the deployment put it, so the position-blind
330
+ // answer reported a live pair over a checker that reads the lcov before anything writes one.
331
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
332
+ const after = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT);
333
+ assert.equal(after.hasProducer, true, 'a producer IS declared somewhere');
334
+ assert.equal(after.checkerInert, true, 'but not before the checker that reads what it writes');
335
+ assert.equal(after.finalCapable, false);
336
+ assert.match(formatPreview(after, 'APPLY'), /INERT/);
337
+
338
+ const before = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT);
339
+ assert.equal(before.checkerInert, false, 'the same entries in producer-first order are a live pair');
340
+ assert.doesNotMatch(formatPreview(before, 'APPLY'), /INERT/);
341
+ });
342
+
343
+ it('the INERT warning names the edit the reader must actually make — order, not a missing gate', () => {
344
+ // The two ways to be inert need two sentences. Telling someone whose suite gate is already
345
+ // declared to declare it again sends them to fix nothing while the real defect stays.
346
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
347
+ const misordered = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT), 'APPLY');
348
+ assert.match(misordered, /runs AFTER this entry/, 'the ORDER is named as the defect, on the row it belongs to');
349
+ assert.match(misordered, /a checker belongs LAST, after its producer/, 'and the remedy is the reorder');
350
+ assert.doesNotMatch(misordered, /declare the suite gate/, 'never advise declaring a gate that is already there');
351
+
352
+ const absent = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
353
+ assert.match(absent, /no declared gate PRODUCES the lcov/, 'with nothing producing, the old sentence still holds');
354
+ assert.match(absent, /declare the suite gate/);
355
+ });
356
+
357
+ it('a mixed declaration renders ONE edit, never a removal and a reorder at once', () => {
358
+ // The two folds met here: canonicalTwin asks for a removal, the inert arm asked for a reorder,
359
+ // and a preview carrying both leaves the reader with no unambiguous next step.
360
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
361
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
362
+ const analysis = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE, canonical], KIT_TOOLS, PROJECT);
363
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => [c.canonicalTwin, c.inert]), [[true, true]]);
364
+ assert.equal(analysis.canonicalCheckerInert, false, 'the canonical checker ends up last, after the producer');
365
+ const preview = formatPreview(analysis, 'APPLY');
366
+ assert.match(preview, /Remove THIS entry by hand/);
367
+ assert.match(preview, /the ONE edit that resolves both/);
368
+ assert.doesNotMatch(preview, /belongs LAST, after its producer/, 'no second, contradictory edit');
369
+ assert.doesNotMatch(preview, /declare the suite gate/);
370
+ });
371
+
372
+ it('a canonical checker declared BESIDE the vendored one changes the recovery — remove, never repoint', () => {
373
+ // Repointing the vendored cmd at the installed copy would leave TWO canonical checkers, and
374
+ // --final accepts exactly one; a recovery that cannot converge is worse than none.
375
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
376
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
377
+ const analysis = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored, canonical], KIT_TOOLS, PROJECT);
378
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.canonicalTwin), [true]);
379
+ const preview = formatPreview(analysis, 'APPLY');
380
+ assert.match(preview, /accepts exactly ONE canonical check/);
381
+ assert.match(preview, /Remove THIS entry by hand/);
382
+ assert.doesNotMatch(preview, /either repoint the cmd/, 'the non-convergent recovery is not offered here');
383
+ });
384
+
385
+ it('a vendored deployment carries the SAME commit-guard consequence a customized one does', () => {
386
+ // Both end in a declaration --final refuses, which mints no receipt, which makes the guard
387
+ // refuse every commit. Naming the consequence for one and not the other is a false asymmetry.
388
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
389
+ const preview = formatPreview(buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
390
+ assert.match(preview, /do NOT install the commit guard/);
391
+ });
392
+
393
+ it('the plan builder REFUSES without the project root — a relative cmd cannot be resolved without it', () => {
394
+ assert.throws(() => buildMigrationPlan([UNIT_DONE], KIT_TOOLS), /project root/);
395
+ });
396
+ });
397
+
206
398
  describe('migrate-gates — preview writes NOTHING; apply is atomic and complete', () => {
207
399
  it('the dry-run default leaves gates.json byte-identical and prints the plan + the apply hint', () => {
208
400
  const root = mkProject([LEGACY_LEDGER, UNIT]);
@@ -12,7 +12,7 @@ The non-obvious traps — scan these before bootstrapping or upgrading. Each is
12
12
  - **No Node runtime → skip enforcement.** If the project has no Node (recon step 1), skip bootstrap steps 8–9 (scripts + hook) and follow the cap/archive/index policy manually, or port the scripts to the project's language.
13
13
  - **Conversational language never translates artifacts.** It governs *dialogue only*. Code, identifiers, paths, commands, log output, abbreviations, and every deployed `docs/ai/` / `AGENTS.md` file stay in their source language. See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract).
14
14
  - **Never auto-commit.** Report quality-gate results and wait for explicit approval — in both modes.
15
- - **Never leak kit internals to the user.** No ADR ids, tool / function / operation names (`reconcile`, `inject`, `ensureSlot`), marker / slot / fragment / anchor terminology, or verbatim tool stderr in anything the user reads. Translate every tool outcome into plain language a third-party user — who has never read this `SKILL.md` — can understand and act on (e.g. the cap-refusal report in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3).
15
+ - **Never leak kit internals to the user — and a tool-COMPOSED user-facing line holds the same bar at the source.** No ADR ids, tool / function / operation names (`reconcile`, `inject`, `ensureSlot`), marker / slot / fragment / anchor terminology, or verbatim tool stderr **inside the human sentence** of anything the user reads. Translate every tool outcome into plain language a third-party user — who has never read this `SKILL.md` — can understand and act on (e.g. the cap-refusal report in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3). The composed lines themselves are **user-grade** language: machine tokens and tool self-labels belong to the **machine-line channel** — the `[run-gates] status=…` grammar (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`), a line's leading self-label/prefix, or a runnable command/path the user can act on — never mid-sentence; alarm words (`PARTIALLY`, `incomplete`, `failed`, `broken`, `persists`) render only in outcomes gated on a **detected abnormal condition**. ONE designed exception, stated not implied: the configuration ensures' LEADING outcome token — one closed-vocabulary token, a failure's closed cause word opening its detail line — is that contract's own machine slot, not a leak. The **verbatim**-paste contract stays: the agent pastes tool-composed outcome lines as written and never re-composes their facts — the lines are user-grade at the source, so pasting them verbatim IS the plain language.
16
16
  - **Uninstall never deletes user-authored content, and dry-runs first.** `/agent-workflow-kit uninstall` removes only what is **provably ours** (a managed skill dir / wrapper symlink / fenced block / marker hook) and **prints — never runs** the `rm` / `git rm --cached` for `docs/ai` and the entry-point docs, and an **edit** instruction (not an `rm`) for `.claude/settings.json`. Always run `--dry-run` first, show the plan, get consent, then `--yes`. A skill dir or symlink that is not provably ours is a STOP, never a clobber (the `setup` posture, inverted). Removing a shared global (memory/engine/a bridge) may affect another project — say so.
17
17
 
18
18
  ---
@@ -1,4 +1,4 @@
1
1
  {
2
- "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd }: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell). This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
2
+ "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd } plus ONE optional key: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell) — and `lcovProducer` (boolean, optional) DECLARES that this gate writes the lcov the coverage checker reads, for a suite the kit's closed `node --test` recognition cannot read on its own. Only the literal true claims it, and it widens what the declaration may CLAIM, never what a run CERTIFIES: a marked gate that produces no lcov still ends `skipped-no-lcov` at run time. This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
3
3
  "gates": []
4
4
  }
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // ack-write.mjs — the consent-gated writer for the family-owned neutral ack store
3
- // (docs/ai/acks.json, AD-055 Part I). The upgrade Recommendations `sandbox-lane` item renders THIS
4
- // tool's PREVIEW one-liner; the preview prints the exact `--apply` command; the agent runs `--apply`
5
- // only after the mode doc's §3 informed-consent confirmation. It records a NEUTRAL recipe fingerprint
6
- // acknowledgement — never a security key; the kit never writes sandbox network/filesystem allowances.
3
+ // (docs/ai/acks.json, AD-055 Part I). An upgrade Recommendations item whose state can only be
4
+ // ANSWERED renders THIS tool's PREVIEW one-liner; the preview prints the exact `--apply` command;
5
+ // the agent runs `--apply` only after the mode doc's §3 informed-consent confirmation. It records a
6
+ // NEUTRAL fingerprint acknowledgement — never a security key; the kit never writes sandbox
7
+ // network/filesystem allowances.
8
+ //
9
+ // The wording is per-LANE or genuinely neutral, never "recipe": the sandbox lane acknowledges a
10
+ // session-sandbox recipe, but the coverage-domain lane acknowledges a census FACT and the
11
+ // source-size-copy lane a set of declared tool claims. One lane's noun stated over all of them was
12
+ // simply false about the others.
7
13
  //
8
14
  // Family writer discipline (velocity / orchestration-write / gate-hook), verbatim:
9
15
  // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
@@ -34,8 +40,9 @@ const EXIT_OK = 0;
34
40
  const EXIT_PRECONDITION = 1;
35
41
  const EXIT_USAGE = 2;
36
42
  const JSON_INDENT = 2;
37
- // The recipeFingerprint shape (recommendations.mjs: sha256 hex sliced to 16) — a fail-closed guard so
38
- // the store never records a malformed or injected value.
43
+ // The shape every advisor fingerprint carries (recommendations.mjs: sha256 hex sliced to 16, whether
44
+ // minted over a recipe or over a canonical fact string) — a fail-closed guard so the store never
45
+ // records a malformed or injected value.
39
46
  export const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u;
40
47
  // The lane this writer records when none is named — the original single-lane contract, so every
41
48
  // pre-existing sandbox-lane invocation and its rendered one-liner stay byte-identical.
@@ -126,13 +133,13 @@ export const formatResult = (result) => {
126
133
  if (result.dryRun) {
127
134
  if (result.alreadyAcked) {
128
135
  return [
129
- `agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this recipe fingerprint (${result.fingerprint}) — nothing to do.`,
136
+ `agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this ${result.lane} fingerprint (${result.fingerprint}) — nothing to do.`,
130
137
  ].join('\n');
131
138
  }
132
139
  return [
133
140
  `agent-workflow ack — DRY RUN (no changes; re-run with --apply)`,
134
141
  ` - would ${result.existed ? 'set' : 'create'} ${ACKS_FILE} "${result.ackKey}" = "${result.fingerprint}"${merge}`,
135
- ` - this is a NEUTRAL recipe acknowledgement, never a security key.`,
142
+ ` - this is a NEUTRAL ${result.lane} acknowledgement, never a security key.`,
136
143
  ` to apply: ${applyCommand(result.root, result.fingerprint, result.lane)}`,
137
144
  ].join('\n');
138
145
  }
@@ -145,9 +152,11 @@ export const formatResult = (result) => {
145
152
  // ── CLI ─────────────────────────────────────────────────────────────────────────────────
146
153
  const USAGE = `usage: ack-write --fingerprint <16-hex> [--lane <${Object.keys(ACK_LANES).join('|')}>] [--dry-run | --apply] [--cwd <dir>] [--help]
147
154
 
148
- Records a NEUTRAL recipe acknowledgement into ${ACKS_FILE} (the family-owned ack store — no host
149
- settings validator guards it). --lane names which advisor item is being acknowledged (default
150
- "${DEFAULT_ACK_LANE}"); each lane owns one top-level key. Default is --dry-run (a preview; writes
155
+ Records a NEUTRAL acknowledgement into ${ACKS_FILE} (the family-owned ack store — no host settings
156
+ validator guards it). --lane names which advisor item is being acknowledged (default
157
+ "${DEFAULT_ACK_LANE}") and what the fingerprint stands for a session-sandbox recipe, a worktrees
158
+ probe dir, a tracked-tree census fact, a set of declared tool claims; each lane owns one top-level
159
+ key. Default is --dry-run (a preview; writes
151
160
  nothing) and prints the exact --apply command. --apply merges that ONE key into ${ACKS_FILE},
152
161
  preserving every existing key. Refuses an absent docs/ai deployment; never a security key; never
153
162
  commits.`;
@@ -8,6 +8,8 @@
8
8
  // (bridges 2.3.0, D6): ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf. The
9
9
  // host dir is CREATED if absent (a host config SHOULD materialize), unlike the docs/ai gate
10
10
  // which REFUSES an absent deployment.
11
+ // • writeProjectFileCreateOnly — a SEED under a project subdirectory (the ensure CLI's enforcement
12
+ // scripts): the parent dir is created + verified, and the write is create-only.
11
13
  //
12
14
  // The discipline (verbatim from the source implementation) — writeContainedFileAtomic(root, dst, …):
13
15
  // - a per-consumer GATE runs first (deployment gate for docs/ai; create+verify for the host dir).
@@ -19,12 +21,19 @@
19
21
  // - tmp cleaned up on any failure after its creation.
20
22
  // - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
21
23
  //
24
+ // `opts.createOnly` swaps the LAST step only: `link(tmp, dst)` instead of `rename(tmp, dst)`. A rename
25
+ // replaces whatever is there — correct for a refresh, wrong for a SEED, where a file that appeared
26
+ // between the probe and the write must survive. link() is the primitive that both keeps the content
27
+ // atomic and fails EEXIST instead of clobbering; the caller learns which happened from `created`,
28
+ // never from a second probe (which would be the same race again). A filesystem that cannot hard-link
29
+ // surfaces its own error — there is no silent fallback to a clobbering write.
30
+ //
22
31
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*) so the guards are
23
32
  // unit-testable. NEVER imported by a read-only module (procedures.mjs — pinned by an import guard).
24
33
 
25
- import { lstatSync, writeFileSync, renameSync, rmSync, mkdirSync } from 'node:fs';
34
+ import { lstatSync, writeFileSync, renameSync, linkSync, rmSync, mkdirSync } from 'node:fs';
26
35
  import { randomBytes } from 'node:crypto';
27
- import { join } from 'node:path';
36
+ import { dirname, join } from 'node:path';
28
37
  import { assertContainedRealPath } from './fs-safe.mjs';
29
38
 
30
39
  export const ATOMIC_WRITE_STOP = 'ATOMIC_WRITE_STOP';
@@ -78,11 +87,12 @@ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
78
87
  }
79
88
  };
80
89
 
81
- // Host config dir gate — CREATE the dir if absent (a host config SHOULD materialize on first write,
82
- // the opposite of the docs/ai deployment gate), then refuse a symlinked / non-directory dir we would
83
- // write THROUGH (a rename into a symlinked dir would land outside where the user thinks). `noun` names
84
- // what the caller writes so each consumer's STOP message stays exactly as its own tests pinned it.
85
- export const assertHostConfigDirSafe = (dir, deps = {}, opts = {}) => {
90
+ // Creatable-dir gate — CREATE the dir if absent (a host config / a project's scripts dir SHOULD
91
+ // materialize on first write, the opposite of the docs/ai deployment gate), then refuse a symlinked /
92
+ // non-directory dir we would write THROUGH (a rename into a symlinked dir would land outside where the
93
+ // user thinks). `noun` names what the caller writes so each consumer's STOP message stays exactly as
94
+ // its own tests pinned it.
95
+ export const assertCreatableDirSafe = (dir, deps = {}, opts = {}) => {
86
96
  const lstat = deps.lstat ?? lstatSync;
87
97
  const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
88
98
  const stop = opts.stop ?? defaultStop;
@@ -95,12 +105,17 @@ export const assertHostConfigDirSafe = (dir, deps = {}, opts = {}) => {
95
105
  };
96
106
 
97
107
  // The hardened atomic flow, parameterized by containment ROOT + an already-passed gate. `dst` is the
98
- // ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message. Returns
99
- // { writtenPath: dst }. THROWS the caller's typed STOP (opts.stop) or a native fs error.
108
+ // ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message; `opts.createOnly`
109
+ // makes the last step a no-clobber link (above). Returns { writtenPath: dst, created, tmpLeftBehind }
110
+ // — `created` is false ONLY under createOnly, when a regular file already stood there, and
111
+ // `tmpLeftBehind` names the temp file when the post-publication cleanup failed (the write STANDS;
112
+ // the leftover is reported, never swallowed). THROWS the caller's typed STOP (opts.stop) or a native
113
+ // fs error.
100
114
  export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {}) => {
101
115
  const lstat = deps.lstat ?? lstatSync;
102
116
  const writeFile = deps.writeFile ?? writeFileSync;
103
117
  const rename = deps.rename ?? renameSync;
118
+ const link = deps.link ?? linkSync;
104
119
  const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
105
120
  const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
106
121
  const stop = opts.stop ?? defaultStop;
@@ -121,6 +136,8 @@ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {})
121
136
  // Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
122
137
  // overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
123
138
  writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
139
+ let created = true;
140
+ let tmpLeftBehind = null;
124
141
  try {
125
142
  // TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
126
143
  guard(dst);
@@ -128,30 +145,66 @@ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {})
128
145
  if (leafAgain && leafAgain.isSymbolicLink()) {
129
146
  throw stop(`${label} became a symlink — refusing to replace it`);
130
147
  }
131
- rename(tmp, dst);
148
+ if (opts.createOnly) {
149
+ try {
150
+ link(tmp, dst);
151
+ } catch (err) {
152
+ if (!err || err.code !== 'EEXIST') throw err;
153
+ // EEXIST is not proof that a REGULAR FILE stands there. Re-check no-follow: a symlink that
154
+ // appeared inside the race window meets the same refusal as one that was there all along, a
155
+ // non-file is refused rather than reported as a preserved seed, and a dst that is already
156
+ // gone again is a loud conflict — never a silent "it was already there".
157
+ const raced = lstatNoFollow(dst, lstat);
158
+ if (raced === null) throw stop(`${label} could not be created, and nothing is there now — something is creating and removing it underneath this write; re-run when the tree is settled`);
159
+ if (raced.isSymbolicLink()) throw stop(`${label} became a symlink — refusing to seed through it`);
160
+ if (!raced.isFile()) throw stop(`${label} exists but is not a regular file — refusing to seed through it`);
161
+ created = false; // it appeared under us — the existing file stands, byte-for-byte
162
+ }
163
+ // The publication is DONE (or the dst already stood): dropping our tmp name may not turn a
164
+ // completed write into a failure. A cleanup that fails is reported instead — never swallowed.
165
+ try {
166
+ rm(tmp);
167
+ } catch {
168
+ tmpLeftBehind = tmp;
169
+ }
170
+ } else {
171
+ rename(tmp, dst);
172
+ }
132
173
  } catch (err) {
133
174
  rm(tmp); // never leave a temp file behind on failure
134
175
  throw err;
135
176
  }
136
- return { writtenPath: dst };
177
+ return { writtenPath: dst, created, tmpLeftBehind };
137
178
  };
138
179
 
139
- // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel } on success; THROWS the
140
- // caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives pre-serialized
141
- // (each consumer owns its canonical serialization). Thin wrapper over the core: gate = deployment
142
- // gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is unchanged.
180
+ // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel, created } on success;
181
+ // THROWS the caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives
182
+ // pre-serialized (each consumer owns its canonical serialization). Thin wrapper over the core: gate =
183
+ // deployment gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is
184
+ // unchanged (`created` is additive — always true unless the caller asked for createOnly).
143
185
  export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
144
186
  assertDocsAiDeployment(cwd, deps, { ...opts, rel });
145
187
  const dst = join(cwd, rel);
146
- writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
147
- return { writtenPath: rel };
188
+ const { created, tmpLeftBehind } = writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
189
+ return { writtenPath: rel, created, tmpLeftBehind };
148
190
  };
149
191
 
150
192
  // writeHostConfigFileAtomic(dir, filename, body, deps, opts) → { writtenPath: dir/filename }. Gate =
151
193
  // create+verify the host dir; root = that dir; target = dir/filename. For the out-of-tree host config
152
194
  // surface (bridge-settings.conf) that no project deployment owns.
153
195
  export const writeHostConfigFileAtomic = (dir, filename, body, deps = {}, opts = {}) => {
154
- assertHostConfigDirSafe(dir, deps, opts);
196
+ assertCreatableDirSafe(dir, deps, opts);
155
197
  const dst = join(dir, filename);
156
198
  return writeContainedFileAtomic(dir, dst, body, deps, { ...opts, label: opts.label ?? dst });
157
199
  };
200
+
201
+ // writeProjectFileCreateOnly(root, rel, body, deps, opts) → { writtenPath: rel, created }. Gate =
202
+ // create+verify the target's PARENT dir inside the project (a symlinked `scripts/` is a STOP, not a
203
+ // write through it); root = the project dir; the write is create-only, so an existing file is left
204
+ // exactly as it is and `created` says so.
205
+ export const writeProjectFileCreateOnly = (root, rel, body, deps = {}, opts = {}) => {
206
+ const dst = join(root, rel);
207
+ assertCreatableDirSafe(dirname(dst), deps, opts);
208
+ const { created, tmpLeftBehind } = writeContainedFileAtomic(root, dst, body, deps, { ...opts, createOnly: true, label: rel });
209
+ return { writtenPath: rel, created, tmpLeftBehind };
210
+ };