@sabaiway/agent-workflow-kit 3.1.0 → 3.2.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,14 +1,17 @@
1
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.
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).
12
15
  //
13
16
  // The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
14
17
  // through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
@@ -31,6 +34,22 @@ import { normalizeCanonical } from './orchestration-config.mjs';
31
34
  export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
32
35
  const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
33
36
  const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
37
+ export const COMMS_HEADING_RE = /^### 2\.(\d+)\. Communication \(user-facing messages\)/;
38
+ const NEUTRAL_COMMS_RE = /^### 2\.x\. Communication \(user-facing messages\)/;
39
+ const COMMS_LABEL = '### 2.x. Communication (user-facing messages)';
40
+
41
+ // The known prior canonical bodies of the Communication region (neutral-headed, like the engine's
42
+ // lens prior store). Append the OUTGOING canon here in the same release that changes the template
43
+ // §2.5 region — the fragment-or-prior reconcile depends on it.
44
+ const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
45
+ Apply this as part of §2 before any user-facing summary:
46
+ - **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.
47
+ - **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
48
+ - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
49
+ - **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.`;
50
+ const COMMS_PRIOR_AD054 = `${COMMS_PRIOR_PRE_AD054}
51
+ - **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.`;
52
+ export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054];
34
53
 
35
54
  const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
36
55
  const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
@@ -66,15 +85,18 @@ export const renderLens = (fragment, number) =>
66
85
  // (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
67
86
  export const normalizeLensBody = (body) =>
68
87
  normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
88
+ export const normalizeCommsBody = normalizeLensBody;
89
+ export const renderComms = (fragment, number) =>
90
+ normalizeCanonical(fragment).replace(NEUTRAL_COMMS_RE, (m) => m.replace('2.x', `2.${number}`));
69
91
 
70
92
  // extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
71
93
  // `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
72
94
  // next structural boundary; EOF is a valid region end (no following boundary required). `body`
73
95
  // is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
74
96
  // region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
75
- export const extractLensRegion = (text) => {
97
+ const extractRegionBy = (text, headingRe) => {
76
98
  const lines = String(text).split('\n');
77
- const start = lines.findIndex((line) => LENS_HEADING_RE.test(stripCr(line)));
99
+ const start = lines.findIndex((line) => headingRe.test(stripCr(line)));
78
100
  if (start === -1) return { found: false };
79
101
  let end = lines.length;
80
102
  for (let i = start + 1; i < lines.length; i += 1) {
@@ -83,13 +105,15 @@ export const extractLensRegion = (text) => {
83
105
  break;
84
106
  }
85
107
  }
86
- const number = stripCr(lines[start]).match(LENS_HEADING_RE)[1];
108
+ const number = stripCr(lines[start]).match(headingRe)[1];
87
109
  const regionLines = lines.slice(start, end);
88
110
  let bodyEnd = regionLines.length;
89
111
  while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
90
112
  const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
91
113
  return { found: true, start, end, number, body };
92
114
  };
115
+ export const extractLensRegion = (text) => extractRegionBy(text, LENS_HEADING_RE);
116
+ export const extractCommsRegion = (text) => extractRegionBy(text, COMMS_HEADING_RE);
93
117
 
94
118
  // replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
95
119
  // lines replaced; trailing blank lines inside the region and every byte outside it are
@@ -119,16 +143,20 @@ export const replaceLensRegion = (text, region, renderedBody) => {
119
143
  // with the file's OWN number (cap-guard is the caller's, so the
120
144
  // decision stays pure).
121
145
  // { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
122
- export const reconcileLensText = (text, fragment, priors) => {
123
- const region = extractLensRegion(text);
146
+ const reconcileRegionText = (text, fragment, priors, { extract, normalize, render }) => {
147
+ const region = extract(text);
124
148
  if (!region.found) return { status: 'no-region', text };
125
- const current = normalizeLensBody(region.body);
126
- const canon = normalizeLensBody(fragment);
149
+ const current = normalize(region.body);
150
+ const canon = normalize(fragment);
127
151
  if (current === canon) return { status: 'current', text };
128
- const known = priors.map((p) => normalizeLensBody(p));
152
+ const known = priors.map((p) => normalize(p));
129
153
  if (!known.includes(current)) return { status: 'custom', text };
130
- return { status: 'refreshed', text: replaceLensRegion(text, region, renderLens(fragment, region.number)) };
154
+ return { status: 'refreshed', text: replaceLensRegion(text, region, render(fragment, region.number)) };
131
155
  };
156
+ export const reconcileLensText = (text, fragment, priors) =>
157
+ reconcileRegionText(text, fragment, priors, { extract: extractLensRegion, normalize: normalizeLensBody, render: renderLens });
158
+ export const reconcileCommsText = (text, fragment, priors) =>
159
+ reconcileRegionText(text, fragment, priors, { extract: extractCommsRegion, normalize: normalizeCommsBody, render: renderComms });
132
160
 
133
161
  // frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
134
162
  // file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
@@ -146,8 +174,9 @@ export const frontmatterMaxLines = (text) => {
146
174
 
147
175
  // ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
148
176
  // 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.
177
+ // every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
178
+ // hard STOP — the absent/invalid engine, or the unreadable bundled template canon or an
179
+ // unexpected fs error, exit 2 on usage.
151
180
  export const runCli = async (argv, deps = {}) => {
152
181
  const log = deps.log ?? console.log;
153
182
  const logError = deps.logError ?? console.error;
@@ -155,6 +184,7 @@ export const runCli = async (argv, deps = {}) => {
155
184
  const fs = deps.fs ?? (await import('node:fs/promises'));
156
185
  const { dirname, basename, join, resolve } = await import('node:path');
157
186
  const { homedir } = await import('node:os');
187
+ const { fileURLToPath } = await import('node:url');
158
188
  const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
159
189
 
160
190
  if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
@@ -177,9 +207,63 @@ export const runCli = async (argv, deps = {}) => {
177
207
  return 0;
178
208
  }
179
209
 
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) {
210
+ const atomicWrite = async (content) => {
211
+ const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
212
+ try {
213
+ await fs.writeFile(tmp, content, 'utf8');
214
+ await fs.rename(tmp, targetPath);
215
+ } catch (err) {
216
+ await fs.rm(tmp, { force: true }).catch(() => {});
217
+ throw err;
218
+ }
219
+ };
220
+
221
+ // 2. The Communication half (AD-061) — canon from the kit's OWN bundled template (the engine is
222
+ // never consulted for this region), same fragment-or-prior policy, own outcome lines.
223
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
224
+ const templateRegion = await (async () => {
225
+ try {
226
+ return extractCommsRegion(await fs.readFile(templatePath, 'utf8'));
227
+ } catch (err) {
228
+ return { found: false, error: err?.message ?? String(err) };
229
+ }
230
+ })();
231
+ if (!templateRegion.found) {
232
+ logError(`[lens-region] reconcile STOP — the kit's bundled agent_rules.md template canon is unreadable${templateRegion.error ? ` (${templateRegion.error})` : ''}; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`);
233
+ return 1;
234
+ }
235
+ const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
236
+ const currentText = await (async () => {
237
+ if (commsResult.status === 'no-region') {
238
+ log(`[lens-region] no "${COMMS_LABEL}" section in ${argv[1]} — left untouched.`);
239
+ log('[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.');
240
+ return text;
241
+ }
242
+ if (commsResult.status === 'current') {
243
+ log('[lens-region] Communication section already current — nothing to do (zero-diff).');
244
+ return text;
245
+ }
246
+ if (commsResult.status === 'custom') {
247
+ log('[lens-region] Communication section carries a custom edit — preserved verbatim.');
248
+ log('[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.');
249
+ return text;
250
+ }
251
+ const commsMax = frontmatterMaxLines(text);
252
+ if (commsMax === null) {
253
+ log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
254
+ }
255
+ if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
256
+ log(`[lens-region] refused — refreshing the Communication section would push ${argv[1]} to ${lineCount(commsResult.text)} lines (cap ${commsMax}); trim the file and re-run. The Communication section was not changed.`);
257
+ return text;
258
+ }
259
+ await atomicWrite(commsResult.text);
260
+ log('[lens-region] refreshed the Communication section to the current canon.');
261
+ return commsResult.text;
262
+ })();
263
+
264
+ // 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
265
+ // preserve regardless, so the lazy contract holds).
266
+ if (!extractLensRegion(currentText).found) {
183
267
  log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
184
268
  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
269
  return 0;
@@ -215,8 +299,8 @@ export const runCli = async (argv, deps = {}) => {
215
299
  return 1;
216
300
  }
217
301
 
218
- // 4. The pure decision + the cap-guard + one atomic write.
219
- const result = reconcileLensText(text, fragment, priors);
302
+ // 5. The pure decision + the cap-guard + one atomic write.
303
+ const result = reconcileLensText(currentText, fragment, priors);
220
304
  if (result.status === 'current') {
221
305
  log('[lens-region] lens section already current — nothing to do (zero-diff).');
222
306
  return 0;
@@ -227,21 +311,14 @@ export const runCli = async (argv, deps = {}) => {
227
311
  return 0;
228
312
  }
229
313
  // refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
230
- const maxLines = frontmatterMaxLines(text);
314
+ const maxLines = frontmatterMaxLines(currentText);
231
315
  if (maxLines === null) {
232
316
  log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
233
317
  } 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.`);
318
+ log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. The planning/review lens section was not changed.`);
235
319
  return 0;
236
320
  }
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
- }
321
+ await atomicWrite(result.text);
245
322
  log('[lens-region] refreshed the planning/review lens section to the current canon.');
246
323
  return 0;
247
324
  };
@@ -36,7 +36,10 @@ const ATTRIBUTION = [
36
36
  { re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
37
37
  ];
38
38
  const REVIEWER_IDENTITY = [
39
+ // backend-then-round: a bridge name, a separator, then r<N> (with optional +/round suffixes).
39
40
  { re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
41
+ // round-then-backend (reverse order): r<N>, a separator, then a bridge name — the release-review gap.
42
+ { re: /\br\d+(?:\s+|-)(?:agy|codex)\b/i, label: 'reviewer-round identity' },
40
43
  ];
41
44
 
42
45
  const allowlistCovers = (matched, allowlist) =>