@sabaiway/agent-workflow-kit 1.5.2 → 1.6.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 CHANGED
@@ -4,6 +4,42 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 1.6.0 — Methodology slot reconciliation; engine becomes the canonical methodology home
8
+
9
+ The workflow methodology now has a **single canonical home** in `agent-workflow-engine`
10
+ (`available:false` — content only, not yet published or wired live), and the kit keeps
11
+ **byte-identical mirror copies** so the existing injection + fallback keep working with **no new
12
+ runtime dependency**. A drift-guard test (`tools/methodology-mirror.test.mjs`) pins the mirrors to
13
+ the engine canon: `references/planning.md` and `tools/methodology-slot.md` must equal their engine
14
+ counterparts byte-for-byte.
15
+
16
+ The user-facing win is **stamp-independent slot reconciliation**. A single atomic, idempotent kit
17
+ operation now **ensures the `workflow:methodology` slot exists and is filled** in a deployed
18
+ `AGENTS.md`, on **bootstrap** and on **every upgrade**:
19
+
20
+ - **`tools/inject-methodology.mjs`** gains `METHODOLOGY_ANCHOR`, `EMPTY_SLOT`, `ensureSlot`, and
21
+ `reconcileSlot` (reusing the existing `findSlot` / `injectMethodology` / `extractSlot` marker
22
+ parser — no second parser). `reconcileSlot` = **ensure the slot exists** (insert an empty marker
23
+ pair right after the Session-Protocols anchor when a legacy entry point lacks one) → **inject the
24
+ bounded fragment ONLY IF the slot is empty** (a filled / user-customized slot is preserved
25
+ verbatim) → **cap-check** (`AGENTS.md` ≤ 100 lines). On a malformed slot or a missing / duplicate
26
+ anchor it **STOPs with an error and never edits** — the file is left byte-for-byte unchanged.
27
+ - A new CLI mode — `inject-methodology.mjs reconcile <AGENTS.md>` — runs that policy as **one
28
+ atomic write** (temp + rename); there is no partial state where markers exist but the fill failed.
29
+ - The kit **fallback** entry-point template (`references/templates/AGENTS.md`) now ships the **empty
30
+ methodology slot** (matching memory's template) instead of an inline methodology line, so a fresh
31
+ fallback bootstrap gets a slot the kit fills. A new test (`test/fallback-template-cap.test.mjs`)
32
+ pins that template — empty and filled — under the 100-line cap.
33
+ - **Bootstrap** and **upgrade** (`SKILL.md`) now run `reconcile`. On upgrade it runs
34
+ **before** the lineage short-circuit, so the slot is reconciled on every upgrade — reaching even
35
+ legacy **`1.3.0`** deployments — **without bumping the deployment-lineage head**.
36
+
37
+ The deployment-lineage head **stays `1.3.0`** and `agent-workflow-memory` is **untouched** (no code,
38
+ version, or migration change): reconciliation is stamp-independent, so it needs no head bump (which
39
+ would have forced a memory republish, since the head is hard-coded in memory's stamp module).
40
+ Additive — no user-facing break. The engine's npm packaging, `available:true`, and the live
41
+ `kit → engine` read selector are deferred to the next plan.
42
+
7
43
  ## 1.5.2 — README uplift to front-door grade (docs)
8
44
 
9
45
  Docs-only patch. The npm-facing `README.md` is uplifted to match the GitHub family front door's
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.5.2'
6
+ version: '1.6.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -49,21 +49,25 @@ made: a partial/broken memory install discovered mid-flow must not disable the w
49
49
  **Hand-off contract (explicit; tested independent of agent interpretation).**
50
50
  - **Delegated** (memory valid): the kit passes the **target project dir** + the **three setup
51
51
  answers** (visibility / language / attribution) to `agent-workflow-memory`, which writes
52
- `docs/ai/` + `AGENTS.md` + **`.memory-version`**. The kit then **injects the bounded
53
- methodology slot** (below) and writes the kit-fallback **`.workflow-version`**. → **both
54
- stamps** present.
52
+ `docs/ai/` + `AGENTS.md` (with the empty slot) + **`.memory-version`**. The kit then
53
+ **reconciles the bounded methodology slot** (below) and writes the kit-fallback
54
+ **`.workflow-version`**. → **both stamps** present.
55
55
  - **Fallback** (memory absent/invalid): the kit runs the bootstrap procedure below from its own
56
- bundled assets and writes **`.workflow-version`** only. Softly suggest installing
56
+ bundled assets whose entry-point template now ships the **empty methodology slot** the kit
57
+ reconciles + fills — and writes **`.workflow-version`** only. Softly suggest installing
57
58
  `agent-workflow-memory` — never a prerequisite.
58
59
 
59
- **Methodology injection (the kit is the ONLY writer of memory's slot).** After `AGENTS.md`
60
- exists, inject the bounded methodology fragment into its `workflow:methodology` slot:
61
- `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs <project>/AGENTS.md`. It injects a short
62
- summary + pointer (Phase-1 source: the kit's bundled `tools/methodology-slot.md`) **not** the
63
- full `references/planning.md` and keeps `AGENTS.md` under its ≤100-line cap. Marker contract:
64
- exactly one ordered `start end` pair replace only the bytes between them; markers absent →
65
- no-op; any malformed state (single, reversed, nested, duplicate) no-op **with an error**,
66
- never edit.
60
+ **Methodology slot reconciliation (the kit is the ONLY writer of memory's slot).** After
61
+ `AGENTS.md` exists, reconcile its `workflow:methodology` slot:
62
+ `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md`. Reconcile is
63
+ **one atomic operation**: **ensure the slot exists** (insert an empty marker pair right after the
64
+ Session-Protocols anchor when a legacy entry point lacks one) **inject the bounded fragment ONLY
65
+ IF the slot is empty** (a filled / user-customized slot is preserved verbatim) **cap-check**
66
+ (keeps `AGENTS.md` ≤100 lines). The fragment is a short summary + pointer (source: the kit's bundled
67
+ `tools/methodology-slot.md`, a **byte-identical mirror of the `agent-workflow-engine` canon**) —
68
+ **not** the full `references/planning.md`. Contract: exactly one ordered `start → end` pair; a
69
+ malformed slot (single, reversed, nested, duplicate) or a missing / duplicate anchor → **STOP with
70
+ an error**, never edit (the file is left byte-for-byte unchanged).
67
71
 
68
72
  **One composition-level commit gate.** The delegated memory mode performs **no** commit and
69
73
  raises **no** "ask to commit". There is exactly **one** gate, owned by the kit, **after**
@@ -103,7 +107,8 @@ Pick the mode from the user's invocation. Auto-detect an existing `docs/ai/` to
103
107
  9. **Wire / hide** per visibility (see contract). Install the pre-commit hook (Node projects): `node scripts/install-git-hooks.mjs`. If the installer reports a pre-existing non-marker hook, stop and ask the user to merge it manually rather than overwriting.
104
108
  10. **Stamp the deployment lineage.** Write the **deployment-lineage head** into
105
109
  `docs/ai/.workflow-version` (one semver line). The lineage head is **`1.3.0`** — the shared
106
- `agent-workflow` deployment lineage, **NOT** this kit's package version (`1.4.0`). The two are
110
+ `agent-workflow` deployment lineage, **NOT** this kit's npm package version (see
111
+ `package.json` / `CHANGELOG.md`). The two are
107
112
  independent axes: a packaging-only release bumps the package but leaves the lineage head until a
108
113
  migration actually changes the deployed `docs/ai` structure. A stamp greater than the head →
109
114
  STOP (never downgrade).
@@ -122,11 +127,13 @@ Fill strategy:
122
127
  ### Mode: upgrade
123
128
 
124
129
  1. Read `docs/ai/.workflow-version` (the project's stamped lineage). If missing, treat as a pre-versioned deployment and offer to re-bootstrap conservatively.
125
- 2. Compare to the **deployment-lineage head** (`1.3.0` — NOT this kit's package version). If equal → report "up to date" and stop. If the stamp is **greater than the head** or unparseable → **STOP and report** (never downgrade).
126
- 3. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
127
- 4. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** safe to re-run.
128
- 5. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`.
129
- 6. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version). Report changes; **ask before committing**.
130
+ 2. **Never-downgrade gate — FIRST, before any write.** Compare the stamp to the **deployment-lineage head** (`1.3.0` — NOT this kit's package version). If the stamp is **greater than the head** or unparseable → **STOP and report**; do not touch a newer / unknown deployment at all (not even the methodology slot).
131
+ 3. **Reconcile the methodology slot — stamp-independent, BEFORE the equal-head short-circuit.** Reached only when the stamp **≤ head**. Run `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md`. This ensures the `workflow:methodology` slot exists and is filled on **every** upgrade, idempotently (zero-diff when already present + filled) — so even a legacy / current **`1.3.0`** deployment gains the slot **without a lineage-head bump** (the head stays `1.3.0`; **no `agent-workflow-memory` change**). It inserts an empty slot at the Session-Protocols anchor if absent, preserves a customized slot verbatim, and STOPs (never edits) on a malformed slot or a missing / duplicate anchor. No-Node project: open `AGENTS.md`, and if there is no `<!-- workflow:methodology:start/end -->` pair, paste it right after the *Read it before any code change.* line and fill it from `tools/methodology-slot.md`.
132
+ 4. **Equal-head short-circuit.** If the stamp **equals** the head the lineage is up to date: **stop here** (the slot was already reconciled in step 3).
133
+ 5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
134
+ 6. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** safe to re-run.
135
+ 7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`.
136
+ 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version). Report changes; **ask before committing**.
130
137
 
131
138
  ### Mode: backends
132
139
 
@@ -207,6 +214,6 @@ Deploy these into `AGENTS.md`; remove rows that don't apply to the stack.
207
214
  - [`references/scripts/`](references/scripts/) — the Node enforcement scripts (caps + staleness + index-freshness gate, 3-tier archive, hook installer) and their unit tests.
208
215
  - [`migrations/`](migrations/) — per-version upgrade steps; see `migrations/README.md`.
209
216
  - [`launchers/`](launchers/) — run the bootstrapper from non-Claude agents (`SKILL.md` is a native Codex skill; a Devin Desktop workflow launcher + install script). See `launchers/README.md`.
210
- - [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot injection), `detect-backends.mjs` (the read-only **backend detector** behind `/agent-workflow-kit backends`), and `release-scan.mjs` (the attribution-off release gate). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
217
+ - [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot reconciliation — ensure-slot / inject-if-empty / cap; the fragment is a byte-identical mirror of the `agent-workflow-engine` canon, pinned by `methodology-mirror.test.mjs`), `detect-backends.mjs` (the read-only **backend detector** behind `/agent-workflow-kit backends`), and `release-scan.mjs` (the attribution-off release gate). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
211
218
  - [`capability.json`](capability.json) — the kit's own `agent-workflow` family manifest (`kind: composition-root`).
212
219
  - [`CHANGELOG.md`](CHANGELOG.md) — version history of this kernel.
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.5.2",
6
+ "version": "1.6.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
@@ -11,7 +11,7 @@ releases add files/templates, which `upgrade` reconciles without a migration.
11
11
  2. Select every migration whose `<version>` is **strictly newer** than the stamp.
12
12
  3. Apply them in **ascending semver order**.
13
13
  4. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0` today — the
14
- shared lineage, **not** this skill's package version `1.4.0`). A stamp greater than the head → STOP.
14
+ shared lineage, **not** this skill's npm package version). A stamp greater than the head → STOP.
15
15
 
16
16
  ## Authoring rules
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -53,7 +53,8 @@ All project knowledge lives in `docs/ai/`. Layered, lazy-loaded context:
53
53
 
54
54
  Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**
55
55
 
56
- Planning (plan files, vocabulary, lifecycle, mandatory Cleanup) → the project's planning skill / `docs/ai/agent_rules.md` §"Planning Workflow".
56
+ <!-- workflow:methodology:start -->
57
+ <!-- workflow:methodology:end -->
57
58
 
58
59
  ---
59
60
 
@@ -83,10 +83,10 @@ export const handoffPlan = (delegate) =>
83
83
  : {
84
84
  mode: 'fallback',
85
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'],
86
+ // Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
87
+ // the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
88
+ // path so both paths end with a FILLED slot, not inline methodology.
89
+ kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
90
90
  stampsPresent: ['.workflow-version'],
91
91
  memoryRaisesCommitGate: false,
92
92
  commitGate: 'kit-only-after-injection',
@@ -106,10 +106,11 @@ describe('handoffPlan — stamp sets + single commit gate', () => {
106
106
  assert.deepEqual(p.memoryWrites, []);
107
107
  assert.equal(p.memoryRaisesCommitGate, false);
108
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).
109
+ // Fallback now ships the kit's own AGENTS.md with the EMPTY methodology slot, which the kit
110
+ // reconciles + fills — the same slot mechanism as the delegate path (Plan 2).
110
111
  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',
112
+ p.kitWrites.some((w) => w.includes('slot')) && !p.kitWrites.some((w) => w.includes('inline')),
113
+ 'fallback kitWrites should describe the methodology slot, not inline methodology',
113
114
  );
114
115
  });
115
116
  });
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // Methodology slot injection — the composition root's only mutation of memory's AGENTS.md.
2
+ // Methodology slot injection + reconciliation — the composition root's only mutation of a
3
+ // deployed AGENTS.md.
3
4
  //
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.
5
+ // Both templates (memory's + the kit fallback) ship an EMPTY delimited slot; the kit (which knows
6
+ // the whole family) fills it. The bounded fragment (tools/methodology-slot.md) is a BOUNDED summary
7
+ // + pointer, NOT the full references/planning.md, so AGENTS.md stays under its line cap; it is a
8
+ // byte-identical MIRROR of the canonical text in agent-workflow-engine (drift-guarded).
8
9
  //
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.
10
+ // Two layers over one marker parser:
11
+ // - injectMethodology fill an EXISTING slot. Marker contract, strictly enforced:
12
+ // exactly one ordered start→end pair replace only the bytes between them;
13
+ // markers absent → NO-OP; any malformed state (single, reversed, nested, duplicate) →
14
+ // NO-OP WITH AN ERROR, never edit. Prefix/suffix preserved exactly; re-run is idempotent.
15
+ // - ensureSlot / reconcileSlot — the bootstrap/upgrade policy (Plan 2): ensure the slot EXISTS
16
+ // (insert an empty pair at the Session-Protocols anchor when a legacy file lacks one) →
17
+ // inject ONLY IF empty (preserve a customized slot verbatim) → cap-check. Stamp-independent.
14
18
  //
15
19
  // Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
16
20
 
@@ -18,6 +22,10 @@ export const START_MARKER = '<!-- workflow:methodology:start -->';
18
22
  export const END_MARKER = '<!-- workflow:methodology:end -->';
19
23
  export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
20
24
 
25
+ // Count lines independent of a trailing newline (CRLF-safe: split on '\n' — a CRLF line still ends
26
+ // in '\n', so the count is the same as for LF).
27
+ const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
28
+
21
29
  const countOccurrences = (haystack, needle) => {
22
30
  let count = 0;
23
31
  let from = 0;
@@ -58,14 +66,15 @@ export const injectMethodology = (text, fragment, { maxLines } = {}) => {
58
66
  const slot = findSlot(text);
59
67
  if (slot.state === 'absent') return { status: 'noop-absent', text };
60
68
  if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
69
+ // Frame the fragment with the DOCUMENT's newline style (and convert the LF-canonical fragment to
70
+ // it) so injecting into a CRLF file does not leave lone LFs around the slot.
71
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
61
72
  const before = text.slice(0, slot.startIdx + START_MARKER.length);
62
73
  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
- }
74
+ const body = fragment.trim().replace(/\r?\n/g, nl);
75
+ const out = `${before}${nl}${body}${nl}${after}`;
76
+ if (maxLines != null && lineCount(out) > maxLines) {
77
+ return { status: 'error', text, error: `injection would push AGENTS.md to ${lineCount(out)} lines (cap ${maxLines}) — trim the fragment or the file` };
69
78
  }
70
79
  return { status: 'injected', text: out };
71
80
  };
@@ -78,19 +87,120 @@ export const extractSlot = (text) => {
78
87
  return text.slice(slot.startIdx + START_MARKER.length, slot.endIdx);
79
88
  };
80
89
 
90
+ // The Session-Protocols anchor line both deployed templates carry (the agent_rules.md §1 sentence).
91
+ // ensureSlot inserts an empty slot right after this line when a legacy entry point has no markers.
92
+ // Contract: EXACTLY ONE match required — 0 or >1 → error (never guess where the methodology lives).
93
+ export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
94
+
95
+ // The canonical empty slot (an ordered start→end pair, nothing between) — what a fresh template
96
+ // ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
97
+ export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
98
+
99
+ const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
100
+
101
+ // Ensure a single, well-formed methodology slot EXISTS — without filling it. Pure; no fs.
102
+ // { status: 'present', text } a well-formed slot already exists → bytes unchanged (idempotent).
103
+ // { status: 'inserted', text } absent + exactly one anchor → an EMPTY slot inserted right after
104
+ // the anchor line, newline style + all other bytes preserved.
105
+ // { status: 'error', text, error } malformed slot, OR (when absent) 0/>1 anchors → bytes unchanged.
106
+ export const ensureSlot = (text) => {
107
+ const slot = findSlot(text);
108
+ if (slot.state === 'ok') return { status: 'present', text };
109
+ if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
110
+ // absent → place an empty slot at the one anchor, or refuse rather than guess.
111
+ const anchors = countMatches(text, METHODOLOGY_ANCHOR);
112
+ if (anchors !== 1) {
113
+ return {
114
+ status: 'error',
115
+ text,
116
+ error: `expected exactly one methodology anchor (the "Read it before any code change." Session-Protocols line), found ${anchors} — refusing to guess where the slot belongs; add the slot markers manually`,
117
+ };
118
+ }
119
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
120
+ const match = text.match(METHODOLOGY_ANCHOR);
121
+ const eol = text.indexOf('\n', match.index);
122
+ const insertAt = eol === -1 ? text.length : eol + 1;
123
+ const block = `${nl}${EMPTY_SLOT.replace(/\n/g, nl)}${nl}`;
124
+ const out = `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
125
+ return { status: 'inserted', text: out };
126
+ };
127
+
128
+ // Bootstrap/upgrade reconciliation policy (pure): ensure the slot exists, then fill it ONLY IF it
129
+ // is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one
130
+ // step the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned
131
+ // unchanged (the intermediate slot-insert is discarded), so there is no partial on-disk state.
132
+ // reconciled-inserted — slot was absent, inserted at the anchor, then filled.
133
+ // reconciled-filled — slot existed but was empty, now filled.
134
+ // present-filled — slot already carried content → preserved verbatim.
135
+ // error — malformed slot, 0/>1 anchors, or cap exceeded → input unchanged.
136
+ export const reconcileSlot = (text, fragment, { maxLines } = {}) => {
137
+ const ensured = ensureSlot(text);
138
+ if (ensured.status === 'error') return { status: 'error', text, error: ensured.error };
139
+ const current = extractSlot(ensured.text);
140
+ const isEmpty = current == null || current.trim() === '';
141
+ if (!isEmpty) {
142
+ // Preserve a filled/customized slot verbatim — but still enforce the cap on the result, so an
143
+ // already-over-cap entry point is surfaced (input unchanged) rather than silently accepted.
144
+ if (maxLines != null && lineCount(ensured.text) > maxLines) {
145
+ return { status: 'error', text, error: `AGENTS.md is ${lineCount(ensured.text)} lines (cap ${maxLines}) — trim the file (a customized methodology slot must still fit the cap)` };
146
+ }
147
+ return { status: 'present-filled', text: ensured.text };
148
+ }
149
+ const injected = injectMethodology(ensured.text, fragment, { maxLines });
150
+ if (injected.status !== 'injected') return { status: 'error', text, error: injected.error };
151
+ const status = ensured.status === 'inserted' ? 'reconciled-inserted' : 'reconciled-filled';
152
+ return { status, text: injected.text };
153
+ };
154
+
81
155
  const main = async (argv) => {
82
- const { readFile, writeFile, rename } = await import('node:fs/promises');
156
+ const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
83
157
  const { dirname, basename, join, resolve } = await import('node:path');
84
158
  const { fileURLToPath } = await import('node:url');
85
159
  const here = dirname(fileURLToPath(import.meta.url));
86
- const agentsPath = argv[0];
160
+
161
+ // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade);
162
+ // `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-slot mode.
163
+ const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
164
+ const rest = mode === 'reconcile' ? argv.slice(1) : argv;
165
+ const agentsPath = rest[0];
87
166
  if (!agentsPath) {
88
- console.error('usage: inject-methodology.mjs <path/to/AGENTS.md> [fragment.md]');
167
+ console.error('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
89
168
  process.exit(2);
90
169
  }
91
- const fragmentPath = argv[1] ? resolve(argv[1]) : resolve(here, 'methodology-slot.md');
170
+ const fragmentPath = rest[1] ? resolve(rest[1]) : resolve(here, 'methodology-slot.md');
92
171
  const text = await readFile(resolve(agentsPath), 'utf8');
93
172
  const fragment = await readFile(fragmentPath, 'utf8');
173
+
174
+ const writeAtomic = async (out) => {
175
+ const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
176
+ try {
177
+ await writeFile(tmp, out, 'utf8');
178
+ await rename(tmp, resolve(agentsPath));
179
+ } catch (err) {
180
+ await rm(tmp, { force: true }).catch(() => {}); // never leave a temp file behind on failure
181
+ throw err;
182
+ }
183
+ };
184
+
185
+ if (mode === 'reconcile') {
186
+ const result = reconcileSlot(text, fragment, { maxLines: AGENTS_MD_CAP });
187
+ if (result.status === 'error') {
188
+ console.error(`[inject-methodology] reconcile refused — ${result.error}`);
189
+ process.exit(1);
190
+ }
191
+ if (result.status === 'present-filled') {
192
+ console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
193
+ return;
194
+ }
195
+ await writeAtomic(result.text);
196
+ const what =
197
+ result.status === 'reconciled-inserted'
198
+ ? 'inserted the methodology slot at the Session-Protocols anchor and filled it'
199
+ : 'filled the empty methodology slot';
200
+ console.log(`[inject-methodology] reconcile: ${what}.`);
201
+ return;
202
+ }
203
+
94
204
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
95
205
  if (result.status === 'error') {
96
206
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
@@ -100,9 +210,7 @@ const main = async (argv) => {
100
210
  console.log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
101
211
  return;
102
212
  }
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));
213
+ await writeAtomic(result.text);
106
214
  console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
107
215
  };
108
216
 
@@ -1,22 +1,56 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
6
8
  import {
7
9
  injectMethodology,
8
10
  findSlot,
9
11
  extractSlot,
12
+ ensureSlot,
13
+ reconcileSlot,
14
+ METHODOLOGY_ANCHOR,
15
+ EMPTY_SLOT,
16
+ AGENTS_MD_CAP,
10
17
  START_MARKER,
11
18
  END_MARKER,
12
19
  } from './inject-methodology.mjs';
13
20
 
14
21
  const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const SCRIPT = join(HERE, 'inject-methodology.mjs');
15
23
  const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
16
24
 
17
25
  const wrap = (inner) =>
18
26
  `# 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
27
 
28
+ // The exact Session-Protocols line both deployed templates carry — the slot anchor.
29
+ const ANCHOR_LINE =
30
+ 'Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**';
31
+
32
+ // A pre-slot (markerless) entry point that still carries the Session-Protocols anchor line —
33
+ // the realistic shape of a legacy deployment the upgrade reconciliation must add a slot to.
34
+ const legacyWithAnchor = (nl = '\n') =>
35
+ [
36
+ '# AGENTS.md',
37
+ '',
38
+ 'prefix bytes',
39
+ '',
40
+ '## 🚀 Session Protocols',
41
+ '',
42
+ ANCHOR_LINE,
43
+ '',
44
+ '---',
45
+ '',
46
+ '## 🚫 Hard Constraints',
47
+ '',
48
+ 'suffix bytes',
49
+ '',
50
+ ].join(nl);
51
+
52
+ const countMatches = (text, re) => (text.match(new RegExp(re.source, 'gm')) || []).length;
53
+
20
54
  describe('findSlot — marker classification', () => {
21
55
  it('one ordered pair → ok', () => {
22
56
  assert.equal(findSlot(wrap('\n')).state, 'ok');
@@ -122,3 +156,200 @@ describe('post-injection cap — AGENTS.md stays under its line budget', () => {
122
156
  assert.ok(lines <= 100, `AGENTS.md would be ${lines} lines after injection (cap 100)`);
123
157
  });
124
158
  });
159
+
160
+ describe('METHODOLOGY_ANCHOR — locating the Session-Protocols slot position', () => {
161
+ it('matches exactly one line in a deployed-style markerless entry point', () => {
162
+ assert.equal(countMatches(legacyWithAnchor(), METHODOLOGY_ANCHOR), 1);
163
+ });
164
+ it('matches exactly one line in BOTH shipped templates', () => {
165
+ const memTmpl = readFileSync(
166
+ join(HERE, '..', '..', 'agent-workflow-memory', 'references', 'templates', 'AGENTS.md'),
167
+ 'utf8',
168
+ );
169
+ const kitTmpl = readFileSync(join(HERE, '..', 'references', 'templates', 'AGENTS.md'), 'utf8');
170
+ assert.equal(countMatches(memTmpl, METHODOLOGY_ANCHOR), 1, 'memory template has exactly one anchor');
171
+ assert.equal(countMatches(kitTmpl, METHODOLOGY_ANCHOR), 1, 'kit fallback template has exactly one anchor');
172
+ });
173
+ it('does not match an entry point that lacks the Session-Protocols line', () => {
174
+ assert.equal(countMatches('# AGENTS.md\nno session protocols here\n', METHODOLOGY_ANCHOR), 0);
175
+ });
176
+ });
177
+
178
+ describe('EMPTY_SLOT — the canonical empty marker pair', () => {
179
+ it('is exactly the start+end markers joined by a newline', () => {
180
+ assert.equal(EMPTY_SLOT, `${START_MARKER}\n${END_MARKER}`);
181
+ assert.equal(findSlot(EMPTY_SLOT).state, 'ok');
182
+ assert.equal(extractSlot(EMPTY_SLOT).trim(), '');
183
+ });
184
+ });
185
+
186
+ describe('ensureSlot — idempotent slot presence (insert at the anchor only when absent)', () => {
187
+ it('present (one ok pair) → status present, bytes unchanged', () => {
188
+ const input = wrap('\nfilled\n');
189
+ const out = ensureSlot(input);
190
+ assert.equal(out.status, 'present');
191
+ assert.equal(out.text, input);
192
+ });
193
+
194
+ it('malformed slot → status error, never edits', () => {
195
+ const input = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
196
+ const out = ensureSlot(input);
197
+ assert.equal(out.status, 'error');
198
+ assert.equal(out.text, input);
199
+ });
200
+
201
+ it('absent + exactly one anchor → inserts EMPTY_SLOT after the anchor line, preserving bytes', () => {
202
+ const input = legacyWithAnchor();
203
+ const out = ensureSlot(input);
204
+ assert.equal(out.status, 'inserted');
205
+ assert.equal(findSlot(out.text).state, 'ok', 'a well-formed slot now exists');
206
+ assert.equal(extractSlot(out.text).trim(), '', 'the inserted slot is empty');
207
+ // the slot lands right after the anchor line
208
+ assert.match(out.text, new RegExp(`Read it before any code change\\.\\*\\*\\n+${START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
209
+ // every original line survives
210
+ for (const line of ['# AGENTS.md', 'prefix bytes', ANCHOR_LINE, '## 🚫 Hard Constraints', 'suffix bytes']) {
211
+ assert.ok(out.text.includes(line), `original line preserved: ${line}`);
212
+ }
213
+ });
214
+
215
+ it('absent + zero anchor → status error with an actionable message, never edits', () => {
216
+ const input = '# AGENTS.md\n\nno anchor at all\n';
217
+ const out = ensureSlot(input);
218
+ assert.equal(out.status, 'error');
219
+ assert.equal(out.text, input);
220
+ assert.match(out.error, /anchor/i);
221
+ });
222
+
223
+ it('absent + multiple anchors → status error (refuses to guess), never edits', () => {
224
+ const input = `${ANCHOR_LINE}\n\nsome text\n\n${ANCHOR_LINE}\n`;
225
+ const out = ensureSlot(input);
226
+ assert.equal(out.status, 'error');
227
+ assert.equal(out.text, input);
228
+ });
229
+
230
+ it('preserves CRLF newline style when inserting', () => {
231
+ const input = legacyWithAnchor('\r\n');
232
+ const out = ensureSlot(input);
233
+ assert.equal(out.status, 'inserted');
234
+ assert.equal(findSlot(out.text).state, 'ok');
235
+ assert.ok(out.text.includes(`${START_MARKER}\r\n${END_MARKER}`), 'markers use CRLF');
236
+ assert.ok(!/[^\r]\n/.test(out.text), 'no lone LF introduced into a CRLF document');
237
+ });
238
+
239
+ it('is idempotent — a second ensureSlot finds the slot present and changes nothing', () => {
240
+ const once = ensureSlot(legacyWithAnchor()).text;
241
+ const twice = ensureSlot(once);
242
+ assert.equal(twice.status, 'present');
243
+ assert.equal(twice.text, once);
244
+ });
245
+ });
246
+
247
+ describe('reconcileSlot — ensure + inject-if-empty + cap, as one atomic policy', () => {
248
+ it('markerless legacy (with anchor) → reconciled-inserted, slot filled, outside bytes preserved', () => {
249
+ const input = legacyWithAnchor();
250
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
251
+ assert.equal(out.status, 'reconciled-inserted');
252
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim(), 'slot now carries the fragment');
253
+ assert.ok(out.text.includes(ANCHOR_LINE), 'anchor preserved');
254
+ assert.ok(out.text.includes('## 🚫 Hard Constraints'), 'suffix preserved');
255
+ });
256
+
257
+ it('present empty slot → reconciled-filled', () => {
258
+ const out = reconcileSlot(wrap('\n'), FRAGMENT, { maxLines: AGENTS_MD_CAP });
259
+ assert.equal(out.status, 'reconciled-filled');
260
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim());
261
+ });
262
+
263
+ it('present customized/filled slot → present-filled, preserved verbatim (byte-for-byte)', () => {
264
+ const custom = wrap('\nuser-authored methodology notes\nsecond line\n');
265
+ const out = reconcileSlot(custom, FRAGMENT, { maxLines: AGENTS_MD_CAP });
266
+ assert.equal(out.status, 'present-filled');
267
+ assert.equal(out.text, custom, 'a filled slot is never overwritten');
268
+ });
269
+
270
+ it('malformed slot → error, input returned byte-for-byte', () => {
271
+ const input = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
272
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
273
+ assert.equal(out.status, 'error');
274
+ assert.equal(out.text, input);
275
+ });
276
+
277
+ it('markerless with no anchor → error, input unchanged (never guesses placement)', () => {
278
+ const input = '# AGENTS.md\n\nno slot, no anchor\n';
279
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
280
+ assert.equal(out.status, 'error');
281
+ assert.equal(out.text, input);
282
+ });
283
+
284
+ it('over-cap result → error, input unchanged (atomic: discards the intermediate slot insert)', () => {
285
+ const input = legacyWithAnchor();
286
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: 5 });
287
+ assert.equal(out.status, 'error');
288
+ assert.equal(out.text, input);
289
+ });
290
+
291
+ it('produces a clean CRLF document when inserting + filling (no lone LF)', () => {
292
+ // ensureSlot preserves the document newline style for the markers, and injectMethodology frames
293
+ // the LF-canonical fragment with the document's EOL — so a CRLF document stays uniformly CRLF.
294
+ const input = legacyWithAnchor('\r\n');
295
+ const out = reconcileSlot(input, FRAGMENT, { maxLines: AGENTS_MD_CAP });
296
+ assert.equal(out.status, 'reconciled-inserted');
297
+ assert.equal(extractSlot(out.text).trim(), FRAGMENT.trim());
298
+ assert.ok(out.text.includes(`${ANCHOR_LINE}\r\n`), 'anchor line keeps CRLF');
299
+ assert.ok(!/[^\r]\n/.test(out.text), 'no lone LF introduced into a CRLF document');
300
+ });
301
+
302
+ it('present filled slot over the line cap → error, input returned unchanged', () => {
303
+ const bigSlot = `\n${Array.from({ length: 30 }, (_, i) => `note ${i}`).join('\n')}\n`;
304
+ const filled = wrap(bigSlot);
305
+ const out = reconcileSlot(filled, FRAGMENT, { maxLines: 10 });
306
+ assert.equal(out.status, 'error');
307
+ assert.equal(out.text, filled, 'an over-cap entry point is surfaced, not silently accepted');
308
+ assert.match(out.error, /cap 10/);
309
+ });
310
+ });
311
+
312
+ describe('reconcile CLI — atomic ensure+inject-if-empty+cap on the real filesystem', () => {
313
+ const withTempAgents = (contents, run) => {
314
+ const dir = mkdtempSync(join(tmpdir(), 'reconcile-cli-'));
315
+ const agents = join(dir, 'AGENTS.md');
316
+ writeFileSync(agents, contents);
317
+ try {
318
+ return run(agents);
319
+ } finally {
320
+ rmSync(dir, { recursive: true, force: true });
321
+ }
322
+ };
323
+
324
+ it('markerless legacy (with anchor) → slot inserted and filled (exit 0)', () => {
325
+ withTempAgents(legacyWithAnchor(), (agents) => {
326
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
327
+ const out = readFileSync(agents, 'utf8');
328
+ assert.equal(findSlot(out).state, 'ok');
329
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
330
+ });
331
+ });
332
+
333
+ it('present empty slot → slot filled (exit 0)', () => {
334
+ withTempAgents(wrap('\n'), (agents) => {
335
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
336
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
337
+ });
338
+ });
339
+
340
+ it('filled/customized slot → file left byte-for-byte untouched', () => {
341
+ const custom = wrap('\nuser notes\n');
342
+ withTempAgents(custom, (agents) => {
343
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
344
+ assert.equal(readFileSync(agents, 'utf8'), custom);
345
+ });
346
+ });
347
+
348
+ it('malformed slot → STOP with non-zero exit, file byte-unchanged', () => {
349
+ const malformed = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
350
+ withTempAgents(malformed, (agents) => {
351
+ assert.throws(() => execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' }));
352
+ assert.equal(readFileSync(agents, 'utf8'), malformed);
353
+ });
354
+ });
355
+ });