@sabaiway/agent-workflow-kit 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -1
- package/README.md +1 -0
- package/SKILL.md +7 -3
- package/bin/install.mjs +10 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +189 -18
- package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +77 -41
- package/bridges/antigravity-cli-bridge/capability.json +4 -2
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
- package/bridges/codex-cli-bridge/SKILL.md +8 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +278 -23
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
- package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +112 -17
- package/bridges/codex-cli-bridge/capability.json +19 -4
- package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +3 -2
- package/references/modes/recommendations.md +1 -0
- package/references/modes/upgrade.md +9 -4
- package/references/modes/velocity.md +2 -0
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +2 -2
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/references/shared/command-shapes.md +22 -0
- package/references/shared/composition-handoff.md +7 -2
- package/references/templates/agent_rules.md +10 -1
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/bridge-settings-read.mjs +25 -5
- package/tools/changed-surface.mjs +8 -8
- package/tools/commands.mjs +9 -0
- package/tools/delegation.mjs +2 -2
- package/tools/detect-backends.mjs +10 -0
- package/tools/grounding.mjs +13 -13
- package/tools/inject-methodology.mjs +71 -40
- package/tools/lens-region.mjs +113 -36
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/procedures.mjs +1 -1
- package/tools/recipes.mjs +2 -2
- package/tools/recommendations.mjs +34 -7
- package/tools/release-scan.mjs +12 -2
- package/tools/review-state.mjs +3 -3
- package/tools/sandbox-masks.mjs +13 -13
- package/tools/set-recipe.mjs +1 -1
- package/tools/velocity-profile.mjs +7 -7
- package/tools/worktrees.mjs +2292 -0
|
@@ -314,11 +314,31 @@ const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && error
|
|
|
314
314
|
export const isFillCapRefusal = (errorMessage) =>
|
|
315
315
|
isCapRefusal(errorMessage) && errorMessage.includes('injection would push');
|
|
316
316
|
|
|
317
|
-
const
|
|
317
|
+
const EXIT = Symbol('inject-methodology.exit');
|
|
318
|
+
|
|
319
|
+
// The return-code entry point (5.1): argv[] + injected env/home → { code, stdout, stderr }, no
|
|
320
|
+
// process.argv / process.exit / console inside — an in-process caller is as hermetic as a spawned
|
|
321
|
+
// one. The thin shell at the bottom is the only process-coupled code.
|
|
322
|
+
export const runCli = async (argv, deps = {}) => {
|
|
318
323
|
const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
|
|
319
324
|
const { dirname, basename, join, resolve } = await import('node:path');
|
|
320
325
|
const { homedir } = await import('node:os');
|
|
321
326
|
const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL } = await import('./engine-source.mjs');
|
|
327
|
+
const env = deps.env ?? process.env;
|
|
328
|
+
const home = deps.home ?? homedir();
|
|
329
|
+
|
|
330
|
+
const stdoutLines = [];
|
|
331
|
+
const stderrLines = [];
|
|
332
|
+
const log = (line) => stdoutLines.push(line);
|
|
333
|
+
const logError = (line) => stderrLines.push(line);
|
|
334
|
+
const result = (code) => ({
|
|
335
|
+
code,
|
|
336
|
+
stdout: stdoutLines.length > 0 ? `${stdoutLines.join('\n')}\n` : '',
|
|
337
|
+
stderr: stderrLines.length > 0 ? `${stderrLines.join('\n')}\n` : '',
|
|
338
|
+
});
|
|
339
|
+
// Nested sourcing helpers end the run from arbitrary depth — a tagged throw the catch below
|
|
340
|
+
// translates into the result code (the process.exit of the pre-entry-point CLI).
|
|
341
|
+
const stop = (code) => Object.assign(new Error(`exit ${code}`), { [EXIT]: code });
|
|
322
342
|
|
|
323
343
|
// `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
|
|
324
344
|
// ALL THREE slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
|
|
@@ -326,10 +346,11 @@ const main = async (argv) => {
|
|
|
326
346
|
const rest = mode === 'reconcile' ? argv.slice(1) : argv;
|
|
327
347
|
const agentsPath = rest[0];
|
|
328
348
|
if (!agentsPath) {
|
|
329
|
-
|
|
330
|
-
|
|
349
|
+
logError('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
|
|
350
|
+
return result(2);
|
|
331
351
|
}
|
|
332
352
|
const explicitFragmentArg = rest[1];
|
|
353
|
+
try {
|
|
333
354
|
const text = await readFile(resolve(agentsPath), 'utf8');
|
|
334
355
|
|
|
335
356
|
// Source a bounded fragment LAZILY, per slot. An explicit [fragment.md] arg (tests + manual) wins and
|
|
@@ -339,7 +360,7 @@ const main = async (argv) => {
|
|
|
339
360
|
// the install command. The caller only invokes this when a fill is actually needed (the laziness).
|
|
340
361
|
const sourceFragment = async (rel) => {
|
|
341
362
|
if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
|
|
342
|
-
const { dir, source } = resolveEngineDir({ env
|
|
363
|
+
const { dir, source } = resolveEngineDir({ env, home });
|
|
343
364
|
return readEngineFragment(dir, { source, rel }); // sync; throws loudly when the engine is absent/invalid
|
|
344
365
|
};
|
|
345
366
|
const sourceFragmentOrStop = async (label, rel) => {
|
|
@@ -348,8 +369,8 @@ const main = async (argv) => {
|
|
|
348
369
|
} catch (err) {
|
|
349
370
|
// Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The "methodology
|
|
350
371
|
// engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
|
|
351
|
-
|
|
352
|
-
|
|
372
|
+
logError(`[inject-methodology] ${label} — ${err.message}`);
|
|
373
|
+
throw stop(1);
|
|
353
374
|
}
|
|
354
375
|
};
|
|
355
376
|
|
|
@@ -362,16 +383,16 @@ const main = async (argv) => {
|
|
|
362
383
|
// an unreadable fragment). Returns { fragment } on success, { skip } for the soft case, or
|
|
363
384
|
// process.exit(1) for the hard STOP.
|
|
364
385
|
const sourceChainedFragment = async (rel) => {
|
|
365
|
-
const { dir, source } = resolveEngineDir({ env
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
386
|
+
const { dir, source } = resolveEngineDir({ env, home });
|
|
387
|
+
const chainedStop = (err) => {
|
|
388
|
+
logError(`[inject-methodology] reconcile STOP — ${err.message}`);
|
|
389
|
+
throw stop(1);
|
|
369
390
|
};
|
|
370
391
|
if (detectEngine(dir, { source, rel }).ok) {
|
|
371
392
|
try {
|
|
372
393
|
return { fragment: readEngineFragment(dir, { source, rel }) };
|
|
373
394
|
} catch (err) {
|
|
374
|
-
|
|
395
|
+
chainedStop(err);
|
|
375
396
|
}
|
|
376
397
|
}
|
|
377
398
|
if (detectEngine(dir, { source }).ok) {
|
|
@@ -380,7 +401,7 @@ const main = async (argv) => {
|
|
|
380
401
|
try {
|
|
381
402
|
readEngineFragment(dir, { source, rel }); // throws the canonical install-me error
|
|
382
403
|
} catch (err) {
|
|
383
|
-
|
|
404
|
+
chainedStop(err);
|
|
384
405
|
}
|
|
385
406
|
};
|
|
386
407
|
|
|
@@ -402,8 +423,8 @@ const main = async (argv) => {
|
|
|
402
423
|
if (methResult.status === 'error') {
|
|
403
424
|
// cap-refusal OR malformed/anchor STOP — preserve the single-slot classification (SKILL.md
|
|
404
425
|
// distinguishes by the message); the file is byte-for-byte unchanged either way.
|
|
405
|
-
|
|
406
|
-
|
|
426
|
+
logError(`[inject-methodology] reconcile refused — ${methResult.error}`);
|
|
427
|
+
return result(1);
|
|
407
428
|
}
|
|
408
429
|
const afterMeth = methResult.text; // === text when the methodology slot was already filled (custom)
|
|
409
430
|
const describeMeth = {
|
|
@@ -421,18 +442,18 @@ const main = async (argv) => {
|
|
|
421
442
|
const u = markerSlotUpgradeHint(afterMeth, METHODOLOGY_DESCRIPTOR); if (u) notes.push(u);
|
|
422
443
|
}
|
|
423
444
|
const reportNotes = () => {
|
|
424
|
-
for (const n of notes)
|
|
445
|
+
for (const n of notes) log(`[inject-methodology] note: ${n}`);
|
|
425
446
|
};
|
|
426
447
|
|
|
427
448
|
// ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration + autonomy reconciles ──
|
|
428
449
|
if (explicitFragmentArg) {
|
|
429
450
|
if (afterMeth === text) {
|
|
430
|
-
|
|
431
|
-
return;
|
|
451
|
+
log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
|
|
452
|
+
return result(0);
|
|
432
453
|
}
|
|
433
454
|
await writeAtomic(afterMeth);
|
|
434
|
-
|
|
435
|
-
return;
|
|
455
|
+
log(`[inject-methodology] reconcile: ${describeMeth}.`);
|
|
456
|
+
return result(0);
|
|
436
457
|
}
|
|
437
458
|
|
|
438
459
|
// ── Slot 2: orchestration, reconciled on the methodology-reconciled text (the cap-check then guards
|
|
@@ -459,8 +480,8 @@ const main = async (argv) => {
|
|
|
459
480
|
describeOrch = `orchestration-recipes pointer skipped — ${orchResult.error}`;
|
|
460
481
|
} else {
|
|
461
482
|
// Malformed orchestration slot/anchor → a hard STOP. No partial write.
|
|
462
|
-
|
|
463
|
-
|
|
483
|
+
logError(`[inject-methodology] reconcile refused (orchestration) — ${orchResult.error}`);
|
|
484
|
+
return result(1);
|
|
464
485
|
}
|
|
465
486
|
} else {
|
|
466
487
|
finalText = orchResult.text;
|
|
@@ -493,8 +514,8 @@ const main = async (argv) => {
|
|
|
493
514
|
// Malformed markers are a hard STOP on EVERY lane — validated before the soft-skip
|
|
494
515
|
// short-circuits so a duplicate/reversed autonomy pair can never ride out as a "skip"
|
|
495
516
|
// alongside a partial (methodology) write.
|
|
496
|
-
|
|
497
|
-
|
|
517
|
+
logError(`[inject-methodology] reconcile refused (autonomy) — ${autSlot.reason}`);
|
|
518
|
+
return result(1);
|
|
498
519
|
}
|
|
499
520
|
if (orchSkipped) {
|
|
500
521
|
// The chain is CAUSAL, not merely positional: a pointer never lands in a run that withheld
|
|
@@ -523,8 +544,8 @@ const main = async (argv) => {
|
|
|
523
544
|
autSkipped = true;
|
|
524
545
|
describeAut = `autonomy pointer skipped — ${autResult.error}`;
|
|
525
546
|
} else {
|
|
526
|
-
|
|
527
|
-
|
|
547
|
+
logError(`[inject-methodology] reconcile refused (autonomy) — ${autResult.error}`);
|
|
548
|
+
return result(1);
|
|
528
549
|
}
|
|
529
550
|
} else {
|
|
530
551
|
finalText = autResult.text;
|
|
@@ -545,34 +566,44 @@ const main = async (argv) => {
|
|
|
545
566
|
// ── One atomic write of the final (three-slot) text ──
|
|
546
567
|
if (finalText === text) {
|
|
547
568
|
// Byte-unchanged. Still report a skip (it is not "nothing to do" — a pointer was withheld).
|
|
548
|
-
if (orchSkipped || autSkipped)
|
|
549
|
-
else
|
|
569
|
+
if (orchSkipped || autSkipped) log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
|
|
570
|
+
else log('[inject-methodology] reconcile: all three pointers already present and filled — nothing to do (zero-diff).');
|
|
550
571
|
reportNotes();
|
|
551
|
-
return;
|
|
572
|
+
return result(0);
|
|
552
573
|
}
|
|
553
574
|
await writeAtomic(finalText);
|
|
554
|
-
|
|
575
|
+
log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
|
|
555
576
|
reportNotes();
|
|
556
|
-
return;
|
|
577
|
+
return result(0);
|
|
557
578
|
}
|
|
558
579
|
|
|
559
580
|
// Legacy inject-into-existing-slot mode (METHODOLOGY only). injectMethodology no-ops on absent markers
|
|
560
581
|
// and errors on a malformed slot WITHOUT reading the fragment, so resolve+read the engine only when
|
|
561
582
|
// there is a present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
|
|
562
583
|
const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP', ENGINE_FRAGMENT_REL) : '';
|
|
563
|
-
const
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
584
|
+
const injected = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
|
|
585
|
+
if (injected.status === 'error') {
|
|
586
|
+
logError(`[inject-methodology] malformed slot — refusing to edit: ${injected.error}`);
|
|
587
|
+
return result(1);
|
|
588
|
+
}
|
|
589
|
+
if (injected.status === 'noop-absent') {
|
|
590
|
+
log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
|
|
591
|
+
return result(0);
|
|
567
592
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
593
|
+
await writeAtomic(injected.text);
|
|
594
|
+
log('[inject-methodology] injected the bounded methodology fragment into the slot.');
|
|
595
|
+
return result(0);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err[EXIT] !== undefined) return result(err[EXIT]);
|
|
598
|
+
throw err;
|
|
571
599
|
}
|
|
572
|
-
await writeAtomic(result.text);
|
|
573
|
-
console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
|
|
574
600
|
};
|
|
575
601
|
|
|
576
602
|
const { pathToFileURL } = await import('node:url');
|
|
577
603
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
578
|
-
if (isDirectRun)
|
|
604
|
+
if (isDirectRun) {
|
|
605
|
+
const { code, stdout, stderr } = await runCli(process.argv.slice(2));
|
|
606
|
+
if (stdout) process.stdout.write(stdout);
|
|
607
|
+
if (stderr) process.stderr.write(stderr);
|
|
608
|
+
process.exitCode = code;
|
|
609
|
+
}
|
package/tools/lens-region.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Heading-anchored
|
|
3
|
-
// `docs/ai/agent_rules.md`.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
2
|
+
// Heading-anchored region reconcile — the composition root's only mutation of a deployed
|
|
3
|
+
// `docs/ai/agent_rules.md`. TWO regions, one policy (refresh IFF the region's normalized body
|
|
4
|
+
// matches the current canon or a KNOWN PRIOR canonical body; anything else preserved
|
|
5
|
+
// byte-for-byte + a one-line advisory — the AD-025 discipline):
|
|
6
|
+
// - the planning/review/process-fidelity lens block (`### 2.x. Planning, review &
|
|
7
|
+
// process-fidelity invariants`) — canon home: the installed engine's
|
|
8
|
+
// `references/agent-rules-lens.md` + its append-only prior store, read live (no kit-side
|
|
9
|
+
// prior constants, so a lens wording change is an engine-only release);
|
|
10
|
+
// - the Communication block (`### 2.x. Communication (user-facing messages)`, AD-061) — canon
|
|
11
|
+
// home: the §2.5 region of the kit's OWN bundled `references/templates/agent_rules.md`
|
|
12
|
+
// (byte-identical to the memory twin by the template-region parity guard); priors are inline
|
|
13
|
+
// constants below (a templates/-side prior file would be deployed into projects by the
|
|
14
|
+
// template loop), appended whenever the template region changes (the vintage rule).
|
|
12
15
|
//
|
|
13
16
|
// The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
|
|
14
17
|
// through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
|
|
@@ -31,6 +34,22 @@ import { normalizeCanonical } from './orchestration-config.mjs';
|
|
|
31
34
|
export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
|
|
32
35
|
const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
|
|
33
36
|
const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
|
|
37
|
+
export const COMMS_HEADING_RE = /^### 2\.(\d+)\. Communication \(user-facing messages\)/;
|
|
38
|
+
const NEUTRAL_COMMS_RE = /^### 2\.x\. Communication \(user-facing messages\)/;
|
|
39
|
+
const COMMS_LABEL = '### 2.x. Communication (user-facing messages)';
|
|
40
|
+
|
|
41
|
+
// The known prior canonical bodies of the Communication region (neutral-headed, like the engine's
|
|
42
|
+
// lens prior store). Append the OUTGOING canon here in the same release that changes the template
|
|
43
|
+
// §2.5 region — the fragment-or-prior reconcile depends on it.
|
|
44
|
+
const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
|
|
45
|
+
Apply this as part of §2 before any user-facing summary:
|
|
46
|
+
- **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
|
|
47
|
+
- **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
|
|
48
|
+
- **No condescension, no filler.** Own a miss plainly and fix it in the same message.
|
|
49
|
+
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.`;
|
|
50
|
+
const COMMS_PRIOR_AD054 = `${COMMS_PRIOR_PRE_AD054}
|
|
51
|
+
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
|
|
52
|
+
export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054];
|
|
34
53
|
|
|
35
54
|
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
36
55
|
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
@@ -66,15 +85,18 @@ export const renderLens = (fragment, number) =>
|
|
|
66
85
|
// (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
|
|
67
86
|
export const normalizeLensBody = (body) =>
|
|
68
87
|
normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
|
|
88
|
+
export const normalizeCommsBody = normalizeLensBody;
|
|
89
|
+
export const renderComms = (fragment, number) =>
|
|
90
|
+
normalizeCanonical(fragment).replace(NEUTRAL_COMMS_RE, (m) => m.replace('2.x', `2.${number}`));
|
|
69
91
|
|
|
70
92
|
// extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
|
|
71
93
|
// `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
|
|
72
94
|
// next structural boundary; EOF is a valid region end (no following boundary required). `body`
|
|
73
95
|
// is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
|
|
74
96
|
// region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
|
|
75
|
-
|
|
97
|
+
const extractRegionBy = (text, headingRe) => {
|
|
76
98
|
const lines = String(text).split('\n');
|
|
77
|
-
const start = lines.findIndex((line) =>
|
|
99
|
+
const start = lines.findIndex((line) => headingRe.test(stripCr(line)));
|
|
78
100
|
if (start === -1) return { found: false };
|
|
79
101
|
let end = lines.length;
|
|
80
102
|
for (let i = start + 1; i < lines.length; i += 1) {
|
|
@@ -83,13 +105,15 @@ export const extractLensRegion = (text) => {
|
|
|
83
105
|
break;
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
|
-
const number = stripCr(lines[start]).match(
|
|
108
|
+
const number = stripCr(lines[start]).match(headingRe)[1];
|
|
87
109
|
const regionLines = lines.slice(start, end);
|
|
88
110
|
let bodyEnd = regionLines.length;
|
|
89
111
|
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
90
112
|
const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
|
|
91
113
|
return { found: true, start, end, number, body };
|
|
92
114
|
};
|
|
115
|
+
export const extractLensRegion = (text) => extractRegionBy(text, LENS_HEADING_RE);
|
|
116
|
+
export const extractCommsRegion = (text) => extractRegionBy(text, COMMS_HEADING_RE);
|
|
93
117
|
|
|
94
118
|
// replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
|
|
95
119
|
// lines replaced; trailing blank lines inside the region and every byte outside it are
|
|
@@ -119,16 +143,20 @@ export const replaceLensRegion = (text, region, renderedBody) => {
|
|
|
119
143
|
// with the file's OWN number (cap-guard is the caller's, so the
|
|
120
144
|
// decision stays pure).
|
|
121
145
|
// { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
|
|
122
|
-
|
|
123
|
-
const region =
|
|
146
|
+
const reconcileRegionText = (text, fragment, priors, { extract, normalize, render }) => {
|
|
147
|
+
const region = extract(text);
|
|
124
148
|
if (!region.found) return { status: 'no-region', text };
|
|
125
|
-
const current =
|
|
126
|
-
const canon =
|
|
149
|
+
const current = normalize(region.body);
|
|
150
|
+
const canon = normalize(fragment);
|
|
127
151
|
if (current === canon) return { status: 'current', text };
|
|
128
|
-
const known = priors.map((p) =>
|
|
152
|
+
const known = priors.map((p) => normalize(p));
|
|
129
153
|
if (!known.includes(current)) return { status: 'custom', text };
|
|
130
|
-
return { status: 'refreshed', text: replaceLensRegion(text, region,
|
|
154
|
+
return { status: 'refreshed', text: replaceLensRegion(text, region, render(fragment, region.number)) };
|
|
131
155
|
};
|
|
156
|
+
export const reconcileLensText = (text, fragment, priors) =>
|
|
157
|
+
reconcileRegionText(text, fragment, priors, { extract: extractLensRegion, normalize: normalizeLensBody, render: renderLens });
|
|
158
|
+
export const reconcileCommsText = (text, fragment, priors) =>
|
|
159
|
+
reconcileRegionText(text, fragment, priors, { extract: extractCommsRegion, normalize: normalizeCommsBody, render: renderComms });
|
|
132
160
|
|
|
133
161
|
// frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
|
|
134
162
|
// file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
|
|
@@ -146,8 +174,9 @@ export const frontmatterMaxLines = (text) => {
|
|
|
146
174
|
|
|
147
175
|
// ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
|
|
148
176
|
// Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
|
|
149
|
-
// every classified outcome (including the soft skips and the cap
|
|
150
|
-
// hard engine
|
|
177
|
+
// every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
|
|
178
|
+
// hard STOP — the absent/invalid engine, or the unreadable bundled template canon — or an
|
|
179
|
+
// unexpected fs error, exit 2 on usage.
|
|
151
180
|
export const runCli = async (argv, deps = {}) => {
|
|
152
181
|
const log = deps.log ?? console.log;
|
|
153
182
|
const logError = deps.logError ?? console.error;
|
|
@@ -155,6 +184,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
155
184
|
const fs = deps.fs ?? (await import('node:fs/promises'));
|
|
156
185
|
const { dirname, basename, join, resolve } = await import('node:path');
|
|
157
186
|
const { homedir } = await import('node:os');
|
|
187
|
+
const { fileURLToPath } = await import('node:url');
|
|
158
188
|
const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
|
|
159
189
|
|
|
160
190
|
if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
|
|
@@ -177,9 +207,63 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
177
207
|
return 0;
|
|
178
208
|
}
|
|
179
209
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
210
|
+
const atomicWrite = async (content) => {
|
|
211
|
+
const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
212
|
+
try {
|
|
213
|
+
await fs.writeFile(tmp, content, 'utf8');
|
|
214
|
+
await fs.rename(tmp, targetPath);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// 2. The Communication half (AD-061) — canon from the kit's OWN bundled template (the engine is
|
|
222
|
+
// never consulted for this region), same fragment-or-prior policy, own outcome lines.
|
|
223
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
|
|
224
|
+
const templateRegion = await (async () => {
|
|
225
|
+
try {
|
|
226
|
+
return extractCommsRegion(await fs.readFile(templatePath, 'utf8'));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return { found: false, error: err?.message ?? String(err) };
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
if (!templateRegion.found) {
|
|
232
|
+
logError(`[lens-region] reconcile STOP — the kit's bundled agent_rules.md template canon is unreadable${templateRegion.error ? ` (${templateRegion.error})` : ''}; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
|
|
236
|
+
const currentText = await (async () => {
|
|
237
|
+
if (commsResult.status === 'no-region') {
|
|
238
|
+
log(`[lens-region] no "${COMMS_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
239
|
+
log('[lens-region] note: the Communication section is absent or renamed — deployments seeded before it existed simply lack it; add it from the current template to enable refresh. Your file is never rewritten.');
|
|
240
|
+
return text;
|
|
241
|
+
}
|
|
242
|
+
if (commsResult.status === 'current') {
|
|
243
|
+
log('[lens-region] Communication section already current — nothing to do (zero-diff).');
|
|
244
|
+
return text;
|
|
245
|
+
}
|
|
246
|
+
if (commsResult.status === 'custom') {
|
|
247
|
+
log('[lens-region] Communication section carries a custom edit — preserved verbatim.');
|
|
248
|
+
log('[lens-region] note: the canonical Communication section has changed since this section was edited — compare it with the current template when convenient; your wording is never overwritten.');
|
|
249
|
+
return text;
|
|
250
|
+
}
|
|
251
|
+
const commsMax = frontmatterMaxLines(text);
|
|
252
|
+
if (commsMax === null) {
|
|
253
|
+
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
254
|
+
}
|
|
255
|
+
if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
|
|
256
|
+
log(`[lens-region] refused — refreshing the Communication section would push ${argv[1]} to ${lineCount(commsResult.text)} lines (cap ${commsMax}); trim the file and re-run. The Communication section was not changed.`);
|
|
257
|
+
return text;
|
|
258
|
+
}
|
|
259
|
+
await atomicWrite(commsResult.text);
|
|
260
|
+
log('[lens-region] refreshed the Communication section to the current canon.');
|
|
261
|
+
return commsResult.text;
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
// 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
|
|
265
|
+
// preserve regardless, so the lazy contract holds).
|
|
266
|
+
if (!extractLensRegion(currentText).found) {
|
|
183
267
|
log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
184
268
|
log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
|
|
185
269
|
return 0;
|
|
@@ -215,8 +299,8 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
215
299
|
return 1;
|
|
216
300
|
}
|
|
217
301
|
|
|
218
|
-
//
|
|
219
|
-
const result = reconcileLensText(
|
|
302
|
+
// 5. The pure decision + the cap-guard + one atomic write.
|
|
303
|
+
const result = reconcileLensText(currentText, fragment, priors);
|
|
220
304
|
if (result.status === 'current') {
|
|
221
305
|
log('[lens-region] lens section already current — nothing to do (zero-diff).');
|
|
222
306
|
return 0;
|
|
@@ -227,21 +311,14 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
227
311
|
return 0;
|
|
228
312
|
}
|
|
229
313
|
// refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
|
|
230
|
-
const maxLines = frontmatterMaxLines(
|
|
314
|
+
const maxLines = frontmatterMaxLines(currentText);
|
|
231
315
|
if (maxLines === null) {
|
|
232
316
|
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
233
317
|
} else if (lineCount(result.text) > maxLines) {
|
|
234
|
-
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run.
|
|
318
|
+
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. The planning/review lens section was not changed.`);
|
|
235
319
|
return 0;
|
|
236
320
|
}
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
await fs.writeFile(tmp, result.text, 'utf8');
|
|
240
|
-
await fs.rename(tmp, targetPath);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
243
|
-
throw err;
|
|
244
|
-
}
|
|
321
|
+
await atomicWrite(result.text);
|
|
245
322
|
log('[lens-region] refreshed the planning/review lens section to the current canon.');
|
|
246
323
|
return 0;
|
|
247
324
|
};
|
|
@@ -125,7 +125,7 @@ const isUnder = (child, parent) => {
|
|
|
125
125
|
|
|
126
126
|
// The ordered snapshot bases that are provably NOT stageable: the git dir first (always safe), then the
|
|
127
127
|
// fallback base ONLY when its snapshot dir lands OUTSIDE cwd (else it is in the work tree and could be
|
|
128
|
-
// committed — reject it
|
|
128
|
+
// committed — reject it). Returns [{ base, dir, viaGitDir }] (possibly empty).
|
|
129
129
|
const snapshotBases = (cwd, stamp, gitDir, fallbackBase) => {
|
|
130
130
|
const bases = [];
|
|
131
131
|
if (gitDir) bases.push({ base: gitDir, dir: resolve(gitDir, `${SNAPSHOT_PREFIX}-${stamp}`), viaGitDir: true });
|
|
@@ -226,14 +226,14 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
226
226
|
log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
|
|
227
227
|
log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
|
|
228
228
|
log(' then the conservation-checked rotation:');
|
|
229
|
-
// Surface the rotation's own exit code
|
|
229
|
+
// Surface the rotation's own exit code: a failed dry-run must NOT print the
|
|
230
230
|
// "run with --apply" go-ahead nor exit 0 — it would send the user to --apply on an unsafe tree.
|
|
231
231
|
const code = runMigrate(['--migrate'], { root: cwd, log: (m) => log(` ${m}`), logError: (m) => error(` ${m}`) });
|
|
232
232
|
if (code !== EXIT_OK) {
|
|
233
233
|
throw stop(`the dry-run rotation would not conserve every ADR (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
|
|
234
234
|
}
|
|
235
235
|
// A null preview means --apply would refuse (no out-of-tree snapshot base) — never green-light it
|
|
236
|
-
//
|
|
236
|
+
// A dry-run go-ahead must not send the user to an apply that will STOP.
|
|
237
237
|
if (preview.dir === null) {
|
|
238
238
|
throw stop('no out-of-tree snapshot location — --apply would refuse; run inside a git repo (or point the fallback outside the project), then re-run.');
|
|
239
239
|
}
|
package/tools/procedures.mjs
CHANGED
|
@@ -234,7 +234,7 @@ const groundingPreStepAdvice = (activity, slots, plans) => {
|
|
|
234
234
|
if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
|
|
235
235
|
const planArg = plans.length === 1 ? `--plan "${PLANS_REL}/${plans[0]}"` : '--plan <path>';
|
|
236
236
|
// plan-authoring reviews the plan FILE — when exactly one plan is in flight, the review command
|
|
237
|
-
// is populated with the same discovered path (
|
|
237
|
+
// is populated with the same discovered path (a known path never renders a placeholder).
|
|
238
238
|
const reviewForm =
|
|
239
239
|
activity === 'plan-authoring'
|
|
240
240
|
? plans.length === 1
|
package/tools/recipes.mjs
CHANGED
|
@@ -460,7 +460,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection, auto
|
|
|
460
460
|
const rec = recommendRecipe(detection);
|
|
461
461
|
const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
|
|
462
462
|
// A MALFORMED policy must surface LOUDLY on this paste surface too — silently rendering cells
|
|
463
|
-
// without levels would hide the required STOP signal (
|
|
463
|
+
// without levels would hide the required STOP signal (Segment B). One line always.
|
|
464
464
|
const malformed = autonomy?.error
|
|
465
465
|
? ` · autonomy: MALFORMED policy — ${String(autonomy.error).replace(/[\s]+/g, ' ').trim()}`
|
|
466
466
|
: '';
|
|
@@ -485,7 +485,7 @@ export const buildReport = (detection, settings = null, autonomy = null, posture
|
|
|
485
485
|
recommendation,
|
|
486
486
|
plans: RECIPES.map((r) => planRecipe(r.id, detection)),
|
|
487
487
|
// The SAME autonomy facts the --status-line surface renders — the --json envelope must never
|
|
488
|
-
// expose a stale machine-composed status line (
|
|
488
|
+
// expose a stale machine-composed status line (Segment B).
|
|
489
489
|
statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture),
|
|
490
490
|
};
|
|
491
491
|
};
|