@sabaiway/agent-workflow-kit 14.5.0 → 14.7.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 +74 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/upgrade.md +11 -7
- package/references/shared/composition-handoff.md +6 -4
- package/references/templates/agent_rules.md +3 -0
- package/tools/delegation.mjs +2 -2
- package/tools/epic-shape-ledger.mjs +5 -1
- package/tools/lens-region.mjs +193 -310
- package/tools/profile-gaps.mjs +86 -0
- package/tools/rules-insert.mjs +203 -0
- package/tools/rules-regions.mjs +183 -0
- package/tools/task-brief.mjs +86 -45
package/tools/lens-region.mjs
CHANGED
|
@@ -1,171 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
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, buildInsertPreview } from './rules-regions.mjs';
|
|
32
6
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
export const LENS_HEADING_RE =
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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 BACKTICK = String.fromCharCode(96);
|
|
17
|
+
const EM_DASH = String.fromCharCode(8212);
|
|
18
|
+
const EXIT_OK = 0;
|
|
19
|
+
const EXIT_STOP = 1;
|
|
20
|
+
const EXIT_USAGE = 2;
|
|
21
|
+
const CLI_ARG_COUNT = 2;
|
|
22
|
+
const SCRIPT_ARG_OFFSET = 2;
|
|
23
|
+
const SINGLE_HEADING = 1;
|
|
24
|
+
const HEX_RADIX = 16;
|
|
25
|
+
const UNICODE_WIDTH = 4;
|
|
26
|
+
const UTF8 = 'utf8';
|
|
27
|
+
const RECONCILE_COMMAND = 'reconcile';
|
|
28
|
+
const USAGE = 'usage: lens-region.mjs reconcile <path/to/agent_rules.md>';
|
|
29
|
+
const PRIOR_START = '<!-- prior';
|
|
30
|
+
const PRIOR_END = '-->';
|
|
31
|
+
const NUMBERED_HEADING_RE = /^### 2[.][0-9]+[.]/;
|
|
32
|
+
const NEUTRAL_HEADING = '### 2.x.';
|
|
33
|
+
const NEUTRAL_LENS_RE = /^### 2[.]x[.] Planning, review & process-fidelity/;
|
|
34
|
+
const NEUTRAL_COMMS_RE = /^### 2[.]x[.] Communication [(]user-facing messages[)]/;
|
|
35
|
+
const NEUTRAL_STORY_RE = /^### 2[.]x[.] Story sessions/;
|
|
36
|
+
const LINE_UNSAFE = new RegExp(`[${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]`, 'g');
|
|
37
|
+
const HUMAN_UNSAFE = new RegExp(`[${BACKSLASH}u0000-${BACKSLASH}u001f${BACKSLASH}u007f-${BACKSLASH}u009f${BACKSLASH}u2028${BACKSLASH}u2029]+`, 'g');
|
|
38
|
+
const SERVED_TARGET = ['docs', 'ai', 'agent_rules.md'];
|
|
39
|
+
const insertNote = (command) => command ? [`[lens-region] note: preview the absent section with \`${command}\`; use \`--apply\` only after an explicit yes.`] : [];
|
|
40
|
+
const OUTCOME_PREFIXES = Object.freeze({ communication: 'comms', 'story-sessions': 'story', lens: 'lens' });
|
|
41
|
+
const stripCr = (line) => (line.endsWith(CR) ? line.slice(0, -1) : line);
|
|
42
|
+
const escUnsafe = (character) => `${BACKSLASH}u${character.codePointAt(0).toString(HEX_RADIX).padStart(UNICODE_WIDTH, '0')}`;
|
|
43
|
+
const errorDetail = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
|
|
44
|
+
const oneLine = (value) => String(value).replace(HUMAN_UNSAFE, ' ');
|
|
45
|
+
const spanFree = (value) => oneLine(value).replaceAll(BACKTICK, escUnsafe(BACKTICK));
|
|
72
46
|
|
|
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
47
|
export const parseLensPriors = (text) => {
|
|
81
48
|
const entries = [];
|
|
82
|
-
|
|
83
|
-
for (const line of String(text).split('\n')) {
|
|
49
|
+
for (const line of String(text).split(LF)) {
|
|
84
50
|
const bare = stripCr(line);
|
|
85
|
-
if (bare.startsWith(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
51
|
+
if (bare.startsWith(PRIOR_START) && bare.endsWith(PRIOR_END)) {
|
|
52
|
+
entries.push([]);
|
|
53
|
+
} else if (entries.length) {
|
|
54
|
+
entries.at(-1).push(line);
|
|
55
|
+
}
|
|
89
56
|
}
|
|
90
|
-
|
|
91
|
-
return entries.map((e) => normalizeCanonical(e)).filter((e) => e !== '');
|
|
57
|
+
return entries.map((entry) => normalizeCanonical(entry.join(LF))).filter((entry) => entry !== '');
|
|
92
58
|
};
|
|
93
59
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
export const
|
|
98
|
-
|
|
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.
|
|
60
|
+
const renderRegion = (fragment, number, headingRe) =>
|
|
61
|
+
normalizeCanonical(fragment).replace(headingRe, (heading) => heading.replace('2.x', `2.${number}`));
|
|
62
|
+
export const renderLens = (fragment, number) => renderRegion(fragment, number, NEUTRAL_LENS_RE);
|
|
63
|
+
export const renderComms = (fragment, number) => renderRegion(fragment, number, NEUTRAL_COMMS_RE);
|
|
64
|
+
export const renderStory = (fragment, number) => renderRegion(fragment, number, NEUTRAL_STORY_RE);
|
|
102
65
|
export const normalizeLensBody = (body) =>
|
|
103
|
-
normalizeCanonical(String(body).replace(
|
|
66
|
+
normalizeCanonical(String(body).replace(NUMBERED_HEADING_RE, NEUTRAL_HEADING));
|
|
104
67
|
export const normalizeCommsBody = normalizeLensBody;
|
|
105
|
-
export const
|
|
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
|
-
};
|
|
68
|
+
export const normalizeStoryBody = normalizeLensBody;
|
|
131
69
|
export const extractLensRegion = (text) => extractRegionBy(text, LENS_HEADING_RE);
|
|
132
70
|
export const extractCommsRegion = (text) => extractRegionBy(text, COMMS_HEADING_RE);
|
|
71
|
+
export const extractStoryRegion = (text) => extractRegionBy(text, STORY.headingRe);
|
|
133
72
|
|
|
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
73
|
export const replaceLensRegion = (text, region, renderedBody) => {
|
|
138
|
-
const lines = String(text).split(
|
|
139
|
-
const crlf = String(text).includes(
|
|
74
|
+
const lines = String(text).split(LF);
|
|
75
|
+
const crlf = String(text).includes(CRLF);
|
|
140
76
|
const regionLines = lines.slice(region.start, region.end);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const newBody = renderedBody.split('\n').map((l) => (crlf ? `${l}\r` : l));
|
|
77
|
+
const bodyEnd = regionLines.findLastIndex((line) => stripCr(line).trim() !== '') + 1;
|
|
78
|
+
const trailing = regionLines.slice(bodyEnd);
|
|
79
|
+
const newBody = renderedBody.split(LF).map((line) => (crlf ? line + CR : line));
|
|
145
80
|
const out = [...lines.slice(0, region.start), ...newBody, ...trailing, ...lines.slice(region.end)];
|
|
146
|
-
//
|
|
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.
|
|
81
|
+
// At EOF without a newline, the final line must not gain a dangling CR.
|
|
149
82
|
if (crlf && trailing.length === 0 && region.end === lines.length) {
|
|
150
|
-
out[out.length - 1] = stripCr(out
|
|
83
|
+
out[out.length - 1] = stripCr(out.at(-1));
|
|
151
84
|
}
|
|
152
|
-
return out.join(
|
|
85
|
+
return out.join(LF);
|
|
153
86
|
};
|
|
154
87
|
|
|
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
88
|
const reconcileRegionText = (text, fragment, priors, { extract, normalize, render }) => {
|
|
163
89
|
const region = extract(text);
|
|
164
90
|
if (!region.found) return { status: 'no-region', text };
|
|
91
|
+
if (region.count > SINGLE_HEADING) return { status: 'custom', text };
|
|
165
92
|
const current = normalize(region.body);
|
|
166
93
|
const canon = normalize(fragment);
|
|
167
94
|
if (current === canon) return { status: 'current', text };
|
|
168
|
-
const known = priors.map((
|
|
95
|
+
const known = priors.map((prior) => normalize(prior));
|
|
169
96
|
if (!known.includes(current)) return { status: 'custom', text };
|
|
170
97
|
return { status: 'refreshed', text: replaceLensRegion(text, region, render(fragment, region.number)) };
|
|
171
98
|
};
|
|
@@ -173,98 +100,104 @@ export const reconcileLensText = (text, fragment, priors) =>
|
|
|
173
100
|
reconcileRegionText(text, fragment, priors, { extract: extractLensRegion, normalize: normalizeLensBody, render: renderLens });
|
|
174
101
|
export const reconcileCommsText = (text, fragment, priors) =>
|
|
175
102
|
reconcileRegionText(text, fragment, priors, { extract: extractCommsRegion, normalize: normalizeCommsBody, render: renderComms });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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, ' ');
|
|
103
|
+
export const reconcileStoryText = (text, fragment, priors) =>
|
|
104
|
+
reconcileRegionText(text, fragment, priors, { extract: extractStoryRegion, normalize: normalizeStoryBody, render: renderStory });
|
|
105
|
+
const RECONCILERS = Object.freeze({ communication: reconcileCommsText, 'story-sessions': reconcileStoryText, lens: reconcileLensText });
|
|
206
106
|
|
|
207
107
|
export const OUTCOME_LINES = Object.freeze({
|
|
208
|
-
errorDetail
|
|
209
|
-
targetAbsent: (target) => `[lens-region] ${target} is absent
|
|
210
|
-
commsNoRegion: (target) => [
|
|
211
|
-
`[lens-region] no "${
|
|
212
|
-
|
|
108
|
+
errorDetail,
|
|
109
|
+
targetAbsent: (target) => `[lens-region] ${oneLine(target)} is absent ${EM_DASH} skipped (nothing to update; the file is seeded at bootstrap).`,
|
|
110
|
+
commsNoRegion: (target, command) => [
|
|
111
|
+
`[lens-region] no "${COMMUNICATION.label}" section in ${spanFree(target)} ${EM_DASH} left untouched.`,
|
|
112
|
+
...insertNote(command),
|
|
213
113
|
],
|
|
214
|
-
commsCurrent: () =>
|
|
114
|
+
commsCurrent: () => `[lens-region] Communication section already current ${EM_DASH} nothing to do (zero-diff).`,
|
|
215
115
|
commsCustom: () => [
|
|
216
|
-
|
|
217
|
-
|
|
116
|
+
`[lens-region] Communication section carries a custom edit ${EM_DASH} preserved verbatim.`,
|
|
117
|
+
`[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
118
|
],
|
|
219
|
-
capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target
|
|
220
|
-
commsCapRefused: (target, count, cap) => `[lens-region] refused
|
|
119
|
+
capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target ' + EM_DASH + ' the line-cap guard is skipped.',
|
|
120
|
+
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
121
|
commsRefreshed: () => '[lens-region] refreshed the Communication section to the current canon.',
|
|
222
|
-
templateCanonStop: () => `[lens-region] STOP
|
|
122
|
+
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`,
|
|
123
|
+
storyNoRegion: (target, command) => [
|
|
124
|
+
`[lens-region] no "${STORY.label}" section in ${spanFree(target)} ${EM_DASH} left untouched.`,
|
|
125
|
+
...insertNote(command),
|
|
126
|
+
],
|
|
127
|
+
storyCurrent: () => `[lens-region] Story sessions section already current ${EM_DASH} nothing to do (zero-diff).`,
|
|
128
|
+
storyCustom: () => [
|
|
129
|
+
`[lens-region] Story sessions section carries a custom edit ${EM_DASH} preserved verbatim.`,
|
|
130
|
+
`[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.`,
|
|
131
|
+
],
|
|
132
|
+
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.`,
|
|
133
|
+
storyRefreshed: () => '[lens-region] refreshed the Story sessions section to the current canon.',
|
|
134
|
+
regionHeadingTwice: (label, target) => [
|
|
135
|
+
`[lens-region] more than one "${oneLine(label)}" section in ${oneLine(target)} ${EM_DASH} preserved verbatim.`,
|
|
136
|
+
'[lens-region] note: give the sections distinct headings before retrying; their wording was not changed.',
|
|
137
|
+
],
|
|
223
138
|
lensNoRegion: (target) => [
|
|
224
|
-
`[lens-region] no "${
|
|
225
|
-
|
|
139
|
+
`[lens-region] no "${LENS.label}" section in ${oneLine(target)} ${EM_DASH} left untouched.`,
|
|
140
|
+
`[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
141
|
],
|
|
227
|
-
engineTooOld: () => '[lens-region] skipped
|
|
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.
|
|
142
|
+
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
143
|
engineStop: (err) => {
|
|
231
|
-
const human = `[lens-region] STOP
|
|
232
|
-
return err?.reason ? [human,
|
|
144
|
+
const human = `[lens-region] STOP ${EM_DASH} ${oneLine(err?.stable ?? err?.message ?? String(err))}`;
|
|
145
|
+
return err?.reason ? [human, errorDetail(err.reason)] : [human];
|
|
233
146
|
},
|
|
234
|
-
lensCurrent: () =>
|
|
147
|
+
lensCurrent: () => `[lens-region] lens section already current ${EM_DASH} nothing to do (zero-diff).`,
|
|
235
148
|
lensCustom: () => [
|
|
236
|
-
|
|
237
|
-
|
|
149
|
+
`[lens-region] lens section carries a custom edit ${EM_DASH} preserved verbatim.`,
|
|
150
|
+
`[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
151
|
],
|
|
239
|
-
lensCapRefused: (target, count, cap) => `[lens-region] refused
|
|
152
|
+
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
153
|
lensRefreshed: () => '[lens-region] refreshed the planning/review lens section to the current canon.',
|
|
241
154
|
});
|
|
242
155
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
156
|
+
const readLensCanon = async (deps, log, logError) => {
|
|
157
|
+
const { homedir } = await import('node:os');
|
|
158
|
+
const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
|
|
159
|
+
const { dir, source } = resolveEngineDir({ env: deps.env ?? process.env, home: deps.home ?? homedir() });
|
|
160
|
+
const lensPairPresent =
|
|
161
|
+
detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
|
|
162
|
+
if (!lensPairPresent) {
|
|
163
|
+
if (detectEngine(dir, { source }).ok) {
|
|
164
|
+
log(OUTCOME_LINES.engineTooOld());
|
|
165
|
+
return { code: EXIT_OK };
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
|
|
169
|
+
return { code: EXIT_STOP };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
|
|
172
|
+
return { code: EXIT_STOP };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL, readFileSync: deps.engineRead });
|
|
177
|
+
const priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL, readFileSync: deps.engineRead }));
|
|
178
|
+
return { fragment, priors };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
|
|
181
|
+
return { code: EXIT_STOP };
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
248
185
|
export const runCli = async (argv, deps = {}) => {
|
|
249
186
|
const log = deps.log ?? console.log;
|
|
250
187
|
const logError = deps.logError ?? console.error;
|
|
251
|
-
|
|
188
|
+
if (argv[0] !== RECONCILE_COMMAND || !argv[1] || argv.length > CLI_ARG_COUNT) {
|
|
189
|
+
logError(USAGE);
|
|
190
|
+
return EXIT_USAGE;
|
|
191
|
+
}
|
|
252
192
|
const fs = deps.fs ?? (await import('node:fs/promises'));
|
|
253
|
-
const { dirname, basename, join, resolve } = await import('node:path');
|
|
254
|
-
const { homedir } = await import('node:os');
|
|
193
|
+
const { dirname, basename, join, resolve, sep } = await import('node:path');
|
|
255
194
|
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
195
|
const targetPath = resolve(argv[1]);
|
|
263
|
-
|
|
264
|
-
|
|
196
|
+
const served = targetPath.endsWith(sep + SERVED_TARGET.join(sep));
|
|
197
|
+
const command = served ? buildInsertPreview(resolve(targetPath, '..', '..', '..')) : '';
|
|
265
198
|
const text = await (async () => {
|
|
266
199
|
try {
|
|
267
|
-
return await fs.readFile(targetPath,
|
|
200
|
+
return await fs.readFile(targetPath, UTF8);
|
|
268
201
|
} catch (err) {
|
|
269
202
|
if (err.code === 'ENOENT') return null;
|
|
270
203
|
throw err;
|
|
@@ -272,122 +205,72 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
272
205
|
})();
|
|
273
206
|
if (text === null) {
|
|
274
207
|
log(OUTCOME_LINES.targetAbsent(argv[1]));
|
|
275
|
-
return
|
|
208
|
+
return EXIT_OK;
|
|
209
|
+
}
|
|
210
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
|
|
211
|
+
const template = await (async () => {
|
|
212
|
+
try {
|
|
213
|
+
const source = await fs.readFile(templatePath, UTF8);
|
|
214
|
+
const spans = RULES_REGIONS.filter((region) => region.canon === 'template')
|
|
215
|
+
.map((region) => [region.id, readTemplateSpan(source, region.headingRe)]);
|
|
216
|
+
return { spans: Object.fromEntries(spans), defect: spans.some(([, span]) => span.defect) };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { defect: true, error: err?.message ?? String(err) };
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
if (template.defect) {
|
|
222
|
+
logError(OUTCOME_LINES.templateCanonStop());
|
|
223
|
+
if (template.error) logError(OUTCOME_LINES.errorDetail(template.error));
|
|
224
|
+
return EXIT_STOP;
|
|
276
225
|
}
|
|
277
|
-
|
|
278
226
|
const atomicWrite = async (content) => {
|
|
279
227
|
const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
280
228
|
try {
|
|
281
|
-
await fs.writeFile(tmp, content,
|
|
229
|
+
await fs.writeFile(tmp, content, UTF8);
|
|
282
230
|
await fs.rename(tmp, targetPath);
|
|
283
231
|
} catch (err) {
|
|
284
232
|
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
285
233
|
throw err;
|
|
286
234
|
}
|
|
287
235
|
};
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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;
|
|
236
|
+
const outcome = await RULES_REGIONS.reduce(async (previous, descriptor) => {
|
|
237
|
+
const state = await previous;
|
|
238
|
+
if (state.code !== EXIT_OK) return state;
|
|
239
|
+
const region = extractRegionBy(state.text, descriptor.headingRe);
|
|
240
|
+
const prefix = OUTCOME_PREFIXES[descriptor.id];
|
|
241
|
+
if (region.count > SINGLE_HEADING) {
|
|
242
|
+
for (const line of OUTCOME_LINES.regionHeadingTwice(descriptor.label, argv[1])) log(line);
|
|
243
|
+
return state;
|
|
313
244
|
}
|
|
314
|
-
if (
|
|
315
|
-
for (const line of OUTCOME_LINES
|
|
316
|
-
return
|
|
245
|
+
if (!region.found) {
|
|
246
|
+
for (const line of OUTCOME_LINES[`${prefix}NoRegion`](argv[1], command)) log(line);
|
|
247
|
+
return state;
|
|
317
248
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
log
|
|
249
|
+
const canon = descriptor.canon === 'template'
|
|
250
|
+
? { fragment: normalizeLensBody(template.spans[descriptor.id].span), priors: descriptor.priors }
|
|
251
|
+
: await readLensCanon(deps, log, logError);
|
|
252
|
+
if (canon.code !== undefined) return { text: state.text, code: canon.code };
|
|
253
|
+
const result = RECONCILERS[descriptor.id](state.text, canon.fragment, canon.priors);
|
|
254
|
+
if (result.status === 'current') {
|
|
255
|
+
log(OUTCOME_LINES[`${prefix}Current`]());
|
|
256
|
+
return state;
|
|
321
257
|
}
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
return
|
|
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;
|
|
258
|
+
if (result.status === 'custom') {
|
|
259
|
+
for (const line of OUTCOME_LINES[`${prefix}Custom`]()) log(line);
|
|
260
|
+
return state;
|
|
347
261
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return
|
|
262
|
+
const cap = frontmatterMaxLines(state.text);
|
|
263
|
+
const decision = exceedsCap(result.text, cap);
|
|
264
|
+
if (decision.skipped) log(OUTCOME_LINES.capSkipNote());
|
|
265
|
+
if (decision.over) {
|
|
266
|
+
log(OUTCOME_LINES[`${prefix}CapRefused`](argv[1], decision.count, cap));
|
|
267
|
+
return state;
|
|
354
268
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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;
|
|
269
|
+
await atomicWrite(result.text);
|
|
270
|
+
log(OUTCOME_LINES[`${prefix}Refreshed`]());
|
|
271
|
+
return { text: result.text, code: EXIT_OK };
|
|
272
|
+
}, Promise.resolve({ text, code: EXIT_OK }));
|
|
273
|
+
return outcome.code;
|
|
391
274
|
};
|
|
392
275
|
|
|
393
|
-
if (isDirectRun(import.meta.url)) process.exitCode = await runCli(process.argv.slice(
|
|
276
|
+
if (isDirectRun(import.meta.url)) process.exitCode = await runCli(process.argv.slice(SCRIPT_ARG_OFFSET));
|