@sabaiway/agent-workflow-kit 14.5.0 → 14.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.
@@ -1,171 +1,95 @@
1
1
  #!/usr/bin/env node
2
- // Heading-anchored region reconcile — the composition root's only mutation of a deployed
3
- // `docs/ai/agent_rules.md`. TWO regions, one policy (refresh IFF the region's normalized body
4
- // matches the current canon or a KNOWN PRIOR canonical body; anything else preserved
5
- // byte-for-byte + a one-line advisory — the AD-025 discipline):
6
- // - the planning/review/process-fidelity lens block (`### 2.x. Planning, review &
7
- // process-fidelity invariants`) — canon home: the installed engine's
8
- // `references/agent-rules-lens.md` + its append-only prior store, read live (no kit-side
9
- // prior constants, so a lens wording change is an engine-only release);
10
- // - the Communication block (`### 2.x. Communication (user-facing messages)`, AD-061) — canon
11
- // home: the §2.5 region of the kit's OWN bundled `references/templates/agent_rules.md`
12
- // (byte-identical to the memory twin by the template-region parity guard); priors are inline
13
- // constants below (a templates/-side prior file would be deployed into projects by the
14
- // template loop), appended whenever the template region changes (the vintage rule).
15
- //
16
- // The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
17
- // through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
18
- // lens-mirror guard pioneered, promoted here as the shipped implementation. A user-renamed
19
- // heading is therefore a natural preserve+advise; bytes outside the region are never touched.
20
- //
21
- // The live read is lazy + fail-loud: the engine is consulted only when a present region must be
22
- // classified; a fully absent/invalid engine is a loud STOP (never a silent fallback), a valid
23
- // engine that merely predates the lens pair (<1.13.0) is a stated soft skip. Cap-guard: a refresh
24
- // that would push the file over its own frontmatter `maxLines` is a loud, non-fatal refusal —
25
- // never a silent truncate; a file without frontmatter/`maxLines` skips the guard with a stated
26
- // note (such a file is outside the docs cap gate anyway).
27
- //
28
- // Pure string functions (fs only in the CLI); dependency-free, Node >= 22.
29
-
2
+ // The only mutation of a deployed rules file the composition root runs WITHOUT a consent ask.
30
3
  import { isDirectRun } from './direct-run.mjs';
31
4
  import { normalizeCanonical } from './orchestration-config.mjs';
5
+ import { RULES_REGIONS, extractRegionBy, readTemplateSpan, frontmatterMaxLines, exceedsCap } from './rules-regions.mjs';
32
6
 
33
- // The deployed-heading matcher (prefix, like the historical extractLensBlock: robust to a future
34
- // heading-tail tweak) and the number-neutral form the engine fragment carries.
35
- export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
36
- const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
37
- const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
38
- export const COMMS_HEADING_RE = /^### 2\.(\d+)\. Communication \(user-facing messages\)/;
39
- const NEUTRAL_COMMS_RE = /^### 2\.x\. Communication \(user-facing messages\)/;
40
- const COMMS_LABEL = '### 2.x. Communication (user-facing messages)';
41
-
42
- // The known prior canonical bodies of the Communication region (neutral-headed, like the engine's
43
- // lens prior store). Append the OUTGOING canon here in the same release that changes the template
44
- // §2.5 region — the fragment-or-prior reconcile depends on it.
45
- const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
46
- Apply this as part of §2 before any user-facing summary:
47
- - **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
48
- - **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
49
- - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
50
- - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.`;
51
- const COMMS_PRIOR_AD054 = `${COMMS_PRIOR_PRE_AD054}
52
- - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
53
- // The canon that shipped between the plain-language bullet and the closing-state-block contract.
54
- // Written out in full rather than composed from the constant above: the plain-language bullet LEADS
55
- // the block, so an append-based composition would produce a body that never shipped and would then
56
- // match no deployed file at all.
57
- const COMMS_PRIOR_PLAIN_LANGUAGE = `### 2.x. Communication (user-facing messages)
58
- Apply this as part of §2 before any user-facing summary:
59
- - **Plain language.** User-facing narration is short, clear, plain words of the dialogue language; when the dialogue language is not English, transliterated English jargon is banned — an English term survives only as the NAME of a thing (a flag / command / file / test), glossed in plain words when helpful; plain English stays plain for English-dialogue users.
60
- - **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
61
- - **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
62
- - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
63
- - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
64
- - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
65
- // The canon that shipped between the closing-state-block contract and the contradicted-skip bullet.
66
- const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
67
- - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
68
- export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
69
-
70
- const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
71
- const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
7
+ export { frontmatterMaxLines } from './rules-regions.mjs';
8
+ const [COMMUNICATION, STORY, LENS] = RULES_REGIONS;
9
+ export const LENS_HEADING_RE = LENS.headingRe;
10
+ export const COMMS_HEADING_RE = COMMUNICATION.headingRe;
11
+ export const COMMS_PRIORS = COMMUNICATION.priors;
12
+ const LF = String.fromCharCode(10);
13
+ const CR = String.fromCharCode(13);
14
+ const CRLF = CR + LF;
15
+ const BACKSLASH = String.fromCharCode(92);
16
+ const EM_DASH = String.fromCharCode(8212);
17
+ const EXIT_OK = 0;
18
+ const EXIT_STOP = 1;
19
+ const EXIT_USAGE = 2;
20
+ const CLI_ARG_COUNT = 2;
21
+ const SCRIPT_ARG_OFFSET = 2;
22
+ const SINGLE_HEADING = 1;
23
+ const HEX_RADIX = 16;
24
+ const UNICODE_WIDTH = 4;
25
+ const UTF8 = 'utf8';
26
+ const RECONCILE_COMMAND = 'reconcile';
27
+ const USAGE = 'usage: lens-region.mjs reconcile <path/to/agent_rules.md>';
28
+ const PRIOR_START = '<!-- prior';
29
+ const PRIOR_END = '-->';
30
+ const NUMBERED_HEADING_RE = /^### 2[.][0-9]+[.]/;
31
+ const NEUTRAL_HEADING = '### 2.x.';
32
+ const NEUTRAL_LENS_RE = /^### 2[.]x[.] Planning, review & process-fidelity/;
33
+ const NEUTRAL_COMMS_RE = /^### 2[.]x[.] Communication [(]user-facing messages[)]/;
34
+ const NEUTRAL_STORY_RE = /^### 2[.]x[.] Story sessions/;
35
+ const LINE_UNSAFE = new RegExp(`[${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]`, 'g');
36
+ const HUMAN_UNSAFE = new RegExp(`[${BACKSLASH}u0000-${BACKSLASH}u001f${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]+`, 'g');
37
+ const INSERT_NOTE = '[lens-region] note: preview the absent section with `node <kit>/tools/rules-insert.mjs --cwd <project>`; use `--apply` only after an explicit yes.';
38
+ const OUTCOME_PREFIXES = Object.freeze({ communication: 'comms', 'story-sessions': 'story', lens: 'lens' });
39
+ const stripCr = (line) => (line.endsWith(CR) ? line.slice(0, -1) : line);
40
+ const escUnsafe = (character) => `${BACKSLASH}u${character.codePointAt(0).toString(HEX_RADIX).padStart(UNICODE_WIDTH, '0')}`;
41
+ const errorDetail = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
42
+ const oneLine = (value) => String(value).replace(HUMAN_UNSAFE, ' ');
72
43
 
73
- // Count lines independent of a trailing newline (the inject-methodology contract).
74
- const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
75
-
76
- // ── the frozen prior-store format (documented in the store file's own header) ─────
77
- // A delimiter is a line that starts with `<!-- prior` and ends with `-->`; an entry body is
78
- // everything after it up to the next delimiter / EOF, trimmed. The pre-delimiter header is
79
- // ignored. APPEND-ONLY on the engine side — this parser must keep reading a newer engine's file.
80
44
  export const parseLensPriors = (text) => {
81
45
  const entries = [];
82
- let current = null;
83
- for (const line of String(text).split('\n')) {
46
+ for (const line of String(text).split(LF)) {
84
47
  const bare = stripCr(line);
85
- if (bare.startsWith('<!-- prior') && bare.endsWith('-->')) {
86
- if (current) entries.push(current.join('\n'));
87
- current = [];
88
- } else if (current) current.push(line);
48
+ if (bare.startsWith(PRIOR_START) && bare.endsWith(PRIOR_END)) {
49
+ entries.push([]);
50
+ } else if (entries.length) {
51
+ entries.at(-1).push(line);
52
+ }
89
53
  }
90
- if (current) entries.push(current.join('\n'));
91
- return entries.map((e) => normalizeCanonical(e)).filter((e) => e !== '');
54
+ return entries.map((entry) => normalizeCanonical(entry.join(LF))).filter((entry) => entry !== '');
92
55
  };
93
56
 
94
- // renderLens(fragment, number) → the number-neutral canonical block bound to the file's OWN
95
- // section number (memory-seeded files say 2.6, kit-fallback files 2.5 — the fragment never
96
- // hardcodes one). LF-canonical; the CLI converts to the document's EOL style on write.
97
- export const renderLens = (fragment, number) =>
98
- normalizeCanonical(fragment).replace(NEUTRAL_HEADING_RE, (m) => m.replace('2.x', `2.${number}`));
99
-
100
- // normalizeLensBody(body) → the number-neutral, whitespace/EOL-normalized comparison form
101
- // (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
57
+ const renderRegion = (fragment, number, headingRe) =>
58
+ normalizeCanonical(fragment).replace(headingRe, (heading) => heading.replace('2.x', `2.${number}`));
59
+ export const renderLens = (fragment, number) => renderRegion(fragment, number, NEUTRAL_LENS_RE);
60
+ export const renderComms = (fragment, number) => renderRegion(fragment, number, NEUTRAL_COMMS_RE);
61
+ export const renderStory = (fragment, number) => renderRegion(fragment, number, NEUTRAL_STORY_RE);
102
62
  export const normalizeLensBody = (body) =>
103
- normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
63
+ normalizeCanonical(String(body).replace(NUMBERED_HEADING_RE, NEUTRAL_HEADING));
104
64
  export const normalizeCommsBody = normalizeLensBody;
105
- export const renderComms = (fragment, number) =>
106
- normalizeCanonical(fragment).replace(NEUTRAL_COMMS_RE, (m) => m.replace('2.x', `2.${number}`));
107
-
108
- // extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
109
- // `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
110
- // next structural boundary; EOF is a valid region end (no following boundary required). `body`
111
- // is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
112
- // region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
113
- const extractRegionBy = (text, headingRe) => {
114
- const lines = String(text).split('\n');
115
- const start = lines.findIndex((line) => headingRe.test(stripCr(line)));
116
- if (start === -1) return { found: false };
117
- let end = lines.length;
118
- for (let i = start + 1; i < lines.length; i += 1) {
119
- if (isBoundary(stripCr(lines[i]))) {
120
- end = i;
121
- break;
122
- }
123
- }
124
- const number = stripCr(lines[start]).match(headingRe)[1];
125
- const regionLines = lines.slice(start, end);
126
- let bodyEnd = regionLines.length;
127
- while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
128
- const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
129
- return { found: true, start, end, number, body };
130
- };
65
+ export const normalizeStoryBody = normalizeLensBody;
131
66
  export const extractLensRegion = (text) => extractRegionBy(text, LENS_HEADING_RE);
132
67
  export const extractCommsRegion = (text) => extractRegionBy(text, COMMS_HEADING_RE);
68
+ export const extractStoryRegion = (text) => extractRegionBy(text, STORY.headingRe);
133
69
 
134
- // replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
135
- // lines replaced; trailing blank lines inside the region and every byte outside it are
136
- // preserved verbatim. Output keeps the document's EOL style (CRLF documents stay CRLF).
137
70
  export const replaceLensRegion = (text, region, renderedBody) => {
138
- const lines = String(text).split('\n');
139
- const crlf = String(text).includes('\r\n');
71
+ const lines = String(text).split(LF);
72
+ const crlf = String(text).includes(CRLF);
140
73
  const regionLines = lines.slice(region.start, region.end);
141
- let bodyEnd = regionLines.length;
142
- while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
143
- const trailing = regionLines.slice(bodyEnd); // preserved verbatim (their own CR bytes intact)
144
- const newBody = renderedBody.split('\n').map((l) => (crlf ? `${l}\r` : l));
74
+ const bodyEnd = regionLines.findLastIndex((line) => stripCr(line).trim() !== '') + 1;
75
+ const trailing = regionLines.slice(bodyEnd);
76
+ const newBody = renderedBody.split(LF).map((line) => (crlf ? line + CR : line));
145
77
  const out = [...lines.slice(0, region.start), ...newBody, ...trailing, ...lines.slice(region.end)];
146
- // In a CRLF document, only a line FOLLOWED by another line carries the CR byte; when the new
147
- // body's last line is the final line of the whole document (EOF region, no trailing newline),
148
- // strip the CR we just added so no stray byte lands at EOF.
78
+ // At EOF without a newline, the final line must not gain a dangling CR.
149
79
  if (crlf && trailing.length === 0 && region.end === lines.length) {
150
- out[out.length - 1] = stripCr(out[out.length - 1]);
80
+ out[out.length - 1] = stripCr(out.at(-1));
151
81
  }
152
- return out.join('\n');
82
+ return out.join(LF);
153
83
  };
154
84
 
155
- // reconcileLensText(text, fragment, priors) — the PURE policy decision:
156
- // { status: 'no-region' } — heading absent/renamed → preserve + advise (caller words it).
157
- // { status: 'current' } — the region already renders the current canon → zero-diff.
158
- // { status: 'refreshed', text } — the region matched the canon or a known prior → re-rendered
159
- // with the file's OWN number (cap-guard is the caller's, so the
160
- // decision stays pure).
161
- // { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
162
85
  const reconcileRegionText = (text, fragment, priors, { extract, normalize, render }) => {
163
86
  const region = extract(text);
164
87
  if (!region.found) return { status: 'no-region', text };
88
+ if (region.count > SINGLE_HEADING) return { status: 'custom', text };
165
89
  const current = normalize(region.body);
166
90
  const canon = normalize(fragment);
167
91
  if (current === canon) return { status: 'current', text };
168
- const known = priors.map((p) => normalize(p));
92
+ const known = priors.map((prior) => normalize(prior));
169
93
  if (!known.includes(current)) return { status: 'custom', text };
170
94
  return { status: 'refreshed', text: replaceLensRegion(text, region, render(fragment, region.number)) };
171
95
  };
@@ -173,98 +97,102 @@ export const reconcileLensText = (text, fragment, priors) =>
173
97
  reconcileRegionText(text, fragment, priors, { extract: extractLensRegion, normalize: normalizeLensBody, render: renderLens });
174
98
  export const reconcileCommsText = (text, fragment, priors) =>
175
99
  reconcileRegionText(text, fragment, priors, { extract: extractCommsRegion, normalize: normalizeCommsBody, render: renderComms });
176
-
177
- // frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
178
- // file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
179
- // with a stated note, never a throw — such a file is outside the docs cap gate anyway).
180
- export const frontmatterMaxLines = (text) => {
181
- const lines = String(text).split('\n').map(stripCr);
182
- if (lines[0] !== '---') return null;
183
- for (let i = 1; i < lines.length; i += 1) {
184
- if (lines[i] === '---') return null;
185
- const m = lines[i].match(/^maxLines:\s*(\d+)\s*$/);
186
- if (m) return Number(m[1]);
187
- }
188
- return null;
189
- };
190
-
191
- // ── the outcome lines (pure composers — the CLI's one voice) ──────────────────────
192
- // Every user-facing outcome line the CLI prints, one pure composer per outcome, so the
193
- // composed-lines guard (test/composed-lines-ux.test.mjs) can render each against the L2
194
- // user-grade invariants. runCli only ever prints through this table. Raw diagnostics never ride
195
- // the human sentence: they land on the ONE machine-formatted detail line (`[lens-region]
196
- // error=<JSON-encoded>` — one line, reversible, control bytes escaped), the `[tool] key=value`
197
- // channel the L2 rule exempts by grammar. JSON.stringify leaves DEL/C1 and the U+2028/U+2029
198
- // separators raw, and a dynamic path can carry any byte — both dynamic parts are therefore made
199
- // line-safe explicitly: the machine value gains extra JSON escapes (still reversible), and the
200
- // human line collapses every control/separator byte to one space.
201
- const LINE_UNSAFE = new RegExp('[\\u007f-\\u009f\\u2028\\u2029]', 'g');
202
- const HUMAN_UNSAFE = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+', 'g');
203
- const escUnsafe = (c) => `\\u${c.codePointAt(0).toString(16).padStart(4, '0')}`;
204
- const ERROR_DETAIL = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
205
- const oneLine = (s) => String(s).replace(HUMAN_UNSAFE, ' ');
100
+ export const reconcileStoryText = (text, fragment, priors) =>
101
+ reconcileRegionText(text, fragment, priors, { extract: extractStoryRegion, normalize: normalizeStoryBody, render: renderStory });
102
+ const RECONCILERS = Object.freeze({ communication: reconcileCommsText, 'story-sessions': reconcileStoryText, lens: reconcileLensText });
206
103
 
207
104
  export const OUTCOME_LINES = Object.freeze({
208
- errorDetail: ERROR_DETAIL,
209
- targetAbsent: (target) => `[lens-region] ${target} is absent — skipped (nothing to update; the file is seeded at bootstrap).`,
105
+ errorDetail,
106
+ targetAbsent: (target) => `[lens-region] ${oneLine(target)} is absent ${EM_DASH} skipped (nothing to update; the file is seeded at bootstrap).`,
210
107
  commsNoRegion: (target) => [
211
- `[lens-region] no "${COMMS_LABEL}" section in ${target} — left untouched.`,
212
- '[lens-region] note: the Communication section is absent or renamed — deployments seeded before it existed simply lack it; add it from the current template to enable refresh. Your file is never rewritten.',
108
+ `[lens-region] no "${COMMUNICATION.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
109
+ INSERT_NOTE,
213
110
  ],
214
- commsCurrent: () => '[lens-region] Communication section already current — nothing to do (zero-diff).',
111
+ commsCurrent: () => `[lens-region] Communication section already current ${EM_DASH} nothing to do (zero-diff).`,
215
112
  commsCustom: () => [
216
- '[lens-region] Communication section carries a custom edit — preserved verbatim.',
217
- '[lens-region] note: the canonical Communication section has changed since this section was edited — compare it with the current template when convenient; your wording is never overwritten.',
113
+ `[lens-region] Communication section carries a custom edit ${EM_DASH} preserved verbatim.`,
114
+ `[lens-region] note: the canonical Communication section has changed since this section was edited ${EM_DASH} compare it with the current template when convenient; your wording is never overwritten.`,
218
115
  ],
219
- capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.',
220
- commsCapRefused: (target, count, cap) => `[lens-region] refused — refreshing the Communication section would push ${target} to ${count} lines (cap ${cap}); trim the file and re-run. The Communication section was not changed.`,
116
+ capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target ' + EM_DASH + ' the line-cap guard is skipped.',
117
+ commsCapRefused: (target, count, cap) => `[lens-region] refused ${EM_DASH} refreshing the Communication section would push ${oneLine(target)} to ${count} lines (cap ${cap}); trim the file and re-run. The Communication section was not changed.`,
221
118
  commsRefreshed: () => '[lens-region] refreshed the Communication section to the current canon.',
222
- templateCanonStop: () => `[lens-region] STOP — the kit's bundled agent_rules.md template canon is unreadable; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`,
119
+ templateCanonStop: () => `[lens-region] STOP ${EM_DASH} the kit's bundled agent_rules.md template canon is unreadable; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`,
120
+ storyNoRegion: (target) => [
121
+ `[lens-region] no "${STORY.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
122
+ INSERT_NOTE,
123
+ ],
124
+ storyCurrent: () => `[lens-region] Story sessions section already current ${EM_DASH} nothing to do (zero-diff).`,
125
+ storyCustom: () => [
126
+ `[lens-region] Story sessions section carries a custom edit ${EM_DASH} preserved verbatim.`,
127
+ `[lens-region] note: this body differs from the bundled template's Story sessions section ${EM_DASH} compare the two when convenient; your wording is never overwritten.`,
128
+ ],
129
+ storyCapRefused: (target, count, cap) => `[lens-region] refused ${EM_DASH} refreshing the Story sessions section would push ${oneLine(target)} to ${count} lines (maxLines ${cap}); trim the file and re-run. The Story sessions section was not changed.`,
130
+ storyRefreshed: () => '[lens-region] refreshed the Story sessions section to the current canon.',
131
+ regionHeadingTwice: (label, target) => [
132
+ `[lens-region] more than one "${oneLine(label)}" section in ${oneLine(target)} ${EM_DASH} preserved verbatim.`,
133
+ '[lens-region] note: give the sections distinct headings before retrying; their wording was not changed.',
134
+ ],
223
135
  lensNoRegion: (target) => [
224
- `[lens-region] no "${HEADING_LABEL}" section in ${target} — left untouched.`,
225
- '[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.',
136
+ `[lens-region] no "${LENS.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
137
+ `[lens-region] note: the planning/review lens section is missing or renamed ${EM_DASH} it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.`,
226
138
  ],
227
- engineTooOld: () => '[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.',
228
- // The human line keeps the classified "methodology engine not found/invalid" contract; a typed
229
- // error (engine-source attaches {stable, reason}) splits its raw reason onto the machine line.
139
+ engineTooOld: () => '[lens-region] skipped ' + EM_DASH + ' 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.',
230
140
  engineStop: (err) => {
231
- const human = `[lens-region] STOP — ${oneLine(err?.stable ?? err?.message ?? String(err))}`;
232
- return err?.reason ? [human, ERROR_DETAIL(err.reason)] : [human];
141
+ const human = `[lens-region] STOP ${EM_DASH} ${oneLine(err?.stable ?? err?.message ?? String(err))}`;
142
+ return err?.reason ? [human, errorDetail(err.reason)] : [human];
233
143
  },
234
- lensCurrent: () => '[lens-region] lens section already current — nothing to do (zero-diff).',
144
+ lensCurrent: () => `[lens-region] lens section already current ${EM_DASH} nothing to do (zero-diff).`,
235
145
  lensCustom: () => [
236
- '[lens-region] lens section carries a custom edit — preserved verbatim.',
237
- '[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.',
146
+ `[lens-region] lens section carries a custom edit ${EM_DASH} preserved verbatim.`,
147
+ `[lens-region] note: the canonical planning/review lens has changed since this section was edited ${EM_DASH} compare it with the project methodology canon when convenient; your wording is never overwritten.`,
238
148
  ],
239
- lensCapRefused: (target, count, cap) => `[lens-region] refused — refreshing would push ${target} to ${count} lines (cap ${cap}); trim the file and re-run. The planning/review lens section was not changed.`,
149
+ lensCapRefused: (target, count, cap) => `[lens-region] refused ${EM_DASH} refreshing would push ${oneLine(target)} to ${count} lines (cap ${cap}); trim the file and re-run. The planning/review lens section was not changed.`,
240
150
  lensRefreshed: () => '[lens-region] refreshed the planning/review lens section to the current canon.',
241
151
  });
242
152
 
243
- // ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
244
- // Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
245
- // every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
246
- // hard STOP — the absent/invalid engine, or the unreadable bundled template canon — or an
247
- // unexpected fs error, exit 2 on usage.
153
+ const readLensCanon = async (deps, log, logError) => {
154
+ const { homedir } = await import('node:os');
155
+ const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
156
+ const { dir, source } = resolveEngineDir({ env: deps.env ?? process.env, home: deps.home ?? homedir() });
157
+ const lensPairPresent =
158
+ detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
159
+ if (!lensPairPresent) {
160
+ if (detectEngine(dir, { source }).ok) {
161
+ log(OUTCOME_LINES.engineTooOld());
162
+ return { code: EXIT_OK };
163
+ }
164
+ try {
165
+ readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
166
+ return { code: EXIT_STOP };
167
+ } catch (err) {
168
+ for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
169
+ return { code: EXIT_STOP };
170
+ }
171
+ }
172
+ try {
173
+ const fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL, readFileSync: deps.engineRead });
174
+ const priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL, readFileSync: deps.engineRead }));
175
+ return { fragment, priors };
176
+ } catch (err) {
177
+ for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
178
+ return { code: EXIT_STOP };
179
+ }
180
+ };
181
+
248
182
  export const runCli = async (argv, deps = {}) => {
249
183
  const log = deps.log ?? console.log;
250
184
  const logError = deps.logError ?? console.error;
251
- const env = deps.env ?? process.env;
185
+ if (argv[0] !== RECONCILE_COMMAND || !argv[1] || argv.length > CLI_ARG_COUNT) {
186
+ logError(USAGE);
187
+ return EXIT_USAGE;
188
+ }
252
189
  const fs = deps.fs ?? (await import('node:fs/promises'));
253
190
  const { dirname, basename, join, resolve } = await import('node:path');
254
- const { homedir } = await import('node:os');
255
191
  const { fileURLToPath } = await import('node:url');
256
- const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
257
-
258
- if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
259
- logError('usage: lens-region.mjs reconcile <path/to/agent_rules.md>');
260
- return 2;
261
- }
262
192
  const targetPath = resolve(argv[1]);
263
-
264
- // 1. Absent file → a stated skip (the memory substrate owns seeding; nothing to reconcile).
265
193
  const text = await (async () => {
266
194
  try {
267
- return await fs.readFile(targetPath, 'utf8');
195
+ return await fs.readFile(targetPath, UTF8);
268
196
  } catch (err) {
269
197
  if (err.code === 'ENOENT') return null;
270
198
  throw err;
@@ -272,122 +200,72 @@ export const runCli = async (argv, deps = {}) => {
272
200
  })();
273
201
  if (text === null) {
274
202
  log(OUTCOME_LINES.targetAbsent(argv[1]));
275
- return 0;
203
+ return EXIT_OK;
204
+ }
205
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
206
+ const template = await (async () => {
207
+ try {
208
+ const source = await fs.readFile(templatePath, UTF8);
209
+ const spans = RULES_REGIONS.filter((region) => region.canon === 'template')
210
+ .map((region) => [region.id, readTemplateSpan(source, region.headingRe)]);
211
+ return { spans: Object.fromEntries(spans), defect: spans.some(([, span]) => span.defect) };
212
+ } catch (err) {
213
+ return { defect: true, error: err?.message ?? String(err) };
214
+ }
215
+ })();
216
+ if (template.defect) {
217
+ logError(OUTCOME_LINES.templateCanonStop());
218
+ if (template.error) logError(OUTCOME_LINES.errorDetail(template.error));
219
+ return EXIT_STOP;
276
220
  }
277
-
278
221
  const atomicWrite = async (content) => {
279
222
  const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
280
223
  try {
281
- await fs.writeFile(tmp, content, 'utf8');
224
+ await fs.writeFile(tmp, content, UTF8);
282
225
  await fs.rename(tmp, targetPath);
283
226
  } catch (err) {
284
227
  await fs.rm(tmp, { force: true }).catch(() => {});
285
228
  throw err;
286
229
  }
287
230
  };
288
-
289
- // 2. The Communication half (AD-061) — canon from the kit's OWN bundled template (the engine is
290
- // never consulted for this region), same fragment-or-prior policy, own outcome lines.
291
- const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
292
- const templateRegion = await (async () => {
293
- try {
294
- return extractCommsRegion(await fs.readFile(templatePath, 'utf8'));
295
- } catch (err) {
296
- return { found: false, error: err?.message ?? String(err) };
297
- }
298
- })();
299
- if (!templateRegion.found) {
300
- logError(OUTCOME_LINES.templateCanonStop());
301
- if (templateRegion.error) logError(OUTCOME_LINES.errorDetail(templateRegion.error));
302
- return 1;
303
- }
304
- const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
305
- const currentText = await (async () => {
306
- if (commsResult.status === 'no-region') {
307
- for (const line of OUTCOME_LINES.commsNoRegion(argv[1])) log(line);
308
- return text;
309
- }
310
- if (commsResult.status === 'current') {
311
- log(OUTCOME_LINES.commsCurrent());
312
- return text;
231
+ const outcome = await RULES_REGIONS.reduce(async (previous, descriptor) => {
232
+ const state = await previous;
233
+ if (state.code !== EXIT_OK) return state;
234
+ const region = extractRegionBy(state.text, descriptor.headingRe);
235
+ const prefix = OUTCOME_PREFIXES[descriptor.id];
236
+ if (region.count > SINGLE_HEADING) {
237
+ for (const line of OUTCOME_LINES.regionHeadingTwice(descriptor.label, argv[1])) log(line);
238
+ return state;
313
239
  }
314
- if (commsResult.status === 'custom') {
315
- for (const line of OUTCOME_LINES.commsCustom()) log(line);
316
- return text;
240
+ if (!region.found) {
241
+ for (const line of OUTCOME_LINES[`${prefix}NoRegion`](argv[1])) log(line);
242
+ return state;
317
243
  }
318
- const commsMax = frontmatterMaxLines(text);
319
- if (commsMax === null) {
320
- log(OUTCOME_LINES.capSkipNote());
244
+ const canon = descriptor.canon === 'template'
245
+ ? { fragment: normalizeLensBody(template.spans[descriptor.id].span), priors: descriptor.priors }
246
+ : await readLensCanon(deps, log, logError);
247
+ if (canon.code !== undefined) return { text: state.text, code: canon.code };
248
+ const result = RECONCILERS[descriptor.id](state.text, canon.fragment, canon.priors);
249
+ if (result.status === 'current') {
250
+ log(OUTCOME_LINES[`${prefix}Current`]());
251
+ return state;
321
252
  }
322
- if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
323
- log(OUTCOME_LINES.commsCapRefused(argv[1], lineCount(commsResult.text), commsMax));
324
- return text;
325
- }
326
- await atomicWrite(commsResult.text);
327
- log(OUTCOME_LINES.commsRefreshed());
328
- return commsResult.text;
329
- })();
330
-
331
- // 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
332
- // preserve regardless, so the lazy contract holds).
333
- if (!extractLensRegion(currentText).found) {
334
- for (const line of OUTCOME_LINES.lensNoRegion(argv[1])) log(line);
335
- return 0;
336
- }
337
-
338
- // 3. A present region must be classified → live-read the engine's fragment + prior store.
339
- // Fully absent/invalid engine → loud STOP; valid-but-pre-lens engine → stated soft skip.
340
- const { dir, source } = resolveEngineDir({ env, home: deps.home ?? homedir() });
341
- const lensPairPresent =
342
- detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
343
- if (!lensPairPresent) {
344
- if (detectEngine(dir, { source }).ok) {
345
- log(OUTCOME_LINES.engineTooOld());
346
- return 0;
253
+ if (result.status === 'custom') {
254
+ for (const line of OUTCOME_LINES[`${prefix}Custom`]()) log(line);
255
+ return state;
347
256
  }
348
- try {
349
- readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
350
- return 1; // defensive: the pair is unusable — never proceed to a read
351
- } catch (err) {
352
- for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
353
- return 1;
257
+ const cap = frontmatterMaxLines(state.text);
258
+ const decision = exceedsCap(result.text, cap);
259
+ if (decision.skipped) log(OUTCOME_LINES.capSkipNote());
260
+ if (decision.over) {
261
+ log(OUTCOME_LINES[`${prefix}CapRefused`](argv[1], decision.count, cap));
262
+ return state;
354
263
  }
355
- }
356
- // TOCTOU guard: a fragment that vanishes between detect and read is a corruption STOP, never a
357
- // silent proceed — readEngineFragment's own throw carries the install command.
358
- let fragment;
359
- let priors;
360
- try {
361
- // deps.engineRead is the injectable read primitive (tests drive the vanished/unreadable arm
362
- // deterministically — a chmod-based fixture is root- and platform-dependent).
363
- fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL, readFileSync: deps.engineRead });
364
- priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL, readFileSync: deps.engineRead }));
365
- } catch (err) {
366
- for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
367
- return 1;
368
- }
369
-
370
- // 5. The pure decision + the cap-guard + one atomic write.
371
- const result = reconcileLensText(currentText, fragment, priors);
372
- if (result.status === 'current') {
373
- log(OUTCOME_LINES.lensCurrent());
374
- return 0;
375
- }
376
- if (result.status === 'custom') {
377
- for (const line of OUTCOME_LINES.lensCustom()) log(line);
378
- return 0;
379
- }
380
- // refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
381
- const maxLines = frontmatterMaxLines(currentText);
382
- if (maxLines === null) {
383
- log(OUTCOME_LINES.capSkipNote());
384
- } else if (lineCount(result.text) > maxLines) {
385
- log(OUTCOME_LINES.lensCapRefused(argv[1], lineCount(result.text), maxLines));
386
- return 0;
387
- }
388
- await atomicWrite(result.text);
389
- log(OUTCOME_LINES.lensRefreshed());
390
- return 0;
264
+ await atomicWrite(result.text);
265
+ log(OUTCOME_LINES[`${prefix}Refreshed`]());
266
+ return { text: result.text, code: EXIT_OK };
267
+ }, Promise.resolve({ text, code: EXIT_OK }));
268
+ return outcome.code;
391
269
  };
392
270
 
393
- if (isDirectRun(import.meta.url)) process.exitCode = await runCli(process.argv.slice(2));
271
+ if (isDirectRun(import.meta.url)) process.exitCode = await runCli(process.argv.slice(SCRIPT_ARG_OFFSET));