@sabaiway/agent-workflow-kit 10.4.0 → 10.5.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 +55 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +7 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +69 -17
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +73 -2
- package/bridges/antigravity-cli-bridge/capability.json +2 -2
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +3 -0
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +89 -18
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +55 -2
- package/bridges/codex-cli-bridge/capability.json +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/review-lens.md +5 -3
- package/references/modes/agents.md +1 -1
- package/references/modes/procedures.md +9 -5
- package/references/modes/recipes.md +2 -2
- package/references/modes/set-recipe.md +4 -4
- package/references/modes/status.md +1 -1
- package/references/modes/velocity.md +1 -0
- package/references/templates/orchestration.json +1 -1
- package/tools/bridge-posture.mjs +48 -0
- package/tools/carriers.mjs +21 -9
- package/tools/cheap-agents-read.mjs +86 -24
- package/tools/cheap-agents.mjs +47 -7
- package/tools/detect-backends.mjs +2 -2
- package/tools/direct-run.mjs +3 -0
- package/tools/fold-scope.mjs +5 -60
- package/tools/grounding.mjs +2 -2
- package/tools/orchestration-config.mjs +19 -78
- package/tools/orchestration-readme.mjs +70 -0
- package/tools/plan-shape-cli.mjs +112 -0
- package/tools/plan-shape-facts.mjs +204 -0
- package/tools/plan-shape.mjs +348 -0
- package/tools/procedures.mjs +132 -31
- package/tools/recipes.mjs +60 -79
- package/tools/repo-lex.mjs +40 -0
- package/tools/review-roster-resolve.mjs +104 -0
- package/tools/review-roster.mjs +128 -0
- package/tools/review-rounds-cli.mjs +92 -0
- package/tools/review-rounds.mjs +115 -0
- package/tools/set-recipe-roster.mjs +167 -0
- package/tools/set-recipe.mjs +80 -23
- package/tools/velocity-profile.mjs +8 -22
|
@@ -22,6 +22,16 @@ import { readFileSync, lstatSync } from 'node:fs';
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { ACTIVITIES, SLOT_RECIPES } from './recipes.mjs';
|
|
24
24
|
import { refuseDirectRun } from './direct-run.mjs';
|
|
25
|
+
import { validateRoster } from './review-roster.mjs';
|
|
26
|
+
import {
|
|
27
|
+
CANON_README,
|
|
28
|
+
KNOWN_PRIOR_README,
|
|
29
|
+
normalizeCanonical,
|
|
30
|
+
refreshIfCanonical,
|
|
31
|
+
refreshReadme,
|
|
32
|
+
} from './orchestration-readme.mjs';
|
|
33
|
+
|
|
34
|
+
export { CANON_README, KNOWN_PRIOR_README, normalizeCanonical, refreshIfCanonical, refreshReadme };
|
|
25
35
|
|
|
26
36
|
// The hand-editable / agent-writable, per-project config (strict JSON). cwd-relative — the error prefix
|
|
27
37
|
// uses this rel path so a user sees a path they can open, never an absolute temp/host path.
|
|
@@ -242,10 +252,18 @@ export const validateConfig = (config) => {
|
|
|
242
252
|
`${CONFIG_REL}: unknown slot "${slot}" for activity "${key}" (${key} slots: ${Object.keys(activityDef.slots).join(', ')})`,
|
|
243
253
|
);
|
|
244
254
|
}
|
|
255
|
+
if (Array.isArray(recipe) && slotType === 'review') {
|
|
256
|
+
try {
|
|
257
|
+
validateRoster(recipe);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw fail(1, `${CONFIG_REL}: invalid review roster for "${key}.${slot}" (${error.message})`);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
245
263
|
if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
|
|
246
264
|
throw fail(
|
|
247
265
|
1,
|
|
248
|
-
`${CONFIG_REL}: invalid value
|
|
266
|
+
`${CONFIG_REL}: invalid value ${JSON.stringify(recipe)} for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
|
|
249
267
|
);
|
|
250
268
|
}
|
|
251
269
|
}
|
|
@@ -338,83 +356,6 @@ export const serializeConfig = (config) => {
|
|
|
338
356
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
339
357
|
};
|
|
340
358
|
|
|
341
|
-
// ── canonical-refresh (shared by the _README refresh + the injected-slot refresh) ───
|
|
342
|
-
// normalizeCanonical: trim + LF-normalize (handles the CRLF / trailing-whitespace trap) so a byte-noisy
|
|
343
|
-
// copy of a canonical string still matches. refreshIfCanonical: replace `current` with `next` IFF it
|
|
344
|
-
// normalize-equals ANY known prior canonical; otherwise return `current` UNCHANGED (preserve a
|
|
345
|
-
// customization). Pure; no fs. Used for the orchestration `_README` and the two injected pointers.
|
|
346
|
-
|
|
347
|
-
export const normalizeCanonical = (s) => String(s).replace(/\r\n/g, '\n').trim();
|
|
348
|
-
|
|
349
|
-
export const refreshIfCanonical = (current, knownPriorCanonicals, next) => {
|
|
350
|
-
const cur = normalizeCanonical(current);
|
|
351
|
-
return knownPriorCanonicals.some((prior) => normalizeCanonical(prior) === cur) ? next : current;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// ── canonical `_README` (drift-guarded, append-only known-prior set) ─────────────────
|
|
355
|
-
// CANON_README is the CURRENT onboarding note — what the templates ship + what a refresh installs. It
|
|
356
|
-
// frames hand-edit as a still-available option AND points at the set-recipe writer (no "never written
|
|
357
|
-
// for you"). KNOWN_PRIOR_README is the APPEND-ONLY set of every PREVIOUS canonical note: any release
|
|
358
|
-
// that changes CANON_README must FIRST append the outgoing string here, so an immediately-previous
|
|
359
|
-
// deployment still normalize-matches and gets refreshed (a customized note never matches → preserved).
|
|
360
|
-
export const CANON_README =
|
|
361
|
-
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. " +
|
|
362
|
-
"Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, " +
|
|
363
|
-
"previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you " +
|
|
364
|
-
"prefer; that option never goes away. Three activities are configured independently, and so is each slot " +
|
|
365
|
-
"within them: 'plan-authoring' (slots author, review), 'plan-execution' (slots execute, review) and " +
|
|
366
|
-
"'routine' (slots carrier, parallel). A slot's value is a recipe: a 'review' slot accepts " +
|
|
367
|
-
"solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an " +
|
|
368
|
-
"'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a " +
|
|
369
|
-
"full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author' " +
|
|
370
|
-
"and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts " +
|
|
371
|
-
"on | off and decides whether file-disjoint subagent slices dispatch concurrently. The default below is " +
|
|
372
|
-
"'solo' for every recipe and carrier slot, and 'on' for the parallel switch — no execution backend required. " +
|
|
373
|
-
"Raise a slot to reviewed or council for a second " +
|
|
374
|
-
"opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' " +
|
|
375
|
-
"needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without " +
|
|
376
|
-
"it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or " +
|
|
377
|
-
"run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review " +
|
|
378
|
-
"backend is ready and otherwise solo for a review slot, solo for author, execute and carrier, on for " +
|
|
379
|
-
"parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for " +
|
|
380
|
-
"your environment. Strict JSON — no comments.";
|
|
381
|
-
|
|
382
|
-
export const KNOWN_PRIOR_README = [
|
|
383
|
-
// v1 (pre-set-recipe) — the "Hand-edit this file — it is never written for you" note. APPEND-ONLY.
|
|
384
|
-
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. Hand-edit this file — it is never written for you. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's line to fall back to the computed default (reviewed when a review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment, and pass a per-run override to change one slot just once. Strict JSON — no comments.",
|
|
385
|
-
// v2 (two activities, an `execute` slot without a carrier) — the note that shipped before AD-124.
|
|
386
|
-
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell " +
|
|
387
|
-
"the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, " +
|
|
388
|
-
"and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option " +
|
|
389
|
-
"never goes away. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is " +
|
|
390
|
-
"each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you " +
|
|
391
|
-
"self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | " +
|
|
392
|
-
"delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no " +
|
|
393
|
-
"execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand " +
|
|
394
|
-
"off execution; those need an execution backend set up first. Remove a slot's line (or run `set-recipe --unset " +
|
|
395
|
-
"<activity>.<slot>`) to fall back to the computed default (reviewed when a review backend is ready, otherwise " +
|
|
396
|
-
"solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your " +
|
|
397
|
-
"environment. Strict JSON — no comments.",
|
|
398
|
-
];
|
|
399
|
-
|
|
400
|
-
// refreshReadme(config) → { config, changed }: refresh ONLY the `_README` value when it normalize-
|
|
401
|
-
// matches a known prior canonical (preserve a customized note untouched); seed it when absent. The
|
|
402
|
-
// stamp-independent config-ensure (kit fallback + memory delegated upgrade paths) uses this so an
|
|
403
|
-
// install-base deployment gains the new note without a migration file — never clobbering a customization.
|
|
404
|
-
export const refreshReadme = (config) => {
|
|
405
|
-
if (config == null || typeof config !== 'object' || Array.isArray(config)) {
|
|
406
|
-
return { config, changed: false };
|
|
407
|
-
}
|
|
408
|
-
const had = config._README;
|
|
409
|
-
const nextReadme = had === undefined ? CANON_README : refreshIfCanonical(had, KNOWN_PRIOR_README, CANON_README);
|
|
410
|
-
if (nextReadme === had) return { config, changed: false };
|
|
411
|
-
const next = { _README: nextReadme };
|
|
412
|
-
for (const [k, v] of Object.entries(config)) {
|
|
413
|
-
if (k !== '_README') next[k] = v;
|
|
414
|
-
}
|
|
415
|
-
return { config: next, changed: true };
|
|
416
|
-
};
|
|
417
|
-
|
|
418
359
|
// The canonical seed file body (what `init` deploys + what serializeConfig round-trips byte-identically).
|
|
419
360
|
export const SEED_CONFIG = { _README: CANON_README, 'plan-authoring': { review: 'solo' }, 'plan-execution': { execute: 'solo', review: 'solo' } };
|
|
420
361
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const normalizeCanonical = (value) => String(value).replace(/\r\n/gu, '\n').trim();
|
|
2
|
+
|
|
3
|
+
export const refreshIfCanonical = (current, knownPriorCanonicals, next) => {
|
|
4
|
+
const normalized = normalizeCanonical(current);
|
|
5
|
+
return knownPriorCanonicals.some((prior) => normalizeCanonical(prior) === normalized) ? next : current;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const V1_README =
|
|
9
|
+
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. Hand-edit this file — it is never written for you. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). " +
|
|
10
|
+
"The default below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's line to fall back to the computed default (reviewed when a review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment, and pass a per-run override to change one slot just once. Strict JSON — no comments.";
|
|
11
|
+
|
|
12
|
+
const V2_README =
|
|
13
|
+
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. Easiest: tell " +
|
|
14
|
+
"the agent in plain language and run the `set-recipe` writer — it interprets your intent, previews the change, " +
|
|
15
|
+
"and writes valid JSON for you. You can still hand-edit this file directly whenever you prefer; that option " +
|
|
16
|
+
"never goes away. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is " +
|
|
17
|
+
"each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you " +
|
|
18
|
+
"self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | " +
|
|
19
|
+
"delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no " +
|
|
20
|
+
"execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand " +
|
|
21
|
+
"off execution; those need an execution backend set up first. Remove a slot's line (or run `set-recipe --unset " +
|
|
22
|
+
"<activity>.<slot>`) to fall back to the computed default (reviewed when a review backend is ready, otherwise " +
|
|
23
|
+
"solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your " +
|
|
24
|
+
"environment. Strict JSON — no comments.";
|
|
25
|
+
|
|
26
|
+
const V3_README =
|
|
27
|
+
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. " +
|
|
28
|
+
"Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, " +
|
|
29
|
+
"previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you " +
|
|
30
|
+
"prefer; that option never goes away. Three activities are configured independently, and so is each slot " +
|
|
31
|
+
"within them: 'plan-authoring' (slots author, review), 'plan-execution' (slots execute, review) and " +
|
|
32
|
+
"'routine' (slots carrier, parallel). A slot's value is a recipe: a 'review' slot accepts " +
|
|
33
|
+
"solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an " +
|
|
34
|
+
"'execute' slot accepts solo | delegated | subagent (you implement / a backend runs a bounded sub-task / a " +
|
|
35
|
+
"full-tool frontier subagent carries a bounded slice you verify); the carrier slots 'plan-authoring.author' " +
|
|
36
|
+
"and 'routine.carrier' accept solo | subagent. 'routine.parallel' is a flag rather than a recipe: it accepts " +
|
|
37
|
+
"on | off and decides whether file-disjoint subagent slices dispatch concurrently. The default below is " +
|
|
38
|
+
"'solo' for every recipe and carrier slot, and 'on' for the parallel switch — no execution backend required. " +
|
|
39
|
+
"Raise a slot to reviewed or council for a second " +
|
|
40
|
+
"opinion, or to delegated to hand off execution; those need an execution backend set up first. 'subagent' " +
|
|
41
|
+
"needs the executor vehicle placed in this project — the composition root's `agents` writer places it; without " +
|
|
42
|
+
"it the slot resolves to solo with the reason stated. Remove a slot's line, or a whole activity block (or " +
|
|
43
|
+
"run `set-recipe --unset <activity>.<slot>`), to fall back to the computed default: reviewed when a review " +
|
|
44
|
+
"backend is ready and otherwise solo for a review slot, solo for author, execute and carrier, on for " +
|
|
45
|
+
"parallel. Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for " +
|
|
46
|
+
"your environment. Strict JSON — no comments.";
|
|
47
|
+
|
|
48
|
+
const ROSTER_README = V3_README.replace(
|
|
49
|
+
"a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize)",
|
|
50
|
+
"a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize), or an explicit roster array such as [\"codex-review\", \"agy-review\", \"review-lens\"] in hand-edit form",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const CANON_README = ROSTER_README
|
|
54
|
+
.replace("'plan-authoring' (slots author, review)", "'plan-authoring' (slots author, fold, review)")
|
|
55
|
+
.replace("the carrier slots 'plan-authoring.author' and 'routine.carrier'", "the carrier slots 'plan-authoring.author', 'plan-authoring.fold' and 'routine.carrier'")
|
|
56
|
+
.replace('solo for author, execute and carrier', 'solo for author, fold, execute and carrier');
|
|
57
|
+
|
|
58
|
+
export const KNOWN_PRIOR_README = Object.freeze([V1_README, V2_README, V3_README, ROSTER_README]);
|
|
59
|
+
|
|
60
|
+
export const refreshReadme = (config) => {
|
|
61
|
+
if (config == null || typeof config !== 'object' || Array.isArray(config)) return { config, changed: false };
|
|
62
|
+
const current = config._README;
|
|
63
|
+
const readme = current === undefined
|
|
64
|
+
? CANON_README
|
|
65
|
+
: refreshIfCanonical(current, KNOWN_PRIOR_README, CANON_README);
|
|
66
|
+
if (readme === current) return { config, changed: false };
|
|
67
|
+
const next = { _README: readme };
|
|
68
|
+
for (const [key, value] of Object.entries(config)) if (key !== '_README') next[key] = value;
|
|
69
|
+
return { config: next, changed: true };
|
|
70
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdirSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
6
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
7
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
8
|
+
import { plansInFlight, PLANS_REL } from './plan-files.mjs';
|
|
9
|
+
import { buildFacts, openRepo } from './plan-shape-facts.mjs';
|
|
10
|
+
import { checkPlan, checkPlanStructure, formatFindings, parseLedger, PLAN_TITLE_PREFIX, verifyPlan } from './plan-shape.mjs';
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage:
|
|
13
|
+
node plan-shape-cli.mjs --check <plan>
|
|
14
|
+
node plan-shape-cli.mjs --verify <plan>
|
|
15
|
+
node plan-shape-cli.mjs --check --in-flight
|
|
16
|
+
node plan-shape-cli.mjs --verify --in-flight`;
|
|
17
|
+
|
|
18
|
+
const readPlan = (path) => {
|
|
19
|
+
const result = readRegularFileNoFollow(path);
|
|
20
|
+
if (result.outcome !== 'ok') throw new Error(`${path} must be a readable regular plan file (${result.className ?? result.code ?? result.outcome})`);
|
|
21
|
+
return result.content;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const readPlanEntries = (cwd) => {
|
|
25
|
+
try {
|
|
26
|
+
return readdirSync(join(cwd, PLANS_REL), { withFileTypes: true });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === 'ENOENT') return [];
|
|
29
|
+
throw new Error(`${PLANS_REL} could not be read (${error?.code ?? 'fs error'})`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const getPaths = (text) => {
|
|
34
|
+
try {
|
|
35
|
+
const rows = parseLedger(text).rows.filter((row) => row.valid);
|
|
36
|
+
return [...rows.map((row) => row.path), ...rows.map((row) => row.anchorPath).filter(Boolean)];
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const isPlanShape = (text) => {
|
|
43
|
+
try {
|
|
44
|
+
const first = tokenizeMarkdown(text, 'the in-flight document').headings[0];
|
|
45
|
+
return Boolean(first && first.level === 1 && first.text.startsWith(PLAN_TITLE_PREFIX));
|
|
46
|
+
} catch {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const writeLine = (write, line) => write(`${line}\n`);
|
|
52
|
+
|
|
53
|
+
// A facts failure scoped to the plan's own paths is that plan's listed finding, so the other plans
|
|
54
|
+
// are still judged; a repository-level refusal (the practice, a package, its pins) keeps its usage class.
|
|
55
|
+
const buildPlanFacts = (cwd, repo, text) => {
|
|
56
|
+
try {
|
|
57
|
+
return { facts: buildFacts(cwd, { paths: getPaths(text), repo }) };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error?.scope !== 'plan') throw error;
|
|
60
|
+
return { findings: [{ line: 1, code: 'facts', message: error.message, rowId: null }], skips: [] };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const judgeWith = (cwd, repo, text, rules) => {
|
|
65
|
+
const built = buildPlanFacts(cwd, repo, text);
|
|
66
|
+
return built.facts ? rules(text, built.facts) : built;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const runExplicit = (cwd, arm, operand, write) => {
|
|
70
|
+
const text = readPlan(resolve(cwd, operand));
|
|
71
|
+
const result = judgeWith(cwd, openRepo(cwd), text, arm === '--check' ? checkPlan : verifyPlan);
|
|
72
|
+
writeLine(write, formatFindings(result, operand));
|
|
73
|
+
return result.findings.length === 0 ? 0 : 1;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const runInFlight = (cwd, write) => {
|
|
77
|
+
const entries = readPlanEntries(cwd);
|
|
78
|
+
const names = plansInFlight(cwd, () => entries);
|
|
79
|
+
const documents = names.map((name) => {
|
|
80
|
+
const label = `${PLANS_REL}/${name}`;
|
|
81
|
+
return { label, text: readPlan(resolve(cwd, label)) };
|
|
82
|
+
});
|
|
83
|
+
const judged = documents.filter((document) => isPlanShape(document.text));
|
|
84
|
+
const repo = openRepo(cwd);
|
|
85
|
+
const results = judged.map((document) => {
|
|
86
|
+
const result = judgeWith(cwd, repo, document.text, checkPlanStructure);
|
|
87
|
+
writeLine(write, formatFindings(result, document.label));
|
|
88
|
+
return result;
|
|
89
|
+
});
|
|
90
|
+
writeLine(write, `plan-shape: judged plans: ${judged.length}`);
|
|
91
|
+
writeLine(write, `plan-shape: skipped by shape: ${documents.length - judged.length}`);
|
|
92
|
+
return results.some((result) => result.findings.length > 0) ? 1 : 0;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const main = (argv = process.argv.slice(2), io = {}) => {
|
|
96
|
+
const cwd = io.cwd ?? process.cwd();
|
|
97
|
+
const stdout = io.stdout ?? ((text) => process.stdout.write(text));
|
|
98
|
+
const stderr = io.stderr ?? ((text) => process.stderr.write(text));
|
|
99
|
+
const [arm, operand, ...rest] = argv;
|
|
100
|
+
if (!['--check', '--verify'].includes(arm) || !operand || rest.length > 0) {
|
|
101
|
+
writeLine(stderr, USAGE);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return operand === '--in-flight' ? runInFlight(cwd, stdout) : runExplicit(cwd, arm, operand, stdout);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
writeLine(stderr, `plan-shape: ${error.message}`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
4
|
+
import { segmentPrefixOf, validateSourceSizeConfig } from './source-size-config.mjs';
|
|
5
|
+
import { getLineCount, isSweep, resolveAnchorCandidates, unique } from './plan-shape.mjs';
|
|
6
|
+
|
|
7
|
+
const SOURCE_SIZE_REL = 'docs/ai/source-size.json';
|
|
8
|
+
const PACKAGE_FILE = 'package.json';
|
|
9
|
+
const PIN_FILE = 'package-content.test.mjs';
|
|
10
|
+
const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
11
|
+
const NEGATED_CLASS = /\[[!^]/;
|
|
12
|
+
const UNSUPPORTED_GLOB = /[()\\\u0000-\u001f]/;
|
|
13
|
+
|
|
14
|
+
const usageError = (message, scope = 'repository') => Object.assign(new Error(message), { exitCode: 2, scope });
|
|
15
|
+
const planError = (message) => usageError(message, 'plan');
|
|
16
|
+
const toPosix = (path) => path.split(sep).join('/');
|
|
17
|
+
const isInside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
|
|
18
|
+
const isLexicallySafe = (path) => typeof path === 'string' && path.length > 0 && !isAbsolute(path) &&
|
|
19
|
+
!path.startsWith('/') && !path.includes('\\') && !path.split('/').includes('..');
|
|
20
|
+
|
|
21
|
+
const getLstat = (path, fail = usageError) => {
|
|
22
|
+
try {
|
|
23
|
+
return lstatSync(path);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
26
|
+
throw fail(`cannot inspect ${path} (${error.message})`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const walkRegularFiles = (root, directory = root) => readdirSync(directory, { withFileTypes: true })
|
|
31
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
32
|
+
.flatMap((entry) => {
|
|
33
|
+
const path = join(directory, entry.name);
|
|
34
|
+
const rel = toPosix(relative(root, path));
|
|
35
|
+
if (entry.isDirectory() && !EXCLUDED_DIRECTORIES.has(entry.name)) return walkRegularFiles(root, path);
|
|
36
|
+
return entry.isFile() ? [rel] : [];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const getRealpath = (path) => {
|
|
40
|
+
try {
|
|
41
|
+
return realpathSync(path);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const resolveAbsentLeaf = (root, path) => {
|
|
48
|
+
const rootReal = realpathSync(root);
|
|
49
|
+
const lexical = resolve(root, path);
|
|
50
|
+
if (!isInside(resolve(root), lexical)) return { contained: false, resolved: lexical };
|
|
51
|
+
const findExisting = (candidate, missing) => {
|
|
52
|
+
const real = getLstat(candidate, planError) ? getRealpath(candidate) : null;
|
|
53
|
+
if (real) return join(real, ...missing);
|
|
54
|
+
const parent = dirname(candidate);
|
|
55
|
+
if (parent === candidate) return candidate;
|
|
56
|
+
return findExisting(parent, [basename(candidate), ...missing]);
|
|
57
|
+
};
|
|
58
|
+
const resolved = findExisting(lexical, []);
|
|
59
|
+
return { contained: isInside(rootReal, resolved), resolved };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readJsonNoFollow = (path, label) => {
|
|
63
|
+
const result = readRegularFileNoFollow(path);
|
|
64
|
+
if (result.outcome === 'absent') return null;
|
|
65
|
+
if (result.outcome !== 'ok') throw usageError(`${label} must be a readable regular file (${result.className ?? result.code ?? result.outcome})`);
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(result.content);
|
|
68
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('the root is not an object');
|
|
69
|
+
return parsed;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw usageError(`${label} is not valid JSON (${error.message})`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const loadPractice = (root) => {
|
|
76
|
+
const parsed = readJsonNoFollow(join(root, SOURCE_SIZE_REL), SOURCE_SIZE_REL);
|
|
77
|
+
if (parsed === null) return { capDeclared: false, cap: null, config: null };
|
|
78
|
+
try {
|
|
79
|
+
const config = validateSourceSizeConfig(parsed);
|
|
80
|
+
return { capDeclared: true, cap: config.defaults.maxLines, config };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw usageError(`${SOURCE_SIZE_REL} is malformed (${error.message})`);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const isInScope = (path, config) => Boolean(config &&
|
|
87
|
+
config.roots.some((root) => segmentPrefixOf(root, path)) &&
|
|
88
|
+
!config.exclude.some((excluded) => segmentPrefixOf(excluded, path)) &&
|
|
89
|
+
config.extensions.some((extension) => path.endsWith(extension)));
|
|
90
|
+
|
|
91
|
+
const expandBraces = (pattern) => {
|
|
92
|
+
const match = /\{([^{}]+)\}/.exec(pattern);
|
|
93
|
+
if (!match) return [pattern];
|
|
94
|
+
return match[1].split(',').flatMap((part) => expandBraces(`${pattern.slice(0, match.index)}${part}${pattern.slice(match.index + match[0].length)}`));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const compileGlob = (pattern) => {
|
|
98
|
+
if (UNSUPPORTED_GLOB.test(pattern) || NEGATED_CLASS.test(pattern) || /\{[^}]*$|^[^{]*\}/.test(pattern)) return null;
|
|
99
|
+
try {
|
|
100
|
+
const alternatives = expandBraces(pattern).map((entry) => entry
|
|
101
|
+
.replace(/[.+^$|]/g, '\\$&')
|
|
102
|
+
.replace(/\*\*\//g, '\u0002')
|
|
103
|
+
.replace(/\*\*/g, '\u0001')
|
|
104
|
+
.replace(/\*/g, '[^/]*')
|
|
105
|
+
.replace(/\?/g, '[^/]')
|
|
106
|
+
.replace(/\u0002/g, '(?:.*/)?')
|
|
107
|
+
.replace(/\u0001/g, '.*'));
|
|
108
|
+
return new RegExp(`^(?:${alternatives.join('|')})$`);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const matchFilesEntry = (entry, ownedPath) => {
|
|
115
|
+
const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
|
|
116
|
+
if (normalized === '' || normalized === '.') return { known: true, matches: true };
|
|
117
|
+
if (!isSweep(normalized)) return { known: true, matches: ownedPath === normalized || ownedPath.startsWith(`${normalized}/`) };
|
|
118
|
+
const compiled = compileGlob(normalized);
|
|
119
|
+
return compiled ? { known: true, matches: compiled.test(ownedPath) } : { known: false, matches: false };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getShipped = (packageJson, ownedPath) => {
|
|
123
|
+
if (packageJson.private === true) return false;
|
|
124
|
+
if (!Object.hasOwn(packageJson, 'files')) return true;
|
|
125
|
+
if (!Array.isArray(packageJson.files) || packageJson.files.some((entry) => typeof entry !== 'string')) return 'unknown';
|
|
126
|
+
return packageJson.files.reduce((state, rawEntry) => {
|
|
127
|
+
const excluded = rawEntry.startsWith('!');
|
|
128
|
+
const entry = excluded ? rawEntry.slice(1) : rawEntry;
|
|
129
|
+
const result = matchFilesEntry(entry, ownedPath);
|
|
130
|
+
if (!result.known) return 'unknown';
|
|
131
|
+
return result.matches ? !excluded : state;
|
|
132
|
+
}, false);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const findPackage = (root, path, cache) => {
|
|
136
|
+
const start = resolve(root, dirname(path));
|
|
137
|
+
const visit = (directory) => {
|
|
138
|
+
if (!isInside(resolve(root), directory)) return null;
|
|
139
|
+
const packagePath = join(directory, PACKAGE_FILE);
|
|
140
|
+
if (getLstat(packagePath)) {
|
|
141
|
+
const relRoot = toPosix(relative(root, directory)) || '.';
|
|
142
|
+
if (!cache.has(relRoot)) cache.set(relRoot, readJsonNoFollow(packagePath, toPosix(relative(root, packagePath))));
|
|
143
|
+
return { root: relRoot, json: cache.get(relRoot) };
|
|
144
|
+
}
|
|
145
|
+
return directory === resolve(root) ? null : visit(dirname(directory));
|
|
146
|
+
};
|
|
147
|
+
return visit(start);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const getPin = (root, owner, repoFiles, cache) => {
|
|
151
|
+
const prefix = owner.root === '.' ? '' : `${owner.root}/`;
|
|
152
|
+
const pins = repoFiles.filter((path) => path.startsWith(prefix) && basename(path) === PIN_FILE &&
|
|
153
|
+
findPackage(root, path, cache)?.root === owner.root);
|
|
154
|
+
if (pins.length > 1) throw usageError(`package ${owner.root} has several ${PIN_FILE} files: ${pins.join(', ')}`);
|
|
155
|
+
return pins[0] ?? null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const describePinSkip = (owner, shipped, pinTest) => {
|
|
159
|
+
if (owner === null) return 'package ownership is unknown';
|
|
160
|
+
if (owner.json.private === true) return `private package ${owner.root}`;
|
|
161
|
+
if (shipped === 'unknown') return `shipping state is unknown for package ${owner.root}`;
|
|
162
|
+
return shipped === true && pinTest === null ? `no ${PIN_FILE} under package ${owner.root}` : null;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const describePath = (root, path, practice, repoFiles, packageCache) => {
|
|
166
|
+
const safe = isLexicallySafe(path);
|
|
167
|
+
const containment = safe ? resolveAbsentLeaf(root, path) : { contained: false, resolved: resolve(root, path) };
|
|
168
|
+
const stat = safe ? getLstat(resolve(root, path), planError) : null;
|
|
169
|
+
const read = stat?.isFile() ? readRegularFileNoFollow(resolve(root, path)) : null;
|
|
170
|
+
if (read && read.outcome !== 'ok') throw planError(`${path} cannot be read without following it (${read.className ?? read.code ?? read.outcome})`);
|
|
171
|
+
const kind = stat === null ? 'absent' : stat.isFile() ? 'regular' : 'other';
|
|
172
|
+
const owner = safe && containment.contained && !isSweep(path) ? findPackage(root, path, packageCache) : null;
|
|
173
|
+
const ownedPath = owner ? toPosix(relative(owner.root === '.' ? root : join(root, owner.root), resolve(root, path))) : null;
|
|
174
|
+
const shipped = owner ? getShipped(owner.json, ownedPath) : 'unknown';
|
|
175
|
+
const pinTest = owner && shipped === true ? getPin(root, owner, repoFiles, packageCache) : null;
|
|
176
|
+
return {
|
|
177
|
+
kind,
|
|
178
|
+
lines: read?.outcome === 'ok' ? getLineCount(read.content) : 0,
|
|
179
|
+
recordedLines: practice.config?.baseline?.[path]?.lines ?? null,
|
|
180
|
+
inScope: isInScope(path, practice.config),
|
|
181
|
+
shipped,
|
|
182
|
+
pinTest,
|
|
183
|
+
pinSkip: describePinSkip(owner, shipped, pinTest),
|
|
184
|
+
contained: containment.contained,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const openRepo = (root) => {
|
|
189
|
+
const repoRoot = realpathSync(root);
|
|
190
|
+
return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), packageCache: new Map() };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const buildFacts = (root, { paths = [], repo = openRepo(root) } = {}) => {
|
|
194
|
+
const { repoRoot, repoFiles, practice, packageCache } = repo;
|
|
195
|
+
const expansions = Object.fromEntries(paths.filter(isSweep).map((pattern) => {
|
|
196
|
+
const compiled = compileGlob(pattern);
|
|
197
|
+
if (!compiled) throw planError(`unsupported glob in plan path: ${pattern}`);
|
|
198
|
+
return [pattern, repoFiles.filter((path) => compiled.test(path))];
|
|
199
|
+
}));
|
|
200
|
+
const allPaths = unique([...paths, ...Object.values(expansions).flat()]);
|
|
201
|
+
const pathFacts = Object.fromEntries(allPaths.map((path) => [path, describePath(repoRoot, path, practice, repoFiles, packageCache)]));
|
|
202
|
+
const candidates = (suffix, precedingPaths = []) => resolveAnchorCandidates(repoFiles, suffix, precedingPaths);
|
|
203
|
+
return { capDeclared: practice.capDeclared, cap: practice.cap, pathFacts, expansions, repoFiles, candidates };
|
|
204
|
+
};
|