@sabaiway/agent-workflow-kit 1.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +15 -5
  3. package/SKILL.md +86 -42
  4. package/bin/install.mjs +59 -8
  5. package/bin/install.test.mjs +66 -0
  6. package/capability.json +21 -0
  7. package/migrations/1.1.0-communication-language.md +5 -5
  8. package/migrations/README.md +2 -1
  9. package/package.json +8 -5
  10. package/references/contracts.md +62 -0
  11. package/references/scripts/archive-changelog.mjs +1 -4
  12. package/references/templates/AGENTS.md +2 -2
  13. package/tools/delegation.mjs +109 -0
  14. package/tools/delegation.test.mjs +115 -0
  15. package/tools/inject-methodology.mjs +111 -0
  16. package/tools/inject-methodology.test.mjs +124 -0
  17. package/tools/manifest/fixtures/bad-available/SKILL.md +7 -0
  18. package/tools/manifest/fixtures/bad-available/capability.json +10 -0
  19. package/tools/manifest/fixtures/detect-array/SKILL.md +7 -0
  20. package/tools/manifest/fixtures/detect-array/capability.json +10 -0
  21. package/tools/manifest/fixtures/malformed-json/capability.json +1 -0
  22. package/tools/manifest/fixtures/metadata-version/SKILL.md +10 -0
  23. package/tools/manifest/fixtures/metadata-version/capability.json +9 -0
  24. package/tools/manifest/fixtures/missing-key/SKILL.md +7 -0
  25. package/tools/manifest/fixtures/missing-key/capability.json +8 -0
  26. package/tools/manifest/fixtures/missing-source/SKILL.md +7 -0
  27. package/tools/manifest/fixtures/missing-source/capability.json +11 -0
  28. package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +10 -0
  29. package/tools/manifest/fixtures/nested-version-decoy/capability.json +9 -0
  30. package/tools/manifest/fixtures/null-root/capability.json +1 -0
  31. package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +7 -0
  32. package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +2 -0
  33. package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +11 -0
  34. package/tools/manifest/fixtures/stub/capability.json +10 -0
  35. package/tools/manifest/fixtures/traversal-source/SKILL.md +7 -0
  36. package/tools/manifest/fixtures/traversal-source/capability.json +11 -0
  37. package/tools/manifest/fixtures/unknown-schema/capability.json +9 -0
  38. package/tools/manifest/fixtures/valid/SKILL.md +10 -0
  39. package/tools/manifest/fixtures/valid/bin/run.sh +3 -0
  40. package/tools/manifest/fixtures/valid/capability.json +18 -0
  41. package/tools/manifest/fixtures/version-mismatch/SKILL.md +7 -0
  42. package/tools/manifest/fixtures/version-mismatch/capability.json +9 -0
  43. package/tools/manifest/fixtures/win-absolute-source/SKILL.md +7 -0
  44. package/tools/manifest/fixtures/win-absolute-source/capability.json +11 -0
  45. package/tools/manifest/schema.md +67 -0
  46. package/tools/manifest/validate.mjs +264 -0
  47. package/tools/manifest/validate.test.mjs +73 -0
  48. package/tools/methodology-slot.md +1 -0
  49. package/tools/release-scan.mjs +103 -0
  50. package/tools/release-scan.test.mjs +41 -0
@@ -0,0 +1,62 @@
1
+ # Setup contracts
2
+
3
+ The three choices the bootstrap makes with the user — **visibility**, **conversational
4
+ language**, and **agent attribution** — each have a contract below. `SKILL.md` links here so the
5
+ main procedure stays lean; load this file when you need the full rule for a contract (e.g. while
6
+ filling the matching `AGENTS.md` block, or when an `upgrade` migration touches it).
7
+
8
+ Ask each as a **structured multiple-choice prompt where your agent supports it** (`AskUserQuestion`
9
+ in Claude Code), otherwise in prose — and always wait for the answer before writing.
10
+
11
+ ---
12
+
13
+ ## Visibility contract
14
+
15
+ The user chooses at bootstrap whether the AI artifacts are visible in the repo or hidden — an
16
+ **explicit up-front question** (bootstrap step 2), never an assumed default. The two modes then
17
+ diverge:
18
+
19
+ - **visible** — artifacts are committed. Wire the project's `package.json` scripts (`docs:check` / `docs:index` / `docs:index:check` / `docs:archive` / `docs:archive:check` / `docs:archive:issues` / `docs:archive:issues:check` / `prepare: node scripts/install-git-hooks.mjs`) and add a minimal `.gitignore` (`docs/plans/`, `.claude/settings.local.json`). This is the canonical model.
20
+ - **hidden** (in-tree) — same files on disk, but the repo "looks normal": append the artifact paths (`AGENTS.md`, `CLAUDE.md`, `docs/ai/`, `docs/plans/`, `scripts/*.mjs` you added, `docs/ai/.workflow-version`) to the global excludes file git **already uses** (`git config --get core.excludesFile`); if none is set, point it at `~/.gitignore_global` (`git config --global core.excludesFile ~/.gitignore_global`) and append there. **Verify `git status` shows the artifacts as ignored** afterwards. **Do not edit `package.json`** — that is a tracked change and would leak; the pre-commit hook (always untracked in `.git/hooks/`) calls the scripts via `node scripts/<x>.mjs` directly.
21
+
22
+ Not in this version: a fully-external hidden mode (artifacts relocated outside the repo tree).
23
+ Deferred to a later release + migration.
24
+
25
+ ---
26
+
27
+ ## Communication contract
28
+
29
+ The user chooses at bootstrap (step 3) which language the agent **talks to them** in. The choice is
30
+ recorded in the *Communication language* block of the project's `AGENTS.md`, so every agent that
31
+ reads the entry point honours it — and stops drifting between languages mid-session.
32
+
33
+ Scope — **dialogue only**:
34
+
35
+ - **In the chosen language** — everything the agent produces *for the user to read*: questions, explanations, plan summaries, status updates, commit-message prose if asked, review notes.
36
+ - **Always in their source language** — code, identifiers, file paths, shell commands, log/console output, error strings, config keys, and abbreviations/acronyms. Translating these breaks copy-paste, search, and tooling.
37
+ - **Files aren't translated** — the deployed `docs/ai/` files, `AGENTS.md`, and this kernel stay in their source language regardless of the chosen language (cross-agent / cross-team portability). The conversational language is about the *chat*, not the *artifacts*.
38
+
39
+ Default to the language the user is already writing in; confirm rather than assume. On `upgrade`, a
40
+ pre-1.1.0 deployment with no block gets one (the agent asks).
41
+
42
+ ---
43
+
44
+ ## Attribution contract
45
+
46
+ The user chooses at bootstrap (step 4) whether the agent may **attribute work to itself or to AI**.
47
+ The choice is recorded in the *Attribution* block of the project's `AGENTS.md`, so every agent that
48
+ reads the entry point honours it. **Default is `off`** — people are routinely surprised to find an
49
+ AI listed as a repo contributor (a `Co-Authored-By` trailer is enough to do it), so opt-in, never
50
+ opt-out.
51
+
52
+ When attribution is **`off`**, no mention of the agent, AI, or the model appears **anywhere**:
53
+
54
+ - **No `Co-Authored-By` trailers** and **no "Generated with …" footers** on commits or PRs.
55
+ - **No AI/agent/model references** in code, comments, commit messages, PR titles/bodies, branch names, or `docs/` prose. The work reads as the human author's.
56
+ - **Two enforcement layers** — the *Attribution* block binds everything an agent writes **by hand**; the automatic `Co-Authored-By` trailer is added by the **harness**, not the prose, so for **Claude Code** the kit also sets `"includeCoAuthoredBy": false` in the project's `.claude/settings.json`. Other tools: disable their equivalent co-author/footer setting if present.
57
+
58
+ When **`on`**, the agent may add its standard trailer / footer per the user's tooling defaults. This
59
+ block is about *attribution*, not authorship of the actual changes — quality, tests, and the "ask
60
+ before commit" rule are unchanged either way.
61
+
62
+ On `upgrade`, a pre-1.2.0 deployment with no block gets one (the agent asks, defaulting to `off`).
@@ -130,9 +130,6 @@ export const parseChangelogText = (text) => {
130
130
 
131
131
  const TRAILING_FOOTER_PATTERNS = [
132
132
  /^\*\*Last Updated:/i,
133
- // Legacy in-tree footer line from the deleted changelog-archive.md — match left in place
134
- // so a re-migration cannot leak the old marker into a freshly-rotated entry.
135
- /^> Записи старше/i,
136
133
  ];
137
134
 
138
135
  export const stripTrailingSeparator = (block) => {
@@ -149,7 +146,7 @@ export const stripTrailingSeparator = (block) => {
149
146
  export const stripBlockquoteHistoryNotice = (preamble) => {
150
147
  const filtered = preamble
151
148
  .split('\n')
152
- .filter((line) => !/changelog-archive\.md/i.test(line) && !/Записи старше/i.test(line));
149
+ .filter((line) => !/changelog-archive\.md/i.test(line));
153
150
 
154
151
  // Strip any previously-inserted "## History" section so re-running the rotator is idempotent.
155
152
  // A History section starts at `## History` and ends at the next `---` separator or end-of-file.
@@ -9,8 +9,8 @@
9
9
  ## 🗣️ Communication language
10
10
 
11
11
  > **Talk to the user in {{COMM_LANGUAGE}}** — every question, explanation, summary, and status update.
12
- > Keep code, identifiers, file paths, shell commands, log output, and abbreviations in their **source language** (usually English) — translating them breaks copy-paste, search, and tooling.
13
- > This sets the **dialogue** language only. The files in `docs/ai/` and this entry point stay in English (kernel is English-only, for cross-agent / cross-team portability).
12
+ > Keep code, identifiers, file paths, shell commands, log output, and abbreviations in their **source language** — translating them breaks copy-paste, search, and tooling.
13
+ > This sets the **dialogue** language only it does not translate the files in `docs/ai/` or this entry point, which stay in their source language (for cross-agent / cross-team portability).
14
14
 
15
15
  ---
16
16
 
@@ -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,7 @@
1
+ ---
2
+ name: bad-available
3
+ metadata:
4
+ version: '1.0.0'
5
+ ---
6
+
7
+ # bad-available fixture — `available` is a string, not a boolean → invalid.
@@ -0,0 +1,10 @@
1
+ {
2
+ "family": "agent-workflow",
3
+ "schema": 1,
4
+ "name": "bad-available",
5
+ "kind": "memory-substrate",
6
+ "version": "1.0.0",
7
+ "available": "yes",
8
+ "provides": [],
9
+ "roles": {}
10
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: detect-array
3
+ metadata:
4
+ version: '1.0.0'
5
+ ---
6
+
7
+ # detect-array fixture — `detect.installed` is an array, not an object.
@@ -0,0 +1,10 @@
1
+ {
2
+ "family": "agent-workflow",
3
+ "schema": 1,
4
+ "name": "detect-array",
5
+ "kind": "memory-substrate",
6
+ "version": "1.0.0",
7
+ "provides": [],
8
+ "roles": {},
9
+ "detect": { "installed": [] }
10
+ }
@@ -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,9 @@
1
+ {
2
+ "family": "agent-workflow",
3
+ "schema": 1,
4
+ "name": "metadata-version",
5
+ "kind": "memory-substrate",
6
+ "version": "1.0.0",
7
+ "provides": [],
8
+ "roles": {}
9
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: missing-key
3
+ metadata:
4
+ version: '1.0.0'
5
+ ---
6
+
7
+ # missing-key fixture — capability.json omits the required `name` key.
@@ -0,0 +1,8 @@
1
+ {
2
+ "family": "agent-workflow",
3
+ "schema": 1,
4
+ "kind": "memory-substrate",
5
+ "version": "1.0.0",
6
+ "provides": [],
7
+ "roles": {}
8
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: missing-source
3
+ metadata:
4
+ version: '1.0.0'
5
+ ---
6
+
7
+ # missing-source fixture — `roles.review.source` points to a file that does not exist.