@sabaiway/agent-workflow-kit 5.5.0 → 5.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 +122 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +7 -1
- package/references/modes/doc-parity.md +1 -1
- package/references/modes/gates.md +20 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +4 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- package/references/modes/velocity.md +1 -0
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/gates.json +1 -1
- package/tools/ack-write.mjs +20 -11
- package/tools/atomic-write.mjs +71 -18
- package/tools/checker-claim.mjs +100 -0
- package/tools/coverage-producer.mjs +43 -6
- package/tools/direct-run.mjs +76 -0
- package/tools/doc-parity.mjs +34 -3
- package/tools/engine-source.mjs +12 -8
- package/tools/ensure-configs.mjs +141 -0
- package/tools/ensure-ops.mjs +284 -0
- package/tools/ensure-vocabulary.mjs +71 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/gates-declaration.mjs +36 -11
- package/tools/gates-init.mjs +140 -25
- package/tools/hide-footprint.mjs +21 -3
- package/tools/lens-region.mjs +74 -23
- package/tools/orchestration-config.mjs +5 -3
- package/tools/orchestration-write.mjs +7 -0
- package/tools/procedures.mjs +64 -5
- package/tools/recommendations.mjs +384 -34
- package/tools/refresh-parity.mjs +263 -0
- package/tools/run-gates.mjs +8 -5
- package/tools/setup-backends.mjs +88 -77
- package/tools/source-size-check.mjs +310 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +59 -0
- package/tools/source-size-gate-cmd.mjs +27 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -10,7 +10,8 @@ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
10
10
|
import { join, isAbsolute } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { isCoverageProducerGate } from './coverage-producer.mjs';
|
|
14
|
+
import { matchesSourceSizeGate } from './source-size-core.mjs';
|
|
14
15
|
|
|
15
16
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
16
17
|
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
@@ -22,33 +23,40 @@ const EXIT_MALFORMED = 5;
|
|
|
22
23
|
|
|
23
24
|
const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
24
25
|
const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
|
|
26
|
+
// The ONE optional gate key: the coverage-producer marker — a declared CLAIM that this gate writes
|
|
27
|
+
// the lcov the canonical checker reads (coverage-producer.mjs owns what the claim means and why
|
|
28
|
+
// recognition itself stays closed). Boolean only, and FORWARD-ONLY by design: an older kit has no
|
|
29
|
+
// such key and rejects a marker-carrying declaration loudly here, which is the honest failure — it
|
|
30
|
+
// could not honor the claim anyway.
|
|
31
|
+
export const LCOV_PRODUCER_KEY = 'lcovProducer';
|
|
32
|
+
const ALLOWED_GATE_KEYS = Object.freeze([...GATE_KEYS, LCOV_PRODUCER_KEY]);
|
|
25
33
|
|
|
26
34
|
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
27
35
|
|
|
28
36
|
// Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
|
|
29
|
-
// { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the
|
|
30
|
-
// names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
37
|
+
// { id, title, cmd, lcovProducer? }) are allowed; unknown keys anywhere are rejected loudly — the
|
|
38
|
+
// declaration names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
31
39
|
export const validateDeclaration = (parsed) => {
|
|
32
40
|
const reject = (reason) => {
|
|
33
41
|
throw fail(EXIT_MALFORMED, `${GATES_REL}: ${reason}`);
|
|
34
42
|
};
|
|
35
43
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
-
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
|
|
44
|
+
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd, lcovProducer? }, ...] }');
|
|
37
45
|
}
|
|
38
46
|
for (const key of Object.keys(parsed)) {
|
|
39
47
|
if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
|
|
40
48
|
}
|
|
41
49
|
if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
|
|
42
|
-
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
|
|
50
|
+
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd, lcovProducer? }');
|
|
43
51
|
const seenIds = new Set();
|
|
44
52
|
parsed.gates.forEach((gate, index) => {
|
|
45
53
|
const at = `gates[${index}]`;
|
|
46
54
|
if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
47
|
-
reject(`${at}: must be an object { id, title, cmd }`);
|
|
55
|
+
reject(`${at}: must be an object { id, title, cmd, lcovProducer? }`);
|
|
48
56
|
}
|
|
49
57
|
for (const key of Object.keys(gate)) {
|
|
50
|
-
if (!
|
|
51
|
-
reject(`${at}: unknown key "${key}" (allowed:
|
|
58
|
+
if (!ALLOWED_GATE_KEYS.includes(key)) {
|
|
59
|
+
reject(`${at}: unknown key "${key}" (allowed: ${ALLOWED_GATE_KEYS.join(', ')} — gates declare WHAT to check, never lane/model/routing)`);
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
for (const key of GATE_KEYS) {
|
|
@@ -56,6 +64,9 @@ export const validateDeclaration = (parsed) => {
|
|
|
56
64
|
reject(`${at}: "${key}" must be a non-empty string`);
|
|
57
65
|
}
|
|
58
66
|
}
|
|
67
|
+
if (gate[LCOV_PRODUCER_KEY] !== undefined && typeof gate[LCOV_PRODUCER_KEY] !== 'boolean') {
|
|
68
|
+
reject(`${at}: "${LCOV_PRODUCER_KEY}" must be a boolean (only the literal true claims this gate writes the lcov the checker reads)`);
|
|
69
|
+
}
|
|
59
70
|
if (/[\r\n]/.test(gate.cmd)) {
|
|
60
71
|
reject(`${at}: "cmd" must be ONE bash command line — embedded newlines (a multi-line script) are rejected; chain with && or move the script into a file`);
|
|
61
72
|
}
|
|
@@ -145,9 +156,12 @@ export const isFinalCapableDeclaration = (gates, projectDir) => {
|
|
|
145
156
|
// index. ORDER is the whole question: a producer declared AFTER the checker writes the lcov too late,
|
|
146
157
|
// so the checker reads nothing — or, worse, stale bytes an earlier run left behind — and still
|
|
147
158
|
// passes. ONE home for the rule: the written-declaration defects below and the advisor's
|
|
148
|
-
// inert-declaration item both decide through it, so they cannot drift apart.
|
|
159
|
+
// inert-declaration item both decide through it, so they cannot drift apart. Producer-ness itself is
|
|
160
|
+
// the canon's gate-level predicate (cmd closed-world OR the declared marker), so the slice is the
|
|
161
|
+
// only thing this function owns — and the slice is what keeps a marker on the CHECKER from
|
|
162
|
+
// self-pairing.
|
|
149
163
|
export const coverageProducerPrecedes = (gates, checkerIndex) =>
|
|
150
|
-
gates.slice(0, checkerIndex).some((gate) =>
|
|
164
|
+
gates.slice(0, checkerIndex).some((gate) => isCoverageProducerGate(gate));
|
|
151
165
|
|
|
152
166
|
// coverageDeclarationDefects(gates, projectDir) → the WRITTEN-declaration coverage rule, as a list
|
|
153
167
|
// of named defects (empty = satisfied): at most ONE canonical coverage checker; if one is present
|
|
@@ -174,7 +188,8 @@ export const coverageDeclarationDefects = (gates, projectDir) => {
|
|
|
174
188
|
message:
|
|
175
189
|
`${GATES_REL}: the canonical coverage checker (${checkers[0].id}) must be the LAST declared gate — ` +
|
|
176
190
|
`${after.map((g) => g.id).join(', ')} would run after it consumed the lcov. REORDER the declaration by hand ` +
|
|
177
|
-
'(the gate itself is fine — this is an ORDERING refusal
|
|
191
|
+
'(the gate itself is fine — this is an ORDERING refusal about entries that are ALREADY declared: the fill ' +
|
|
192
|
+
'places a new entry before a trailing checker, but it never reorders what it did not write)',
|
|
178
193
|
}];
|
|
179
194
|
}
|
|
180
195
|
if (!coverageProducerPrecedes(gates, index)) {
|
|
@@ -202,6 +217,16 @@ const REVIEW_DEPENDENT_CHECKS = ['review-state', 'commit-guard', 'coverage-check
|
|
|
202
217
|
export const isReviewDependentGate = (gate, projectDir) =>
|
|
203
218
|
REVIEW_DEPENDENT_CHECKS.some((check) => matchesCanonicalCheck(check, gate.cmd, projectDir));
|
|
204
219
|
|
|
220
|
+
// isKitOwnedCheckerGate — is this gate one of the KIT's own checkers, rather than something the
|
|
221
|
+
// project declared to verify itself? A separate question from review-dependence, and the two stopped
|
|
222
|
+
// coinciding the moment a kit checker arrived that needs no receipt: the source-size gate is
|
|
223
|
+
// deliberately in neither FINAL_CORE_CHECKS nor REVIEW_DEPENDENT_CHECKS, so every surface asking
|
|
224
|
+
// "does this declaration verify the PROJECT?" through the review-dependent predicate alone read a
|
|
225
|
+
// matrix of nothing but that gate as project verification. Three surfaces asked it, each knowing a
|
|
226
|
+
// different half; this is the one home, so a future kit checker is added once.
|
|
227
|
+
export const isKitOwnedCheckerGate = (gate, projectDir) =>
|
|
228
|
+
isReviewDependentGate(gate, projectDir) || matchesSourceSizeGate(gate.cmd, projectDir);
|
|
229
|
+
|
|
205
230
|
// ── the pregate subset derivation (#66 / Decision 7 — ONE home for producer and factory) ─────
|
|
206
231
|
|
|
207
232
|
export const unknownPregateExcludeIds = (gates, exclude) => {
|
package/tools/gates-init.mjs
CHANGED
|
@@ -45,11 +45,12 @@
|
|
|
45
45
|
// tool path (spaces survive; executes from the project root).
|
|
46
46
|
//
|
|
47
47
|
// Write discipline: preview (dry-run) is the DEFAULT and writes NOTHING — a declined offer leaves
|
|
48
|
-
// the file byte-identical. `--apply`
|
|
48
|
+
// the file byte-identical. `--apply` writes EXACTLY the consented entries (`--only <id>`
|
|
49
49
|
// repeatable) through the shared atomic-write core (tools/atomic-write.mjs — exclusive-create
|
|
50
|
-
// tmp+rename, TOCTOU re-check, symlink STOPs):
|
|
51
|
-
// entry, refuses id collisions, refuses a malformed declaration (never writes over what it
|
|
52
|
-
// parse).
|
|
50
|
+
// tmp+rename, TOCTOU re-check, symlink STOPs): ADD-ONLY (it never modifies, removes or reorders an
|
|
51
|
+
// existing entry), refuses id collisions, refuses a malformed declaration (never writes over what it
|
|
52
|
+
// cannot parse). WHERE a consented entry lands is a PLACEMENT rule, not a blind append — see
|
|
53
|
+
// placeEntries below. Deployment-gated: docs/ai presence (lstat, no-follow) on EVERY run; the
|
|
53
54
|
// .workflow-version == lineage-head stamp gate on --apply only (the velocity/gate-hook precedent).
|
|
54
55
|
//
|
|
55
56
|
// Exit codes: 0 done / dry-run; 1 precondition STOP (no deployment, stamp, symlink, malformed
|
|
@@ -60,10 +61,20 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
60
61
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
62
|
import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
62
63
|
import { GATES_REL, validateDeclaration } from './run-gates.mjs';
|
|
63
|
-
import { coverageDeclarationDefects,
|
|
64
|
-
import { COVERAGE_PRODUCER_BODY,
|
|
64
|
+
import { canonicalCheckerGates, coverageDeclarationDefects, isKitOwnedCheckerGate } from './gates-declaration.mjs';
|
|
65
|
+
import { COVERAGE_PRODUCER_BODY, isCoverageProducerGate } from './coverage-producer.mjs';
|
|
65
66
|
import { loadConfig } from './orchestration-config.mjs';
|
|
66
67
|
import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
68
|
+
// The source-size practice, through its PURE READ core only (D-18): this module asks whether the
|
|
69
|
+
// practice is minted, never how to mint it — the checker's writer half stays outside the read graph.
|
|
70
|
+
import {
|
|
71
|
+
INITIAL_ADOPTION_REASON,
|
|
72
|
+
SOURCE_SIZE_GATE_ID,
|
|
73
|
+
SOURCE_SIZE_TOOL_PATH,
|
|
74
|
+
escapeForLine,
|
|
75
|
+
isLineUnsafe,
|
|
76
|
+
loadSourceSizeConfig,
|
|
77
|
+
} from './source-size-core.mjs';
|
|
67
78
|
|
|
68
79
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
69
80
|
const KIT_ROOT = resolve(HERE, '..');
|
|
@@ -95,9 +106,11 @@ export const TRUST_CHAIN_DISCLOSURE =
|
|
|
95
106
|
const USAGE = `usage: gates-init [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
|
|
96
107
|
|
|
97
108
|
The consented FILL preview for the project's own docs/ai/gates.json (D9). Default is --dry-run:
|
|
98
|
-
prints the derived { id, title, cmd } entries and writes NOTHING. --apply
|
|
99
|
-
consented entries (--only <id> selects a subset;
|
|
100
|
-
|
|
109
|
+
prints the derived { id, title, cmd } entries and writes NOTHING. --apply WRITES exactly the
|
|
110
|
+
consented entries (--only <id> selects a subset; add-only — existing entries are never modified,
|
|
111
|
+
removed or reordered, an id collision is refused). A consented entry lands at the END, except that
|
|
112
|
+
a non-checker entry goes BEFORE a declaration's trailing canonical coverage checker, which must
|
|
113
|
+
stay last. The offer is CLOSED-WORLD: only a
|
|
101
114
|
terminating-class script name (test / lint / type-check / build — never release/publish/deploy,
|
|
102
115
|
never watch/serve, never a write-mode variant) whose BODY is a member of the literal runner
|
|
103
116
|
allowlist is offered, as the hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for
|
|
@@ -410,6 +423,77 @@ export const flowCheckCandidate = (cwd, deps = {}) => {
|
|
|
410
423
|
}
|
|
411
424
|
};
|
|
412
425
|
|
|
426
|
+
// The conditional SOURCE-SIZE candidate — keyed on the practice's own declaration rather than on a
|
|
427
|
+
// recipe: the gate is offered ONLY over a MINTED docs/ai/source-size.json, because on any other
|
|
428
|
+
// state the checker REFUSES by design (an absent config has no declared scope; an authored or
|
|
429
|
+
// half-written one records nothing yet), and a declared gate that refuses is a matrix this preview
|
|
430
|
+
// would have reddened on the user's behalf. The two states are told apart out loud: a project that
|
|
431
|
+
// never declared the practice hears nothing (there is no offer to make), while one that authored it
|
|
432
|
+
// and stopped short hears WHY the gate is not offered yet — the mint is the step it is missing.
|
|
433
|
+
// A path is unrenderable when EITHER guard fires, and both are asked ONCE, before anything about
|
|
434
|
+
// this candidate is printed. They are the same question wearing two coats: double-quoting cannot
|
|
435
|
+
// survive a shell-active byte, and a line-unsafe byte cannot be printed at all — U+2028 quotes
|
|
436
|
+
// perfectly and still breaks the line, so a surface screening only shell metacharacters lets it walk
|
|
437
|
+
// into a declared gate cmd while reading as fully guarded.
|
|
438
|
+
const unrenderableToolPath = (text) => isLineUnsafe(text) || DQ_UNSAFE_PATH_PATTERN.test(text);
|
|
439
|
+
|
|
440
|
+
export const sourceSizeCandidate = (cwd, deps = {}) => {
|
|
441
|
+
const toolPath = deps.sourceSizeTool ?? SOURCE_SIZE_TOOL_PATH;
|
|
442
|
+
try {
|
|
443
|
+
const { state } = loadSourceSizeConfig(resolve(cwd), deps);
|
|
444
|
+
if (state === 'absent') return { candidate: null, note: null };
|
|
445
|
+
// Asked BEFORE the state branches: every branch below prints this path, so a check that only one
|
|
446
|
+
// of them performs is a guard the other silently lacks.
|
|
447
|
+
if (unrenderableToolPath(toolPath)) {
|
|
448
|
+
// The withheld path is NAMED, so it crosses the line-safety boundary before it is rendered —
|
|
449
|
+
// escaped rather than dropped, because two different paths must never print as the same one.
|
|
450
|
+
// The advice still follows the STATE: over an unminted record a hand-declared gate is red on
|
|
451
|
+
// every run, so pointing there would hand the reader their next failure as the way out.
|
|
452
|
+
const lane = state === 'minted'
|
|
453
|
+
? 'declare the gate by hand'
|
|
454
|
+
: `mint the record first — run source-size-check.mjs --adopt --reason "${INITIAL_ADOPTION_REASON}" with the working directory set to this project, then declare the gate by hand`;
|
|
455
|
+
return {
|
|
456
|
+
candidate: null,
|
|
457
|
+
note:
|
|
458
|
+
`the source-size candidate was withheld: the resolved kit path cannot be rendered into a ` +
|
|
459
|
+
`command — it carries bytes that do not survive double-quoting, or a byte that cannot appear ` +
|
|
460
|
+
`in a printed line at all (${escapeForLine(toolPath)}) — ${lane}`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (state !== 'minted') {
|
|
464
|
+
// The named recovery is the reader's next keystroke, so it is a command they can actually run:
|
|
465
|
+
// the resolved absolute path and an explicit --cwd, carrying the reason a FIRST mint requires
|
|
466
|
+
// (recording a value for the first time is a raise, and the bare verb would refuse). The
|
|
467
|
+
// PROJECT path is screened by the same predicate — a rendered command naming a path the shell
|
|
468
|
+
// would read differently could run somewhere other than the project it names.
|
|
469
|
+
const project = resolve(cwd);
|
|
470
|
+
const recovery = unrenderableToolPath(project)
|
|
471
|
+
? `run source-size-check.mjs --adopt --reason "${INITIAL_ADOPTION_REASON}" yourself, with the working directory set to this project (no command is printed: this project's path cannot be rendered into one)`
|
|
472
|
+
: `run node "${toolPath}" --adopt --reason "${INITIAL_ADOPTION_REASON}" --cwd "${project}"`;
|
|
473
|
+
return {
|
|
474
|
+
candidate: null,
|
|
475
|
+
note:
|
|
476
|
+
`the source-size candidate was withheld: the declared practice is not yet MINTED (${state}) — ` +
|
|
477
|
+
`the checker refuses until it records this tree, so declaring the gate now would red the matrix; ` +
|
|
478
|
+
`${recovery}, then re-run this preview`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
candidate: {
|
|
483
|
+
id: SOURCE_SIZE_GATE_ID,
|
|
484
|
+
title: 'Source files within the declared size caps (the recorded ratchet)',
|
|
485
|
+
cmd: `node "${toolPath}" --check`,
|
|
486
|
+
},
|
|
487
|
+
note: null,
|
|
488
|
+
};
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return {
|
|
491
|
+
candidate: null,
|
|
492
|
+
note: `the source-size practice config is unreadable (${err.message}) — the source-size candidate was not evaluated`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
413
497
|
// Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
|
|
414
498
|
// any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
|
|
415
499
|
// "nothing to offer" success.
|
|
@@ -424,23 +508,27 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
|
|
|
424
508
|
}
|
|
425
509
|
};
|
|
426
510
|
|
|
427
|
-
// The full offer: script entries + the conditional review-state / flow-check /
|
|
428
|
-
// candidates (coverage-check LAST — the `run-gates --final` declaration-shape rule
|
|
429
|
-
// checker as the last declared gate, so a whole-offer apply is final-ready by
|
|
430
|
-
// pair keys on plan-execution.review reviewed/council OR a flow block (the P21
|
|
431
|
-
// itself appears only under a flow block.
|
|
511
|
+
// The full offer: script entries + the conditional review-state / flow-check / source-size /
|
|
512
|
+
// coverage-check candidates (coverage-check LAST — the `run-gates --final` declaration-shape rule
|
|
513
|
+
// requires the checker as the last declared gate, so a whole-offer apply is final-ready by
|
|
514
|
+
// construction). The pair keys on plan-execution.review reviewed/council OR a flow block (the P21
|
|
515
|
+
// trio); flow-check itself appears only under a flow block; source-size only over a minted practice.
|
|
432
516
|
export const buildOffer = (cwd, deps = {}) => {
|
|
433
517
|
const scripts = deriveScripts(cwd, deps);
|
|
434
518
|
const rs = reviewStateCandidate(cwd, deps);
|
|
435
519
|
const fc = flowCheckCandidate(cwd, deps);
|
|
520
|
+
const ss = sourceSizeCandidate(cwd, deps);
|
|
436
521
|
const cc = coverageCheckCandidate(cwd, deps);
|
|
437
522
|
// Decision 3 — the checker is never offered DEAD. The producer may come from this offer or from
|
|
438
523
|
// a declaration the user already wrote by hand, so the rule reads the merged picture; a
|
|
439
524
|
// declaration this preview cannot read degrades to a stated note, never to a silent withhold.
|
|
440
525
|
const existing = declaredGatesBestEffort(cwd, deps);
|
|
526
|
+
// Both sides ask the ONE gate-level predicate. The offer side can only ever be a recognized cmd
|
|
527
|
+
// (this preview never EMITS the marker — it offers nothing it cannot verify), but a declaration
|
|
528
|
+
// the user wrote by hand may carry it, and the two sides must not answer through two predicates.
|
|
441
529
|
const producerPresent =
|
|
442
|
-
scripts.entries.some((entry) =>
|
|
443
|
-
existing.gates.some((gate) =>
|
|
530
|
+
scripts.entries.some((entry) => isCoverageProducerGate(entry)) ||
|
|
531
|
+
existing.gates.some((gate) => isCoverageProducerGate(gate));
|
|
444
532
|
const withholdCoverage = cc.candidate !== null && !producerPresent;
|
|
445
533
|
const coverageNote = withholdCoverage
|
|
446
534
|
? `the coverage-check candidate was withheld: nothing would PRODUCE the lcov it reads — no offered or ` +
|
|
@@ -462,7 +550,7 @@ export const buildOffer = (cwd, deps = {}) => {
|
|
|
462
550
|
// readable, no gate → the claim and the advice both hold;
|
|
463
551
|
// readable, has gate → a green matrix proves plenty and the user already declared their own.
|
|
464
552
|
const declarationState =
|
|
465
|
-
existing.unreadable !== null ? 'unreadable' : existing.gates.some((gate) => !
|
|
553
|
+
existing.unreadable !== null ? 'unreadable' : existing.gates.some((gate) => !isKitOwnedCheckerGate(gate, cwd)) ? 'has-gate' : 'no-gate';
|
|
466
554
|
const noVerificationNote =
|
|
467
555
|
scripts.entries.length > 0
|
|
468
556
|
? null
|
|
@@ -471,10 +559,10 @@ export const buildOffer = (cwd, deps = {}) => {
|
|
|
471
559
|
? `, and none is declared either, so a green matrix would prove only that the kit's own checkers ran; declare your own in ${GATES_REL}`
|
|
472
560
|
: ''
|
|
473
561
|
}`;
|
|
474
|
-
const candidates = [rs.candidate, fc.candidate, withholdCoverage ? null : cc.candidate].filter(Boolean);
|
|
562
|
+
const candidates = [rs.candidate, fc.candidate, ss.candidate, withholdCoverage ? null : cc.candidate].filter(Boolean);
|
|
475
563
|
return {
|
|
476
564
|
entries: [...scripts.entries, ...candidates],
|
|
477
|
-
notes: [...scripts.notes, noVerificationNote, unreadableNote, rs.note, fc.note, coverageNote].filter(Boolean),
|
|
565
|
+
notes: [...scripts.notes, noVerificationNote, unreadableNote, rs.note, fc.note, ss.note, coverageNote].filter(Boolean),
|
|
478
566
|
};
|
|
479
567
|
};
|
|
480
568
|
|
|
@@ -510,7 +598,7 @@ export const formatPreview = (offer, applyInvocation = null, { explicitOnly = fa
|
|
|
510
598
|
return lines.join('\n');
|
|
511
599
|
};
|
|
512
600
|
|
|
513
|
-
// ── the existing declaration (
|
|
601
|
+
// ── the existing declaration (the base every placement is computed against) ────────────
|
|
514
602
|
const loadExistingDeclaration = (cwd, deps = {}) => {
|
|
515
603
|
const read = deps.readFile ?? readFileSync;
|
|
516
604
|
const lstat = deps.lstat ?? lstatSync;
|
|
@@ -572,7 +660,30 @@ const readStampValue = (cwd, deps = {}) => {
|
|
|
572
660
|
}
|
|
573
661
|
};
|
|
574
662
|
|
|
575
|
-
// ──
|
|
663
|
+
// ── the PLACEMENT rule (D-8) ───────────────────────────────────────────────────────────
|
|
664
|
+
// WHERE a consented entry lands. A blind append made the fill unusable on exactly the declarations
|
|
665
|
+
// it should serve best: the canonical coverage checker must be the LAST gate, so appending any
|
|
666
|
+
// other entry after it produced a declaration the written-declaration validator correctly reds —
|
|
667
|
+
// and the only way to consent to a new gate on a final-capable declaration was to hand-edit.
|
|
668
|
+
//
|
|
669
|
+
// The rule is one sentence: a non-checker entry goes BEFORE a trailing canonical checker, everything
|
|
670
|
+
// else goes at the end. It is ADD-ONLY still — existing entries keep their relative order, none is
|
|
671
|
+
// modified or removed; only the insertion point moves. A consented CHECKER stays after the trailing
|
|
672
|
+
// one on purpose: two canonical checkers is a duplicate the validator must refuse by name, and
|
|
673
|
+
// hiding that shape behind a clever placement would refuse it for the wrong reason.
|
|
674
|
+
export const placeEntries = (existingGates, selected, projectDir) => {
|
|
675
|
+
const last = existingGates[existingGates.length - 1];
|
|
676
|
+
const isChecker = (gate) => canonicalCheckerGates([gate], projectDir).length === 1;
|
|
677
|
+
if (last === undefined || !isChecker(last)) return [...existingGates, ...selected];
|
|
678
|
+
return [
|
|
679
|
+
...existingGates.slice(0, -1),
|
|
680
|
+
...selected.filter((entry) => !isChecker(entry)),
|
|
681
|
+
last,
|
|
682
|
+
...selected.filter(isChecker),
|
|
683
|
+
];
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// ── apply (write exactly the consented entries, each at its placement) ─────────────────
|
|
576
687
|
export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
577
688
|
assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
|
|
578
689
|
const stampValue = readStampValue(cwd, deps);
|
|
@@ -593,14 +704,14 @@ export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
593
704
|
const collisions = selected.filter((e) => existingIds.has(e.id)).map((e) => e.id);
|
|
594
705
|
if (collisions.length) {
|
|
595
706
|
throw stop(
|
|
596
|
-
`id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (
|
|
707
|
+
`id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (add-only: the ` +
|
|
597
708
|
`fill never modifies or removes an existing entry; pick the others with --only, or edit by hand)`,
|
|
598
709
|
);
|
|
599
710
|
}
|
|
600
711
|
|
|
601
712
|
const merged = {
|
|
602
713
|
_README: existing.outcome === 'loaded' && existing.readme !== undefined ? existing.readme : templateReadme(deps),
|
|
603
|
-
gates:
|
|
714
|
+
gates: placeEntries(existingGates, selected, cwd),
|
|
604
715
|
};
|
|
605
716
|
validateDeclaration(merged); // every written declaration passes the runner's validator, always
|
|
606
717
|
// Decision 4 — the coverage invariant is enforced on the declaration that GETS WRITTEN, not on
|
|
@@ -610,7 +721,11 @@ export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
610
721
|
if (defects.length) throw stop(`${defects[0].message} — nothing was written`);
|
|
611
722
|
const body = `${JSON.stringify(merged, null, 2)}\n`;
|
|
612
723
|
const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
|
|
613
|
-
|
|
724
|
+
const placed = selected.map((e) => e.id);
|
|
725
|
+
// `appended` is the DEPRECATED alias of `placed`, carrying the same array: this result is a public
|
|
726
|
+
// tools/ payload, and dropping a field an external consumer reads would make a placement rule a
|
|
727
|
+
// BREAKING change. The name is the only thing that was ever wrong — the value never was.
|
|
728
|
+
return { outcome: 'written', writtenPath, placed, appended: placed, notes: offer.notes };
|
|
614
729
|
};
|
|
615
730
|
|
|
616
731
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -670,7 +785,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
670
785
|
for (const note of result.notes) log(` note: ${note}`); // the user learns WHY (same note as the preview)
|
|
671
786
|
return EXIT_OK;
|
|
672
787
|
}
|
|
673
|
-
log(`[agent-workflow-kit]
|
|
788
|
+
log(`[agent-workflow-kit] declared ${result.placed.length} consented gate(s) in ${GATES_REL}: ${result.placed.join(', ')}`);
|
|
674
789
|
for (const note of result.notes) log(` note: ${note}`); // a mixed offer never silently omits what was screened
|
|
675
790
|
log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
|
|
676
791
|
return EXIT_OK;
|
package/tools/hide-footprint.mjs
CHANGED
|
@@ -444,6 +444,15 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
444
444
|
const writtenPatterns = buildBlock(writtenList.map((c) => c.pattern));
|
|
445
445
|
const needsUntrack = includedAsks.filter((a) => a.verdict === 'ask-tracked');
|
|
446
446
|
|
|
447
|
+
// The +N/−N delta against the CURRENT managed block (L3). The current set is the RAW fence body
|
|
448
|
+
// (canonicalized where recognized): a stale pattern the wholesale re-derive would silently drop
|
|
449
|
+
// is exactly what the removed list must surface.
|
|
450
|
+
const currentBlockPatterns = [...new Set(fenceBodyLines.map((l) => lineToPattern(l)).filter(Boolean).map((p) => recognizeHideRule(p) ?? p))];
|
|
451
|
+
const writtenSet = new Set(writtenPatterns);
|
|
452
|
+
const currentSet = new Set(currentBlockPatterns);
|
|
453
|
+
const added = writtenPatterns.filter((p) => !currentSet.has(p));
|
|
454
|
+
const removed = currentBlockPatterns.filter((p) => !writtenSet.has(p)).sort();
|
|
455
|
+
|
|
447
456
|
// ── build the new file (splice the fence; preserve outside lines) ──────────────
|
|
448
457
|
const fenceLines = writtenPatterns.length ? [START_MARKER, ...writtenPatterns, END_MARKER] : [];
|
|
449
458
|
const newLines = writtenPatterns.length
|
|
@@ -471,6 +480,8 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
471
480
|
action,
|
|
472
481
|
visibility: 'hidden',
|
|
473
482
|
wrote: writtenPatterns,
|
|
483
|
+
added,
|
|
484
|
+
removed,
|
|
474
485
|
asks: asks.filter((a) => !includedAsks.some((i) => i.pattern === a.pattern)).map((a) => ({ path: a.pattern, reason: a.reason, owner: a.owner })),
|
|
475
486
|
needsUntrack: needsUntrack.map((a) => {
|
|
476
487
|
const target = patternToProbe(a.pattern).replace(/\/$/, '');
|
|
@@ -530,16 +541,23 @@ const fmtGlobal = (g) => {
|
|
|
530
541
|
return [];
|
|
531
542
|
};
|
|
532
543
|
|
|
533
|
-
const formatReport = (r, dryRun) => {
|
|
544
|
+
export const formatReport = (r, dryRun) => {
|
|
534
545
|
const lines = [dryRun ? 'hide-footprint — DRY RUN (no changes)' : 'hide-footprint'];
|
|
535
|
-
if (r.visibility === 'visible') return [...lines, ` • deployment is VISIBLE (
|
|
536
|
-
if (r.ambiguous) return [...lines, ` • AMBIGUOUS visibility (
|
|
546
|
+
if (r.visibility === 'visible') return [...lines, ` • deployment is VISIBLE (${r.anchor} is tracked) — nothing to hide; wrote zero bytes`].join('\n');
|
|
547
|
+
if (r.ambiguous) return [...lines, ` • AMBIGUOUS visibility (${r.anchor} is untracked AND not ignored) — cannot tell fresh-uncommitted from broken-hidden; ASK the user before writing`].join('\n');
|
|
537
548
|
lines.push(` • ${r.action} ${r.excludeFile}`);
|
|
538
549
|
// The block contains every written pattern, but a TRACKED --include path is NOT hidden by it (it is
|
|
539
550
|
// reported separately, below) — so the "hidden" line lists only the genuinely-hidden untracked paths.
|
|
540
551
|
const untrackedOnly = new Set(r.needsUntrack.map((n) => n.path));
|
|
541
552
|
const hiddenNow = r.wrote.filter((p) => !untrackedOnly.has(p));
|
|
542
553
|
if (hiddenNow.length) lines.push(` • hidden (${hiddenNow.length}): ${hiddenNow.join(', ')}`);
|
|
554
|
+
// The block delta (L3) — rendered in dry-run and apply alike; sets listed, never counted alone.
|
|
555
|
+
// --unhide and the reconcile no-op paths carry no delta fields: their reports stay unchanged.
|
|
556
|
+
if (Array.isArray(r.added) && Array.isArray(r.removed)) {
|
|
557
|
+
if (r.added.length) lines.push(` • +${r.added.length} added: ${r.added.join(', ')}`);
|
|
558
|
+
if (r.removed.length) lines.push(` • −${r.removed.length} removed: ${r.removed.join(', ')}`);
|
|
559
|
+
if (!r.added.length && !r.removed.length) lines.push(' • +0/−0 — the hidden set is unchanged');
|
|
560
|
+
}
|
|
543
561
|
for (const a of r.asks) lines.push(` • ASK ${a.path} — ${a.reason}`);
|
|
544
562
|
for (const n of r.needsUntrack) lines.push(` • tracked, NOT hidden: ${n.path} — run \`${n.command}\` to un-track (kept on disk)`);
|
|
545
563
|
if (r.dropped.length) lines.push(` • skipped ${r.dropped.length} already-ignored (tracked .gitignore)`);
|
package/tools/lens-region.mjs
CHANGED
|
@@ -172,6 +172,58 @@ export const frontmatterMaxLines = (text) => {
|
|
|
172
172
|
return null;
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
+
// ── the outcome lines (pure composers — the CLI's one voice) ──────────────────────
|
|
176
|
+
// Every user-facing outcome line the CLI prints, one pure composer per outcome, so the
|
|
177
|
+
// composed-lines guard (test/composed-lines-ux.test.mjs) can render each against the L2
|
|
178
|
+
// user-grade invariants. runCli only ever prints through this table. Raw diagnostics never ride
|
|
179
|
+
// the human sentence: they land on the ONE machine-formatted detail line (`[lens-region]
|
|
180
|
+
// error=<JSON-encoded>` — one line, reversible, control bytes escaped), the `[tool] key=value`
|
|
181
|
+
// channel the L2 rule exempts by grammar. JSON.stringify leaves DEL/C1 and the U+2028/U+2029
|
|
182
|
+
// separators raw, and a dynamic path can carry any byte — both dynamic parts are therefore made
|
|
183
|
+
// line-safe explicitly: the machine value gains extra JSON escapes (still reversible), and the
|
|
184
|
+
// human line collapses every control/separator byte to one space.
|
|
185
|
+
const LINE_UNSAFE = new RegExp('[\\u007f-\\u009f\\u2028\\u2029]', 'g');
|
|
186
|
+
const HUMAN_UNSAFE = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+', 'g');
|
|
187
|
+
const escUnsafe = (c) => `\\u${c.codePointAt(0).toString(16).padStart(4, '0')}`;
|
|
188
|
+
const ERROR_DETAIL = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
|
|
189
|
+
const oneLine = (s) => String(s).replace(HUMAN_UNSAFE, ' ');
|
|
190
|
+
|
|
191
|
+
export const OUTCOME_LINES = Object.freeze({
|
|
192
|
+
errorDetail: ERROR_DETAIL,
|
|
193
|
+
targetAbsent: (target) => `[lens-region] ${target} is absent — skipped (nothing to update; the file is seeded at bootstrap).`,
|
|
194
|
+
commsNoRegion: (target) => [
|
|
195
|
+
`[lens-region] no "${COMMS_LABEL}" section in ${target} — left untouched.`,
|
|
196
|
+
'[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.',
|
|
197
|
+
],
|
|
198
|
+
commsCurrent: () => '[lens-region] Communication section already current — nothing to do (zero-diff).',
|
|
199
|
+
commsCustom: () => [
|
|
200
|
+
'[lens-region] Communication section carries a custom edit — preserved verbatim.',
|
|
201
|
+
'[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.',
|
|
202
|
+
],
|
|
203
|
+
capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.',
|
|
204
|
+
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.`,
|
|
205
|
+
commsRefreshed: () => '[lens-region] refreshed the Communication section to the current canon.',
|
|
206
|
+
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`,
|
|
207
|
+
lensNoRegion: (target) => [
|
|
208
|
+
`[lens-region] no "${HEADING_LABEL}" section in ${target} — left untouched.`,
|
|
209
|
+
'[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.',
|
|
210
|
+
],
|
|
211
|
+
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.',
|
|
212
|
+
// The human line keeps the classified "methodology engine not found/invalid" contract; a typed
|
|
213
|
+
// error (engine-source attaches {stable, reason}) splits its raw reason onto the machine line.
|
|
214
|
+
engineStop: (err) => {
|
|
215
|
+
const human = `[lens-region] STOP — ${oneLine(err?.stable ?? err?.message ?? String(err))}`;
|
|
216
|
+
return err?.reason ? [human, ERROR_DETAIL(err.reason)] : [human];
|
|
217
|
+
},
|
|
218
|
+
lensCurrent: () => '[lens-region] lens section already current — nothing to do (zero-diff).',
|
|
219
|
+
lensCustom: () => [
|
|
220
|
+
'[lens-region] lens section carries a custom edit — preserved verbatim.',
|
|
221
|
+
'[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.',
|
|
222
|
+
],
|
|
223
|
+
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.`,
|
|
224
|
+
lensRefreshed: () => '[lens-region] refreshed the planning/review lens section to the current canon.',
|
|
225
|
+
});
|
|
226
|
+
|
|
175
227
|
// ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
|
|
176
228
|
// Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
|
|
177
229
|
// every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
|
|
@@ -203,7 +255,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
203
255
|
}
|
|
204
256
|
})();
|
|
205
257
|
if (text === null) {
|
|
206
|
-
log(
|
|
258
|
+
log(OUTCOME_LINES.targetAbsent(argv[1]));
|
|
207
259
|
return 0;
|
|
208
260
|
}
|
|
209
261
|
|
|
@@ -229,43 +281,41 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
229
281
|
}
|
|
230
282
|
})();
|
|
231
283
|
if (!templateRegion.found) {
|
|
232
|
-
logError(
|
|
284
|
+
logError(OUTCOME_LINES.templateCanonStop());
|
|
285
|
+
if (templateRegion.error) logError(OUTCOME_LINES.errorDetail(templateRegion.error));
|
|
233
286
|
return 1;
|
|
234
287
|
}
|
|
235
288
|
const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
|
|
236
289
|
const currentText = await (async () => {
|
|
237
290
|
if (commsResult.status === 'no-region') {
|
|
238
|
-
|
|
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.');
|
|
291
|
+
for (const line of OUTCOME_LINES.commsNoRegion(argv[1])) log(line);
|
|
240
292
|
return text;
|
|
241
293
|
}
|
|
242
294
|
if (commsResult.status === 'current') {
|
|
243
|
-
log(
|
|
295
|
+
log(OUTCOME_LINES.commsCurrent());
|
|
244
296
|
return text;
|
|
245
297
|
}
|
|
246
298
|
if (commsResult.status === 'custom') {
|
|
247
|
-
|
|
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.');
|
|
299
|
+
for (const line of OUTCOME_LINES.commsCustom()) log(line);
|
|
249
300
|
return text;
|
|
250
301
|
}
|
|
251
302
|
const commsMax = frontmatterMaxLines(text);
|
|
252
303
|
if (commsMax === null) {
|
|
253
|
-
log(
|
|
304
|
+
log(OUTCOME_LINES.capSkipNote());
|
|
254
305
|
}
|
|
255
306
|
if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
|
|
256
|
-
log(
|
|
307
|
+
log(OUTCOME_LINES.commsCapRefused(argv[1], lineCount(commsResult.text), commsMax));
|
|
257
308
|
return text;
|
|
258
309
|
}
|
|
259
310
|
await atomicWrite(commsResult.text);
|
|
260
|
-
log(
|
|
311
|
+
log(OUTCOME_LINES.commsRefreshed());
|
|
261
312
|
return commsResult.text;
|
|
262
313
|
})();
|
|
263
314
|
|
|
264
315
|
// 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
|
|
265
316
|
// preserve regardless, so the lazy contract holds).
|
|
266
317
|
if (!extractLensRegion(currentText).found) {
|
|
267
|
-
|
|
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.');
|
|
318
|
+
for (const line of OUTCOME_LINES.lensNoRegion(argv[1])) log(line);
|
|
269
319
|
return 0;
|
|
270
320
|
}
|
|
271
321
|
|
|
@@ -276,14 +326,14 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
276
326
|
detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
|
|
277
327
|
if (!lensPairPresent) {
|
|
278
328
|
if (detectEngine(dir, { source }).ok) {
|
|
279
|
-
log(
|
|
329
|
+
log(OUTCOME_LINES.engineTooOld());
|
|
280
330
|
return 0;
|
|
281
331
|
}
|
|
282
332
|
try {
|
|
283
333
|
readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
|
|
284
334
|
return 1; // defensive: the pair is unusable — never proceed to a read
|
|
285
335
|
} catch (err) {
|
|
286
|
-
|
|
336
|
+
for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
|
|
287
337
|
return 1;
|
|
288
338
|
}
|
|
289
339
|
}
|
|
@@ -292,34 +342,35 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
292
342
|
let fragment;
|
|
293
343
|
let priors;
|
|
294
344
|
try {
|
|
295
|
-
|
|
296
|
-
|
|
345
|
+
// deps.engineRead is the injectable read primitive (tests drive the vanished/unreadable arm
|
|
346
|
+
// deterministically — a chmod-based fixture is root- and platform-dependent).
|
|
347
|
+
fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL, readFileSync: deps.engineRead });
|
|
348
|
+
priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL, readFileSync: deps.engineRead }));
|
|
297
349
|
} catch (err) {
|
|
298
|
-
|
|
350
|
+
for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
|
|
299
351
|
return 1;
|
|
300
352
|
}
|
|
301
353
|
|
|
302
354
|
// 5. The pure decision + the cap-guard + one atomic write.
|
|
303
355
|
const result = reconcileLensText(currentText, fragment, priors);
|
|
304
356
|
if (result.status === 'current') {
|
|
305
|
-
log(
|
|
357
|
+
log(OUTCOME_LINES.lensCurrent());
|
|
306
358
|
return 0;
|
|
307
359
|
}
|
|
308
360
|
if (result.status === 'custom') {
|
|
309
|
-
|
|
310
|
-
log('[lens-region] note: the canonical planning/review lens has changed since this section was edited — compare it with the project methodology canon when convenient; your wording is never overwritten.');
|
|
361
|
+
for (const line of OUTCOME_LINES.lensCustom()) log(line);
|
|
311
362
|
return 0;
|
|
312
363
|
}
|
|
313
364
|
// refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
|
|
314
365
|
const maxLines = frontmatterMaxLines(currentText);
|
|
315
366
|
if (maxLines === null) {
|
|
316
|
-
log(
|
|
367
|
+
log(OUTCOME_LINES.capSkipNote());
|
|
317
368
|
} else if (lineCount(result.text) > maxLines) {
|
|
318
|
-
log(
|
|
369
|
+
log(OUTCOME_LINES.lensCapRefused(argv[1], lineCount(result.text), maxLines));
|
|
319
370
|
return 0;
|
|
320
371
|
}
|
|
321
372
|
await atomicWrite(result.text);
|
|
322
|
-
log(
|
|
373
|
+
log(OUTCOME_LINES.lensRefreshed());
|
|
323
374
|
return 0;
|
|
324
375
|
};
|
|
325
376
|
|