@sabaiway/agent-workflow-kit 1.32.0 → 1.33.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,45 @@ 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.33.0 — The agent-rules lens region: render + reconcile from the engine canon (AD-041)
8
+
9
+ A **feature** release (new shipped tool + wiring; deployment-lineage head stays `1.3.0` — the
10
+ refresh is stamp-independent, no migration). The deployed `docs/ai/agent_rules.md` lens section
11
+ is now a RENDER of the engine's canonical fragment, kept current by the kit:
12
+
13
+ - **New `tools/lens-region.mjs`** — the lens reconcile: heading-anchored region (no markers; a
14
+ renamed heading is a natural preserve+advise), render with the file's OWN section number,
15
+ refresh IFF the body matches the engine fragment or a known-prior body (fragment + prior store
16
+ read LIVE from the installed engine — no kit-side prior constants), a customized region
17
+ preserved verbatim + a one-line advisory, cap-guard from the target's frontmatter `maxLines`
18
+ (loud non-fatal refusal; no frontmatter → stated skip), atomic write, document EOL preserved.
19
+ Lazy + fail-loud: an absent/invalid engine is a STOP with the install command; a valid engine
20
+ older than 1.13.0 (no lens pair) is a stated soft skip. CLI:
21
+ `node tools/lens-region.mjs reconcile <path/to/agent_rules.md>`; invariants pinned by
22
+ `tools/lens-region.test.mjs` incl. the canon-change simulation (v1 deploy + v2 engine →
23
+ refreshed; re-run → zero-diff).
24
+ - **`Mode: upgrade` step 3** gains the SEVENTH stamp-independent reconcile (plain-language
25
+ outcomes: refreshed / already current / custom preserved + note / file absent / engine too
26
+ old / over the line cap), reported in both the step-4 and step-8 exit reports; the stale
27
+ "other three reconciles" phrasing went count-free.
28
+ `references/shared/composition-handoff.md` runs the same reconcile in BOTH bootstrap paths
29
+ (its own precondition: after `docs/ai/agent_rules.md` exists) — this is what converges a
30
+ stale-memory seed; `handoffPlan` names the lens region in `kitWrites` for both paths.
31
+ - **`family-registry`**: a distinct plain-language `status` caveat for an engine that does not
32
+ ship the lens canon — keyed on the PAIR (fragment + prior store), so a half-shipped engine
33
+ never reports healthy.
34
+ - **`test/lens-mirror.test.mjs` REWRITTEN** to render-parity vs the known-canonical set (each
35
+ template lens block byte-equals a render of the engine fragment or a prior-store entry; the
36
+ checkout additionally pins the CURRENT render; injected non-vacuity). The 22-token × 4-file
37
+ vocabulary mesh is deleted — token presence now lives in the ENGINE's own lens-fragment guard,
38
+ so a future lens wording change is an engine-only release (no forced kit/memory diffs).
39
+ - `references/templates/agent_rules.md` §2.5 intro carries the provenance clause (the render);
40
+ `tools/engine-source.mjs` exports `LENS_FRAGMENT_REL` + `LENS_PRIORS_REL`. Tarball 115 → 116
41
+ (`lens-region.mjs` reverse-pinned). AD-039 byte budgets hold unchanged.
42
+ - Repo-local release harness: `scripts/release/smoke-init.mjs` gains repeatable
43
+ `--expect-file <sandbox-HOME-relative path>=<substring>` (installed-file content assertions;
44
+ both path dialects fenced to the sandbox HOME).
45
+
7
46
  ## 1.32.0 — Approval-idle reduction: the opt-in `velocity --kit-tools` tier, an audited core extension, and the standing-consent advisory (AD-040)
8
47
 
9
48
  A **feature** release; packaging-only for deployments (lineage stays `1.3.0` — no `docs/ai`
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.32.0'
6
+ version: '1.33.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -75,7 +75,7 @@ Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide
75
75
 
76
76
  - **absent** → bootstrap (a fresh deployment).
77
77
  - **stamp < `1.3.0`** (the deployment-lineage head) → `upgrade`.
78
- - **stamp == `1.3.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3 (pointer slots · hidden-mode footprint · both `.json` config ensures · the enforcement-script ensure · the placed-bridge refresh); run step 3 rather than enumerating from memory.
78
+ - **stamp == `1.3.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3 (pointer slots · hidden-mode footprint · both `.json` config ensures · the enforcement-script ensure · the placed-bridge refresh · the agent-rules lens refresh); run step 3 rather than enumerating from memory.
79
79
  - **stamp > head / unparseable** → STOP — never-downgrade gate (see `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 2).
80
80
 
81
81
  **Two independent version axes — never conflate them:**
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.32.0",
6
+ "version": "1.33.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.32.0",
3
+ "version": "1.33.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",
@@ -32,15 +32,25 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
32
32
  bundled copies and re-links its wrappers; an **absent** bridge is a stated skip, **never a first
33
33
  placement** (placement stays the opt-in `${CLAUDE_SKILL_DIR}/references/modes/setup.md` — AD-009/AD-011 honesty intact), and a
34
34
  placed bridge **newer** than the bundled copy is a stated skip naming the kit update (**never a
35
- downgrade**). Like the other three reconciles it runs on **every** upgrade — including an
36
- equal-head one — with no lineage-head bump. A *could not refresh* line is non-fatal: relay it
37
- plainly with its recovery and continue the upgrade.
38
- 4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the methodology-slot **and** hidden-mode footprint reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
39
- - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
35
+ downgrade**). Like the other stamp-independent reconciles it runs on **every** upgrade —
36
+ including an equal-head one — with no lineage-head bump. A *could not refresh* line is
37
+ non-fatal: relay it plainly with its recovery and continue the upgrade.
38
+
39
+ **Agent-rules lens refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.**
40
+ Run `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md`
41
+ and relay its outcome in plain language: the planning/review lens section was **refreshed** to
42
+ the current canon · was **already current** · carries a **custom edit — preserved verbatim** (+
43
+ its one-line note) · the **file is absent — skipped** · the **engine is too old — skipped**
44
+ (refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run) · **over the
45
+ file's line cap — refused** (trim the file, re-run). The section is found by its heading — no
46
+ markers; a renamed heading is preserved + noted. A fully absent/invalid engine is the same hard
47
+ STOP as (c). Exit 0 covers every soft outcome; only the STOP is non-zero.
48
+ 4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
49
+ - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
40
50
  - **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
41
51
  - **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity`).
42
52
  - **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
43
53
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
44
54
  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.
45
55
  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`.
46
- 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
56
+ 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) and the step-3 **agent-rules lens** outcome (same outcome set as step 4) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
@@ -37,3 +37,15 @@ ordered `start → end` pair; a malformed slot (single, reversed, nested, duplic
37
37
  duplicate anchor → **STOP with an error**, never edit (the file is left byte-for-byte unchanged). The
38
38
  **orchestration pointer is soft-skipped** (reported, never silent) when — and only when — adding it
39
39
  would exceed the 100-line cap: the methodology pointer still lands and the upgrade continues.
40
+
41
+ **Agent-rules lens refresh (runs in BOTH paths).** Its precondition is its own target file — run it
42
+ only after `<project>/docs/ai/agent_rules.md` exists: after the substrate deploy in the
43
+ **delegated** path, after the fallback-template copy in the **kit** path (never anchored to the
44
+ `AGENTS.md` step above). ONE command:
45
+ `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md`.
46
+ This is what converges a substrate seeded by an older memory — a lens section matching a known
47
+ prior canonical body is refreshed from the installed engine; a current seed reports *already
48
+ current* (zero-diff). Relay its outcome in plain language (*refreshed* / *already current* /
49
+ *custom edit preserved + note* / *file absent — skipped* / *engine too old — skipped* / *over the
50
+ line cap — refused*); a fully absent/invalid engine is the same loud STOP + one-line install
51
+ command as the pointer reconcile.
@@ -63,7 +63,7 @@ Before proposing changes or committing, review against:
63
63
  - Always run type-checker, linter, and all tests before committing.
64
64
 
65
65
  ### 2.5. Planning, review & process-fidelity invariants
66
- Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon.)
66
+ Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
67
67
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
68
68
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
69
69
  - **No code-mechanics in the plan.** A Step still carries its exact paths + commands (the plan-structure / self-review canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
@@ -83,7 +83,9 @@ export const handoffPlan = (delegate) =>
83
83
  ? {
84
84
  mode: 'delegate',
85
85
  memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
86
- kitWrites: ['AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
86
+ // The lens region runs AFTER the substrate deploy (its own precondition: the file exists)
87
+ // — it converges a stale-memory seed to the installed engine's canon (AD-041).
88
+ kitWrites: ['AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
87
89
  stampsPresent: ['.memory-version', '.workflow-version'],
88
90
  memoryRaisesCommitGate: false,
89
91
  commitGate: 'kit-only-after-injection',
@@ -93,8 +95,9 @@ export const handoffPlan = (delegate) =>
93
95
  memoryWrites: [],
94
96
  // Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
95
97
  // the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
96
- // path — so both paths end with a FILLED slot, not inline methodology.
97
- kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
98
+ // path — so both paths end with a FILLED slot, not inline methodology. The lens region
99
+ // runs after the fallback-template copy of docs/ai (same reconcile, both paths AD-041).
100
+ kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
98
101
  stampsPresent: ['.workflow-version'],
99
102
  memoryRaisesCommitGate: false,
100
103
  commitGate: 'kit-only-after-injection',
@@ -31,6 +31,12 @@ export const ORCHESTRATION_FRAGMENT_REL = 'references/orchestration-slot.md';
31
31
  // CLI fail loudly on — an engine too old to ship it. readEngineFragment accepts an arbitrary `rel`
32
32
  // (no whitelist), so no further plumbing is needed beyond this constant.
33
33
  export const PROCEDURES_FRAGMENT_REL = 'references/procedures.md';
34
+ // The agent-rules lens pair — the canonical lens block + its append-only prior store (engine
35
+ // >= 1.13.0; an older engine ships neither). The kit's lens-region reconcile live-reads BOTH via
36
+ // readEngineFragment({ rel }); detectEngine({ rel }) lets it soft-skip a too-old engine (and
37
+ // `status` caveat it) instead of failing the whole reconcile.
38
+ export const LENS_FRAGMENT_REL = 'references/agent-rules-lens.md';
39
+ export const LENS_PRIORS_REL = 'references/agent-rules-lens-priors.md';
34
40
  const ENGINE_DEFAULT_REL = '.claude/skills/agent-workflow-engine';
35
41
 
36
42
  const defaultStatType = (path) => {
@@ -26,7 +26,7 @@ import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
26
26
  import { parseSemver, compareSemver } from './semver-lite.mjs';
27
27
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
28
28
  import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
29
- import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
29
+ import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
30
30
  import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
31
31
  // The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
32
32
  // settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
@@ -171,9 +171,13 @@ export const classifyMember = (member, deps = {}) => {
171
171
  // the manifest AND reads the fragment — so an absent, non-file, OR present-but-unreadable fragment all
172
172
  // surface (status never claims "ok" for a fragment a reconcile / the procedures CLI would STOP on), and
173
173
  // a current, readable fragment never gets the caveat. Read-only, best-effort.
174
+ // Each caveat names the rel(s) its consumer actually needs: the lens reconcile requires the
175
+ // fragment AND its prior store as a PAIR (lens-region soft-skips when either is missing), so its
176
+ // caveat keys on both — an engine missing only the prior store must never report healthy.
174
177
  const ENGINE_FRAGMENT_CAVEATS = [
175
- { rel: ORCHESTRATION_FRAGMENT_REL, caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
176
- { rel: PROCEDURES_FRAGMENT_REL, caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
178
+ { rels: [ORCHESTRATION_FRAGMENT_REL], caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
179
+ { rels: [PROCEDURES_FRAGMENT_REL], caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
180
+ { rels: [LENS_FRAGMENT_REL, LENS_PRIORS_REL], caveat: 'engine present but does not ship the agent-rules lens canon (the fragment + its prior store; too old / incomplete — engine >= 1.13.0) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
177
181
  ];
178
182
 
179
183
  // The orchestration-config TEMPLATE a current memory ships (added in memory 1.2.0; absent in older
@@ -244,7 +248,7 @@ export const surveyFamily = (deps = {}) =>
244
248
  return false; // absent / non-file / unreadable fragment → the engine can't supply it
245
249
  }
246
250
  };
247
- const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !fragmentUsable(f.rel)).map((f) => f.caveat);
251
+ const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !f.rels.every(fragmentUsable)).map((f) => f.caveat);
248
252
  if (caveats.length) row.caveats = caveats;
249
253
  }
250
254
  // Memory offline caveat (Step 2.2): a distinct probe — the orchestration TEMPLATE file's existence.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ // Heading-anchored lens-region reconcile — the composition root's only mutation of a deployed
3
+ // `docs/ai/agent_rules.md`. The planning/review/process-fidelity lens block (`### 2.x. Planning,
4
+ // review & process-fidelity invariants`) has ONE canonical home — the installed engine's
5
+ // `references/agent-rules-lens.md` — and every deployed/template copy is a RENDER of it (the
6
+ // file's own section number substituted into the number-neutral heading). This tool refreshes a
7
+ // deployed region to the current canon under the AD-025 discipline: refresh IFF the region's
8
+ // normalized body matches the current fragment (already current → zero-diff) or a KNOWN PRIOR
9
+ // canonical body (the engine's append-only `references/agent-rules-lens-priors.md`, read live
10
+ // beside the fragment — no kit-side prior constants, so a canon wording change is an engine-only
11
+ // release). Anything else is preserved byte-for-byte + a one-line advisory.
12
+ //
13
+ // The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
14
+ // through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
15
+ // lens-mirror guard pioneered, promoted here as the shipped implementation. A user-renamed
16
+ // heading is therefore a natural preserve+advise; bytes outside the region are never touched.
17
+ //
18
+ // The live read is lazy + fail-loud: the engine is consulted only when a present region must be
19
+ // classified; a fully absent/invalid engine is a loud STOP (never a silent fallback), a valid
20
+ // engine that merely predates the lens pair (<1.13.0) is a stated soft skip. Cap-guard: a refresh
21
+ // that would push the file over its own frontmatter `maxLines` is a loud, non-fatal refusal —
22
+ // never a silent truncate; a file without frontmatter/`maxLines` skips the guard with a stated
23
+ // note (such a file is outside the docs cap gate anyway).
24
+ //
25
+ // Pure string functions (fs only in the CLI); dependency-free, Node >= 18.
26
+
27
+ import { normalizeCanonical } from './orchestration-config.mjs';
28
+
29
+ // The deployed-heading matcher (prefix, like the historical extractLensBlock: robust to a future
30
+ // heading-tail tweak) and the number-neutral form the engine fragment carries.
31
+ export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
32
+ const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
33
+ const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
34
+
35
+ const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
36
+ const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
37
+
38
+ // Count lines independent of a trailing newline (the inject-methodology contract).
39
+ const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
40
+
41
+ // ── the frozen prior-store format (documented in the store file's own header) ─────
42
+ // A delimiter is a line that starts with `<!-- prior` and ends with `-->`; an entry body is
43
+ // everything after it up to the next delimiter / EOF, trimmed. The pre-delimiter header is
44
+ // ignored. APPEND-ONLY on the engine side — this parser must keep reading a newer engine's file.
45
+ export const parseLensPriors = (text) => {
46
+ const entries = [];
47
+ let current = null;
48
+ for (const line of String(text).split('\n')) {
49
+ const bare = stripCr(line);
50
+ if (bare.startsWith('<!-- prior') && bare.endsWith('-->')) {
51
+ if (current) entries.push(current.join('\n'));
52
+ current = [];
53
+ } else if (current) current.push(line);
54
+ }
55
+ if (current) entries.push(current.join('\n'));
56
+ return entries.map((e) => normalizeCanonical(e)).filter((e) => e !== '');
57
+ };
58
+
59
+ // renderLens(fragment, number) → the number-neutral canonical block bound to the file's OWN
60
+ // section number (memory-seeded files say 2.6, kit-fallback files 2.5 — the fragment never
61
+ // hardcodes one). LF-canonical; the CLI converts to the document's EOL style on write.
62
+ export const renderLens = (fragment, number) =>
63
+ normalizeCanonical(fragment).replace(NEUTRAL_HEADING_RE, (m) => m.replace('2.x', `2.${number}`));
64
+
65
+ // normalizeLensBody(body) → the number-neutral, whitespace/EOL-normalized comparison form
66
+ // (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
67
+ export const normalizeLensBody = (body) =>
68
+ normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
69
+
70
+ // extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
71
+ // `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
72
+ // next structural boundary; EOF is a valid region end (no following boundary required). `body`
73
+ // is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
74
+ // region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
75
+ export const extractLensRegion = (text) => {
76
+ const lines = String(text).split('\n');
77
+ const start = lines.findIndex((line) => LENS_HEADING_RE.test(stripCr(line)));
78
+ if (start === -1) return { found: false };
79
+ let end = lines.length;
80
+ for (let i = start + 1; i < lines.length; i += 1) {
81
+ if (isBoundary(stripCr(lines[i]))) {
82
+ end = i;
83
+ break;
84
+ }
85
+ }
86
+ const number = stripCr(lines[start]).match(LENS_HEADING_RE)[1];
87
+ const regionLines = lines.slice(start, end);
88
+ let bodyEnd = regionLines.length;
89
+ while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
90
+ const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
91
+ return { found: true, start, end, number, body };
92
+ };
93
+
94
+ // replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
95
+ // lines replaced; trailing blank lines inside the region and every byte outside it are
96
+ // preserved verbatim. Output keeps the document's EOL style (CRLF documents stay CRLF).
97
+ export const replaceLensRegion = (text, region, renderedBody) => {
98
+ const lines = String(text).split('\n');
99
+ const crlf = String(text).includes('\r\n');
100
+ const regionLines = lines.slice(region.start, region.end);
101
+ let bodyEnd = regionLines.length;
102
+ while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
103
+ const trailing = regionLines.slice(bodyEnd); // preserved verbatim (their own CR bytes intact)
104
+ const newBody = renderedBody.split('\n').map((l) => (crlf ? `${l}\r` : l));
105
+ const out = [...lines.slice(0, region.start), ...newBody, ...trailing, ...lines.slice(region.end)];
106
+ // In a CRLF document, only a line FOLLOWED by another line carries the CR byte; when the new
107
+ // body's last line is the final line of the whole document (EOF region, no trailing newline),
108
+ // strip the CR we just added so no stray byte lands at EOF.
109
+ if (crlf && trailing.length === 0 && region.end === lines.length) {
110
+ out[out.length - 1] = stripCr(out[out.length - 1]);
111
+ }
112
+ return out.join('\n');
113
+ };
114
+
115
+ // reconcileLensText(text, fragment, priors) — the PURE policy decision:
116
+ // { status: 'no-region' } — heading absent/renamed → preserve + advise (caller words it).
117
+ // { status: 'current' } — the region already renders the current canon → zero-diff.
118
+ // { status: 'refreshed', text } — the region matched the canon or a known prior → re-rendered
119
+ // with the file's OWN number (cap-guard is the caller's, so the
120
+ // decision stays pure).
121
+ // { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
122
+ export const reconcileLensText = (text, fragment, priors) => {
123
+ const region = extractLensRegion(text);
124
+ if (!region.found) return { status: 'no-region', text };
125
+ const current = normalizeLensBody(region.body);
126
+ const canon = normalizeLensBody(fragment);
127
+ if (current === canon) return { status: 'current', text };
128
+ const known = priors.map((p) => normalizeLensBody(p));
129
+ if (!known.includes(current)) return { status: 'custom', text };
130
+ return { status: 'refreshed', text: replaceLensRegion(text, region, renderLens(fragment, region.number)) };
131
+ };
132
+
133
+ // frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
134
+ // file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
135
+ // with a stated note, never a throw — such a file is outside the docs cap gate anyway).
136
+ export const frontmatterMaxLines = (text) => {
137
+ const lines = String(text).split('\n').map(stripCr);
138
+ if (lines[0] !== '---') return null;
139
+ for (let i = 1; i < lines.length; i += 1) {
140
+ if (lines[i] === '---') return null;
141
+ const m = lines[i].match(/^maxLines:\s*(\d+)\s*$/);
142
+ if (m) return Number(m[1]);
143
+ }
144
+ return null;
145
+ };
146
+
147
+ // ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
148
+ // Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
149
+ // every classified outcome (including the soft skips and the cap refusal), exit 1 ONLY on the
150
+ // hard engine STOP or an unexpected fs error, exit 2 on usage.
151
+ export const runCli = async (argv, deps = {}) => {
152
+ const log = deps.log ?? console.log;
153
+ const logError = deps.logError ?? console.error;
154
+ const env = deps.env ?? process.env;
155
+ const fs = deps.fs ?? (await import('node:fs/promises'));
156
+ const { dirname, basename, join, resolve } = await import('node:path');
157
+ const { homedir } = await import('node:os');
158
+ const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
159
+
160
+ if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
161
+ logError('usage: lens-region.mjs reconcile <path/to/agent_rules.md>');
162
+ return 2;
163
+ }
164
+ const targetPath = resolve(argv[1]);
165
+
166
+ // 1. Absent file → a stated skip (the memory substrate owns seeding; nothing to reconcile).
167
+ const text = await (async () => {
168
+ try {
169
+ return await fs.readFile(targetPath, 'utf8');
170
+ } catch (err) {
171
+ if (err.code === 'ENOENT') return null;
172
+ throw err;
173
+ }
174
+ })();
175
+ if (text === null) {
176
+ log(`[lens-region] ${argv[1]} is absent — skipped (nothing to reconcile; the substrate seeds it at bootstrap).`);
177
+ return 0;
178
+ }
179
+
180
+ // 2. No matching heading → preserve + advise, engine never consulted (the outcome is preserve
181
+ // regardless, so the lazy contract holds).
182
+ if (!extractLensRegion(text).found) {
183
+ log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
184
+ log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
185
+ return 0;
186
+ }
187
+
188
+ // 3. A present region must be classified → live-read the engine's fragment + prior store.
189
+ // Fully absent/invalid engine → loud STOP; valid-but-pre-lens engine → stated soft skip.
190
+ const { dir, source } = resolveEngineDir({ env, home: deps.home ?? homedir() });
191
+ const lensPairPresent =
192
+ detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
193
+ if (!lensPairPresent) {
194
+ if (detectEngine(dir, { source }).ok) {
195
+ log('[lens-region] skipped — the installed engine is too old (or incomplete) to supply the lens canon; refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run.');
196
+ return 0;
197
+ }
198
+ try {
199
+ readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
200
+ return 1; // defensive: the pair is unusable — never proceed to a read
201
+ } catch (err) {
202
+ logError(`[lens-region] reconcile STOP — ${err.message}`);
203
+ return 1;
204
+ }
205
+ }
206
+ // TOCTOU guard: a fragment that vanishes between detect and read is a corruption STOP, never a
207
+ // silent proceed — readEngineFragment's own throw carries the install command.
208
+ let fragment;
209
+ let priors;
210
+ try {
211
+ fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
212
+ priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL }));
213
+ } catch (err) {
214
+ logError(`[lens-region] reconcile STOP — ${err.message}`);
215
+ return 1;
216
+ }
217
+
218
+ // 4. The pure decision + the cap-guard + one atomic write.
219
+ const result = reconcileLensText(text, fragment, priors);
220
+ if (result.status === 'current') {
221
+ log('[lens-region] lens section already current — nothing to do (zero-diff).');
222
+ return 0;
223
+ }
224
+ if (result.status === 'custom') {
225
+ log('[lens-region] lens section carries a custom edit — preserved verbatim.');
226
+ log('[lens-region] note: the canonical planning/review lens has changed since this section was edited — compare it with the project methodology canon when convenient; your wording is never overwritten.');
227
+ return 0;
228
+ }
229
+ // refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
230
+ const maxLines = frontmatterMaxLines(text);
231
+ if (maxLines === null) {
232
+ log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
233
+ } else if (lineCount(result.text) > maxLines) {
234
+ log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. Nothing was changed.`);
235
+ return 0;
236
+ }
237
+ const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
238
+ try {
239
+ await fs.writeFile(tmp, result.text, 'utf8');
240
+ await fs.rename(tmp, targetPath);
241
+ } catch (err) {
242
+ await fs.rm(tmp, { force: true }).catch(() => {});
243
+ throw err;
244
+ }
245
+ log('[lens-region] refreshed the planning/review lens section to the current canon.');
246
+ return 0;
247
+ };
248
+
249
+ const { pathToFileURL } = await import('node:url');
250
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
251
+ if (isDirectRun) process.exitCode = await runCli(process.argv.slice(2));