@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 +36 -0
- package/SKILL.md +27 -20
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -1
- package/tools/delegation.mjs +4 -4
- package/tools/delegation.test.mjs +4 -3
- package/tools/inject-methodology.mjs +131 -23
- package/tools/inject-methodology.test.mjs +232 -1
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.
|
|
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
|
|
53
|
-
methodology slot** (below) and writes the kit-fallback
|
|
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
|
|
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
|
|
60
|
-
exists,
|
|
61
|
-
`node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs <project>/AGENTS.md`.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 (
|
|
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
|
|
126
|
-
3.
|
|
127
|
-
4.
|
|
128
|
-
5.
|
|
129
|
-
6.
|
|
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
|
|
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
package/migrations/README.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
56
|
+
<!-- workflow:methodology:start -->
|
|
57
|
+
<!-- workflow:methodology:end -->
|
|
57
58
|
|
|
58
59
|
---
|
|
59
60
|
|
package/tools/delegation.mjs
CHANGED
|
@@ -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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology
|
|
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
|
|
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('
|
|
112
|
-
'fallback kitWrites should describe
|
|
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
|
|
2
|
+
// Methodology slot injection + reconciliation — the composition root's only mutation of a
|
|
3
|
+
// deployed AGENTS.md.
|
|
3
4
|
//
|
|
4
|
-
// memory
|
|
5
|
-
// family) fills it. The
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// -
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// Prefix/suffix
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
});
|