@sabaiway/agent-workflow-kit 1.12.0 → 1.14.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.
@@ -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,114 @@ 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
+ // The routing token the methodology pointer should carry so NL like "write a plan" auto-discovers the
205
+ // activity procedures. A deployment whose methodology slot was filled (legacy / customized) BEFORE this
206
+ // clause existed will NOT auto-receive it — reconcile preserves a filled slot verbatim (AD-019 §3.1a).
207
+ export const PROCEDURES_POINTER = '/agent-workflow-kit procedures';
208
+
209
+ // Read-only upgrade advisory (NO mutation): when the methodology slot is present + FILLED but lacks the
210
+ // procedures route, return a one-line note the upgrade flow surfaces — add it for auto-discovery; the
211
+ // feature is reachable now via the explicit command. Returns null for an absent / empty / malformed slot
212
+ // or one that already routes to procedures. Pure; never edits the file.
213
+ export const methodologyProceduresHint = (text) => {
214
+ const content = extractSlot(text);
215
+ if (content == null || content.trim() === '') return null; // only a FILLED methodology slot
216
+ if (content.includes(PROCEDURES_POINTER)) return null; // already routes to the procedures advisor
217
+ return `the methodology pointer has no procedures route — add "${PROCEDURES_POINTER} <activity>" for auto-discovery; the activity procedures are reachable now via ${PROCEDURES_POINTER}.`;
218
+ };
219
+
220
+ // A cap-refusal is a SOFT, reported skip (distinct from a malformed/anchor STOP) — keyed off the
221
+ // stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
222
+ // orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
223
+ const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && errorMessage.includes('(cap ');
224
+
171
225
  const main = async (argv) => {
172
226
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
173
227
  const { dirname, basename, join, resolve } = await import('node:path');
174
228
  const { homedir } = await import('node:os');
175
- const { resolveEngineDir, readEngineFragment } = await import('./engine-source.mjs');
229
+ const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL } = await import('./engine-source.mjs');
176
230
 
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.
231
+ // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
232
+ // BOTH slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
179
233
  const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
180
234
  const rest = mode === 'reconcile' ? argv.slice(1) : argv;
181
235
  const agentsPath = rest[0];
@@ -186,27 +240,62 @@ const main = async (argv) => {
186
240
  const explicitFragmentArg = rest[1];
187
241
  const text = await readFile(resolve(agentsPath), 'utf8');
188
242
 
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 () => {
243
+ // Source a bounded fragment LAZILY, per slot. An explicit [fragment.md] arg (tests + manual) wins and
244
+ // skips engine resolution entirely; it binds the METHODOLOGY slot only. Otherwise read it LIVE from
245
+ // the installed engine (no bundled mirror) readEngineFragment THROWS (never falls back) when the
246
+ // engine is needed but absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying
247
+ // the install command. The caller only invokes this when a fill is actually needed (the laziness).
248
+ const sourceFragment = async (rel) => {
195
249
  if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
196
250
  const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
197
- return readEngineFragment(dir, { source }); // sync; throws loudly when the engine is absent/invalid
251
+ return readEngineFragment(dir, { source, rel }); // sync; throws loudly when the engine is absent/invalid
198
252
  };
199
- const sourceFragmentOrStop = async (label) => {
253
+ const sourceFragmentOrStop = async (label, rel) => {
200
254
  try {
201
- return await sourceFragment();
255
+ return await sourceFragment(rel);
202
256
  } 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).
257
+ // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The "methodology
258
+ // engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
205
259
  console.error(`[inject-methodology] ${label} — ${err.message}`);
206
260
  process.exit(1);
207
261
  }
208
262
  };
209
263
 
264
+ // The orchestration fragment is the SECOND (less-critical) pointer. Source it lazily, but DISTINGUISH
265
+ // two failures keyed off the engine's own detection (not message text): an engine that is VALID but
266
+ // simply TOO OLD to ship `orchestration-slot.md` (e.g. <1.2.0) is a SOFT skip — the methodology fill
267
+ // is kept and the recipes pointer is reported as withheld (parallel to the cap-skip); only a FULLY
268
+ // absent/invalid engine (it cannot supply EITHER pointer) is a hard STOP. Returns
269
+ // { fragment } on success, { skip } for the soft case, or process.exit(1) for the hard STOP.
270
+ const sourceOrchestrationFragment = async () => {
271
+ const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
272
+ const stop = (err) => {
273
+ console.error(`[inject-methodology] reconcile STOP — ${err.message}`);
274
+ process.exit(1);
275
+ };
276
+ // The orchestration fragment FILE is present + the engine is valid → read it. A read error on a
277
+ // PRESENT fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current
278
+ // engine with an unreadable fragment).
279
+ if (detectEngine(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }).ok) {
280
+ try {
281
+ return { fragment: readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }) };
282
+ } catch (err) {
283
+ stop(err);
284
+ }
285
+ }
286
+ // The orchestration fragment is NOT a usable file. SOFT-skip ONLY when the engine is otherwise
287
+ // valid (it can still supply the methodology fragment) — a genuine too-old (<1.2.0) engine. A
288
+ // fully absent/invalid engine cannot supply EITHER fragment → hard STOP with the install command.
289
+ if (detectEngine(dir, { source }).ok) {
290
+ return { skip: 'the installed engine is too old to supply it — refresh with `npx @sabaiway/agent-workflow-engine@latest init`' };
291
+ }
292
+ try {
293
+ readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }); // throws the canonical install-me error
294
+ } catch (err) {
295
+ stop(err);
296
+ }
297
+ };
298
+
210
299
  const writeAtomic = async (out) => {
211
300
  const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
212
301
  try {
@@ -219,32 +308,95 @@ const main = async (argv) => {
219
308
  };
220
309
 
221
310
  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}`);
311
+ // ── Slot 1: methodology (lazy engine read, then reconcile) ──
312
+ const methFragment = slotNeedsFill(text) ? await sourceFragmentOrStop('reconcile STOP', ENGINE_FRAGMENT_REL) : '';
313
+ const methResult = reconcileSlot(text, methFragment, { maxLines: AGENTS_MD_CAP });
314
+ if (methResult.status === 'error') {
315
+ // cap-refusal OR malformed/anchor STOP preserve the single-slot classification (SKILL.md
316
+ // distinguishes by the message); the file is byte-for-byte unchanged either way.
317
+ console.error(`[inject-methodology] reconcile refused — ${methResult.error}`);
229
318
  process.exit(1);
230
319
  }
231
- if (result.status === 'present-filled') {
232
- console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
320
+ const afterMeth = methResult.text; // === text when the methodology slot was already filled
321
+ const describeMeth = {
322
+ 'reconciled-inserted': 'inserted the workflow-methodology pointer at the Session-Protocols anchor and filled it',
323
+ 'reconciled-filled': 'filled the empty workflow-methodology pointer',
324
+ 'present-filled': 'workflow-methodology pointer already present',
325
+ }[methResult.status];
326
+ // Read-only upgrade advisory (AD-019 §3.1a): a pre-existing FILLED methodology pointer that predates
327
+ // the procedures clause won't be re-rendered (reconcile preserves it verbatim), so surface a hint to
328
+ // add the procedures route. No mutation — purely a reported note appended to the success report.
329
+ const proceduresNote = methResult.status === 'present-filled' ? methodologyProceduresHint(afterMeth) : null;
330
+ const reportNote = () => {
331
+ if (proceduresNote) console.log(`[inject-methodology] note: ${proceduresNote}`);
332
+ };
333
+
334
+ // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
335
+ if (explicitFragmentArg) {
336
+ if (afterMeth === text) {
337
+ console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
338
+ return;
339
+ }
340
+ await writeAtomic(afterMeth);
341
+ console.log(`[inject-methodology] reconcile: ${describeMeth}.`);
342
+ return;
343
+ }
344
+
345
+ // ── Slot 2: orchestration, reconciled on the methodology-reconciled text (the cap-check then guards
346
+ // the COMBINED ≤100). Lazy: the engine is read only when the orchestration slot needs filling. ──
347
+ let finalText = afterMeth;
348
+ let describeOrch;
349
+ let orchSkipped = false;
350
+
351
+ const orchSource = markerSlotNeedsFill(afterMeth, ORCHESTRATION_DESCRIPTOR)
352
+ ? await sourceOrchestrationFragment()
353
+ : { fragment: '' };
354
+ if (orchSource.skip) {
355
+ // Engine too old to supply the recipes pointer → SOFT skip, parallel to the cap-skip: the
356
+ // methodology fill is preserved and written; the recipes pointer is reported as withheld.
357
+ orchSkipped = true;
358
+ describeOrch = `orchestration-recipes pointer skipped — ${orchSource.skip}`;
359
+ } else {
360
+ const orchResult = reconcileMarkerSlot(afterMeth, ORCHESTRATION_DESCRIPTOR, orchSource.fragment, { maxLines: AGENTS_MD_CAP });
361
+ if (orchResult.status === 'error') {
362
+ if (isCapRefusal(orchResult.error)) {
363
+ // SOFT skip — keep the methodology result, report the orchestration cap-skip loudly (not
364
+ // silent). The methodology fill (if any) is preserved; the orchestration pointer is not added.
365
+ orchSkipped = true;
366
+ describeOrch = `orchestration-recipes pointer skipped — ${orchResult.error}`;
367
+ } else {
368
+ // Malformed orchestration slot/anchor → a hard STOP. No partial write.
369
+ console.error(`[inject-methodology] reconcile refused (orchestration) — ${orchResult.error}`);
370
+ process.exit(1);
371
+ }
372
+ } else {
373
+ finalText = orchResult.text;
374
+ describeOrch = {
375
+ 'reconciled-inserted': 'inserted the orchestration-recipes pointer below it and filled it',
376
+ 'reconciled-filled': 'filled the empty orchestration-recipes pointer',
377
+ 'present-filled': 'orchestration-recipes pointer already present',
378
+ }[orchResult.status];
379
+ }
380
+ }
381
+
382
+ // ── One atomic write of the final (both-slot) text ──
383
+ if (finalText === text) {
384
+ // Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
385
+ if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
386
+ else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
387
+ reportNote();
233
388
  return;
234
389
  }
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}.`);
390
+ await writeAtomic(finalText);
391
+ console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
392
+ reportNote();
241
393
  return;
242
394
  }
243
395
 
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') : '';
396
+ // Legacy inject-into-existing-slot mode (METHODOLOGY only). injectMethodology no-ops on absent markers
397
+ // and errors on a malformed slot WITHOUT reading the fragment, so resolve+read the engine only when
398
+ // there is a present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
399
+ const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP', ENGINE_FRAGMENT_REL) : '';
248
400
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
249
401
  if (result.status === 'error') {
250
402
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);