@sabaiway/agent-workflow-kit 1.32.0 → 1.34.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 +100 -0
- package/README.md +1 -1
- package/SKILL.md +2 -2
- package/bin/install.mjs +22 -1
- package/capability.json +1 -1
- package/launchers/windsurf-workflow.md +3 -1
- package/package.json +1 -1
- package/references/contracts.md +3 -2
- package/references/modes/bootstrap.md +11 -6
- package/references/modes/gates.md +4 -2
- package/references/modes/review-state.md +1 -1
- package/references/modes/status.md +2 -0
- package/references/modes/upgrade.md +19 -9
- package/references/shared/composition-handoff.md +12 -0
- package/references/shared/deploy-tail.md +1 -1
- package/references/shared/report-footer.md +11 -4
- package/references/templates/agent_rules.md +1 -1
- package/tools/atomic-write.mjs +106 -0
- package/tools/commands.mjs +15 -1
- package/tools/delegation.mjs +6 -3
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +41 -5
- package/tools/lens-region.mjs +251 -0
- package/tools/orchestration-write.mjs +8 -78
- package/tools/presentation.mjs +1 -0
- package/tools/renderers.mjs +10 -2
- package/tools/review-state.mjs +4 -2
- package/tools/seed-gates.mjs +413 -0
- package/tools/velocity-profile.mjs +4 -0
- package/tools/view-model.mjs +13 -0
package/tools/commands.mjs
CHANGED
|
@@ -96,7 +96,7 @@ const CATALOG = [
|
|
|
96
96
|
invocation: invocationOf('gates'),
|
|
97
97
|
group: 'Inspect',
|
|
98
98
|
kind: PROJECT_EXEC,
|
|
99
|
-
oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
|
|
99
|
+
oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
key: 'setup',
|
|
@@ -212,6 +212,19 @@ const KIND_TAG = {
|
|
|
212
212
|
};
|
|
213
213
|
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
214
214
|
|
|
215
|
+
// The "Tune" tail — the opt-in accelerator funnel, rendered AFTER the catalog groups. NOT a new
|
|
216
|
+
// catalog key or mode (the frozen CATALOG + GROUP_ORDER stay; the router SKILL.md is untouched):
|
|
217
|
+
// the same four entries the bootstrap accelerators block presents, one line of why each.
|
|
218
|
+
const TUNE_TAIL = Object.freeze([
|
|
219
|
+
'',
|
|
220
|
+
'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
|
|
221
|
+
` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
|
|
222
|
+
` ${BARE_INVOCATION} agents cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage)`,
|
|
223
|
+
` ${BARE_INVOCATION} gates run your declared gates (docs/ai/gates.json) as one batch; its guide also offers the consent-gated seeding preview — writes only on your yes`,
|
|
224
|
+
` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
|
|
225
|
+
` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
|
|
226
|
+
]);
|
|
227
|
+
|
|
215
228
|
// formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
|
|
216
229
|
// catalog order within each group. The header states the index itself is read-only.
|
|
217
230
|
export const formatHelp = () => {
|
|
@@ -229,6 +242,7 @@ export const formatHelp = () => {
|
|
|
229
242
|
lines.push(` ${pad(c.invocation, invocWidth)} ${KIND_TAG[c.kind] ?? c.kind} ${c.oneLine}`);
|
|
230
243
|
}
|
|
231
244
|
}
|
|
245
|
+
lines.push(...TUNE_TAIL);
|
|
232
246
|
return lines.join('\n');
|
|
233
247
|
};
|
|
234
248
|
|
package/tools/delegation.mjs
CHANGED
|
@@ -83,7 +83,9 @@ export const handoffPlan = (delegate) =>
|
|
|
83
83
|
? {
|
|
84
84
|
mode: 'delegate',
|
|
85
85
|
memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
|
|
86
|
-
|
|
86
|
+
// The lens region runs AFTER the substrate deploy (its own precondition: the file exists)
|
|
87
|
+
// — it converges a stale-memory seed to the installed engine's canon (AD-041).
|
|
88
|
+
kitWrites: ['AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
|
|
87
89
|
stampsPresent: ['.memory-version', '.workflow-version'],
|
|
88
90
|
memoryRaisesCommitGate: false,
|
|
89
91
|
commitGate: 'kit-only-after-injection',
|
|
@@ -93,8 +95,9 @@ export const handoffPlan = (delegate) =>
|
|
|
93
95
|
memoryWrites: [],
|
|
94
96
|
// Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
|
|
95
97
|
// the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
|
|
96
|
-
// path — so both paths end with a FILLED slot, not inline methodology.
|
|
97
|
-
|
|
98
|
+
// path — so both paths end with a FILLED slot, not inline methodology. The lens region
|
|
99
|
+
// runs after the fallback-template copy of docs/ai (same reconcile, both paths — AD-041).
|
|
100
|
+
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
|
|
98
101
|
stampsPresent: ['.workflow-version'],
|
|
99
102
|
memoryRaisesCommitGate: false,
|
|
100
103
|
commitGate: 'kit-only-after-injection',
|
package/tools/engine-source.mjs
CHANGED
|
@@ -31,6 +31,12 @@ export const ORCHESTRATION_FRAGMENT_REL = 'references/orchestration-slot.md';
|
|
|
31
31
|
// CLI fail loudly on — an engine too old to ship it. readEngineFragment accepts an arbitrary `rel`
|
|
32
32
|
// (no whitelist), so no further plumbing is needed beyond this constant.
|
|
33
33
|
export const PROCEDURES_FRAGMENT_REL = 'references/procedures.md';
|
|
34
|
+
// The agent-rules lens pair — the canonical lens block + its append-only prior store (engine
|
|
35
|
+
// >= 1.13.0; an older engine ships neither). The kit's lens-region reconcile live-reads BOTH via
|
|
36
|
+
// readEngineFragment({ rel }); detectEngine({ rel }) lets it soft-skip a too-old engine (and
|
|
37
|
+
// `status` caveat it) instead of failing the whole reconcile.
|
|
38
|
+
export const LENS_FRAGMENT_REL = 'references/agent-rules-lens.md';
|
|
39
|
+
export const LENS_PRIORS_REL = 'references/agent-rules-lens-priors.md';
|
|
34
40
|
const ENGINE_DEFAULT_REL = '.claude/skills/agent-workflow-engine';
|
|
35
41
|
|
|
36
42
|
const defaultStatType = (path) => {
|
|
@@ -26,7 +26,7 @@ import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
|
|
|
26
26
|
import { parseSemver, compareSemver } from './semver-lite.mjs';
|
|
27
27
|
import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
28
28
|
import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
|
|
29
|
-
import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
|
|
29
|
+
import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
|
|
30
30
|
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
31
31
|
// The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
|
|
32
32
|
// settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
|
|
@@ -47,7 +47,10 @@ import {
|
|
|
47
47
|
// The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
|
|
48
48
|
// implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
|
|
49
49
|
import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
|
|
50
|
-
import { GATES_REL } from './run-gates.mjs';
|
|
50
|
+
import { GATES_REL, loadDeclaration } from './run-gates.mjs';
|
|
51
|
+
// The cheap-agents writer's own bundle reader + placement planner — reused by the settings survey
|
|
52
|
+
// (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
|
|
53
|
+
import { readBundledAgents, planPlacement } from './cheap-agents.mjs';
|
|
51
54
|
// The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
|
|
52
55
|
// forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
|
|
53
56
|
// nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
|
|
@@ -171,9 +174,13 @@ export const classifyMember = (member, deps = {}) => {
|
|
|
171
174
|
// the manifest AND reads the fragment — so an absent, non-file, OR present-but-unreadable fragment all
|
|
172
175
|
// surface (status never claims "ok" for a fragment a reconcile / the procedures CLI would STOP on), and
|
|
173
176
|
// a current, readable fragment never gets the caveat. Read-only, best-effort.
|
|
177
|
+
// Each caveat names the rel(s) its consumer actually needs: the lens reconcile requires the
|
|
178
|
+
// fragment AND its prior store as a PAIR (lens-region soft-skips when either is missing), so its
|
|
179
|
+
// caveat keys on both — an engine missing only the prior store must never report healthy.
|
|
174
180
|
const ENGINE_FRAGMENT_CAVEATS = [
|
|
175
|
-
{
|
|
176
|
-
{
|
|
181
|
+
{ rels: [ORCHESTRATION_FRAGMENT_REL], caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
182
|
+
{ rels: [PROCEDURES_FRAGMENT_REL], caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
183
|
+
{ rels: [LENS_FRAGMENT_REL, LENS_PRIORS_REL], caveat: 'engine present but does not ship the agent-rules lens canon (the fragment + its prior store; too old / incomplete — engine >= 1.13.0) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
177
184
|
];
|
|
178
185
|
|
|
179
186
|
// The orchestration-config TEMPLATE a current memory ships (added in memory 1.2.0; absent in older
|
|
@@ -244,7 +251,7 @@ export const surveyFamily = (deps = {}) =>
|
|
|
244
251
|
return false; // absent / non-file / unreadable fragment → the engine can't supply it
|
|
245
252
|
}
|
|
246
253
|
};
|
|
247
|
-
const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !
|
|
254
|
+
const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !f.rels.every(fragmentUsable)).map((f) => f.caveat);
|
|
248
255
|
if (caveats.length) row.caveats = caveats;
|
|
249
256
|
}
|
|
250
257
|
// Memory offline caveat (Step 2.2): a distinct probe — the orchestration TEMPLATE file's existence.
|
|
@@ -452,21 +459,50 @@ export const surveyGateHook = (dir, deps = {}) => {
|
|
|
452
459
|
const exists = deps.exists ?? existsSync;
|
|
453
460
|
const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
|
|
454
461
|
const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
|
|
462
|
+
// How many gates are actually DECLARED — the welcome-mat hook rung keys on a non-empty
|
|
463
|
+
// declaration (a fresh bootstrap seeds gates.json EMPTY, so file presence alone would misfire).
|
|
464
|
+
// 0 = absent or empty list; null = present but unreadable/malformed (unknown — never a false
|
|
465
|
+
// count, and never an area-wide error: the wired/placed probes still report; the validation
|
|
466
|
+
// REASON is preserved beside the null so status never renders a bare question mark).
|
|
467
|
+
const declaration = (() => {
|
|
468
|
+
try {
|
|
469
|
+
const res = loadDeclaration(d, deps);
|
|
470
|
+
return { declaredGates: res.outcome === 'loaded' ? res.gates.length : 0 };
|
|
471
|
+
} catch (err) {
|
|
472
|
+
return { declaredGates: null, declarationError: localizeError(err) };
|
|
473
|
+
}
|
|
474
|
+
})();
|
|
455
475
|
return {
|
|
456
476
|
wired: isHookWired(project.data) || isHookWired(local.data),
|
|
457
477
|
filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
|
|
458
478
|
declarationPresent: Boolean(exists(join(d, GATES_REL))),
|
|
479
|
+
...declaration,
|
|
459
480
|
};
|
|
460
481
|
} catch (err) {
|
|
461
482
|
return { error: localizeError(err) };
|
|
462
483
|
}
|
|
463
484
|
};
|
|
464
485
|
|
|
486
|
+
// cheap agents: the kit-placed .claude/agents/ vehicles (Mode: agents) — how many of the bundled
|
|
487
|
+
// cheap-lane definitions are present in the project. A customized copy counts as PLACED (it exists;
|
|
488
|
+
// the writer preserves it) — the welcome-mat agents rung keys on zero placed. Read-only; REUSES the
|
|
489
|
+
// writer's own bundle reader + placement planner (cheap-agents.mjs — one implementation).
|
|
490
|
+
export const surveyCheapAgents = (dir, deps = {}) => {
|
|
491
|
+
try {
|
|
492
|
+
const templates = readBundledAgents(deps);
|
|
493
|
+
const plan = planPlacement(templates, resolve(dir), deps);
|
|
494
|
+
return { bundled: templates.length, placed: plan.filter((p) => p.action !== 'place').length };
|
|
495
|
+
} catch (err) {
|
|
496
|
+
return { error: localizeError(err) };
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
465
500
|
// the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
|
|
466
501
|
export const surveySettings = (dir, deps = {}) => ({
|
|
467
502
|
recipes: surveyRecipes(dir, deps),
|
|
468
503
|
attribution: surveyAttribution(dir, deps),
|
|
469
504
|
velocity: surveyVelocity(dir, deps),
|
|
505
|
+
agents: surveyCheapAgents(dir, deps),
|
|
470
506
|
hook: surveyGateHook(dir, deps),
|
|
471
507
|
});
|
|
472
508
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Heading-anchored lens-region reconcile — the composition root's only mutation of a deployed
|
|
3
|
+
// `docs/ai/agent_rules.md`. The planning/review/process-fidelity lens block (`### 2.x. Planning,
|
|
4
|
+
// review & process-fidelity invariants`) has ONE canonical home — the installed engine's
|
|
5
|
+
// `references/agent-rules-lens.md` — and every deployed/template copy is a RENDER of it (the
|
|
6
|
+
// file's own section number substituted into the number-neutral heading). This tool refreshes a
|
|
7
|
+
// deployed region to the current canon under the AD-025 discipline: refresh IFF the region's
|
|
8
|
+
// normalized body matches the current fragment (already current → zero-diff) or a KNOWN PRIOR
|
|
9
|
+
// canonical body (the engine's append-only `references/agent-rules-lens-priors.md`, read live
|
|
10
|
+
// beside the fragment — no kit-side prior constants, so a canon wording change is an engine-only
|
|
11
|
+
// release). Anything else is preserved byte-for-byte + a one-line advisory.
|
|
12
|
+
//
|
|
13
|
+
// The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
|
|
14
|
+
// through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
|
|
15
|
+
// lens-mirror guard pioneered, promoted here as the shipped implementation. A user-renamed
|
|
16
|
+
// heading is therefore a natural preserve+advise; bytes outside the region are never touched.
|
|
17
|
+
//
|
|
18
|
+
// The live read is lazy + fail-loud: the engine is consulted only when a present region must be
|
|
19
|
+
// classified; a fully absent/invalid engine is a loud STOP (never a silent fallback), a valid
|
|
20
|
+
// engine that merely predates the lens pair (<1.13.0) is a stated soft skip. Cap-guard: a refresh
|
|
21
|
+
// that would push the file over its own frontmatter `maxLines` is a loud, non-fatal refusal —
|
|
22
|
+
// never a silent truncate; a file without frontmatter/`maxLines` skips the guard with a stated
|
|
23
|
+
// note (such a file is outside the docs cap gate anyway).
|
|
24
|
+
//
|
|
25
|
+
// Pure string functions (fs only in the CLI); dependency-free, Node >= 18.
|
|
26
|
+
|
|
27
|
+
import { normalizeCanonical } from './orchestration-config.mjs';
|
|
28
|
+
|
|
29
|
+
// The deployed-heading matcher (prefix, like the historical extractLensBlock: robust to a future
|
|
30
|
+
// heading-tail tweak) and the number-neutral form the engine fragment carries.
|
|
31
|
+
export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
|
|
32
|
+
const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
|
|
33
|
+
const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
|
|
34
|
+
|
|
35
|
+
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
36
|
+
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
37
|
+
|
|
38
|
+
// Count lines independent of a trailing newline (the inject-methodology contract).
|
|
39
|
+
const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
40
|
+
|
|
41
|
+
// ── the frozen prior-store format (documented in the store file's own header) ─────
|
|
42
|
+
// A delimiter is a line that starts with `<!-- prior` and ends with `-->`; an entry body is
|
|
43
|
+
// everything after it up to the next delimiter / EOF, trimmed. The pre-delimiter header is
|
|
44
|
+
// ignored. APPEND-ONLY on the engine side — this parser must keep reading a newer engine's file.
|
|
45
|
+
export const parseLensPriors = (text) => {
|
|
46
|
+
const entries = [];
|
|
47
|
+
let current = null;
|
|
48
|
+
for (const line of String(text).split('\n')) {
|
|
49
|
+
const bare = stripCr(line);
|
|
50
|
+
if (bare.startsWith('<!-- prior') && bare.endsWith('-->')) {
|
|
51
|
+
if (current) entries.push(current.join('\n'));
|
|
52
|
+
current = [];
|
|
53
|
+
} else if (current) current.push(line);
|
|
54
|
+
}
|
|
55
|
+
if (current) entries.push(current.join('\n'));
|
|
56
|
+
return entries.map((e) => normalizeCanonical(e)).filter((e) => e !== '');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// renderLens(fragment, number) → the number-neutral canonical block bound to the file's OWN
|
|
60
|
+
// section number (memory-seeded files say 2.6, kit-fallback files 2.5 — the fragment never
|
|
61
|
+
// hardcodes one). LF-canonical; the CLI converts to the document's EOL style on write.
|
|
62
|
+
export const renderLens = (fragment, number) =>
|
|
63
|
+
normalizeCanonical(fragment).replace(NEUTRAL_HEADING_RE, (m) => m.replace('2.x', `2.${number}`));
|
|
64
|
+
|
|
65
|
+
// normalizeLensBody(body) → the number-neutral, whitespace/EOL-normalized comparison form
|
|
66
|
+
// (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
|
|
67
|
+
export const normalizeLensBody = (body) =>
|
|
68
|
+
normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
|
|
69
|
+
|
|
70
|
+
// extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
|
|
71
|
+
// `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
|
|
72
|
+
// next structural boundary; EOF is a valid region end (no following boundary required). `body`
|
|
73
|
+
// is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
|
|
74
|
+
// region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
|
|
75
|
+
export const extractLensRegion = (text) => {
|
|
76
|
+
const lines = String(text).split('\n');
|
|
77
|
+
const start = lines.findIndex((line) => LENS_HEADING_RE.test(stripCr(line)));
|
|
78
|
+
if (start === -1) return { found: false };
|
|
79
|
+
let end = lines.length;
|
|
80
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
81
|
+
if (isBoundary(stripCr(lines[i]))) {
|
|
82
|
+
end = i;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const number = stripCr(lines[start]).match(LENS_HEADING_RE)[1];
|
|
87
|
+
const regionLines = lines.slice(start, end);
|
|
88
|
+
let bodyEnd = regionLines.length;
|
|
89
|
+
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
90
|
+
const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
|
|
91
|
+
return { found: true, start, end, number, body };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
|
|
95
|
+
// lines replaced; trailing blank lines inside the region and every byte outside it are
|
|
96
|
+
// preserved verbatim. Output keeps the document's EOL style (CRLF documents stay CRLF).
|
|
97
|
+
export const replaceLensRegion = (text, region, renderedBody) => {
|
|
98
|
+
const lines = String(text).split('\n');
|
|
99
|
+
const crlf = String(text).includes('\r\n');
|
|
100
|
+
const regionLines = lines.slice(region.start, region.end);
|
|
101
|
+
let bodyEnd = regionLines.length;
|
|
102
|
+
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
103
|
+
const trailing = regionLines.slice(bodyEnd); // preserved verbatim (their own CR bytes intact)
|
|
104
|
+
const newBody = renderedBody.split('\n').map((l) => (crlf ? `${l}\r` : l));
|
|
105
|
+
const out = [...lines.slice(0, region.start), ...newBody, ...trailing, ...lines.slice(region.end)];
|
|
106
|
+
// In a CRLF document, only a line FOLLOWED by another line carries the CR byte; when the new
|
|
107
|
+
// body's last line is the final line of the whole document (EOF region, no trailing newline),
|
|
108
|
+
// strip the CR we just added so no stray byte lands at EOF.
|
|
109
|
+
if (crlf && trailing.length === 0 && region.end === lines.length) {
|
|
110
|
+
out[out.length - 1] = stripCr(out[out.length - 1]);
|
|
111
|
+
}
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// reconcileLensText(text, fragment, priors) — the PURE policy decision:
|
|
116
|
+
// { status: 'no-region' } — heading absent/renamed → preserve + advise (caller words it).
|
|
117
|
+
// { status: 'current' } — the region already renders the current canon → zero-diff.
|
|
118
|
+
// { status: 'refreshed', text } — the region matched the canon or a known prior → re-rendered
|
|
119
|
+
// with the file's OWN number (cap-guard is the caller's, so the
|
|
120
|
+
// decision stays pure).
|
|
121
|
+
// { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
|
|
122
|
+
export const reconcileLensText = (text, fragment, priors) => {
|
|
123
|
+
const region = extractLensRegion(text);
|
|
124
|
+
if (!region.found) return { status: 'no-region', text };
|
|
125
|
+
const current = normalizeLensBody(region.body);
|
|
126
|
+
const canon = normalizeLensBody(fragment);
|
|
127
|
+
if (current === canon) return { status: 'current', text };
|
|
128
|
+
const known = priors.map((p) => normalizeLensBody(p));
|
|
129
|
+
if (!known.includes(current)) return { status: 'custom', text };
|
|
130
|
+
return { status: 'refreshed', text: replaceLensRegion(text, region, renderLens(fragment, region.number)) };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
|
|
134
|
+
// file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
|
|
135
|
+
// with a stated note, never a throw — such a file is outside the docs cap gate anyway).
|
|
136
|
+
export const frontmatterMaxLines = (text) => {
|
|
137
|
+
const lines = String(text).split('\n').map(stripCr);
|
|
138
|
+
if (lines[0] !== '---') return null;
|
|
139
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
140
|
+
if (lines[i] === '---') return null;
|
|
141
|
+
const m = lines[i].match(/^maxLines:\s*(\d+)\s*$/);
|
|
142
|
+
if (m) return Number(m[1]);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
|
|
148
|
+
// Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
|
|
149
|
+
// every classified outcome (including the soft skips and the cap refusal), exit 1 ONLY on the
|
|
150
|
+
// hard engine STOP or an unexpected fs error, exit 2 on usage.
|
|
151
|
+
export const runCli = async (argv, deps = {}) => {
|
|
152
|
+
const log = deps.log ?? console.log;
|
|
153
|
+
const logError = deps.logError ?? console.error;
|
|
154
|
+
const env = deps.env ?? process.env;
|
|
155
|
+
const fs = deps.fs ?? (await import('node:fs/promises'));
|
|
156
|
+
const { dirname, basename, join, resolve } = await import('node:path');
|
|
157
|
+
const { homedir } = await import('node:os');
|
|
158
|
+
const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
|
|
159
|
+
|
|
160
|
+
if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
|
|
161
|
+
logError('usage: lens-region.mjs reconcile <path/to/agent_rules.md>');
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
const targetPath = resolve(argv[1]);
|
|
165
|
+
|
|
166
|
+
// 1. Absent file → a stated skip (the memory substrate owns seeding; nothing to reconcile).
|
|
167
|
+
const text = await (async () => {
|
|
168
|
+
try {
|
|
169
|
+
return await fs.readFile(targetPath, 'utf8');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err.code === 'ENOENT') return null;
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
if (text === null) {
|
|
176
|
+
log(`[lens-region] ${argv[1]} is absent — skipped (nothing to reconcile; the substrate seeds it at bootstrap).`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. No matching heading → preserve + advise, engine never consulted (the outcome is preserve
|
|
181
|
+
// regardless, so the lazy contract holds).
|
|
182
|
+
if (!extractLensRegion(text).found) {
|
|
183
|
+
log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
184
|
+
log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3. A present region must be classified → live-read the engine's fragment + prior store.
|
|
189
|
+
// Fully absent/invalid engine → loud STOP; valid-but-pre-lens engine → stated soft skip.
|
|
190
|
+
const { dir, source } = resolveEngineDir({ env, home: deps.home ?? homedir() });
|
|
191
|
+
const lensPairPresent =
|
|
192
|
+
detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
|
|
193
|
+
if (!lensPairPresent) {
|
|
194
|
+
if (detectEngine(dir, { source }).ok) {
|
|
195
|
+
log('[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.');
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
|
|
200
|
+
return 1; // defensive: the pair is unusable — never proceed to a read
|
|
201
|
+
} catch (err) {
|
|
202
|
+
logError(`[lens-region] reconcile STOP — ${err.message}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// TOCTOU guard: a fragment that vanishes between detect and read is a corruption STOP, never a
|
|
207
|
+
// silent proceed — readEngineFragment's own throw carries the install command.
|
|
208
|
+
let fragment;
|
|
209
|
+
let priors;
|
|
210
|
+
try {
|
|
211
|
+
fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
|
|
212
|
+
priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL }));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logError(`[lens-region] reconcile STOP — ${err.message}`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 4. The pure decision + the cap-guard + one atomic write.
|
|
219
|
+
const result = reconcileLensText(text, fragment, priors);
|
|
220
|
+
if (result.status === 'current') {
|
|
221
|
+
log('[lens-region] lens section already current — nothing to do (zero-diff).');
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if (result.status === 'custom') {
|
|
225
|
+
log('[lens-region] lens section carries a custom edit — preserved verbatim.');
|
|
226
|
+
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.');
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
// refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
|
|
230
|
+
const maxLines = frontmatterMaxLines(text);
|
|
231
|
+
if (maxLines === null) {
|
|
232
|
+
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
233
|
+
} else if (lineCount(result.text) > maxLines) {
|
|
234
|
+
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. Nothing was changed.`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
238
|
+
try {
|
|
239
|
+
await fs.writeFile(tmp, result.text, 'utf8');
|
|
240
|
+
await fs.rename(tmp, targetPath);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
log('[lens-region] refreshed the planning/review lens section to the current canon.');
|
|
246
|
+
return 0;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const { pathToFileURL } = await import('node:url');
|
|
250
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
251
|
+
if (isDirectRun) process.exitCode = await runCli(process.argv.slice(2));
|
|
@@ -4,94 +4,24 @@
|
|
|
4
4
|
// can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
|
|
5
5
|
// assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
|
|
13
|
-
// docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
|
|
14
|
-
// - atomic: write a UNIQUE *.json.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
|
|
15
|
-
// - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
|
|
16
|
-
// - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
|
|
7
|
+
// The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create
|
|
8
|
+
// tmp + rename with a TOCTOU re-check → tmp cleanup; LAST-WRITER-WINS, documented) lives in the
|
|
9
|
+
// shared atomic-write core (tools/atomic-write.mjs, extracted from this module — AD-042) and is
|
|
10
|
+
// consumed here with this module's own STOP identity, so the public API + error contract are
|
|
11
|
+
// unchanged (this file's tests pin them, characterize-first).
|
|
17
12
|
//
|
|
18
13
|
// Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
|
|
19
14
|
|
|
20
|
-
import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
21
|
-
import { randomBytes } from 'node:crypto';
|
|
22
|
-
import { join } from 'node:path';
|
|
23
|
-
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
24
15
|
import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
|
|
16
|
+
import { writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
25
17
|
|
|
26
18
|
// A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
|
|
27
19
|
// native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
|
|
28
20
|
export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
|
|
29
21
|
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
|
|
30
22
|
|
|
31
|
-
// lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
|
|
32
|
-
const lstatNoFollow = (path, lstat) => {
|
|
33
|
-
try {
|
|
34
|
-
return lstat(path);
|
|
35
|
-
} catch (err) {
|
|
36
|
-
if (err && err.code === 'ENOENT') return null;
|
|
37
|
-
throw err;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
23
|
// writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
|
|
42
24
|
// symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
|
|
43
25
|
// creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
|
|
44
|
-
export const writeConfig = (cwd, config, deps = {}) =>
|
|
45
|
-
|
|
46
|
-
const writeFile = deps.writeFile ?? writeFileSync;
|
|
47
|
-
const rename = deps.rename ?? renameSync;
|
|
48
|
-
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
49
|
-
const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
50
|
-
const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
|
|
51
|
-
|
|
52
|
-
// Deployment gate — never scatter a config into a non-deployed repo (mirror velocity / setup). lstat,
|
|
53
|
-
// NOT existsSync: existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent"
|
|
54
|
-
// and mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true ENOENT
|
|
55
|
-
// → no deployment (STOP, run init); a symlink or non-directory → STOP loud (never write through it).
|
|
56
|
-
const docsAi = join(cwd, 'docs', 'ai');
|
|
57
|
-
const docsAiStat = lstatNoFollow(docsAi, lstat);
|
|
58
|
-
if (docsAiStat === null) {
|
|
59
|
-
throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${CONFIG_REL}`);
|
|
60
|
-
}
|
|
61
|
-
if (docsAiStat.isSymbolicLink()) {
|
|
62
|
-
throw stop(`docs/ai is a symlink — refusing to write a config through it (run init/bootstrap in a real deployment)`);
|
|
63
|
-
}
|
|
64
|
-
if (!docsAiStat.isDirectory()) {
|
|
65
|
-
throw stop(`docs/ai exists but is not a directory — refusing to write ${CONFIG_REL}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const dst = join(cwd, CONFIG_REL);
|
|
69
|
-
// Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
|
|
70
|
-
// would silently replace the link rather than the file the user thinks they are editing).
|
|
71
|
-
const leaf = lstatNoFollow(dst, lstat);
|
|
72
|
-
if (leaf && leaf.isSymbolicLink()) {
|
|
73
|
-
throw stop(`${CONFIG_REL} is a symlink — refusing to replace it (a write would clobber the link target)`);
|
|
74
|
-
}
|
|
75
|
-
// Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
|
|
76
|
-
guard(dst);
|
|
77
|
-
const tmp = `${dst}.${rand()}.tmp`;
|
|
78
|
-
guard(tmp);
|
|
79
|
-
|
|
80
|
-
const body = serializeConfig(config);
|
|
81
|
-
// Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
|
|
82
|
-
// overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
|
|
83
|
-
writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
84
|
-
try {
|
|
85
|
-
// TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
|
|
86
|
-
guard(dst);
|
|
87
|
-
const leafAgain = lstatNoFollow(dst, lstat);
|
|
88
|
-
if (leafAgain && leafAgain.isSymbolicLink()) {
|
|
89
|
-
throw stop(`${CONFIG_REL} became a symlink — refusing to replace it`);
|
|
90
|
-
}
|
|
91
|
-
rename(tmp, dst);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
rm(tmp); // never leave a temp file behind on failure
|
|
94
|
-
throw err;
|
|
95
|
-
}
|
|
96
|
-
return { writtenPath: CONFIG_REL };
|
|
97
|
-
};
|
|
26
|
+
export const writeConfig = (cwd, config, deps = {}) =>
|
|
27
|
+
writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
|
package/tools/presentation.mjs
CHANGED
package/tools/renderers.mjs
CHANGED
|
@@ -101,11 +101,19 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
101
101
|
else if (s.velocity) {
|
|
102
102
|
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
103
103
|
}
|
|
104
|
-
//
|
|
104
|
+
// cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
|
|
105
|
+
if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
|
|
106
|
+
else if (s.agents) {
|
|
107
|
+
lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
|
|
108
|
+
}
|
|
109
|
+
// gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
|
|
110
|
+
// declared gate count (null → '?' — unknown is shown as unknown, never as a number).
|
|
105
111
|
if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
|
|
106
112
|
else if (s.hook) {
|
|
107
113
|
const yn = (b) => (b ? 'yes' : 'no');
|
|
108
|
-
|
|
114
|
+
// A null count carries its preserved validation reason — never a silent bare "?".
|
|
115
|
+
const declared = s.hook.declaredGates ?? (s.hook.declarationError ? `? (${s.hook.declarationError})` : '?');
|
|
116
|
+
lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)} · declared=${declared}`);
|
|
109
117
|
}
|
|
110
118
|
return lines;
|
|
111
119
|
};
|
package/tools/review-state.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
|
|
6
6
|
// recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
|
|
7
7
|
// backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
|
|
8
|
-
// report into a gate exit code (declare it in docs/ai/gates.json — by hand
|
|
8
|
+
// report into a gate exit code (declare it in docs/ai/gates.json — by hand OR via the
|
|
9
|
+
// explicit-consent seeder, tools/seed-gates.mjs — never without consent, AD-021/AD-042).
|
|
9
10
|
//
|
|
10
11
|
// Normative `--check` exit contract (the single home of this list — SKILL.md points here):
|
|
11
12
|
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
|
|
@@ -353,7 +354,8 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
|
|
|
353
354
|
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
354
355
|
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
|
|
355
356
|
recipe-named backend is missing, stale (edited after review), or grounded:false under
|
|
356
|
-
reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json)
|
|
357
|
+
reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
|
|
358
|
+
explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
|
|
357
359
|
|
|
358
360
|
Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
|
|
359
361
|
Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a
|