@sabaiway/agent-workflow-kit 1.5.2 → 1.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 (34) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +12 -5
  3. package/SKILL.md +48 -20
  4. package/bin/install.mjs +33 -50
  5. package/bin/install.test.mjs +30 -1
  6. package/bridges/antigravity-cli-bridge/SKILL.md +178 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.sh +133 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -0
  9. package/bridges/antigravity-cli-bridge/capability.json +22 -0
  10. package/bridges/antigravity-cli-bridge/references/driving-agy.md +108 -0
  11. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +93 -0
  12. package/bridges/antigravity-cli-bridge/references/review-prompt.md +51 -0
  13. package/bridges/antigravity-cli-bridge/setup/README.md +65 -0
  14. package/bridges/codex-cli-bridge/SKILL.md +148 -0
  15. package/bridges/codex-cli-bridge/bin/codex-exec.sh +143 -0
  16. package/bridges/codex-cli-bridge/bin/codex-review.sh +84 -0
  17. package/bridges/codex-cli-bridge/capability.json +22 -0
  18. package/bridges/codex-cli-bridge/references/driving-codex.md +97 -0
  19. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +105 -0
  20. package/bridges/codex-cli-bridge/setup/README.md +78 -0
  21. package/capability.json +1 -1
  22. package/migrations/README.md +1 -1
  23. package/package.json +3 -2
  24. package/references/templates/AGENTS.md +2 -1
  25. package/tools/delegation.mjs +4 -4
  26. package/tools/delegation.test.mjs +4 -3
  27. package/tools/detect-backends.mjs +36 -0
  28. package/tools/detect-backends.test.mjs +102 -0
  29. package/tools/fs-safe.mjs +129 -0
  30. package/tools/fs-safe.test.mjs +200 -0
  31. package/tools/inject-methodology.mjs +131 -23
  32. package/tools/inject-methodology.test.mjs +232 -1
  33. package/tools/setup-backends.mjs +468 -0
  34. package/tools/setup-backends.test.mjs +500 -0
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // Methodology slot injection — the composition root's only mutation of memory's AGENTS.md.
2
+ // Methodology slot injection + reconciliation — the composition root's only mutation of a
3
+ // deployed AGENTS.md.
3
4
  //
4
- // memory ships an EMPTY delimited slot in templates/AGENTS.md; the kit (which knows the whole
5
- // family) fills it. The engine only *provides* the methodology text Plan 2 repoints the
6
- // source to it. Phase 1 source = the kit's bundled tools/methodology-slot.md (a BOUNDED summary
7
- // + pointer, NOT the full references/planning.md), so AGENTS.md stays under its line cap.
5
+ // Both templates (memory's + the kit fallback) ship an EMPTY delimited slot; the kit (which knows
6
+ // the whole family) fills it. The bounded fragment (tools/methodology-slot.md) is a BOUNDED summary
7
+ // + pointer, NOT the full references/planning.md, so AGENTS.md stays under its line cap; it is a
8
+ // byte-identical MIRROR of the canonical text in agent-workflow-engine (drift-guarded).
8
9
  //
9
- // Marker contract (shared with memory's upgrade extract-and-reinsert), strictly enforced:
10
- // - exactly one ordered start→end pair → replace only the bytes between them.
11
- // - markers absent (legacy AGENTS.md) gracefully NO-OP (slot migration is Plan 2).
12
- // - any malformed state (single, reversed, nested, duplicate) → NO-OP WITH AN ERROR; never edit.
13
- // Prefix/suffix bytes are preserved exactly. Re-running with the same fragment is idempotent.
10
+ // Two layers over one marker parser:
11
+ // - injectMethodology fill an EXISTING slot. Marker contract, strictly enforced:
12
+ // exactly one ordered start→end pair replace only the bytes between them;
13
+ // markers absent → NO-OP; any malformed state (single, reversed, nested, duplicate) →
14
+ // NO-OP WITH AN ERROR, never edit. Prefix/suffix preserved exactly; re-run is idempotent.
15
+ // - ensureSlot / reconcileSlot — the bootstrap/upgrade policy (Plan 2): ensure the slot EXISTS
16
+ // (insert an empty pair at the Session-Protocols anchor when a legacy file lacks one) →
17
+ // inject ONLY IF empty (preserve a customized slot verbatim) → cap-check. Stamp-independent.
14
18
  //
15
19
  // Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
16
20
 
@@ -18,6 +22,10 @@ export const START_MARKER = '<!-- workflow:methodology:start -->';
18
22
  export const END_MARKER = '<!-- workflow:methodology:end -->';
19
23
  export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
20
24
 
25
+ // Count lines independent of a trailing newline (CRLF-safe: split on '\n' — a CRLF line still ends
26
+ // in '\n', so the count is the same as for LF).
27
+ const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
28
+
21
29
  const countOccurrences = (haystack, needle) => {
22
30
  let count = 0;
23
31
  let from = 0;
@@ -58,14 +66,15 @@ export const injectMethodology = (text, fragment, { maxLines } = {}) => {
58
66
  const slot = findSlot(text);
59
67
  if (slot.state === 'absent') return { status: 'noop-absent', text };
60
68
  if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
69
+ // Frame the fragment with the DOCUMENT's newline style (and convert the LF-canonical fragment to
70
+ // it) so injecting into a CRLF file does not leave lone LFs around the slot.
71
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
61
72
  const before = text.slice(0, slot.startIdx + START_MARKER.length);
62
73
  const after = text.slice(slot.endIdx);
63
- const out = `${before}\n${fragment.trim()}\n${after}`;
64
- if (maxLines != null) {
65
- const lines = out.split('\n').length - (out.endsWith('\n') ? 1 : 0);
66
- if (lines > maxLines) {
67
- return { status: 'error', text, error: `injection would push AGENTS.md to ${lines} lines (cap ${maxLines}) — trim the fragment or the file` };
68
- }
74
+ const body = fragment.trim().replace(/\r?\n/g, nl);
75
+ const out = `${before}${nl}${body}${nl}${after}`;
76
+ if (maxLines != null && lineCount(out) > maxLines) {
77
+ return { status: 'error', text, error: `injection would push AGENTS.md to ${lineCount(out)} lines (cap ${maxLines}) — trim the fragment or the file` };
69
78
  }
70
79
  return { status: 'injected', text: out };
71
80
  };
@@ -78,19 +87,120 @@ export const extractSlot = (text) => {
78
87
  return text.slice(slot.startIdx + START_MARKER.length, slot.endIdx);
79
88
  };
80
89
 
90
+ // The Session-Protocols anchor line both deployed templates carry (the agent_rules.md §1 sentence).
91
+ // ensureSlot inserts an empty slot right after this line when a legacy entry point has no markers.
92
+ // Contract: EXACTLY ONE match required — 0 or >1 → error (never guess where the methodology lives).
93
+ export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
94
+
95
+ // The canonical empty slot (an ordered start→end pair, nothing between) — what a fresh template
96
+ // ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
97
+ export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
98
+
99
+ const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
100
+
101
+ // Ensure a single, well-formed methodology slot EXISTS — without filling it. Pure; no fs.
102
+ // { status: 'present', text } a well-formed slot already exists → bytes unchanged (idempotent).
103
+ // { status: 'inserted', text } absent + exactly one anchor → an EMPTY slot inserted right after
104
+ // the anchor line, newline style + all other bytes preserved.
105
+ // { status: 'error', text, error } malformed slot, OR (when absent) 0/>1 anchors → bytes unchanged.
106
+ export const ensureSlot = (text) => {
107
+ const slot = findSlot(text);
108
+ if (slot.state === 'ok') return { status: 'present', text };
109
+ if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
110
+ // absent → place an empty slot at the one anchor, or refuse rather than guess.
111
+ const anchors = countMatches(text, METHODOLOGY_ANCHOR);
112
+ if (anchors !== 1) {
113
+ return {
114
+ status: 'error',
115
+ text,
116
+ error: `expected exactly one methodology anchor (the "Read it before any code change." Session-Protocols line), found ${anchors} — refusing to guess where the slot belongs; add the slot markers manually`,
117
+ };
118
+ }
119
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
120
+ const match = text.match(METHODOLOGY_ANCHOR);
121
+ const eol = text.indexOf('\n', match.index);
122
+ const insertAt = eol === -1 ? text.length : eol + 1;
123
+ const block = `${nl}${EMPTY_SLOT.replace(/\n/g, nl)}${nl}`;
124
+ const out = `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
125
+ return { status: 'inserted', text: out };
126
+ };
127
+
128
+ // Bootstrap/upgrade reconciliation policy (pure): ensure the slot exists, then fill it ONLY IF it
129
+ // is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one
130
+ // step the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned
131
+ // unchanged (the intermediate slot-insert is discarded), so there is no partial on-disk state.
132
+ // reconciled-inserted — slot was absent, inserted at the anchor, then filled.
133
+ // reconciled-filled — slot existed but was empty, now filled.
134
+ // present-filled — slot already carried content → preserved verbatim.
135
+ // error — malformed slot, 0/>1 anchors, or cap exceeded → input unchanged.
136
+ export const reconcileSlot = (text, fragment, { maxLines } = {}) => {
137
+ const ensured = ensureSlot(text);
138
+ if (ensured.status === 'error') return { status: 'error', text, error: ensured.error };
139
+ const current = extractSlot(ensured.text);
140
+ const isEmpty = current == null || current.trim() === '';
141
+ if (!isEmpty) {
142
+ // Preserve a filled/customized slot verbatim — but still enforce the cap on the result, so an
143
+ // already-over-cap entry point is surfaced (input unchanged) rather than silently accepted.
144
+ if (maxLines != null && lineCount(ensured.text) > maxLines) {
145
+ return { status: 'error', text, error: `AGENTS.md is ${lineCount(ensured.text)} lines (cap ${maxLines}) — trim the file (a customized methodology slot must still fit the cap)` };
146
+ }
147
+ return { status: 'present-filled', text: ensured.text };
148
+ }
149
+ const injected = injectMethodology(ensured.text, fragment, { maxLines });
150
+ if (injected.status !== 'injected') return { status: 'error', text, error: injected.error };
151
+ const status = ensured.status === 'inserted' ? 'reconciled-inserted' : 'reconciled-filled';
152
+ return { status, text: injected.text };
153
+ };
154
+
81
155
  const main = async (argv) => {
82
- const { readFile, writeFile, rename } = await import('node:fs/promises');
156
+ const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
83
157
  const { dirname, basename, join, resolve } = await import('node:path');
84
158
  const { fileURLToPath } = await import('node:url');
85
159
  const here = dirname(fileURLToPath(import.meta.url));
86
- const agentsPath = argv[0];
160
+
161
+ // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade);
162
+ // `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-slot mode.
163
+ const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
164
+ const rest = mode === 'reconcile' ? argv.slice(1) : argv;
165
+ const agentsPath = rest[0];
87
166
  if (!agentsPath) {
88
- console.error('usage: inject-methodology.mjs <path/to/AGENTS.md> [fragment.md]');
167
+ console.error('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
89
168
  process.exit(2);
90
169
  }
91
- const fragmentPath = argv[1] ? resolve(argv[1]) : resolve(here, 'methodology-slot.md');
170
+ const fragmentPath = rest[1] ? resolve(rest[1]) : resolve(here, 'methodology-slot.md');
92
171
  const text = await readFile(resolve(agentsPath), 'utf8');
93
172
  const fragment = await readFile(fragmentPath, 'utf8');
173
+
174
+ const writeAtomic = async (out) => {
175
+ const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
176
+ try {
177
+ await writeFile(tmp, out, 'utf8');
178
+ await rename(tmp, resolve(agentsPath));
179
+ } catch (err) {
180
+ await rm(tmp, { force: true }).catch(() => {}); // never leave a temp file behind on failure
181
+ throw err;
182
+ }
183
+ };
184
+
185
+ if (mode === 'reconcile') {
186
+ const result = reconcileSlot(text, fragment, { maxLines: AGENTS_MD_CAP });
187
+ if (result.status === 'error') {
188
+ console.error(`[inject-methodology] reconcile refused — ${result.error}`);
189
+ process.exit(1);
190
+ }
191
+ if (result.status === 'present-filled') {
192
+ console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
193
+ return;
194
+ }
195
+ await writeAtomic(result.text);
196
+ const what =
197
+ result.status === 'reconciled-inserted'
198
+ ? 'inserted the methodology slot at the Session-Protocols anchor and filled it'
199
+ : 'filled the empty methodology slot';
200
+ console.log(`[inject-methodology] reconcile: ${what}.`);
201
+ return;
202
+ }
203
+
94
204
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
95
205
  if (result.status === 'error') {
96
206
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
@@ -100,9 +210,7 @@ const main = async (argv) => {
100
210
  console.log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
101
211
  return;
102
212
  }
103
- const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
104
- await writeFile(tmp, result.text, 'utf8');
105
- await rename(tmp, resolve(agentsPath));
213
+ await writeAtomic(result.text);
106
214
  console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
107
215
  };
108
216
 
@@ -1,22 +1,56 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
6
8
  import {
7
9
  injectMethodology,
8
10
  findSlot,
9
11
  extractSlot,
12
+ ensureSlot,
13
+ reconcileSlot,
14
+ METHODOLOGY_ANCHOR,
15
+ EMPTY_SLOT,
16
+ AGENTS_MD_CAP,
10
17
  START_MARKER,
11
18
  END_MARKER,
12
19
  } from './inject-methodology.mjs';
13
20
 
14
21
  const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const SCRIPT = join(HERE, 'inject-methodology.mjs');
15
23
  const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
16
24
 
17
25
  const wrap = (inner) =>
18
26
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
19
27
 
28
+ // The exact Session-Protocols line both deployed templates carry — the slot anchor.
29
+ const ANCHOR_LINE =
30
+ 'Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**';
31
+
32
+ // A pre-slot (markerless) entry point that still carries the Session-Protocols anchor line —
33
+ // the realistic shape of a legacy deployment the upgrade reconciliation must add a slot to.
34
+ const legacyWithAnchor = (nl = '\n') =>
35
+ [
36
+ '# AGENTS.md',
37
+ '',
38
+ 'prefix bytes',
39
+ '',
40
+ '## 🚀 Session Protocols',
41
+ '',
42
+ ANCHOR_LINE,
43
+ '',
44
+ '---',
45
+ '',
46
+ '## 🚫 Hard Constraints',
47
+ '',
48
+ 'suffix bytes',
49
+ '',
50
+ ].join(nl);
51
+
52
+ const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
53
+
20
54
  describe('findSlot — marker classification', () => {
21
55
  it('one ordered pair → ok', () => {
22
56
  assert.equal(findSlot(wrap('\n')).state, 'ok');
@@ -122,3 +156,200 @@ describe('post-injection cap — AGENTS.md stays under its line budget', () => {
122
156
  assert.ok(lines <= 100, `AGENTS.md would be ${lines} lines after injection (cap 100)`);
123
157
  });
124
158
  });
159
+
160
+ describe('METHODOLOGY_ANCHOR — locating the Session-Protocols slot position', () => {
161
+ it('matches exactly one line in a deployed-style markerless entry point', () => {
162
+ assert.equal(countMatches(legacyWithAnchor(), METHODOLOGY_ANCHOR), 1);
163
+ });
164
+ it('matches exactly one line in BOTH shipped templates', () => {
165
+ const memTmpl = readFileSync(
166
+ join(HERE, '..', '..', 'agent-workflow-memory', 'references', 'templates', 'AGENTS.md'),
167
+ 'utf8',
168
+ );
169
+ const kitTmpl = readFileSync(join(HERE, '..', 'references', 'templates', 'AGENTS.md'), 'utf8');
170
+ assert.equal(countMatches(memTmpl, METHODOLOGY_ANCHOR), 1, 'memory template has exactly one anchor');
171
+ assert.equal(countMatches(kitTmpl, METHODOLOGY_ANCHOR), 1, 'kit fallback template has exactly one anchor');
172
+ });
173
+ it('does not match an entry point that lacks the Session-Protocols line', () => {
174
+ assert.equal(countMatches('# AGENTS.md\nno session protocols here\n', METHODOLOGY_ANCHOR), 0);
175
+ });
176
+ });
177
+
178
+ describe('EMPTY_SLOT — the canonical empty marker pair', () => {
179
+ it('is exactly the start+end markers joined by a newline', () => {
180
+ assert.equal(EMPTY_SLOT, `${START_MARKER}\n${END_MARKER}`);
181
+ assert.equal(findSlot(EMPTY_SLOT).state, 'ok');
182
+ assert.equal(extractSlot(EMPTY_SLOT).trim(), '');
183
+ });
184
+ });
185
+
186
+ describe('ensureSlot — idempotent slot presence (insert at the anchor only when absent)', () => {
187
+ it('present (one ok pair) → status present, bytes unchanged', () => {
188
+ const input = wrap('\nfilled\n');
189
+ const out = ensureSlot(input);
190
+ assert.equal(out.status, 'present');
191
+ assert.equal(out.text, input);
192
+ });
193
+
194
+ it('malformed slot → status error, never edits', () => {
195
+ const input = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
196
+ const out = ensureSlot(input);
197
+ assert.equal(out.status, 'error');
198
+ assert.equal(out.text, input);
199
+ });
200
+
201
+ it('absent + exactly one anchor → inserts EMPTY_SLOT after the anchor line, preserving bytes', () => {
202
+ const input = legacyWithAnchor();
203
+ const out = ensureSlot(input);
204
+ assert.equal(out.status, 'inserted');
205
+ assert.equal(findSlot(out.text).state, 'ok', 'a well-formed slot now exists');
206
+ assert.equal(extractSlot(out.text).trim(), '', 'the inserted slot is empty');
207
+ // the slot lands right after the anchor line
208
+ assert.match(out.text, new RegExp(`Read it before any code change\\.\\*\\*\\n+${START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
209
+ // every original line survives
210
+ for (const line of ['# AGENTS.md', 'prefix bytes', ANCHOR_LINE, '## 🚫 Hard Constraints', 'suffix bytes']) {
211
+ assert.ok(out.text.includes(line), `original line preserved: ${line}`);
212
+ }
213
+ });
214
+
215
+ it('absent + zero anchor → status error with an actionable message, never edits', () => {
216
+ const input = '# AGENTS.md\n\nno anchor at all\n';
217
+ const out = ensureSlot(input);
218
+ assert.equal(out.status, 'error');
219
+ assert.equal(out.text, input);
220
+ assert.match(out.error, /anchor/i);
221
+ });
222
+
223
+ it('absent + multiple anchors → status error (refuses to guess), never edits', () => {
224
+ const input = `${ANCHOR_LINE}\n\nsome text\n\n${ANCHOR_LINE}\n`;
225
+ const out = ensureSlot(input);
226
+ assert.equal(out.status, 'error');
227
+ assert.equal(out.text, input);
228
+ });
229
+
230
+ it('preserves CRLF newline style when inserting', () => {
231
+ const input = legacyWithAnchor('\r\n');
232
+ const out = ensureSlot(input);
233
+ assert.equal(out.status, 'inserted');
234
+ assert.equal(findSlot(out.text).state, 'ok');
235
+ assert.ok(out.text.includes(`${START_MARKER}\r\n${END_MARKER}`), 'markers use CRLF');
236
+ assert.ok(!/[^\r]\n/.test(out.text), 'no lone LF introduced into a CRLF document');
237
+ });
238
+
239
+ it('is idempotent — a second ensureSlot finds the slot present and changes nothing', () => {
240
+ const once = ensureSlot(legacyWithAnchor()).text;
241
+ const twice = ensureSlot(once);
242
+ assert.equal(twice.status, 'present');
243
+ assert.equal(twice.text, once);
244
+ });
245
+ });
246
+
247
+ describe('reconcileSlot — ensure + inject-if-empty + cap, as one atomic policy', () => {
248
+ it('markerless legacy (with anchor) → reconciled-inserted, slot filled, outside bytes preserved', () => {
249
+ const input = legacyWithAnchor();
250
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
251
+ assert.equal(out.status, 'reconciled-inserted');
252
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim(), 'slot now carries the fragment');
253
+ assert.ok(out.text.includes(ANCHOR_LINE), 'anchor preserved');
254
+ assert.ok(out.text.includes('## 🚫 Hard Constraints'), 'suffix preserved');
255
+ });
256
+
257
+ it('present empty slot → reconciled-filled', () => {
258
+ const out = reconcileSlot(wrap('\n'), FRAGMENT, { maxLines: AGENTS_MD_CAP });
259
+ assert.equal(out.status, 'reconciled-filled');
260
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim());
261
+ });
262
+
263
+ it('present customized/filled slot → present-filled, preserved verbatim (byte-for-byte)', () => {
264
+ const custom = wrap('\nuser-authored methodology notes\nsecond line\n');
265
+ const out = reconcileSlot(custom, FRAGMENT, { maxLines: AGENTS_MD_CAP });
266
+ assert.equal(out.status, 'present-filled');
267
+ assert.equal(out.text, custom, 'a filled slot is never overwritten');
268
+ });
269
+
270
+ it('malformed slot → error, input returned byte-for-byte', () => {
271
+ const input = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
272
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
273
+ assert.equal(out.status, 'error');
274
+ assert.equal(out.text, input);
275
+ });
276
+
277
+ it('markerless with no anchor → error, input unchanged (never guesses placement)', () => {
278
+ const input = '# AGENTS.md\n\nno slot, no anchor\n';
279
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
280
+ assert.equal(out.status, 'error');
281
+ assert.equal(out.text, input);
282
+ });
283
+
284
+ it('over-cap result → error, input unchanged (atomic: discards the intermediate slot insert)', () => {
285
+ const input = legacyWithAnchor();
286
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: 5 });
287
+ assert.equal(out.status, 'error');
288
+ assert.equal(out.text, input);
289
+ });
290
+
291
+ it('produces a clean CRLF document when inserting + filling (no lone LF)', () => {
292
+ // ensureSlot preserves the document newline style for the markers, and injectMethodology frames
293
+ // the LF-canonical fragment with the document's EOL — so a CRLF document stays uniformly CRLF.
294
+ const input = legacyWithAnchor('\r\n');
295
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
296
+ assert.equal(out.status, 'reconciled-inserted');
297
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim());
298
+ assert.ok(out.text.includes(`${ANCHOR_LINE}\r\n`), 'anchor line keeps CRLF');
299
+ assert.ok(!/[^\r]\n/.test(out.text), 'no lone LF introduced into a CRLF document');
300
+ });
301
+
302
+ it('present filled slot over the line cap → error, input returned unchanged', () => {
303
+ const bigSlot = `\n${Array.from({ length: 30 }, (_, i) => `note ${i}`).join('\n')}\n`;
304
+ const filled = wrap(bigSlot);
305
+ const out = reconcileSlot(filled, FRAGMENT, { maxLines: 10 });
306
+ assert.equal(out.status, 'error');
307
+ assert.equal(out.text, filled, 'an over-cap entry point is surfaced, not silently accepted');
308
+ assert.match(out.error, /cap 10/);
309
+ });
310
+ });
311
+
312
+ describe('reconcile CLI — atomic ensure+inject-if-empty+cap on the real filesystem', () => {
313
+ const withTempAgents = (contents, run) => {
314
+ const dir = mkdtempSync(join(tmpdir(), 'reconcile-cli-'));
315
+ const agents = join(dir, 'AGENTS.md');
316
+ writeFileSync(agents, contents);
317
+ try {
318
+ return run(agents);
319
+ } finally {
320
+ rmSync(dir, { recursive: true, force: true });
321
+ }
322
+ };
323
+
324
+ it('markerless legacy (with anchor) → slot inserted and filled (exit 0)', () => {
325
+ withTempAgents(legacyWithAnchor(), (agents) => {
326
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
327
+ const out = readFileSync(agents, 'utf8');
328
+ assert.equal(findSlot(out).state, 'ok');
329
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
330
+ });
331
+ });
332
+
333
+ it('present empty slot → slot filled (exit 0)', () => {
334
+ withTempAgents(wrap('\n'), (agents) => {
335
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
336
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
337
+ });
338
+ });
339
+
340
+ it('filled/customized slot → file left byte-for-byte untouched', () => {
341
+ const custom = wrap('\nuser notes\n');
342
+ withTempAgents(custom, (agents) => {
343
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
344
+ assert.equal(readFileSync(agents, 'utf8'), custom);
345
+ });
346
+ });
347
+
348
+ it('malformed slot → STOP with non-zero exit, file byte-unchanged', () => {
349
+ const malformed = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
350
+ withTempAgents(malformed, (agents) => {
351
+ assert.throws(() => execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' }));
352
+ assert.equal(readFileSync(agents, 'utf8'), malformed);
353
+ });
354
+ });
355
+ });