@sabaiway/agent-workflow-kit 1.3.0 → 1.4.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 +37 -1
- package/README.md +14 -5
- package/SKILL.md +68 -7
- package/bin/install.mjs +59 -8
- package/bin/install.test.mjs +66 -0
- package/capability.json +21 -0
- package/migrations/1.1.0-communication-language.md +5 -5
- package/migrations/README.md +2 -1
- package/package.json +8 -5
- package/references/contracts.md +2 -2
- package/references/scripts/archive-changelog.mjs +1 -4
- package/references/templates/AGENTS.md +2 -2
- package/tools/delegation.mjs +109 -0
- package/tools/delegation.test.mjs +115 -0
- package/tools/inject-methodology.mjs +111 -0
- package/tools/inject-methodology.test.mjs +124 -0
- package/tools/manifest/fixtures/bad-available/SKILL.md +7 -0
- package/tools/manifest/fixtures/bad-available/capability.json +10 -0
- package/tools/manifest/fixtures/detect-array/SKILL.md +7 -0
- package/tools/manifest/fixtures/detect-array/capability.json +10 -0
- package/tools/manifest/fixtures/malformed-json/capability.json +1 -0
- package/tools/manifest/fixtures/metadata-version/SKILL.md +10 -0
- package/tools/manifest/fixtures/metadata-version/capability.json +9 -0
- package/tools/manifest/fixtures/missing-key/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-key/capability.json +8 -0
- package/tools/manifest/fixtures/missing-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-source/capability.json +11 -0
- package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +10 -0
- package/tools/manifest/fixtures/nested-version-decoy/capability.json +9 -0
- package/tools/manifest/fixtures/null-root/capability.json +1 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +2 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +11 -0
- package/tools/manifest/fixtures/stub/capability.json +10 -0
- package/tools/manifest/fixtures/traversal-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/traversal-source/capability.json +11 -0
- package/tools/manifest/fixtures/unknown-schema/capability.json +9 -0
- package/tools/manifest/fixtures/valid/SKILL.md +10 -0
- package/tools/manifest/fixtures/valid/bin/run.sh +3 -0
- package/tools/manifest/fixtures/valid/capability.json +18 -0
- package/tools/manifest/fixtures/version-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/version-mismatch/capability.json +9 -0
- package/tools/manifest/fixtures/win-absolute-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/win-absolute-source/capability.json +11 -0
- package/tools/manifest/schema.md +67 -0
- package/tools/manifest/validate.mjs +264 -0
- package/tools/manifest/validate.test.mjs +73 -0
- package/tools/methodology-slot.md +1 -0
- package/tools/release-scan.mjs +103 -0
- package/tools/release-scan.test.mjs +41 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Delegation decision + hand-off plan — the kit-owned, executable form of the composition
|
|
3
|
+
// contract, so the "delegate vs fall back" choice and the stamp/commit responsibilities are
|
|
4
|
+
// pinned down by code + tests, not left to agent interpretation (Plan §1.7).
|
|
5
|
+
//
|
|
6
|
+
// detectMemory(dir) → { delegate, reason, ... } runs the kit's OWN validator + asset check
|
|
7
|
+
// handoffPlan(delegate) → who writes what, which stamps end up present, who owns the commit gate
|
|
8
|
+
//
|
|
9
|
+
// Pure (dependency-injectable validator + fs), dependency-free, Node >= 18.
|
|
10
|
+
|
|
11
|
+
import { statSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import { validateManifest, VALID } from './manifest/validate.mjs';
|
|
15
|
+
|
|
16
|
+
// The exact skill name a delegable memory candidate must declare (guards against a wrong-name
|
|
17
|
+
// manifest that happens to be a valid memory-substrate with the right assets).
|
|
18
|
+
export const EXPECTED_MEMORY_NAME = 'agent-workflow-memory';
|
|
19
|
+
|
|
20
|
+
// The assets a memory candidate must carry, AND their required type. A partial install (manifest +
|
|
21
|
+
// SKILL.md only) is missing these → invalid → fallback. Checking the type (not just existence)
|
|
22
|
+
// rejects a wrong-shaped install (e.g. a file where a dir is expected) BEFORE any project write.
|
|
23
|
+
export const REQUIRED_MEMORY_ASSETS = [
|
|
24
|
+
{ path: 'references/templates', type: 'dir' },
|
|
25
|
+
{ path: 'references/contracts.md', type: 'file' },
|
|
26
|
+
{ path: 'references/scripts', type: 'dir' },
|
|
27
|
+
{ path: 'scripts/stamp-takeover.mjs', type: 'file' },
|
|
28
|
+
{ path: 'migrations', type: 'dir' },
|
|
29
|
+
{ path: 'capability.json', type: 'file' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const defaultStatType = (path) => {
|
|
33
|
+
try {
|
|
34
|
+
const s = statSync(path);
|
|
35
|
+
return s.isDirectory() ? 'dir' : s.isFile() ? 'file' : 'other';
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Decide whether to delegate substrate deployment to a memory candidate. The kit runs its OWN
|
|
42
|
+
// validator (never one shipped by the candidate). Delegate only on valid + kind memory-substrate +
|
|
43
|
+
// right name + available + all required assets present AT THE RIGHT TYPE; otherwise fall back.
|
|
44
|
+
export const detectMemory = (memorySkillDir, deps = {}) => {
|
|
45
|
+
const validate = deps.validate ?? validateManifest;
|
|
46
|
+
const statType = deps.statType ?? defaultStatType;
|
|
47
|
+
const report = validate(memorySkillDir);
|
|
48
|
+
const missingAssets = REQUIRED_MEMORY_ASSETS.filter(
|
|
49
|
+
(asset) => statType(join(memorySkillDir, asset.path)) !== asset.type,
|
|
50
|
+
).map((asset) => asset.path);
|
|
51
|
+
const delegate =
|
|
52
|
+
report.result === VALID &&
|
|
53
|
+
report.kind === 'memory-substrate' &&
|
|
54
|
+
report.name === EXPECTED_MEMORY_NAME &&
|
|
55
|
+
report.available !== false &&
|
|
56
|
+
missingAssets.length === 0;
|
|
57
|
+
const reason = delegate
|
|
58
|
+
? 'memory manifest valid (kind: memory-substrate) and all required assets present'
|
|
59
|
+
: report.result !== VALID
|
|
60
|
+
? `memory manifest ${report.result} — using bundled fallback`
|
|
61
|
+
: report.kind !== 'memory-substrate'
|
|
62
|
+
? `memory manifest kind "${report.kind}" is not memory-substrate — using bundled fallback`
|
|
63
|
+
: report.name !== EXPECTED_MEMORY_NAME
|
|
64
|
+
? `memory manifest name "${report.name}" is not "${EXPECTED_MEMORY_NAME}" — using bundled fallback`
|
|
65
|
+
: report.available === false
|
|
66
|
+
? 'memory manifest is a declared stub (available:false) — using bundled fallback'
|
|
67
|
+
: `memory install incomplete (missing: ${missingAssets.join(', ')}) — using bundled fallback`;
|
|
68
|
+
return { delegate, reason, validatorResult: report.result, kind: report.kind, name: report.name, available: report.available, missingAssets };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The hand-off matrix. Memory NEVER raises its own commit gate; the kit owns exactly ONE
|
|
72
|
+
// composition-level gate, after injection. Delegated → both stamps; fallback → .workflow-version only.
|
|
73
|
+
export const handoffPlan = (delegate) =>
|
|
74
|
+
delegate
|
|
75
|
+
? {
|
|
76
|
+
mode: 'delegate',
|
|
77
|
+
memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
|
|
78
|
+
kitWrites: ['AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
|
|
79
|
+
stampsPresent: ['.memory-version', '.workflow-version'],
|
|
80
|
+
memoryRaisesCommitGate: false,
|
|
81
|
+
commitGate: 'kit-only-after-injection',
|
|
82
|
+
}
|
|
83
|
+
: {
|
|
84
|
+
mode: 'fallback',
|
|
85
|
+
memoryWrites: [],
|
|
86
|
+
// Fallback ships the kit's OWN AGENTS.md, which carries the methodology INLINE (no slot
|
|
87
|
+
// markers) — so injection is a deliberate no-op here. Label it as inline, not a "slot"
|
|
88
|
+
// (the "slot" mechanism only exists in the delegate branch, on memory's AGENTS.md).
|
|
89
|
+
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology (inline)', 'docs/ai/.workflow-version'],
|
|
90
|
+
stampsPresent: ['.workflow-version'],
|
|
91
|
+
memoryRaisesCommitGate: false,
|
|
92
|
+
commitGate: 'kit-only-after-injection',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const main = (argv) => {
|
|
96
|
+
const dir = argv[0];
|
|
97
|
+
if (!dir) {
|
|
98
|
+
console.error('usage: delegation.mjs <memory-skill-dir> (prints the delegate/fallback decision + hand-off plan)');
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
const decision = detectMemory(resolve(dir));
|
|
102
|
+
const plan = handoffPlan(decision.delegate);
|
|
103
|
+
console.log(`[delegation] ${plan.mode}: ${decision.reason}`);
|
|
104
|
+
console.log(`[delegation] stamps present after deploy: ${plan.stampsPresent.join(', ')}`);
|
|
105
|
+
console.log(`[delegation] commit gate: ${plan.commitGate} (memory raises its own gate: ${plan.memoryRaisesCommitGate})`);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
109
|
+
if (isDirectRun) main(process.argv.slice(2));
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { detectMemory, handoffPlan, REQUIRED_MEMORY_ASSETS } from './delegation.mjs';
|
|
4
|
+
|
|
5
|
+
// Inject a fake validator + fs so the decision matrix is tested independent of real files/agents.
|
|
6
|
+
const fakeValidate = (over = {}) => () => ({
|
|
7
|
+
result: 'valid',
|
|
8
|
+
kind: 'memory-substrate',
|
|
9
|
+
name: 'agent-workflow-memory',
|
|
10
|
+
available: true,
|
|
11
|
+
errors: [],
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
const ASSET_TYPE = {
|
|
15
|
+
'references/templates': 'dir',
|
|
16
|
+
'references/contracts.md': 'file',
|
|
17
|
+
'references/scripts': 'dir',
|
|
18
|
+
'scripts/stamp-takeover.mjs': 'file',
|
|
19
|
+
migrations: 'dir',
|
|
20
|
+
'capability.json': 'file',
|
|
21
|
+
};
|
|
22
|
+
const typeFor = (p) => {
|
|
23
|
+
for (const [k, t] of Object.entries(ASSET_TYPE)) if (p.endsWith(k)) return t;
|
|
24
|
+
return 'file';
|
|
25
|
+
};
|
|
26
|
+
const allPresent = (p) => typeFor(p);
|
|
27
|
+
const missing = (absent) => (p) => (absent.some((a) => p.endsWith(a)) ? null : typeFor(p));
|
|
28
|
+
const wrongType = (paths) => (p) => {
|
|
29
|
+
for (const [k, t] of Object.entries(ASSET_TYPE)) {
|
|
30
|
+
if (p.endsWith(k)) return paths.includes(k) ? (t === 'dir' ? 'file' : 'dir') : t;
|
|
31
|
+
}
|
|
32
|
+
return 'file';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('detectMemory — decision matrix', () => {
|
|
36
|
+
it('valid + memory-substrate + right name + available + all assets → delegate', () => {
|
|
37
|
+
const d = detectMemory('/m', { validate: fakeValidate(), statType: allPresent });
|
|
38
|
+
assert.equal(d.delegate, true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('invalid manifest → fallback', () => {
|
|
42
|
+
const d = detectMemory('/m', { validate: fakeValidate({ result: 'invalid' }), statType: allPresent });
|
|
43
|
+
assert.equal(d.delegate, false);
|
|
44
|
+
assert.match(d.reason, /invalid/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('unsupported schema → fallback (treated like invalid)', () => {
|
|
48
|
+
const d = detectMemory('/m', { validate: fakeValidate({ result: 'unsupported' }), statType: allPresent });
|
|
49
|
+
assert.equal(d.delegate, false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('wrong kind → fallback', () => {
|
|
53
|
+
const d = detectMemory('/m', { validate: fakeValidate({ kind: 'composition-root' }), statType: allPresent });
|
|
54
|
+
assert.equal(d.delegate, false);
|
|
55
|
+
assert.match(d.reason, /not memory-substrate/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('wrong name → fallback (even if kind + assets are right)', () => {
|
|
59
|
+
const d = detectMemory('/m', { validate: fakeValidate({ name: 'evil-substrate' }), statType: allPresent });
|
|
60
|
+
assert.equal(d.delegate, false);
|
|
61
|
+
assert.match(d.reason, /name/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('available:false stub → fallback', () => {
|
|
65
|
+
const d = detectMemory('/m', { validate: fakeValidate({ available: false }), statType: allPresent });
|
|
66
|
+
assert.equal(d.delegate, false);
|
|
67
|
+
assert.match(d.reason, /stub/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('partial install (missing stamp-takeover) → fallback', () => {
|
|
71
|
+
const d = detectMemory('/m', {
|
|
72
|
+
validate: fakeValidate(),
|
|
73
|
+
statType: missing(['scripts/stamp-takeover.mjs']),
|
|
74
|
+
});
|
|
75
|
+
assert.equal(d.delegate, false);
|
|
76
|
+
assert.match(d.reason, /stamp-takeover/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('wrong-type asset (templates is a file, not a dir) → fallback', () => {
|
|
80
|
+
const d = detectMemory('/m', { validate: fakeValidate(), statType: wrongType(['references/templates']) });
|
|
81
|
+
assert.equal(d.delegate, false);
|
|
82
|
+
assert.match(d.reason, /references\/templates/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('required assets use real (references/) paths', () => {
|
|
86
|
+
const paths = REQUIRED_MEMORY_ASSETS.map((a) => a.path);
|
|
87
|
+
assert.ok(paths.includes('references/templates'));
|
|
88
|
+
assert.ok(paths.includes('references/contracts.md'));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('handoffPlan — stamp sets + single commit gate', () => {
|
|
93
|
+
it('delegate → both stamps present; memory never raises its own commit gate', () => {
|
|
94
|
+
const p = handoffPlan(true);
|
|
95
|
+
assert.deepEqual(p.stampsPresent, ['.memory-version', '.workflow-version']);
|
|
96
|
+
assert.equal(p.memoryRaisesCommitGate, false);
|
|
97
|
+
assert.equal(p.commitGate, 'kit-only-after-injection');
|
|
98
|
+
assert.ok(p.memoryWrites.includes('docs/ai/.memory-version'));
|
|
99
|
+
// Delegate is the ONLY branch with a real slot (memory ships it empty; the kit injects).
|
|
100
|
+
assert.ok(p.kitWrites.some((w) => w.includes('slot')), 'delegate kitWrites should name the methodology slot');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('fallback → only .workflow-version; kit writes everything; one kit gate', () => {
|
|
104
|
+
const p = handoffPlan(false);
|
|
105
|
+
assert.deepEqual(p.stampsPresent, ['.workflow-version']);
|
|
106
|
+
assert.deepEqual(p.memoryWrites, []);
|
|
107
|
+
assert.equal(p.memoryRaisesCommitGate, false);
|
|
108
|
+
assert.equal(p.commitGate, 'kit-only-after-injection');
|
|
109
|
+
// Fallback ships the kit's own AGENTS.md with methodology INLINE — never a "slot" (no markers).
|
|
110
|
+
assert.ok(
|
|
111
|
+
p.kitWrites.some((w) => w.includes('inline')) && !p.kitWrites.some((w) => w.includes('slot')),
|
|
112
|
+
'fallback kitWrites should describe inline methodology, not a slot',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Methodology slot injection — the composition root's only mutation of memory's AGENTS.md.
|
|
3
|
+
//
|
|
4
|
+
// memory ships an EMPTY delimited slot in templates/AGENTS.md; the kit (which knows the whole
|
|
5
|
+
// family) fills it. The engine only *provides* the methodology text — Plan 2 repoints the
|
|
6
|
+
// source to it. Phase 1 source = the kit's bundled tools/methodology-slot.md (a BOUNDED summary
|
|
7
|
+
// + pointer, NOT the full references/planning.md), so AGENTS.md stays under its line cap.
|
|
8
|
+
//
|
|
9
|
+
// Marker contract (shared with memory's upgrade extract-and-reinsert), strictly enforced:
|
|
10
|
+
// - exactly one ordered start→end pair → replace only the bytes between them.
|
|
11
|
+
// - markers absent (legacy AGENTS.md) → gracefully NO-OP (slot migration is Plan 2).
|
|
12
|
+
// - any malformed state (single, reversed, nested, duplicate) → NO-OP WITH AN ERROR; never edit.
|
|
13
|
+
// Prefix/suffix bytes are preserved exactly. Re-running with the same fragment is idempotent.
|
|
14
|
+
//
|
|
15
|
+
// Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
|
|
16
|
+
|
|
17
|
+
export const START_MARKER = '<!-- workflow:methodology:start -->';
|
|
18
|
+
export const END_MARKER = '<!-- workflow:methodology:end -->';
|
|
19
|
+
export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
|
|
20
|
+
|
|
21
|
+
const countOccurrences = (haystack, needle) => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
let from = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const idx = haystack.indexOf(needle, from);
|
|
26
|
+
if (idx === -1) return count;
|
|
27
|
+
count += 1;
|
|
28
|
+
from = idx + needle.length;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Classify the marker state of an AGENTS.md text. Pure; no fs.
|
|
33
|
+
// { state: 'ok', startIdx, endIdx } exactly one ordered pair
|
|
34
|
+
// { state: 'absent' } no markers at all → caller no-ops
|
|
35
|
+
// { state: 'malformed', reason } anything else → caller no-ops WITH error
|
|
36
|
+
export const findSlot = (text) => {
|
|
37
|
+
const starts = countOccurrences(text, START_MARKER);
|
|
38
|
+
const ends = countOccurrences(text, END_MARKER);
|
|
39
|
+
if (starts === 0 && ends === 0) return { state: 'absent' };
|
|
40
|
+
if (starts !== 1 || ends !== 1) {
|
|
41
|
+
return { state: 'malformed', reason: `expected exactly one start/end marker pair, found ${starts} start / ${ends} end` };
|
|
42
|
+
}
|
|
43
|
+
const startIdx = text.indexOf(START_MARKER);
|
|
44
|
+
const endIdx = text.indexOf(END_MARKER);
|
|
45
|
+
if (endIdx < startIdx) return { state: 'malformed', reason: 'end marker precedes start marker' };
|
|
46
|
+
return { state: 'ok', startIdx, endIdx };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Inject `fragment` between the markers, replacing only the bytes between them.
|
|
50
|
+
// Returns { status: 'injected' | 'noop-absent' | 'error', text, error? }. On absent/error the
|
|
51
|
+
// returned text is the INPUT, byte-for-byte (never edit on a malformed slot). Pass
|
|
52
|
+
// `{ maxLines }` to enforce the AGENTS.md line cap as a postcondition (refuse, don't bust it).
|
|
53
|
+
export const injectMethodology = (text, fragment, { maxLines } = {}) => {
|
|
54
|
+
// A fragment that itself contains a marker would create a duplicate/nested slot — refuse.
|
|
55
|
+
if (fragment.includes(START_MARKER) || fragment.includes(END_MARKER)) {
|
|
56
|
+
return { status: 'error', text, error: 'fragment contains a methodology marker — refusing to inject (would create a duplicate/nested slot)' };
|
|
57
|
+
}
|
|
58
|
+
const slot = findSlot(text);
|
|
59
|
+
if (slot.state === 'absent') return { status: 'noop-absent', text };
|
|
60
|
+
if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
|
|
61
|
+
const before = text.slice(0, slot.startIdx + START_MARKER.length);
|
|
62
|
+
const after = text.slice(slot.endIdx);
|
|
63
|
+
const out = `${before}\n${fragment.trim()}\n${after}`;
|
|
64
|
+
if (maxLines != null) {
|
|
65
|
+
const lines = out.split('\n').length - (out.endsWith('\n') ? 1 : 0);
|
|
66
|
+
if (lines > maxLines) {
|
|
67
|
+
return { status: 'error', text, error: `injection would push AGENTS.md to ${lines} lines (cap ${maxLines}) — trim the fragment or the file` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { status: 'injected', text: out };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Inverse used by memory's upgrade: extract the current slot content (preserve-on-upgrade).
|
|
74
|
+
// Returns the bytes strictly between the markers, or null on absent/malformed.
|
|
75
|
+
export const extractSlot = (text) => {
|
|
76
|
+
const slot = findSlot(text);
|
|
77
|
+
if (slot.state !== 'ok') return null;
|
|
78
|
+
return text.slice(slot.startIdx + START_MARKER.length, slot.endIdx);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const main = async (argv) => {
|
|
82
|
+
const { readFile, writeFile, rename } = await import('node:fs/promises');
|
|
83
|
+
const { dirname, basename, join, resolve } = await import('node:path');
|
|
84
|
+
const { fileURLToPath } = await import('node:url');
|
|
85
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const agentsPath = argv[0];
|
|
87
|
+
if (!agentsPath) {
|
|
88
|
+
console.error('usage: inject-methodology.mjs <path/to/AGENTS.md> [fragment.md]');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
const fragmentPath = argv[1] ? resolve(argv[1]) : resolve(here, 'methodology-slot.md');
|
|
92
|
+
const text = await readFile(resolve(agentsPath), 'utf8');
|
|
93
|
+
const fragment = await readFile(fragmentPath, 'utf8');
|
|
94
|
+
const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
|
|
95
|
+
if (result.status === 'error') {
|
|
96
|
+
console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
if (result.status === 'noop-absent') {
|
|
100
|
+
console.log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
104
|
+
await writeFile(tmp, result.text, 'utf8');
|
|
105
|
+
await rename(tmp, resolve(agentsPath));
|
|
106
|
+
console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const { pathToFileURL } = await import('node:url');
|
|
110
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
111
|
+
if (isDirectRun) await main(process.argv.slice(2));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
injectMethodology,
|
|
8
|
+
findSlot,
|
|
9
|
+
extractSlot,
|
|
10
|
+
START_MARKER,
|
|
11
|
+
END_MARKER,
|
|
12
|
+
} from './inject-methodology.mjs';
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
|
|
16
|
+
|
|
17
|
+
const wrap = (inner) =>
|
|
18
|
+
`# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
|
|
19
|
+
|
|
20
|
+
describe('findSlot — marker classification', () => {
|
|
21
|
+
it('one ordered pair → ok', () => {
|
|
22
|
+
assert.equal(findSlot(wrap('\n')).state, 'ok');
|
|
23
|
+
});
|
|
24
|
+
it('no markers → absent', () => {
|
|
25
|
+
assert.equal(findSlot('# AGENTS.md\nno markers here\n').state, 'absent');
|
|
26
|
+
});
|
|
27
|
+
it('duplicate pair → malformed', () => {
|
|
28
|
+
const text = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
|
|
29
|
+
assert.equal(findSlot(text).state, 'malformed');
|
|
30
|
+
});
|
|
31
|
+
it('single start only → malformed', () => {
|
|
32
|
+
assert.equal(findSlot(`x\n${START_MARKER}\ny\n`).state, 'malformed');
|
|
33
|
+
});
|
|
34
|
+
it('single end only → malformed', () => {
|
|
35
|
+
assert.equal(findSlot(`x\n${END_MARKER}\ny\n`).state, 'malformed');
|
|
36
|
+
});
|
|
37
|
+
it('reversed (end before start) → malformed', () => {
|
|
38
|
+
assert.equal(findSlot(`${END_MARKER}\nmiddle\n${START_MARKER}\n`).state, 'malformed');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('injectMethodology — byte preservation', () => {
|
|
43
|
+
it('injects into an empty slot, preserving prefix/suffix exactly', () => {
|
|
44
|
+
const input = wrap('\n');
|
|
45
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
46
|
+
assert.equal(out.status, 'injected');
|
|
47
|
+
assert.ok(out.text.startsWith('# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n'));
|
|
48
|
+
assert.ok(out.text.endsWith('\n\n## Hard Constraints\n\nsuffix bytes\n'));
|
|
49
|
+
assert.ok(out.text.includes(FRAGMENT.trim()));
|
|
50
|
+
// markers themselves are preserved
|
|
51
|
+
assert.equal((out.text.match(new RegExp(START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length, 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('is idempotent — re-injecting the same fragment is stable', () => {
|
|
55
|
+
const once = injectMethodology(wrap('\n'), FRAGMENT).text;
|
|
56
|
+
const twice = injectMethodology(once, FRAGMENT).text;
|
|
57
|
+
assert.equal(twice, once);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('overwrites a previously-filled slot (bootstrap composition), preserving outside bytes', () => {
|
|
61
|
+
const filled = wrap('\nstale content\n');
|
|
62
|
+
const out = injectMethodology(filled, FRAGMENT);
|
|
63
|
+
assert.equal(out.status, 'injected');
|
|
64
|
+
assert.ok(!out.text.includes('stale content'));
|
|
65
|
+
assert.ok(out.text.endsWith('\n\n## Hard Constraints\n\nsuffix bytes\n'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a fragment that itself contains a marker (would nest/duplicate the slot)', () => {
|
|
69
|
+
const out = injectMethodology(wrap('\n'), `bad ${START_MARKER} fragment`);
|
|
70
|
+
assert.equal(out.status, 'error');
|
|
71
|
+
assert.equal(out.text, wrap('\n'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses to bust the line cap (maxLines) instead of silently overflowing it', () => {
|
|
75
|
+
const huge = Array.from({ length: 40 }, (_, i) => `methodology line ${i}`).join('\n');
|
|
76
|
+
const out = injectMethodology(wrap('\n'), huge, { maxLines: 20 });
|
|
77
|
+
assert.equal(out.status, 'error');
|
|
78
|
+
assert.match(out.error, /cap 20/);
|
|
79
|
+
assert.equal(out.text, wrap('\n')); // unchanged
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('absent markers → no-op, returns input byte-for-byte', () => {
|
|
83
|
+
const input = '# AGENTS.md\nlegacy file, no slot\n';
|
|
84
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
85
|
+
assert.equal(out.status, 'noop-absent');
|
|
86
|
+
assert.equal(out.text, input);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const [label, input] of [
|
|
90
|
+
['duplicate pair', `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`],
|
|
91
|
+
['single start', `head\n${START_MARKER}\ntail\n`],
|
|
92
|
+
['single end', `head\n${END_MARKER}\ntail\n`],
|
|
93
|
+
['reversed', `${END_MARKER}\nx\n${START_MARKER}\n`],
|
|
94
|
+
]) {
|
|
95
|
+
it(`malformed (${label}) → error, returns input byte-for-byte`, () => {
|
|
96
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
97
|
+
assert.equal(out.status, 'error');
|
|
98
|
+
assert.equal(out.text, input); // never edits a malformed slot
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('extractSlot — preserve-on-upgrade inverse', () => {
|
|
104
|
+
it('returns the bytes strictly between the markers', () => {
|
|
105
|
+
assert.equal(extractSlot(wrap('\nkeep me\n')), '\nkeep me\n');
|
|
106
|
+
});
|
|
107
|
+
it('null on absent/malformed', () => {
|
|
108
|
+
assert.equal(extractSlot('no markers'), null);
|
|
109
|
+
assert.equal(extractSlot(`${START_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), null);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('post-injection cap — AGENTS.md stays under its line budget', () => {
|
|
114
|
+
it('injecting the bounded fragment into the real memory template keeps AGENTS.md ≤ 100 lines', () => {
|
|
115
|
+
const template = readFileSync(
|
|
116
|
+
join(HERE, '..', '..', 'agent-workflow-memory', 'references', 'templates', 'AGENTS.md'),
|
|
117
|
+
'utf8',
|
|
118
|
+
);
|
|
119
|
+
const out = injectMethodology(template, FRAGMENT);
|
|
120
|
+
assert.equal(out.status, 'injected');
|
|
121
|
+
const lines = out.text.split('\n').length - (out.text.endsWith('\n') ? 1 : 0);
|
|
122
|
+
assert.ok(lines <= 100, `AGENTS.md would be ${lines} lines after injection (cap 100)`);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "family": "agent-workflow", "schema": 1, "name": "broken",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metadata-version
|
|
3
|
+
version: '9.9.9'
|
|
4
|
+
description: A decoy top-level `version:` that must NOT be read as the authoritative version.
|
|
5
|
+
metadata:
|
|
6
|
+
version: '1.0.0'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# metadata-version fixture — authoritative version is `metadata.version` (1.0.0), not the
|
|
10
|
+
# stray top-level `version: 9.9.9`. capability.json declares 1.0.0, so this is VALID.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nested-version-decoy
|
|
3
|
+
metadata:
|
|
4
|
+
version: '1.0.0'
|
|
5
|
+
nested:
|
|
6
|
+
version: '9.9.9'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# nested-version-decoy fixture — the DIRECT metadata.version (1.0.0) wins; the deeper
|
|
10
|
+
# metadata.nested.version (9.9.9) must be ignored. capability.json declares 1.0.0 → VALID.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
null
|