@sabaiway/agent-workflow-kit 5.6.0 → 5.8.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 +92 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +26 -17
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -5
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +13 -4
- package/bridges/antigravity-cli-bridge/bin/agy.sh +7 -4
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +24 -0
- package/bridges/antigravity-cli-bridge/capability.json +3 -3
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +9 -8
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +31 -14
- package/bridges/antigravity-cli-bridge/setup/README.md +4 -3
- 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 +16 -3
- package/references/modes/grounding.md +4 -3
- package/references/modes/recommendations.md +3 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- 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/gates-declaration.mjs +23 -10
- package/tools/gates-init.mjs +6 -3
- package/tools/grounding.mjs +105 -16
- 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/recommendations.mjs +315 -66
- 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 +6 -16
- package/tools/source-size-core.mjs +7 -1
- package/tools/source-size-gate-cmd.mjs +18 -46
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
package/tools/grounding.mjs
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// it is not a heading in canon) and `## Verification` (REQUIRED — STOP if
|
|
12
12
|
// missing), plus `## Decisions (locked)` (optional-if-absent, the engine §7
|
|
13
13
|
// heading this release adds); a DUPLICATE heading is always a STOP.
|
|
14
|
+
// --extra <text|@file> append orchestrator-supplied facts verbatim AFTER the mechanical halves
|
|
15
|
+
// (repeatable; @file reads are confined to the work tree + the system temp
|
|
16
|
+
// surface — the merge happens INSIDE the tool, corpus #88/#95).
|
|
14
17
|
//
|
|
15
18
|
// Byte budget: the output honors the same AGY_MAX_PROMPT_BYTES contract the agy wrapper enforces
|
|
16
19
|
// (default 120000; the override may only TIGHTEN — above the OS single-argv ceiling ~131000 is
|
|
@@ -31,6 +34,7 @@ import { tmpdir } from 'node:os';
|
|
|
31
34
|
import { pathToFileURL } from 'node:url';
|
|
32
35
|
import { spawnSync } from 'node:child_process';
|
|
33
36
|
import { fail } from './orchestration-config.mjs';
|
|
37
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
34
38
|
// (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
|
|
35
39
|
// READ core only — never autonomy-write.mjs (the import-split invariant).
|
|
36
40
|
import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
|
|
@@ -81,7 +85,7 @@ export const sliceSection = (text, heading, { optional = false, label = 'documen
|
|
|
81
85
|
|
|
82
86
|
// ── assembly ───────────────────────────────────────────────────────────────────────
|
|
83
87
|
|
|
84
|
-
export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan' } = {}) => {
|
|
88
|
+
export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan', extraTexts = [] } = {}) => {
|
|
85
89
|
const parts = [];
|
|
86
90
|
if (constraintsText != null) {
|
|
87
91
|
parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
|
|
@@ -96,6 +100,10 @@ export const assembleGrounding = ({ constraintsText = null, autonomyText = null,
|
|
|
96
100
|
if (section != null) parts.push(section);
|
|
97
101
|
}
|
|
98
102
|
}
|
|
103
|
+
// Orchestrator extras ride LAST, verbatim in argv order — live judgment facts read after the
|
|
104
|
+
// mechanical slices, and the merge happens INSIDE the tool (corpus #88/#95: a shell append onto
|
|
105
|
+
// the emitted facts file was the recurring un-covered lane).
|
|
106
|
+
for (const t of extraTexts) parts.push(t);
|
|
99
107
|
return parts.join('\n');
|
|
100
108
|
};
|
|
101
109
|
|
|
@@ -151,10 +159,23 @@ const resolveAutonomyFacts = ({ cwd }) => {
|
|
|
151
159
|
return renderAutonomyFacts(config, source);
|
|
152
160
|
};
|
|
153
161
|
|
|
162
|
+
// The realpath'd system temp surface ($TMPDIR / os.tmpdir() / /tmp) — the shared scratch boundary
|
|
163
|
+
// for the --out write guard and the --extra read guard.
|
|
164
|
+
const systemTempRoots = () => [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
|
|
165
|
+
try {
|
|
166
|
+
return realpathSync(p);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}).filter(Boolean))];
|
|
171
|
+
|
|
154
172
|
// ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
|
|
155
173
|
|
|
156
174
|
const gitLine = (args, cwd) => {
|
|
157
|
-
|
|
175
|
+
// Ambient GIT_* location vars (GIT_DIR / GIT_WORK_TREE / …) would let rev-parse prove a FOREIGN
|
|
176
|
+
// tree — every location answer must come from cwd alone, for every gitLine consumer.
|
|
177
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k)));
|
|
178
|
+
const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', windowsHide: true });
|
|
158
179
|
return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
|
|
159
180
|
};
|
|
160
181
|
|
|
@@ -199,13 +220,7 @@ export const assertScratchDestination = (outPath, cwd) => {
|
|
|
199
220
|
// the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
|
|
200
221
|
// $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
|
|
201
222
|
const assertTempScratch = () => {
|
|
202
|
-
const tempRoots =
|
|
203
|
-
try {
|
|
204
|
-
return realpathSync(p);
|
|
205
|
-
} catch {
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
}).filter(Boolean))];
|
|
223
|
+
const tempRoots = systemTempRoots();
|
|
209
224
|
if (!tempRoots.some((t) => full === t || full.startsWith(`${t}${sep}`))) {
|
|
210
225
|
throw fail(1, `--out refuses an outside-repo destination that is not under a system temp root (${full}) — grounding output is scratch: use $TMPDIR//tmp, or a fresh gitignored in-repo path (temp roots checked: ${tempRoots.join(', ')})`);
|
|
211
226
|
}
|
|
@@ -243,7 +258,8 @@ export const assertScratchDestination = (outPath, cwd) => {
|
|
|
243
258
|
const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
|
|
244
259
|
|
|
245
260
|
Usage:
|
|
246
|
-
node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--
|
|
261
|
+
node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--extra <text|@file>]...
|
|
262
|
+
[--reserve-bytes <n>] [--out <path>]
|
|
247
263
|
|
|
248
264
|
--constraints slice the root AGENTS.md "Hard Constraints" section verbatim
|
|
249
265
|
(exactly one matching heading, else a loud STOP)
|
|
@@ -255,6 +271,14 @@ Usage:
|
|
|
255
271
|
--plan <path> extract the plan's decision-bearing sections verbatim + whole:
|
|
256
272
|
"## Approach" + "## Verification" (REQUIRED — STOP if missing),
|
|
257
273
|
"## Decisions (locked)" when present; a duplicate heading is a STOP
|
|
274
|
+
--extra <text|@file> append orchestrator-supplied extra facts byte-verbatim AFTER the
|
|
275
|
+
mechanical sections (repeatable, argv order; the agy-review --facts
|
|
276
|
+
convention: literal text, or @path read whole through a race-free
|
|
277
|
+
descriptor). An @file must resolve inside the PROVEN git work tree
|
|
278
|
+
(rev-parse success; the git dir itself refused) or the system temp
|
|
279
|
+
surface — anything else refuses loudly, as does a missing, empty, or
|
|
280
|
+
non-regular file. The merge happens INSIDE the tool: no shell append
|
|
281
|
+
onto the emitted facts file
|
|
258
282
|
--reserve-bytes <n> the artifact share agy-review will add around these facts — the output
|
|
259
283
|
budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
|
|
260
284
|
--out <path> write instead of stdout — system-temp scratch (rewritable), or a FRESH
|
|
@@ -274,11 +298,19 @@ const parseArgs = (argv) => {
|
|
|
274
298
|
let plan = null;
|
|
275
299
|
let out = null;
|
|
276
300
|
let reserve = 0;
|
|
301
|
+
const extra = [];
|
|
277
302
|
for (let i = 0; i < argv.length; i += 1) {
|
|
278
303
|
const a = argv[i];
|
|
279
304
|
if (a === '--constraints') constraints = true;
|
|
280
305
|
else if (a === '--autonomy') autonomy = true;
|
|
281
|
-
else if (a === '--
|
|
306
|
+
else if (a === '--extra') {
|
|
307
|
+
const val = argv[i + 1];
|
|
308
|
+
if (val == null || val === '' || val === '@' || val.startsWith('--')) {
|
|
309
|
+
throw fail(2, '--extra requires <text|@file> (repeatable)');
|
|
310
|
+
}
|
|
311
|
+
extra.push(val);
|
|
312
|
+
i += 1;
|
|
313
|
+
} else if (a === '--plan') {
|
|
282
314
|
plan = argv[i + 1];
|
|
283
315
|
if (!plan || plan.startsWith('--')) throw fail(2, '--plan requires a <path>');
|
|
284
316
|
i += 1;
|
|
@@ -293,10 +325,10 @@ const parseArgs = (argv) => {
|
|
|
293
325
|
i += 1;
|
|
294
326
|
} else throw fail(2, `unknown argument: ${a}`);
|
|
295
327
|
}
|
|
296
|
-
if (!constraints && !autonomy && plan == null) {
|
|
297
|
-
throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, and/or --
|
|
328
|
+
if (!constraints && !autonomy && plan == null && extra.length === 0) {
|
|
329
|
+
throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, --plan <path>, and/or --extra <text|@file>');
|
|
298
330
|
}
|
|
299
|
-
return { constraints, autonomy, plan, out, reserve };
|
|
331
|
+
return { constraints, autonomy, plan, out, reserve, extra };
|
|
300
332
|
};
|
|
301
333
|
|
|
302
334
|
const resolveBudget = (env, reserve) => {
|
|
@@ -316,7 +348,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
316
348
|
const env = ctx.env ?? process.env;
|
|
317
349
|
try {
|
|
318
350
|
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
319
|
-
const { constraints, autonomy, plan, out, reserve } = parseArgs(argv);
|
|
351
|
+
const { constraints, autonomy, plan, out, reserve, extra } = parseArgs(argv);
|
|
320
352
|
const budget = resolveBudget(env, reserve);
|
|
321
353
|
|
|
322
354
|
const readOrStop = (path, label) => {
|
|
@@ -348,8 +380,65 @@ export const main = (argv, ctx = {}) => {
|
|
|
348
380
|
}
|
|
349
381
|
const planText = plan != null ? readOrStop(plan, 'plan file') : null;
|
|
350
382
|
|
|
383
|
+
// --extra @file reads are CONFINED: the bridge tier auto-allows this tool with an args
|
|
384
|
+
// wildcard, so an unconfined @file would let an unattended run ship ANY readable file
|
|
385
|
+
// (~/.ssh, ~/.bashrc) into a prompt payload bound for a subscription CLI. The admitted read
|
|
386
|
+
// surface — computed ONCE per invocation — is the PROVEN git work tree (rev-parse success
|
|
387
|
+
// required; a cwd fallback would collapse the guard when cwd=$HOME) plus the system temp
|
|
388
|
+
// surface, MINUS the git dir(s) — repository internals never enter a facts payload. A non-@
|
|
389
|
+
// value is literal fact text (the agy-review --facts convention).
|
|
390
|
+
const extraReadSurface = () => {
|
|
391
|
+
const tempRoots = systemTempRoots();
|
|
392
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
393
|
+
if (top == null || top.status !== 0) return { tempRoots, topReal: null, gitDirsReal: [] };
|
|
394
|
+
const topReal = realpathSync(top.stdout.replace(/\r?\n$/, ''));
|
|
395
|
+
const gitDirsReal = ['--absolute-git-dir', '--git-common-dir'].map((flag) => {
|
|
396
|
+
const r = gitLine(['rev-parse', flag], cwd);
|
|
397
|
+
if (r == null || r.status !== 0) {
|
|
398
|
+
throw fail(1, `--extra cannot resolve the git dir (git rev-parse ${flag} failed) — refusing @file reads in an unmappable repo`);
|
|
399
|
+
}
|
|
400
|
+
return realpathSync(resolve(cwd, r.stdout.replace(/\r?\n$/, '')));
|
|
401
|
+
});
|
|
402
|
+
// The linked-worktree `.git` is a FILE inside the tree yet outside both answers above —
|
|
403
|
+
// repository metadata all the same.
|
|
404
|
+
gitDirsReal.push(join(topReal, '.git'));
|
|
405
|
+
return { tempRoots, topReal, gitDirsReal };
|
|
406
|
+
};
|
|
407
|
+
const surface = extra.some((v) => v.startsWith('@')) ? extraReadSurface() : null;
|
|
408
|
+
const resolveExtra = (value) => {
|
|
409
|
+
if (!value.startsWith('@')) return value;
|
|
410
|
+
const ref = value.slice(1);
|
|
411
|
+
const real = (() => {
|
|
412
|
+
try {
|
|
413
|
+
// Canonicalize the PARENT only — the leaf stays un-dereferenced so the no-follow open
|
|
414
|
+
// refuses a symlink leaf instead of silently reading its target.
|
|
415
|
+
const lexical = resolve(cwd, ref);
|
|
416
|
+
return join(realpathSync(dirname(lexical)), basename(lexical));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
throw fail(1, `--extra file '${ref}' is unreadable (${(err && err.code) || err}) — STOP`);
|
|
419
|
+
}
|
|
420
|
+
})();
|
|
421
|
+
const within = (root) => real === root || real.startsWith(`${root}${sep}`);
|
|
422
|
+
const inTree = surface.topReal != null && within(surface.topReal);
|
|
423
|
+
if (!inTree && !surface.tempRoots.some(within)) {
|
|
424
|
+
throw fail(1, `--extra '@${ref}' resolves outside the work tree and the system temp surface (${real}) — refusing to read it into the facts payload`);
|
|
425
|
+
}
|
|
426
|
+
if (surface.gitDirsReal.some(within)) {
|
|
427
|
+
throw fail(1, `--extra '@${ref}' resolves inside the git dir (${real}) — repository internals never enter a facts payload`);
|
|
428
|
+
}
|
|
429
|
+
// Descriptor-bound read (the kit's ONE no-follow door): a FIFO cannot block the open, and a
|
|
430
|
+
// leaf swapped after the containment checks cannot change what the fd reads.
|
|
431
|
+
const r = readRegularFileNoFollow(real);
|
|
432
|
+
if (r.outcome === 'absent') throw fail(1, `--extra file '${ref}' is unreadable (ENOENT) — STOP`);
|
|
433
|
+
if (r.outcome === 'foreign') throw fail(1, `--extra file '${ref}' is not a regular file (${r.className}) — refusing; STOP`);
|
|
434
|
+
if (r.outcome !== 'ok') throw fail(1, `--extra file '${ref}' is unreadable (${r.code}) — STOP`);
|
|
435
|
+
if (r.content.trim() === '') throw fail(1, `--extra file '${ref}' is empty — nothing to append; STOP`);
|
|
436
|
+
return r.content; // byte-verbatim — no trailing-newline normalization
|
|
437
|
+
};
|
|
438
|
+
const extraTexts = extra.map(resolveExtra);
|
|
439
|
+
|
|
351
440
|
const parts = [];
|
|
352
|
-
const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan' });
|
|
441
|
+
const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan', extraTexts });
|
|
353
442
|
if (assembled) parts.push(assembled);
|
|
354
443
|
const payload = parts.join('\n');
|
|
355
444
|
const { text, trimmedBytes } = trimToBudget(payload, budget);
|
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
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
// orchestration-config.mjs — the schema / read / pure-transform core for the per-project
|
|
3
2
|
// orchestration config (docs/ai/orchestration.json). It is the SINGLE source of the config contract:
|
|
4
3
|
//
|
|
@@ -16,12 +15,13 @@
|
|
|
16
15
|
//
|
|
17
16
|
// This module performs NO filesystem WRITES — only reads (loadConfig). The single fs-writer lives in
|
|
18
17
|
// orchestration-write.mjs, which procedures.mjs never imports DIRECTLY (the pinned import-split
|
|
19
|
-
// rule).
|
|
20
|
-
//
|
|
18
|
+
// rule). It has NO CLI while upgrade.md names it — hence the registered refusal at the foot of the
|
|
19
|
+
// file (direct-run.mjs), and no shebang. Fs-injectable, dependency-free, Node >= 22; nothing on import.
|
|
21
20
|
|
|
22
21
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
23
22
|
import { join } from 'node:path';
|
|
24
23
|
import { ACTIVITIES, SLOT_RECIPES } from './recipes.mjs';
|
|
24
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
25
25
|
|
|
26
26
|
// The hand-editable / agent-writable, per-project config (strict JSON). cwd-relative — the error prefix
|
|
27
27
|
// uses this rel path so a user sees a path they can open, never an absolute temp/host path.
|
|
@@ -396,3 +396,5 @@ export const refreshReadme = (config) => {
|
|
|
396
396
|
|
|
397
397
|
// The canonical seed file body (what `init` deploys + what serializeConfig round-trips byte-identically).
|
|
398
398
|
export const SEED_CONFIG = { _README: CANON_README, 'plan-authoring': { review: 'solo' }, 'plan-execution': { execute: 'solo', review: 'solo' } };
|
|
399
|
+
|
|
400
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -25,3 +25,10 @@ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${messag
|
|
|
25
25
|
// creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
|
|
26
26
|
export const writeConfig = (cwd, config, deps = {}) =>
|
|
27
27
|
writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
|
|
28
|
+
|
|
29
|
+
// seedConfig(cwd, config, deps) → { writtenPath, created }. Same writer, CREATE-ONLY: it is the
|
|
30
|
+
// seed-if-missing arm the ensure CLI runs, where a config that appeared between the probe and the
|
|
31
|
+
// write must survive untouched (`created: false` says it did). writeConfig stays the arm for a
|
|
32
|
+
// content update of a file the caller has just read.
|
|
33
|
+
export const seedConfig = (cwd, config, deps = {}) =>
|
|
34
|
+
writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config', createOnly: true });
|