@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.
- package/CHANGELOG.md +50 -0
- package/README.md +9 -3
- package/SKILL.md +95 -40
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size.mjs +4 -1
- package/references/scripts/check-docs-size.test.mjs +24 -0
- package/references/templates/AGENTS.md +2 -3
- package/references/templates/orchestration.json +5 -0
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +15 -5
- package/tools/engine-source.test.mjs +98 -0
- package/tools/family-registry.mjs +33 -1
- package/tools/family-registry.test.mjs +90 -0
- package/tools/inject-methodology.mjs +262 -110
- package/tools/inject-methodology.test.mjs +201 -12
- package/tools/procedures.mjs +324 -0
- package/tools/procedures.test.mjs +303 -0
- package/tools/recipes.mjs +340 -0
- package/tools/recipes.test.mjs +538 -0
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 absent → ensure-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
|
-
|
|
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
|
|
48
|
-
const starts = countOccurrences(text,
|
|
49
|
-
const ends = countOccurrences(text,
|
|
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(
|
|
55
|
-
const endIdx = text.indexOf(
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 =
|
|
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 +
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
export const
|
|
88
|
-
const slot =
|
|
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 +
|
|
130
|
+
return text.slice(slot.startIdx + descriptor.startMarker.length, slot.endIdx);
|
|
91
131
|
};
|
|
92
132
|
|
|
93
|
-
//
|
|
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
|
|
110
|
-
const slot =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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
|
|
140
|
-
const ensured =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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 =
|
|
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
|
|
190
|
-
// engine resolution entirely. Otherwise read it LIVE from
|
|
191
|
-
// bundled mirror
|
|
192
|
-
// absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying
|
|
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
|
-
//
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
console.error(`[inject-methodology] reconcile refused — ${
|
|
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
|
-
|
|
232
|
-
|
|
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(
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
245
|
-
// malformed slot WITHOUT reading the fragment, so resolve+read the engine only when
|
|
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}`);
|