@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.
- package/CHANGELOG.md +56 -1
- package/README.md +12 -4
- package/SKILL.md +103 -37
- package/capability.json +5 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +276 -0
- package/tools/family-registry.test.mjs +247 -0
- package/tools/fs-safe.mjs +50 -1
- package/tools/fs-safe.test.mjs +140 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/manifest/validate.mjs +3 -1
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
- package/tools/uninstall.integration.test.mjs +144 -0
- package/tools/uninstall.mjs +420 -0
- package/tools/uninstall.test.mjs +372 -0
package/tools/fs-safe.test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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,98 @@ 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
|
+
// 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
|
|
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 () => {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
console.error(`[inject-methodology] reconcile refused — ${
|
|
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
|
-
|
|
232
|
-
|
|
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(
|
|
236
|
-
|
|
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
|
|
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') : '';
|
|
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}`);
|