@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 +39 -0
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/upgrade.md +16 -6
- package/references/shared/composition-handoff.md +12 -0
- package/references/templates/agent_rules.md +1 -1
- package/tools/delegation.mjs +6 -3
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +8 -4
- package/tools/lens-region.mjs +251 -0
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
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
|
|
36
|
-
equal-head one — with no lineage-head bump. A *could not refresh* line is
|
|
37
|
-
plainly with its recovery and continue the upgrade.
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
package/tools/delegation.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
package/tools/engine-source.mjs
CHANGED
|
@@ -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
|
-
{
|
|
176
|
-
{
|
|
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) => !
|
|
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));
|