@sabaiway/agent-workflow-kit 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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
+ ## 7.1.0 — `fold-scope`: the fold channel gets a checker that refuses a claim whose reference does not resolve (AD-110)
8
+
9
+ The engine canon now asks every finding to NAME the invariant its fix enforces before the edit
10
+ (engine **3.1.0**). Prose cannot check that a named invariant is really where you say it is.
11
+
12
+ - **`tools/fold-scope.mjs` + `tools/fold-scope-cli.mjs`** — the pure rule and its CLI half:
13
+ `--class in-scope|new-invariant|blocking`, `--claim "<the invariant>"`, `--plan <plan-file>`,
14
+ `--queue <queue-file>`. Exit 0 for every ACCEPT, non-zero for every REFUSE, one case per arm.
15
+ - **`--plan` and `--queue` are REQUIRED and never defaulted.** A project can have two registers that
16
+ both look like "the queue" — the planning lifecycle's `docs/plans/queue.md` and a declared
17
+ `flow.debtQueue` — and a checker that guessed would attest a deferral against the wrong file.
18
+ - **What it refuses**, each naming the lane out of it: an `in-scope` claim matching no acceptance
19
+ bullet; a `new-invariant` whose invariant IS an acceptance bullet (route to fold); a deferral with
20
+ no queue row, with several, whose row title carries `DONE`/`CLOSED`, that repeats a field label,
21
+ that is missing a field (named, including an `origin` that is not a `file:line`), whose claim is
22
+ not inside the row's `invariant` field, or whose residual exposure is declared live (route to
23
+ blocking) or declares neither.
24
+ - **`/agent-workflow-kit procedures plan-execution` renders the populated command** — under solo,
25
+ reviewed AND council, because the rule routes every finding and a Solo project must see the checker
26
+ too — and NAMES which of the two registers it chose for `--queue`. Every PATH operand goes through
27
+ the family's shell quoter (bare when the value is already safe, single-quoted when it is not), and
28
+ the two placeholders are single-quoted literals so their angle brackets and pipe are inert — the
29
+ line is safe to paste as rendered. `--json` gains an additive `foldScope` array.
30
+ - **Markdown is read through `references/scripts/markdown-blocks.mjs`**, the block model the
31
+ archivers already use, so fenced examples, heading levels and CRLF are solved once for the family;
32
+ a document that model refuses is a loud refusal, never a silent empty read.
33
+
34
+ **Advisory by design:** nothing records that the checker ran, so a skipped or late call is
35
+ indistinguishable from a pre-edit declaration. A fingerprint-bound receipt a gate reads is the next
36
+ slice. Additive throughout — no existing command, flag, output field or exit code changed.
37
+
7
38
  ## 7.0.0 — `/agent-workflow-kit mcp` registers the typed channel in ONE project, and `uninstall` stops reporting an interrupted teardown as a success (AD-108)
8
39
 
9
40
  > ### ⚠ BREAKING — an `uninstall` run that silently passed can now refuse, and its plan reports more
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: '7.0.0'
6
+ version: '7.1.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "7.0.0",
6
+ "version": "7.1.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "7.0.0",
3
+ "version": "7.1.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",
@@ -76,6 +76,7 @@ Apply this as part of §2 before any user-facing summary:
76
76
  ### 2.6. Planning, review & process-fidelity invariants
77
77
  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.)
78
78
  - **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.
79
+ - **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
79
80
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
80
81
  - **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape 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.
81
82
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of the finding-scope checker: argv and fs, no rule (the rule is fold-scope.mjs).
3
+ //
4
+ // `--plan` and `--queue` are REQUIRED and never defaulted. A project has two registers that can both
5
+ // look like "the queue" - the planning lifecycle's docs/plans/queue.md and a declared
6
+ // flow.debtQueue - and a checker that guessed would attest a deferral against the wrong file. The
7
+ // procedures advisor renders this command already populated, and NAMES which register it chose.
8
+ //
9
+ // Read-only: reads the two files it is pointed at, writes nothing, records nothing (advisory).
10
+ // Dependency-free, Node >= 22.
11
+ //
12
+ // Exit codes: 0 ACCEPT; 1 a matrix REFUSE; 2 usage - a missing/unknown flag, an unreadable path, or
13
+ // a refusal about the arguments themselves (an unknown or absent --class, an absent --claim).
14
+
15
+ import { readFileSync } from 'node:fs';
16
+ import { isDirectRun } from './direct-run.mjs';
17
+ import { CLASSES, ROW_FIELDS, decideFoldScope } from './fold-scope.mjs';
18
+
19
+ const HELP = `fold-scope — declare a finding's SCOPE before the edit (agent-workflow family).
20
+
21
+ Usage:
22
+ node fold-scope-cli.mjs --class <${CLASSES.join('|')}> --claim "<the invariant>" \\
23
+ --plan <plan-file> --queue <queue-file>
24
+
25
+ Every finding NAMES the invariant its fix enforces, BEFORE the edit. Where that invariant already
26
+ lives decides the arm, and this checker refuses a claim whose reference does not resolve:
27
+
28
+ in-scope the claim matches WITHIN ONE \`- \` bullet under the plan's ## Verification
29
+ (those bullets ARE the acceptance criteria) -> fold here.
30
+ new-invariant the claim matches NO acceptance bullet AND exactly one queue row carries all five
31
+ fields (${ROW_FIELDS.join(', ')}), its residual exposure
32
+ declared "not live" -> the narrow fix ships now, only the generalization defers.
33
+ blocking no correct narrow fix exists -> the phase does not close. There is no queue arm.
34
+
35
+ --plan and --queue are required and never defaulted: guessing the register would attest a deferral
36
+ against the wrong file. The check is advisory — nothing records that it ran, so a skipped or late
37
+ call is indistinguishable from a pre-edit declaration.
38
+
39
+ Exit codes: 0 ACCEPT; 1 REFUSE; 2 usage (missing/unknown flag, unreadable path, bad --class/--claim).`;
40
+
41
+ const FLAGS = ['class', 'claim', 'plan', 'queue'];
42
+
43
+ const parseArgs = (argv) => {
44
+ const opts = {};
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const arg = argv[i];
47
+ const eq = arg.indexOf('=');
48
+ const name = arg.startsWith('--') ? arg.slice(2, eq === -1 ? undefined : eq) : null;
49
+ if (!name || !FLAGS.includes(name)) throw new Error(`unexpected argument "${arg}" (flags: ${FLAGS.map((f) => `--${f}`).join(', ')})`);
50
+ if (eq !== -1) {
51
+ opts[name] = arg.slice(eq + 1);
52
+ continue;
53
+ }
54
+ const value = argv[i + 1];
55
+ if (value === undefined || value.startsWith('--')) throw new Error(`--${name} requires a value`);
56
+ opts[name] = value;
57
+ i += 1;
58
+ }
59
+ for (const name of ['plan', 'queue']) {
60
+ if (!opts[name]) throw new Error(`--${name} is required and is never defaulted — name the file this claim is checked against`);
61
+ }
62
+ return opts;
63
+ };
64
+
65
+ // main(argv, deps) -> { code, stdout, stderr }. Never calls process.exit itself (the direct-run
66
+ // guard does), and never reads anything the caller did not point it at.
67
+ export const main = (argv, deps = {}) => {
68
+ const read = deps.readFileSync ?? readFileSync;
69
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
70
+ let opts;
71
+ try {
72
+ opts = parseArgs(argv);
73
+ } catch (err) {
74
+ return { code: 2, stdout: '', stderr: `fold-scope: ${err.message}` };
75
+ }
76
+ const text = {};
77
+ for (const name of ['plan', 'queue']) {
78
+ try {
79
+ text[name] = read(opts[name], 'utf8');
80
+ } catch (err) {
81
+ return { code: 2, stdout: '', stderr: `fold-scope: --${name} "${opts[name]}" is unreadable — ${(err && err.message) || err}` };
82
+ }
83
+ }
84
+ const decided = decideFoldScope({ cls: opts.class, claim: opts.claim, planText: text.plan, queueText: text.queue });
85
+ return { code: decided.exit, stdout: decided.lines.join('\n'), stderr: '' };
86
+ };
87
+
88
+ if (isDirectRun(import.meta.url)) {
89
+ const result = main(process.argv.slice(2));
90
+ if (result.stdout) console.log(result.stdout);
91
+ if (result.stderr) console.error(result.stderr);
92
+ process.exit(result.code);
93
+ }
@@ -0,0 +1,307 @@
1
+ // The finding-scope rule as a checker — the half prose cannot do (the engine canon: procedures.md,
2
+ // plan-execution step 5). A finding NAMES the invariant its fix enforces BEFORE the edit; WHERE that
3
+ // invariant already lives decides the disposition, and this module REFUSES a claim whose reference
4
+ // does not resolve:
5
+ //
6
+ // in-scope the claim matches WITHIN ONE acceptance bullet of the plan -> fold here.
7
+ // new-invariant the claim matches NO acceptance bullet AND one OPEN queue row carries the claim in
8
+ // its invariant field and all five fields (invariant, origin as file:line, narrow
9
+ // fix, proof, residual exposure) with that exposure declared NOT live -> the narrow
10
+ // fix ships now, only the generalization defers.
11
+ // blocking no correct narrow fix exists -> the phase does not close; there is no queue arm.
12
+ //
13
+ // Markdown is read through the family's ONE block model (references/scripts/markdown-blocks.mjs, the
14
+ // module the archivers already read through): fenced regions, ATX headings at 0-3 columns of indent,
15
+ // CRLF and the backtick-info-string rule are ITS problem, not a second hand-rolled grammar here. A
16
+ // document it refuses is a loud refusal, never a silent empty read.
17
+ //
18
+ // ADVISORY in this slice: nothing records that it ran, so a skipped or late call is
19
+ // indistinguishable from a pre-edit declaration. A fingerprint-bound receipt a gate reads is next.
20
+ //
21
+ // Pure string functions - every input is text; the CLI half (fold-scope-cli.mjs) owns the fs.
22
+ // Dependency-free, Node >= 22.
23
+
24
+ import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
25
+
26
+ export const CLASSES = ['in-scope', 'new-invariant', 'blocking'];
27
+ export const ROW_FIELDS = ['invariant', 'origin', 'narrow fix', 'proof', 'residual exposure'];
28
+ export const ACCEPTANCE_HEADING = '## Verification';
29
+ // The canon says a deferral row carries "the origin `file:line`". Anchored at the start of the value
30
+ // and a POSITIVE line number, so "file.mjs:12junk" and "file.mjs:0" are not one; trailing context
31
+ // after the token is fine, because the canon asks the row to CARRY a file:line, not to carry nothing
32
+ // else.
33
+ const ORIGIN_SHAPE = /^\S+:[1-9]\d*(\s|$)/;
34
+ // A closed row is not a live deferral. Narrow on purpose: the row TITLE carrying one of these
35
+ // literals refuses fail-closed; the general per-project status grammar is queued, not guessed here.
36
+ const CLOSED_MARKERS = ['DONE', 'CLOSED'];
37
+ const ORIGIN_MISSING = 'origin (the canon requires a file:line)';
38
+ const BULLET = /^-\s+\S/;
39
+
40
+ const normalize = (s) => String(s ?? '').replace(/\r/g, '').replace(/\s+/g, ' ').trim();
41
+ const contains = (haystack, needle) => normalize(haystack).toLowerCase().includes(needle);
42
+
43
+ // The ONE bullet scan both readers use, over the block model's lines. A fenced region is a quotation
44
+ // AND a boundary: it closes the block it interrupts, so text past a fence can never join the bullet
45
+ // before it (which would let a far-side literal satisfy a near-side claim). A `-` plus any whitespace
46
+ // run opens a block; a blank or indented line continues it; any other unindented line closes it.
47
+ // Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them.
48
+ const bulletBlocks = (lines, fencedLines, from, to) => {
49
+ const blocks = [];
50
+ let current = null;
51
+ const close = () => {
52
+ if (current) blocks.push(current);
53
+ current = null;
54
+ };
55
+ for (let index = from; index < to; index += 1) {
56
+ if (fencedLines.has(index)) {
57
+ close();
58
+ continue;
59
+ }
60
+ const line = lines[index];
61
+ if (BULLET.test(line)) {
62
+ close();
63
+ current = [line];
64
+ } else if (current && (line.trim() === '' || /^\s+\S/.test(line))) {
65
+ current.push(line);
66
+ } else {
67
+ close();
68
+ }
69
+ }
70
+ close();
71
+ return blocks;
72
+ };
73
+
74
+ // extractAcceptance(planText) -> the top-level bullets under `## Verification`, each collapsed to one
75
+ // line. Per the planning canon those bullets ARE the acceptance criteria and they are the WHOLE list.
76
+ // ONE heading recognizer decides both ends: the section opens on the block model's `## Verification`
77
+ // heading and closes at the next heading of level 1 or 2, so a level-3 subheading stays INSIDE (which
78
+ // is what "in this section" means) and a 4-space line, being an indented code block rather than a
79
+ // heading, neither opens nor closes it. A Verification with no bullets declares NO criteria.
80
+ export const extractAcceptance = (planText) => {
81
+ const { lines, headings, fencedLines } = tokenizeMarkdown(String(planText ?? ''), 'the plan');
82
+ const open = headings.find((heading) => heading.text.trim() === ACCEPTANCE_HEADING);
83
+ if (!open) return [];
84
+ const next = headings.find((heading) => heading.index > open.index && heading.level <= 2);
85
+ return bulletBlocks(lines, fencedLines, open.index + 1, next ? next.index : lines.length)
86
+ .map((block) => normalize(block.join('\n').replace(/^-\s+/, '')))
87
+ .filter(Boolean);
88
+ };
89
+
90
+ // ONE question per pattern: this one asks whether the line CARRIES a label, never whether the value
91
+ // behind it is any good. Requiring a non-empty value here made an empty repeat invisible, so a row
92
+ // could repeat a label and still be admitted; the value is judged downstream, where "" is missing.
93
+ const FIELD_LINE = new RegExp(`^\\s*(?:[-*]\\s+)?(${ROW_FIELDS.map((f) => f.replace(' ', '\\s+')).join('|')})\\s*:\\s*(.*)$`, 'i');
94
+
95
+ // The labelled fields of one row, folding CONTINUATION lines into the value they belong to (a wrapped
96
+ // invariant read to its first line only would refuse a legitimate deferral). A REPEATED label is
97
+ // recorded and REFUSED, never resolved by keeping the first value: a row saying "not live" and then
98
+ // "live" would otherwise be accepted as not-live, which is the contradiction this fails closed on.
99
+ const parseFields = (block) => {
100
+ const values = {};
101
+ let open = null;
102
+ for (const line of block.split('\n')) {
103
+ const match = line.match(FIELD_LINE);
104
+ if (match) {
105
+ open = match[1].toLowerCase().replace(/\s+/g, ' ');
106
+ values[open] = [...(values[open] ?? []), match[2].trim()];
107
+ } else if (open && /^\s+\S/.test(line) && !BULLET.test(line.trim())) {
108
+ values[open][values[open].length - 1] += ` ${line.trim()}`;
109
+ } else {
110
+ open = null;
111
+ }
112
+ }
113
+ const fields = {};
114
+ for (const label of ROW_FIELDS) fields[label] = values[label]?.[0] ?? null;
115
+ return { fields, values, duplicates: ROW_FIELDS.filter((label) => (values[label] ?? []).length > 1) };
116
+ };
117
+
118
+ // The disposition a residual exposure declares. Exactly TWO forms declare the negative: "not live"
119
+ // and "not-live", each a standalone token on both sides — so "not--live", "not - live",
120
+ // "not-live-ish" and "maybe-not-live" declare nothing. EVERY not-live span is removed before the
121
+ // positive is looked for, malformed ones included: the `live` inside a botched negative is not a
122
+ // declaration of the positive, and reading it as one would route the author to blocking over a row
123
+ // that only needs re-wording. A standalone bare `live` then decides, and a row carrying both a
124
+ // declared negative and a real positive is a contradiction that fails closed as live.
125
+ const NEGATIVE_SPAN = /(?<![-\w])not[\s-]*live(?![-\w])/g;
126
+ const DECLARED_NEGATIVE = /(?<![-\w])not(?:\s+|-)live(?![-\w])/;
127
+ const LIVE_TOKEN = /(?<![-\w])live(?![-\w])/;
128
+ const exposureOf = (value) => {
129
+ const text = String(value ?? '').toLowerCase();
130
+ const declaredNegative = DECLARED_NEGATIVE.test(text);
131
+ if (LIVE_TOKEN.test(text.replace(NEGATIVE_SPAN, ' '))) return 'live';
132
+ return declaredNegative ? 'not-live' : null;
133
+ };
134
+
135
+ const topLevelRows = (queueText) => {
136
+ const { lines, fencedLines } = tokenizeMarkdown(String(queueText ?? ''), 'the queue');
137
+ return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.join('\n'));
138
+ };
139
+
140
+ const EMPTY_ROW = () => ({ found: false, matches: 0, fields: {}, missing: [...ROW_FIELDS], duplicates: [], exposure: null, closed: null, claimInInvariant: false });
141
+
142
+ // findDebtRow(queueText, claim) -> the ONE queue row FOR this invariant, its fields and its
143
+ // disposition. Discovery prefers the row whose INVARIANT field carries the claim; the whole-block
144
+ // match is only the fallback, and it exists so a row that carries the claim but no invariant field is
145
+ // still FOUND and its missing field can be named. Zero and several matches are both "not found" with
146
+ // the count: a deferral names ONE row, and guessing which is the unresolved reference this refuses.
147
+ export const findDebtRow = (queueText, claim) => {
148
+ const needle = normalize(claim).toLowerCase();
149
+ if (!needle) return EMPTY_ROW();
150
+ const rows = topLevelRows(queueText).map((block) => ({ block, ...parseFields(block) }));
151
+ // EVERY recorded value of `invariant` is a candidate key, not just the first: a claim sitting in a
152
+ // repeated label would otherwise be invisible here and resolve to some OTHER row.
153
+ const owns = (r) => (r.values.invariant ?? []).some((v) => contains(v, needle));
154
+ const byInvariant = rows.filter(owns);
155
+ const hits = byInvariant.length ? byInvariant : rows.filter((r) => contains(r.block, needle));
156
+ // A candidate that repeats a label cannot be reasoned about at all, so the refusal comes BEFORE the
157
+ // row is resolved — ahead of the ambiguity count and ahead of every field judgement.
158
+ const broken = [...new Set(hits.flatMap((r) => r.duplicates))];
159
+ if (broken.length) return { ...EMPTY_ROW(), matches: hits.length, duplicates: broken };
160
+ if (hits.length !== 1) return { ...EMPTY_ROW(), matches: hits.length };
161
+ const { block, fields, duplicates } = hits[0];
162
+ const missing = ROW_FIELDS.filter((label) => !fields[label]);
163
+ if (fields.origin && !ORIGIN_SHAPE.test(fields.origin)) missing.push(ORIGIN_MISSING);
164
+ return {
165
+ found: true,
166
+ matches: 1,
167
+ block,
168
+ fields,
169
+ missing,
170
+ duplicates,
171
+ exposure: exposureOf(fields['residual exposure']),
172
+ closed: CLOSED_MARKERS.find((marker) => block.split('\n')[0].includes(marker)) ?? null,
173
+ claimInInvariant: owns(hits[0]),
174
+ };
175
+ };
176
+
177
+ const verdict = (kind, code, exit, claim, lines) => ({
178
+ verdict: kind,
179
+ code,
180
+ exit,
181
+ lines: [`fold-scope: ${kind} ${code}${claim ? ` — "${claim}"` : ''}`, ...lines],
182
+ });
183
+ const accept = (code, claim, lines) => verdict('ACCEPT', code, 0, claim, lines);
184
+ const refuse = (code, claim, lines, exit = 1) => verdict('REFUSE', code, exit, claim, lines);
185
+
186
+ const FIVE_FIELDS = ROW_FIELDS.join(', ');
187
+
188
+ // A document read is the ONE place a throw is expected: the block model REFUSES an unclosed fence and
189
+ // an ambiguous leading `---` rather than guessing, and its message names the file and line.
190
+ const read = (fn) => {
191
+ try {
192
+ return { value: fn() };
193
+ } catch (err) {
194
+ return { error: (err && err.message) || String(err) };
195
+ }
196
+ };
197
+ const unreadable = (claim, message) => refuse('document-unreadable', claim, [
198
+ ` ${message}`,
199
+ ' a document that cannot be read as markdown is never guessed around — fix it, then re-run.',
200
+ ], 2);
201
+
202
+ // decideFoldScope({ cls, claim, planText, queueText }) -> { verdict, code, exit, lines }.
203
+ // Exit 0 for every ACCEPT, 1 for every matrix REFUSE, 2 for a refusal about the ARGUMENTS themselves
204
+ // (an unknown or absent class, an absent claim, an unreadable document) - the CLI prints the lines
205
+ // and returns the code.
206
+ export const decideFoldScope = ({ cls, claim, planText, queueText } = {}) => {
207
+ const claimText = normalize(claim);
208
+ if (!CLASSES.includes(cls)) {
209
+ return refuse('class-unknown', typeof cls === 'string' ? cls : '', [
210
+ ` --class must be one of: ${CLASSES.join(', ')} — there is no default arm.`,
211
+ ' name the invariant the fix would enforce, then say where it already lives.',
212
+ ], 2);
213
+ }
214
+ if (!claimText) {
215
+ return refuse('claim-absent', '', [
216
+ ' --claim carries the invariant the fix would enforce, as a literal.',
217
+ ' a finding with no named invariant has no scope to decide.',
218
+ ], 2);
219
+ }
220
+ if (cls === 'blocking') {
221
+ return accept('blocking', claimText, [
222
+ ' no correct narrow fix exists, so the phase does not close.',
223
+ ' there is no deferral arm here: fix it in this phase, or the phase stays open.',
224
+ ]);
225
+ }
226
+ const criteria = read(() => extractAcceptance(planText));
227
+ if (criteria.error) return unreadable(claimText, criteria.error);
228
+ const acceptance = criteria.value;
229
+ const needle = claimText.toLowerCase();
230
+ const matched = acceptance.find((bullet) => contains(bullet, needle)) ?? null;
231
+ if (cls === 'in-scope') {
232
+ return matched
233
+ ? accept('in-scope', claimText, [
234
+ ` matched acceptance bullet: ${matched}`,
235
+ ' fold here: the invariant is already required by this plan. Fold the finding as a red->green test and re-review.',
236
+ ])
237
+ : refuse('in-scope-unmatched', claimText, [
238
+ ` no acceptance bullet of the plan carries this literal (${acceptance.length} bullet(s) read).`,
239
+ ' the lane: ship the NARROW fix for the found site (red first, then green); the generalization defers only as',
240
+ ` --class new-invariant, once a queue row carries ${FIVE_FIELDS}. With no correct narrow fix, --class blocking.`,
241
+ ]);
242
+ }
243
+ if (matched) {
244
+ return refuse('new-invariant-already-accepted', claimText, [
245
+ ` the invariant IS an acceptance bullet: ${matched}`,
246
+ ' route to the fold arm: --class in-scope. Already-required work is never a deferral.',
247
+ ]);
248
+ }
249
+ const found = read(() => findDebtRow(queueText, claimText));
250
+ if (found.error) return unreadable(claimText, found.error);
251
+ const row = found.value;
252
+ if (row.duplicates.length) {
253
+ return refuse('new-invariant-row-duplicate-field', claimText, [
254
+ ` a candidate queue row declares more than once: ${row.duplicates.join(', ')}.`,
255
+ ' a repeated label is not resolved by keeping the first value — a row that says both "not live"',
256
+ ' and "live" declares a contradiction, and a claim hidden in a repeat would resolve to another row.',
257
+ ' Delete the duplicate so the row states ONE value per field.',
258
+ ]);
259
+ }
260
+ if (row.matches > 1) {
261
+ return refuse('new-invariant-row-ambiguous', claimText, [
262
+ ` ${row.matches} queue rows carry this literal — a deferral names ONE row.`,
263
+ ' narrow the claim to the invariant statement of the row you mean.',
264
+ ]);
265
+ }
266
+ if (!row.found) {
267
+ return refuse('new-invariant-row-absent', claimText, [
268
+ ' no queue row carries this literal.',
269
+ ` a deferral owes a row carrying all five fields: ${FIVE_FIELDS}, that exposure declared NOT live.`,
270
+ ' if the exposure IS live, this is not a deferral at all — it is --class blocking.',
271
+ ]);
272
+ }
273
+ if (row.closed) {
274
+ return refuse('new-invariant-row-closed', claimText, [
275
+ ` the queue row title carries "${row.closed}" — a closed row is not a live deferral.`,
276
+ ' re-open that row, or write a new one for the invariant this finding names.',
277
+ ]);
278
+ }
279
+ if (row.missing.length) {
280
+ return refuse('new-invariant-row-incomplete', claimText, [
281
+ ` the queue row is missing: ${row.missing.join(', ')}.`,
282
+ ' a row short of a field is a note, not a deferral — write the field, then re-run.',
283
+ ]);
284
+ }
285
+ if (!row.claimInInvariant) {
286
+ return refuse('new-invariant-claim-not-invariant', claimText, [
287
+ ` the row carries this literal, but NOT in its invariant field: ${row.fields.invariant}`,
288
+ ' a deferral is keyed on the invariant the row states — quote that, or write the row this finding needs.',
289
+ ]);
290
+ }
291
+ if (row.exposure === 'live') {
292
+ return refuse('new-invariant-exposure-live', claimText, [
293
+ ` the residual exposure is declared LIVE: ${row.fields['residual exposure']}`,
294
+ ' a live defect in shipped behaviour is never deferred — route to --class blocking.',
295
+ ]);
296
+ }
297
+ if (row.exposure === null) {
298
+ return refuse('new-invariant-exposure-undeclared', claimText, [
299
+ ` the residual exposure declares neither "live" nor "not live": ${row.fields['residual exposure']}`,
300
+ ' silence is not a declaration — state the disposition in the row.',
301
+ ]);
302
+ }
303
+ return accept('new-invariant', claimText, [
304
+ ` the queue row carries all five fields and declares its residual exposure NOT live: ${row.fields.origin}`,
305
+ ' the narrow fix ships in this phase; ONLY the generalization defers.',
306
+ ]);
307
+ };
@@ -31,6 +31,9 @@ import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from '.
31
31
  // The plan-in-flight detector (AD-038) — imported from the plan-files.mjs LEAF (read-only fs by
32
32
  // construction); the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported.
33
33
  import { plansInFlight, PLANS_REL } from './plan-files.mjs';
34
+ // The family's ONE shell quoter for a RENDERED command operand (bare when the value is already safe,
35
+ // single-quoted otherwise) — the same leaf eight other command renderers here read through.
36
+ import { shellQuoteArg } from './repo-lex.mjs';
34
37
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
35
38
  // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
36
39
  // (orchestration-write.mjs) DIRECTLY — the import-split test pins the direct-import rule.
@@ -298,6 +301,24 @@ const autonomyAdvice = (activity, facts) => {
298
301
  ];
299
302
  };
300
303
 
304
+ // The finding-scope block (procedures.md plan-execution step 5) — plan-execution ONLY and
305
+ // UNCONDITIONAL: the rule routes EVERY finding, review-backed or not, so gating it on REVIEW_RECIPES
306
+ // (which gates only the loop economics above) would hide it from every Solo project. The canon
307
+ // section is printed VERBATIM above, so this block never re-states the rule — it carries only what
308
+ // the canon cannot: the POPULATED checker command, and which of the two registers `--queue` names.
309
+ export const FOLD_SCOPE_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'fold-scope-cli.mjs');
310
+ const foldScopeAdvice = (activity, config, plans) => {
311
+ if (activity !== 'plan-execution') return [];
312
+ const declared = config?.flow?.debtQueue ?? null;
313
+ const queue = declared ?? `${PLANS_REL}/queue.md`;
314
+ const plan = plans.length === 1 ? `${PLANS_REL}/${plans[0]}` : '<plan-file>';
315
+ return [
316
+ 'Finding scope (procedures.md plan-execution step 5) — the rule is the section above; this is the checker it names:',
317
+ ` • node ${shellQuoteArg(FOLD_SCOPE_TOOL)} --class '<in-scope|new-invariant|blocking>' --claim '<the invariant>' --plan ${shellQuoteArg(plan)} --queue ${shellQuoteArg(queue)}`,
318
+ ` • --queue is ${declared ? `${declared}, the declared flow.debtQueue` : `${queue}, the planning lifecycle queue (no flow.debtQueue is declared)`}. Advisory: nothing records that it ran, so a skipped or late call is indistinguishable from a pre-edit declaration.`,
319
+ ];
320
+ };
321
+
301
322
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
302
323
  // at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
303
324
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -445,7 +466,7 @@ const contractLines = ({ cmd, contract, settings }) => {
445
466
  return lines;
446
467
  };
447
468
 
448
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice }) => {
469
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => {
449
470
  const lines = [
450
471
  section,
451
472
  '',
@@ -464,6 +485,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
464
485
  if (grounding.length) lines.push('', ...grounding);
465
486
  const advice = reviewLoopAdvice(slots, activity);
466
487
  if (advice.length) lines.push('', ...advice);
488
+ if (foldScope.length) lines.push('', ...foldScope);
467
489
  lines.push('', ...costLanesAdvice());
468
490
  if (declaredPractice.length) lines.push('', ...declaredPractice);
469
491
  if (warnings.length) {
@@ -473,7 +495,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
473
495
  return lines.join('\n');
474
496
  };
475
497
 
476
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }) => ({
498
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => ({
477
499
  activity,
478
500
  section,
479
501
  slots: Object.fromEntries(
@@ -486,6 +508,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
486
508
  groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
487
509
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
488
510
  costLanes: costLanesAdvice(),
511
+ // ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
512
+ foldScope,
489
513
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
490
514
  autonomy: autonomyAdvice(activity, autonomy),
491
515
  // ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
@@ -574,9 +598,10 @@ export const main = (argv, ctx = {}) => {
574
598
  const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
575
599
  const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
576
600
  const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
601
+ const foldScope = foldScopeAdvice(activity, config, plans);
577
602
  const stdout = json
578
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }), null, 2)
579
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice });
603
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }), null, 2)
604
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope });
580
605
  if (autonomy?.error) {
581
606
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
582
607
  }