@sabaiway/agent-workflow-kit 1.11.0 → 1.13.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.
@@ -9,6 +9,8 @@ import {
9
9
  assertContainedRealPath,
10
10
  copyTreeRefresh,
11
11
  linkManaged,
12
+ removeTreeManaged,
13
+ unlinkManaged,
12
14
  MANAGED_LINK_CONFLICT,
13
15
  } from './fs-safe.mjs';
14
16
 
@@ -204,3 +206,141 @@ describe('linkManaged', () => {
204
206
  assert.throws(() => linkManaged(srcDir, join(root, 'cmd'), root), /regular file/i);
205
207
  });
206
208
  });
209
+
210
+ // ── removeTreeManaged ───────────────────────────────────────────────────────────
211
+
212
+ describe('removeTreeManaged', () => {
213
+ it('recursively removes a managed dir tree', () => {
214
+ const root = join(dir, 'skills');
215
+ const skill = join(root, 'agent-workflow-kit');
216
+ mkdirSync(join(skill, 'tools'), { recursive: true });
217
+ writeFileSync(join(skill, 'SKILL.md'), 'x');
218
+ writeFileSync(join(skill, 'tools', 'a.mjs'), 'y');
219
+ const result = removeTreeManaged(skill, root);
220
+ assert.equal(result, 'removed');
221
+ assert.equal(existsSync(skill), false);
222
+ assert.equal(existsSync(root), true); // only the target went, not the parent
223
+ });
224
+
225
+ it('is a no-op when the target is already absent', () => {
226
+ const root = join(dir, 'skills');
227
+ mkdirSync(root);
228
+ assert.equal(removeTreeManaged(join(root, 'gone'), root), 'noop');
229
+ });
230
+
231
+ it('STOPs on a symlinked target (never follows + deletes through it)', () => {
232
+ const root = join(dir, 'skills');
233
+ const real = join(dir, 'real-skill');
234
+ mkdirSync(root);
235
+ mkdirSync(real);
236
+ writeFileSync(join(real, 'keep.txt'), 'keep');
237
+ symlinkSync(real, join(root, 'agent-workflow-kit')); // the skill dir is a symlink
238
+ assert.throws(() => removeTreeManaged(join(root, 'agent-workflow-kit'), root), /symlink/i);
239
+ assert.equal(existsSync(join(real, 'keep.txt')), true); // the target it pointed at is untouched
240
+ });
241
+
242
+ it('removes a symlink ENTRY inside the tree without touching what it points at', () => {
243
+ const root = join(dir, 'skills');
244
+ const skill = join(root, 'agent-workflow-kit');
245
+ const outside = join(dir, 'outside');
246
+ mkdirSync(skill, { recursive: true });
247
+ mkdirSync(outside);
248
+ writeFileSync(join(outside, 'precious.txt'), 'precious');
249
+ symlinkSync(outside, join(skill, 'link-to-outside')); // an internal symlink
250
+ removeTreeManaged(skill, root);
251
+ assert.equal(existsSync(skill), false);
252
+ assert.equal(existsSync(join(outside, 'precious.txt')), true); // never recursed through the link
253
+ });
254
+
255
+ it('refuses a target outside the root', () => {
256
+ const root = join(dir, 'skills');
257
+ mkdirSync(root);
258
+ assert.throws(() => removeTreeManaged(join(dir, 'elsewhere'), root), /outside/);
259
+ });
260
+
261
+ it('rm is injectable (no real deletion when injected)', () => {
262
+ const root = join(dir, 'skills');
263
+ const skill = join(root, 'agent-workflow-kit');
264
+ mkdirSync(skill, { recursive: true });
265
+ let removed = null;
266
+ const result = removeTreeManaged(skill, root, { rm: (p) => { removed = p; } });
267
+ assert.equal(result, 'removed');
268
+ assert.equal(removed, skill);
269
+ assert.equal(existsSync(skill), true); // the injected rm did nothing
270
+ });
271
+ });
272
+
273
+ // ── unlinkManaged ───────────────────────────────────────────────────────────────
274
+
275
+ describe('unlinkManaged', () => {
276
+ const makeSrc = () => {
277
+ const src = join(dir, 'src.sh');
278
+ writeFileSync(src, '#!/bin/sh\n');
279
+ return src;
280
+ };
281
+
282
+ it('unlinks a symlink that points at our source', () => {
283
+ const src = makeSrc();
284
+ const root = join(dir, 'bin');
285
+ mkdirSync(root);
286
+ const dest = join(root, 'cmd');
287
+ symlinkSync(src, dest);
288
+ const result = unlinkManaged(dest, src, root);
289
+ assert.equal(result, 'unlinked');
290
+ assert.equal(existsSync(dest), false);
291
+ assert.equal(existsSync(src), true); // the source it pointed at is untouched
292
+ });
293
+
294
+ it('is a no-op when the dest is absent', () => {
295
+ const src = makeSrc();
296
+ const root = join(dir, 'bin');
297
+ mkdirSync(root);
298
+ assert.equal(unlinkManaged(join(root, 'cmd'), src, root), 'noop');
299
+ });
300
+
301
+ it('STOPs on a non-symlink dest (typed ManagedLinkConflict)', () => {
302
+ const src = makeSrc();
303
+ const root = join(dir, 'bin');
304
+ mkdirSync(root);
305
+ const dest = join(root, 'cmd');
306
+ writeFileSync(dest, 'someone-elses-file');
307
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
308
+ assert.equal(readFileSync(dest, 'utf8'), 'someone-elses-file'); // untouched
309
+ });
310
+
311
+ it('STOPs on a foreign symlink (points elsewhere)', () => {
312
+ const src = makeSrc();
313
+ const root = join(dir, 'bin');
314
+ mkdirSync(root);
315
+ const dest = join(root, 'cmd');
316
+ const foreign = join(dir, 'foreign.sh');
317
+ writeFileSync(foreign, '#!/bin/sh\n');
318
+ symlinkSync(foreign, dest);
319
+ assert.throws(() => unlinkManaged(dest, src, root), (err) => err.code === MANAGED_LINK_CONFLICT);
320
+ assert.equal(readlinkSync(dest), foreign); // untouched
321
+ });
322
+
323
+ it('removes a dangling symlink that still textually points at our source', () => {
324
+ const src = join(dir, 'src.sh'); // never created → the link is dangling
325
+ const root = join(dir, 'bin');
326
+ mkdirSync(root);
327
+ const dest = join(root, 'cmd');
328
+ symlinkSync(src, dest);
329
+ assert.equal(unlinkManaged(dest, src, root), 'unlinked');
330
+ assert.equal(lstatSync(root).isDirectory(), true);
331
+ assert.equal(existsSync(dest), false);
332
+ });
333
+
334
+ it('unlink is injectable', () => {
335
+ const src = makeSrc();
336
+ const root = join(dir, 'bin');
337
+ mkdirSync(root);
338
+ const dest = join(root, 'cmd');
339
+ symlinkSync(src, dest);
340
+ let unlinked = null;
341
+ const result = unlinkManaged(dest, src, root, { unlink: (p) => { unlinked = p; } });
342
+ assert.equal(result, 'unlinked');
343
+ assert.equal(unlinked, dest);
344
+ assert.equal(existsSync(dest), true); // the injected unlink did nothing
345
+ });
346
+ });
@@ -1,30 +1,32 @@
1
1
  #!/usr/bin/env node
2
- // Methodology slot injection + reconciliation — the composition root's only mutation of a
3
- // deployed AGENTS.md.
2
+ // Marker-slot injection + reconciliation — the composition root's only mutation of a deployed
3
+ // AGENTS.md. A deployed entry point carries TWO reconciled marker slots, each filled LIVE from the
4
+ // installed agent-workflow-engine (the family's one source of truth, no bundled mirror):
5
+ // 1. workflow:methodology — the plan→execute→review pointer (references/methodology-slot.md).
6
+ // 2. workflow:orchestration — the recipe-vocabulary pointer (references/orchestration-slot.md).
4
7
  //
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 is a BOUNDED summary + pointer (NOT the full
7
- // references/planning.md), so AGENTS.md stays under its line cap. It is read LIVE from the installed
8
- // agent-workflow-engine (references/methodology-slot.md) via engine-source.mjs the family's one
9
- // source of truth; there is no bundled mirror (retired in Plan 3D, AD-016). The live read is lazy +
10
- // fail-loud: resolve+read the engine ONLY when a fill is actually needed, and STOP loudly (never a
11
- // silent fallback) when the engine is needed but absent/invalid.
8
+ // Both share ONE generic marker engine (a slot DESCRIPTOR parameterizes the markers / anchor /
9
+ // empty-slot / leading-blank). The methodology exports (findSlot / injectMethodology / ensureSlot /
10
+ // reconcileSlot / slotNeedsFill / extractSlot) delegate to it byte-for-byte, so the methodology
11
+ // contract is unchanged; the orchestration slot is the SAME engine with a different descriptor.
12
12
  //
13
- // Two layers over one marker parser:
14
- // - injectMethodology fill an EXISTING slot. Marker contract, strictly enforced:
15
- // exactly one ordered startend pair replace only the bytes between them;
16
- // markers absent → NO-OP; any malformed state (single, reversed, nested, duplicate) →
17
- // NO-OP WITH AN ERROR, never edit. Prefix/suffix preserved exactly; re-run is idempotent.
18
- // - ensureSlot / reconcileSlot the bootstrap/upgrade policy (Plan 2): ensure the slot EXISTS
19
- // (insert an empty pair at the Session-Protocols anchor when a legacy file lacks one) →
20
- // inject ONLY IF empty (preserve a customized slot verbatim) → cap-check. Stamp-independent.
13
+ // Contract per slot, strictly enforced:
14
+ // exactly one ordered start→end pair replace only the bytes between them;
15
+ // markers absentensure-insert an empty pair at the slot's anchor (or NO-OP in legacy inject);
16
+ // any malformed state (single, reversed, nested, duplicate) → NO-OP WITH AN ERROR, never edit.
17
+ // The live read is lazy + fail-loud: resolve+read the engine ONLY when a fill is actually needed, and
18
+ // STOP loudly (never a silent fallback) when the engine is needed but absent/invalid.
21
19
  //
22
20
  // Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
23
21
 
24
22
  export const START_MARKER = '<!-- workflow:methodology:start -->';
25
23
  export const END_MARKER = '<!-- workflow:methodology:end -->';
24
+ export const ORCH_START_MARKER = '<!-- workflow:orchestration:start -->';
25
+ export const ORCH_END_MARKER = '<!-- workflow:orchestration:end -->';
26
26
  export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
27
27
 
28
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+
28
30
  // Count lines independent of a trailing newline (CRLF-safe: split on '\n' — a CRLF line still ends
29
31
  // in '\n', so the count is the same as for LF).
30
32
  const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
@@ -40,39 +42,77 @@ const countOccurrences = (haystack, needle) => {
40
42
  }
41
43
  };
42
44
 
43
- // Classify the marker state of an AGENTS.md text. Pure; no fs.
45
+ const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
46
+
47
+ // The Session-Protocols anchor line both deployed templates carry (the agent_rules.md §1 sentence).
48
+ // ensureSlot inserts an empty METHODOLOGY slot right after this line when a legacy entry point has no
49
+ // markers. Contract: EXACTLY ONE match required — 0 or >1 → error (never guess where it lives).
50
+ export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
51
+ // The orchestration pair lands right BELOW the methodology end marker — so its anchor is that line.
52
+ // A well-formed entry point carries exactly one methodology end marker → exactly one anchor.
53
+ export const ORCH_ANCHOR = new RegExp(`^.*${escapeRegExp(END_MARKER)}.*$`, 'm');
54
+
55
+ // The canonical empty slots (an ordered start→end pair, nothing between) — what a fresh template
56
+ // ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
57
+ export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
58
+ export const ORCH_EMPTY_SLOT = `${ORCH_START_MARKER}\n${ORCH_END_MARKER}`;
59
+
60
+ // A slot descriptor bundles everything the generic engine needs to operate on ONE marker pair.
61
+ // `leadingBlank` controls whether the inserted empty pair gets a blank separator line above it — the
62
+ // methodology insert keeps it (readability), the orchestration insert omits it to save a cap line
63
+ // (the pair already sits right under the methodology block).
64
+ const METHODOLOGY_DESCRIPTOR = {
65
+ startMarker: START_MARKER,
66
+ endMarker: END_MARKER,
67
+ anchor: METHODOLOGY_ANCHOR,
68
+ emptySlot: EMPTY_SLOT,
69
+ leadingBlank: true,
70
+ markerName: 'methodology',
71
+ anchorLabel: 'methodology anchor (the "Read it before any code change." Session-Protocols line)',
72
+ };
73
+ export const ORCHESTRATION_DESCRIPTOR = {
74
+ startMarker: ORCH_START_MARKER,
75
+ endMarker: ORCH_END_MARKER,
76
+ anchor: ORCH_ANCHOR,
77
+ emptySlot: ORCH_EMPTY_SLOT,
78
+ leadingBlank: false,
79
+ markerName: 'orchestration',
80
+ anchorLabel: 'orchestration anchor (the methodology end-marker line)',
81
+ };
82
+
83
+ // ── generic marker-slot engine (descriptor-parameterized) ──────────────────────
84
+
85
+ // Classify the marker state of a slot. Pure; no fs.
44
86
  // { state: 'ok', startIdx, endIdx } exactly one ordered pair
45
- // { state: 'absent' } no markers at all → caller no-ops
87
+ // { state: 'absent' } no markers at all → caller no-ops / ensure-inserts
46
88
  // { state: 'malformed', reason } anything else → caller no-ops WITH error
47
- export const findSlot = (text) => {
48
- const starts = countOccurrences(text, START_MARKER);
49
- const ends = countOccurrences(text, END_MARKER);
89
+ export const findMarkerSlot = (text, descriptor) => {
90
+ const starts = countOccurrences(text, descriptor.startMarker);
91
+ const ends = countOccurrences(text, descriptor.endMarker);
50
92
  if (starts === 0 && ends === 0) return { state: 'absent' };
51
93
  if (starts !== 1 || ends !== 1) {
52
94
  return { state: 'malformed', reason: `expected exactly one start/end marker pair, found ${starts} start / ${ends} end` };
53
95
  }
54
- const startIdx = text.indexOf(START_MARKER);
55
- const endIdx = text.indexOf(END_MARKER);
96
+ const startIdx = text.indexOf(descriptor.startMarker);
97
+ const endIdx = text.indexOf(descriptor.endMarker);
56
98
  if (endIdx < startIdx) return { state: 'malformed', reason: 'end marker precedes start marker' };
57
99
  return { state: 'ok', startIdx, endIdx };
58
100
  };
59
101
 
60
- // Inject `fragment` between the markers, replacing only the bytes between them.
61
- // Returns { status: 'injected' | 'noop-absent' | 'error', text, error? }. On absent/error the
62
- // returned text is the INPUT, byte-for-byte (never edit on a malformed slot). Pass
63
- // `{ maxLines }` to enforce the AGENTS.md line cap as a postcondition (refuse, don't bust it).
64
- export const injectMethodology = (text, fragment, { maxLines } = {}) => {
65
- // A fragment that itself contains a marker would create a duplicate/nested slot — refuse.
66
- if (fragment.includes(START_MARKER) || fragment.includes(END_MARKER)) {
67
- return { status: 'error', text, error: 'fragment contains a methodology marker — refusing to inject (would create a duplicate/nested slot)' };
102
+ // Inject `fragment` between the markers, replacing only the bytes between them. Returns
103
+ // { status: 'injected' | 'noop-absent' | 'error', text, error? }. On absent/error the returned text
104
+ // is the INPUT, byte-for-byte. Pass `{ maxLines }` to enforce the AGENTS.md line cap (refuse, don't bust).
105
+ export const injectIntoSlot = (text, descriptor, fragment, { maxLines } = {}) => {
106
+ if (fragment.includes(descriptor.startMarker) || fragment.includes(descriptor.endMarker)) {
107
+ return { status: 'error', text, error: `fragment contains a ${descriptor.markerName} marker — refusing to inject (would create a duplicate/nested slot)` };
68
108
  }
69
- const slot = findSlot(text);
109
+ const slot = findMarkerSlot(text, descriptor);
70
110
  if (slot.state === 'absent') return { status: 'noop-absent', text };
71
111
  if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
72
112
  // Frame the fragment with the DOCUMENT's newline style (and convert the LF-canonical fragment to
73
113
  // it) so injecting into a CRLF file does not leave lone LFs around the slot.
74
114
  const nl = text.includes('\r\n') ? '\r\n' : '\n';
75
- const before = text.slice(0, slot.startIdx + START_MARKER.length);
115
+ const before = text.slice(0, slot.startIdx + descriptor.startMarker.length);
76
116
  const after = text.slice(slot.endIdx);
77
117
  const body = fragment.trim().replace(/\r?\n/g, nl);
78
118
  const out = `${before}${nl}${body}${nl}${after}`;
@@ -82,100 +122,98 @@ export const injectMethodology = (text, fragment, { maxLines } = {}) => {
82
122
  return { status: 'injected', text: out };
83
123
  };
84
124
 
85
- // Inverse used by memory's upgrade: extract the current slot content (preserve-on-upgrade).
86
- // Returns the bytes strictly between the markers, or null on absent/malformed.
87
- export const extractSlot = (text) => {
88
- const slot = findSlot(text);
125
+ // Extract the current slot content (preserve-on-upgrade inverse). Bytes strictly between the markers,
126
+ // or null on absent/malformed.
127
+ export const extractMarkerSlot = (text, descriptor) => {
128
+ const slot = findMarkerSlot(text, descriptor);
89
129
  if (slot.state !== 'ok') return null;
90
- return text.slice(slot.startIdx + START_MARKER.length, slot.endIdx);
130
+ return text.slice(slot.startIdx + descriptor.startMarker.length, slot.endIdx);
91
131
  };
92
132
 
93
- // The Session-Protocols anchor line both deployed templates carry (the agent_rules.md §1 sentence).
94
- // ensureSlot inserts an empty slot right after this line when a legacy entry point has no markers.
95
- // Contract: EXACTLY ONE match required — 0 or >1 → error (never guess where the methodology lives).
96
- export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
97
-
98
- // The canonical empty slot (an ordered start→end pair, nothing between) — what a fresh template
99
- // ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
100
- export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
101
-
102
- const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
103
-
104
- // Ensure a single, well-formed methodology slot EXISTS — without filling it. Pure; no fs.
133
+ // Ensure a single, well-formed slot EXISTS without filling it. Pure; no fs.
105
134
  // { status: 'present', text } a well-formed slot already exists → bytes unchanged (idempotent).
106
135
  // { status: 'inserted', text } absent + exactly one anchor → an EMPTY slot inserted right after
107
136
  // the anchor line, newline style + all other bytes preserved.
108
137
  // { status: 'error', text, error } malformed slot, OR (when absent) 0/>1 anchors → bytes unchanged.
109
- export const ensureSlot = (text) => {
110
- const slot = findSlot(text);
138
+ export const ensureMarkerSlot = (text, descriptor) => {
139
+ const slot = findMarkerSlot(text, descriptor);
111
140
  if (slot.state === 'ok') return { status: 'present', text };
112
141
  if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
113
- // absent place an empty slot at the one anchor, or refuse rather than guess.
114
- const anchors = countMatches(text, METHODOLOGY_ANCHOR);
142
+ const anchors = countMatches(text, descriptor.anchor);
115
143
  if (anchors !== 1) {
116
144
  return {
117
145
  status: 'error',
118
146
  text,
119
- 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`,
147
+ error: `expected exactly one ${descriptor.anchorLabel}, found ${anchors} — refusing to guess where the slot belongs; add the slot markers manually`,
120
148
  };
121
149
  }
122
150
  const nl = text.includes('\r\n') ? '\r\n' : '\n';
123
- const match = text.match(METHODOLOGY_ANCHOR);
151
+ const match = text.match(descriptor.anchor);
124
152
  const eol = text.indexOf('\n', match.index);
125
153
  const insertAt = eol === -1 ? text.length : eol + 1;
126
- const block = `${nl}${EMPTY_SLOT.replace(/\n/g, nl)}${nl}`;
154
+ const slotText = descriptor.emptySlot.replace(/\n/g, nl);
155
+ const block = descriptor.leadingBlank ? `${nl}${slotText}${nl}` : `${slotText}${nl}`;
127
156
  const out = `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
128
157
  return { status: 'inserted', text: out };
129
158
  };
130
159
 
131
160
  // Bootstrap/upgrade reconciliation policy (pure): ensure the slot exists, then fill it ONLY IF it
132
- // is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one
133
- // step the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned
134
- // unchanged (the intermediate slot-insert is discarded), so there is no partial on-disk state.
161
+ // is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one step
162
+ // the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned unchanged.
135
163
  // reconciled-inserted — slot was absent, inserted at the anchor, then filled.
136
164
  // reconciled-filled — slot existed but was empty, now filled.
137
165
  // present-filled — slot already carried content → preserved verbatim.
138
166
  // error — malformed slot, 0/>1 anchors, or cap exceeded → input unchanged.
139
- export const reconcileSlot = (text, fragment, { maxLines } = {}) => {
140
- const ensured = ensureSlot(text);
167
+ export const reconcileMarkerSlot = (text, descriptor, fragment, { maxLines } = {}) => {
168
+ const ensured = ensureMarkerSlot(text, descriptor);
141
169
  if (ensured.status === 'error') return { status: 'error', text, error: ensured.error };
142
- const current = extractSlot(ensured.text);
170
+ const current = extractMarkerSlot(ensured.text, descriptor);
143
171
  const isEmpty = current == null || current.trim() === '';
144
172
  if (!isEmpty) {
145
- // Preserve a filled/customized slot verbatim — but still enforce the cap on the result, so an
146
- // already-over-cap entry point is surfaced (input unchanged) rather than silently accepted.
147
173
  if (maxLines != null && lineCount(ensured.text) > maxLines) {
148
- 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)` };
174
+ return { status: 'error', text, error: `AGENTS.md is ${lineCount(ensured.text)} lines (cap ${maxLines}) — trim the file (a customized ${descriptor.markerName} slot must still fit the cap)` };
149
175
  }
150
176
  return { status: 'present-filled', text: ensured.text };
151
177
  }
152
- const injected = injectMethodology(ensured.text, fragment, { maxLines });
178
+ const injected = injectIntoSlot(ensured.text, descriptor, fragment, { maxLines });
153
179
  if (injected.status !== 'injected') return { status: 'error', text, error: injected.error };
154
180
  const status = ensured.status === 'inserted' ? 'reconciled-inserted' : 'reconciled-filled';
155
181
  return { status, text: injected.text };
156
182
  };
157
183
 
158
- // Pure predicate (no fs): does this AGENTS.md actually need the methodology fragment? True only when
159
- // the slot can be ensured (present or insertable) AND is empty — i.e. when reconcileSlot would
160
- // inject. False when the slot is already filled (preserve-verbatim, no fragment read) OR when the
161
- // slot/anchor is malformed (so reconcileSlot's own precise error path still fires). It reuses the
162
- // SAME primitives as reconcileSlot (ensureSlot + extractSlot), so the lazy "read the engine only
163
- // when needed" guard in main() cannot diverge from the actual fill decision.
164
- export const slotNeedsFill = (text) => {
165
- const ensured = ensureSlot(text);
184
+ // Pure predicate (no fs): does this slot actually need its fragment? True only when the slot can be
185
+ // ensured (present or insertable) AND is empty — i.e. when reconcileMarkerSlot would inject. Reuses
186
+ // the SAME primitives as reconcileMarkerSlot, so the lazy "read the engine only when needed" guard in
187
+ // main() cannot diverge from the actual fill decision.
188
+ export const markerSlotNeedsFill = (text, descriptor) => {
189
+ const ensured = ensureMarkerSlot(text, descriptor);
166
190
  if (ensured.status === 'error') return false;
167
- const current = extractSlot(ensured.text);
191
+ const current = extractMarkerSlot(ensured.text, descriptor);
168
192
  return current == null || current.trim() === '';
169
193
  };
170
194
 
195
+ // ── methodology-slot exports (delegate to the generic engine, byte-for-byte) ────
196
+
197
+ export const findSlot = (text) => findMarkerSlot(text, METHODOLOGY_DESCRIPTOR);
198
+ export const injectMethodology = (text, fragment, opts) => injectIntoSlot(text, METHODOLOGY_DESCRIPTOR, fragment, opts);
199
+ export const extractSlot = (text) => extractMarkerSlot(text, METHODOLOGY_DESCRIPTOR);
200
+ export const ensureSlot = (text) => ensureMarkerSlot(text, METHODOLOGY_DESCRIPTOR);
201
+ export const reconcileSlot = (text, fragment, opts) => reconcileMarkerSlot(text, METHODOLOGY_DESCRIPTOR, fragment, opts);
202
+ export const slotNeedsFill = (text) => markerSlotNeedsFill(text, METHODOLOGY_DESCRIPTOR);
203
+
204
+ // A cap-refusal is a SOFT, reported skip (distinct from a malformed/anchor STOP) — keyed off the
205
+ // stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
206
+ // orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
207
+ const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && errorMessage.includes('(cap ');
208
+
171
209
  const main = async (argv) => {
172
210
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
173
211
  const { dirname, basename, join, resolve } = await import('node:path');
174
212
  const { homedir } = await import('node:os');
175
- const { resolveEngineDir, readEngineFragment } = await import('./engine-source.mjs');
213
+ const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL } = await import('./engine-source.mjs');
176
214
 
177
- // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade);
178
- // `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-slot mode.
215
+ // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
216
+ // BOTH slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
179
217
  const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
180
218
  const rest = mode === 'reconcile' ? argv.slice(1) : argv;
181
219
  const agentsPath = rest[0];
@@ -186,27 +224,62 @@ const main = async (argv) => {
186
224
  const explicitFragmentArg = rest[1];
187
225
  const text = await readFile(resolve(agentsPath), 'utf8');
188
226
 
189
- // Source the bounded fragment LAZILY. An explicit [fragment.md] arg (tests + manual) wins and skips
190
- // engine resolution entirely. Otherwise read it LIVE from the installed engine — there is no
191
- // bundled mirror. readEngineFragment THROWS (never falls back) when the engine is needed but
192
- // absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying the install
193
- // command. The caller only invokes this when a fill is actually needed (the laziness).
194
- const sourceFragment = async () => {
227
+ // Source a bounded fragment LAZILY, per slot. An explicit [fragment.md] arg (tests + manual) wins and
228
+ // skips engine resolution entirely; it binds the METHODOLOGY slot only. Otherwise read it LIVE from
229
+ // the installed engine (no bundled mirror) readEngineFragment THROWS (never falls back) when the
230
+ // engine is needed but absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying
231
+ // the install command. The caller only invokes this when a fill is actually needed (the laziness).
232
+ const sourceFragment = async (rel) => {
195
233
  if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
196
234
  const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
197
- return readEngineFragment(dir, { source }); // sync; throws loudly when the engine is absent/invalid
235
+ return readEngineFragment(dir, { source, rel }); // sync; throws loudly when the engine is absent/invalid
198
236
  };
199
- const sourceFragmentOrStop = async (label) => {
237
+ const sourceFragmentOrStop = async (label, rel) => {
200
238
  try {
201
- return await sourceFragment();
239
+ return await sourceFragment(rel);
202
240
  } catch (err) {
203
- // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The
204
- // "methodology engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
241
+ // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The "methodology
242
+ // engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
205
243
  console.error(`[inject-methodology] ${label} — ${err.message}`);
206
244
  process.exit(1);
207
245
  }
208
246
  };
209
247
 
248
+ // The orchestration fragment is the SECOND (less-critical) pointer. Source it lazily, but DISTINGUISH
249
+ // two failures keyed off the engine's own detection (not message text): an engine that is VALID but
250
+ // simply TOO OLD to ship `orchestration-slot.md` (e.g. <1.2.0) is a SOFT skip — the methodology fill
251
+ // is kept and the recipes pointer is reported as withheld (parallel to the cap-skip); only a FULLY
252
+ // absent/invalid engine (it cannot supply EITHER pointer) is a hard STOP. Returns
253
+ // { fragment } on success, { skip } for the soft case, or process.exit(1) for the hard STOP.
254
+ const sourceOrchestrationFragment = async () => {
255
+ const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
256
+ const stop = (err) => {
257
+ console.error(`[inject-methodology] reconcile STOP — ${err.message}`);
258
+ process.exit(1);
259
+ };
260
+ // The orchestration fragment FILE is present + the engine is valid → read it. A read error on a
261
+ // PRESENT fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current
262
+ // engine with an unreadable fragment).
263
+ if (detectEngine(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }).ok) {
264
+ try {
265
+ return { fragment: readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }) };
266
+ } catch (err) {
267
+ stop(err);
268
+ }
269
+ }
270
+ // The orchestration fragment is NOT a usable file. SOFT-skip ONLY when the engine is otherwise
271
+ // valid (it can still supply the methodology fragment) — a genuine too-old (<1.2.0) engine. A
272
+ // fully absent/invalid engine cannot supply EITHER fragment → hard STOP with the install command.
273
+ if (detectEngine(dir, { source }).ok) {
274
+ return { skip: 'the installed engine is too old to supply it — refresh with `npx @sabaiway/agent-workflow-engine@latest init`' };
275
+ }
276
+ try {
277
+ readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }); // throws the canonical install-me error
278
+ } catch (err) {
279
+ stop(err);
280
+ }
281
+ };
282
+
210
283
  const writeAtomic = async (out) => {
211
284
  const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
212
285
  try {
@@ -219,32 +292,86 @@ const main = async (argv) => {
219
292
  };
220
293
 
221
294
  if (mode === 'reconcile') {
222
- // Read the engine only when the slot actually needs filling (lazy). slotNeedsFill reuses the same
223
- // primitives reconcileSlot does, so it cannot disagree with the fill decision below — a filled
224
- // slot reconciles to a zero-diff no-op WITHOUT consulting the engine.
225
- const fragment = slotNeedsFill(text) ? await sourceFragmentOrStop('reconcile STOP') : '';
226
- const result = reconcileSlot(text, fragment, { maxLines: AGENTS_MD_CAP });
227
- if (result.status === 'error') {
228
- console.error(`[inject-methodology] reconcile refused — ${result.error}`);
295
+ // ── Slot 1: methodology (lazy engine read, then reconcile) ──
296
+ const methFragment = slotNeedsFill(text) ? await sourceFragmentOrStop('reconcile STOP', ENGINE_FRAGMENT_REL) : '';
297
+ const methResult = reconcileSlot(text, methFragment, { maxLines: AGENTS_MD_CAP });
298
+ if (methResult.status === 'error') {
299
+ // cap-refusal OR malformed/anchor STOP preserve the single-slot classification (SKILL.md
300
+ // distinguishes by the message); the file is byte-for-byte unchanged either way.
301
+ console.error(`[inject-methodology] reconcile refused — ${methResult.error}`);
229
302
  process.exit(1);
230
303
  }
231
- if (result.status === 'present-filled') {
232
- console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
304
+ const afterMeth = methResult.text; // === text when the methodology slot was already filled
305
+ const describeMeth = {
306
+ 'reconciled-inserted': 'inserted the workflow-methodology pointer at the Session-Protocols anchor and filled it',
307
+ 'reconciled-filled': 'filled the empty workflow-methodology pointer',
308
+ 'present-filled': 'workflow-methodology pointer already present',
309
+ }[methResult.status];
310
+
311
+ // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
312
+ if (explicitFragmentArg) {
313
+ if (afterMeth === text) {
314
+ console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
315
+ return;
316
+ }
317
+ await writeAtomic(afterMeth);
318
+ console.log(`[inject-methodology] reconcile: ${describeMeth}.`);
319
+ return;
320
+ }
321
+
322
+ // ── Slot 2: orchestration, reconciled on the methodology-reconciled text (the cap-check then guards
323
+ // the COMBINED ≤100). Lazy: the engine is read only when the orchestration slot needs filling. ──
324
+ let finalText = afterMeth;
325
+ let describeOrch;
326
+ let orchSkipped = false;
327
+
328
+ const orchSource = markerSlotNeedsFill(afterMeth, ORCHESTRATION_DESCRIPTOR)
329
+ ? await sourceOrchestrationFragment()
330
+ : { fragment: '' };
331
+ if (orchSource.skip) {
332
+ // Engine too old to supply the recipes pointer → SOFT skip, parallel to the cap-skip: the
333
+ // methodology fill is preserved and written; the recipes pointer is reported as withheld.
334
+ orchSkipped = true;
335
+ describeOrch = `orchestration-recipes pointer skipped — ${orchSource.skip}`;
336
+ } else {
337
+ const orchResult = reconcileMarkerSlot(afterMeth, ORCHESTRATION_DESCRIPTOR, orchSource.fragment, { maxLines: AGENTS_MD_CAP });
338
+ if (orchResult.status === 'error') {
339
+ if (isCapRefusal(orchResult.error)) {
340
+ // SOFT skip — keep the methodology result, report the orchestration cap-skip loudly (not
341
+ // silent). The methodology fill (if any) is preserved; the orchestration pointer is not added.
342
+ orchSkipped = true;
343
+ describeOrch = `orchestration-recipes pointer skipped — ${orchResult.error}`;
344
+ } else {
345
+ // Malformed orchestration slot/anchor → a hard STOP. No partial write.
346
+ console.error(`[inject-methodology] reconcile refused (orchestration) — ${orchResult.error}`);
347
+ process.exit(1);
348
+ }
349
+ } else {
350
+ finalText = orchResult.text;
351
+ describeOrch = {
352
+ 'reconciled-inserted': 'inserted the orchestration-recipes pointer below it and filled it',
353
+ 'reconciled-filled': 'filled the empty orchestration-recipes pointer',
354
+ 'present-filled': 'orchestration-recipes pointer already present',
355
+ }[orchResult.status];
356
+ }
357
+ }
358
+
359
+ // ── One atomic write of the final (both-slot) text ──
360
+ if (finalText === text) {
361
+ // Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
362
+ if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
363
+ else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
233
364
  return;
234
365
  }
235
- await writeAtomic(result.text);
236
- const what =
237
- result.status === 'reconciled-inserted'
238
- ? 'inserted the methodology slot at the Session-Protocols anchor and filled it'
239
- : 'filled the empty methodology slot';
240
- console.log(`[inject-methodology] reconcile: ${what}.`);
366
+ await writeAtomic(finalText);
367
+ console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
241
368
  return;
242
369
  }
243
370
 
244
- // Legacy inject-into-existing-slot mode. injectMethodology no-ops on absent markers and errors on a
245
- // malformed slot WITHOUT reading the fragment, so resolve+read the engine only when there is a
246
- // present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
247
- const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP') : '';
371
+ // Legacy inject-into-existing-slot mode (METHODOLOGY only). injectMethodology no-ops on absent markers
372
+ // and errors on a malformed slot WITHOUT reading the fragment, so resolve+read the engine only when
373
+ // there is a present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
374
+ const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP', ENGINE_FRAGMENT_REL) : '';
248
375
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
249
376
  if (result.status === 'error') {
250
377
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);