@sabaiway/agent-workflow-kit 10.4.0 → 10.6.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 +109 -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/core-evidence.md +1 -1
- package/references/modes/procedures.md +11 -5
- package/references/modes/recipes.md +2 -2
- package/references/modes/review-state.md +3 -3
- 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/commit-guard.mjs +4 -3
- package/tools/detect-backends.mjs +2 -2
- package/tools/direct-run.mjs +3 -0
- package/tools/dispatch-store-read.mjs +269 -0
- package/tools/dispatch-store.mjs +21 -357
- package/tools/fold-scope.mjs +5 -60
- package/tools/grounding.mjs +2 -2
- package/tools/held-session.mjs +165 -0
- 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 +161 -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/review-state.mjs +85 -6
- package/tools/set-recipe-roster.mjs +167 -0
- package/tools/set-recipe.mjs +80 -23
- package/tools/velocity-profile.mjs +8 -22
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { safeLine, REVIEW_CMD_ALIASES } from './carriers.mjs';
|
|
2
|
+
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
3
|
+
import { parseSlotToken, validateRoster } from './review-roster.mjs';
|
|
4
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
5
|
+
|
|
6
|
+
const READY_STATES = new Set([READY, 'placed', 'customized']);
|
|
7
|
+
|
|
8
|
+
const setupTarget = (entry) => entry?.setupHint?.local ?? entry?.setupHint?.url ?? null;
|
|
9
|
+
|
|
10
|
+
export const remedyFor = (entry = {}) => {
|
|
11
|
+
const setup = setupTarget(entry);
|
|
12
|
+
if (entry.readiness === NEEDS_SKILL) return setup ? `bridge skill not installed — ${safeLine(setup)}` : 'bridge skill not installed — run /agent-workflow-kit setup';
|
|
13
|
+
if (entry.readiness === NEEDS_CLI) return setup ? `the CLI is not installed — ${safeLine(setup)}` : 'the CLI is not installed';
|
|
14
|
+
if (entry.readiness === NEEDS_CREDENTIALS) return 'not signed in (credentials missing)';
|
|
15
|
+
if (entry.readiness === DEGRADED) return 'wrapper not on PATH — run /agent-workflow-kit setup';
|
|
16
|
+
return entry.readiness ? safeLine(entry.readiness) : 'bridge readiness unavailable';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const lensVehicleSpec = (member) => {
|
|
20
|
+
const parsed = typeof member === 'string' ? parseSlotToken(member) : member;
|
|
21
|
+
if (parsed.kind !== 'lens') throw new Error(`not a lens member: ${parsed.member ?? member}`);
|
|
22
|
+
return {
|
|
23
|
+
stem: parsed.stem,
|
|
24
|
+
template: parsed.template,
|
|
25
|
+
model: parsed.model,
|
|
26
|
+
effort: parsed.effort,
|
|
27
|
+
tools: 'read-only',
|
|
28
|
+
derived: parsed.derived,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const deriveLensTemplate = (template, spec) => {
|
|
33
|
+
if (!spec?.derived) return String(template);
|
|
34
|
+
return String(template)
|
|
35
|
+
.replace(/^name:.*$/mu, `name: ${spec.stem}`)
|
|
36
|
+
.replace(/^model:.*$/mu, `model: ${spec.model}`)
|
|
37
|
+
.replace(/^effort:.*$/mu, `effort: ${spec.effort}`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const postureValue = (postures, receiptId) => {
|
|
41
|
+
const value = postures?.[receiptId];
|
|
42
|
+
if (typeof value === 'string') return value;
|
|
43
|
+
return value?.state === 'valid' ? value.posture : null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const bridgeRow = (parsed, readiness, postures) => {
|
|
47
|
+
const alias = REVIEW_CMD_ALIASES[parsed.instrument];
|
|
48
|
+
const entry = readiness.find((candidate) => candidate?.name === alias.backend);
|
|
49
|
+
const state = entry?.readiness ?? NEEDS_SKILL;
|
|
50
|
+
return {
|
|
51
|
+
member: parsed.member,
|
|
52
|
+
stem: parsed.stem,
|
|
53
|
+
kind: 'bridge',
|
|
54
|
+
state,
|
|
55
|
+
reason: state === READY ? null : remedyFor({ ...entry, readiness: state }),
|
|
56
|
+
posture: postureValue(postures, parsed.stem),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const lensRow = (parsed, surveyLens) => {
|
|
61
|
+
if (typeof surveyLens !== 'function') {
|
|
62
|
+
return {
|
|
63
|
+
member: parsed.member, stem: parsed.stem, kind: 'lens', state: 'unsurveyed',
|
|
64
|
+
reason: null, posture: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const survey = surveyLens(lensVehicleSpec(parsed)) ?? {};
|
|
68
|
+
const state = survey.state ?? 'unusable';
|
|
69
|
+
const posture = READY_STATES.has(state) && survey.model && survey.effort
|
|
70
|
+
? `model=${safeLine(survey.model)} effort=${safeLine(survey.effort)}`
|
|
71
|
+
: null;
|
|
72
|
+
return {
|
|
73
|
+
member: parsed.member,
|
|
74
|
+
stem: parsed.stem,
|
|
75
|
+
kind: 'lens',
|
|
76
|
+
state,
|
|
77
|
+
reason: survey.reason == null ? null : safeLine(survey.reason),
|
|
78
|
+
posture,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const resolveRoster = ({ value, readiness = [], surveyLens, postures } = {}) => {
|
|
83
|
+
validateRoster(value);
|
|
84
|
+
return value.map((member) => {
|
|
85
|
+
const parsed = parseSlotToken(member);
|
|
86
|
+
return parsed.kind === 'bridge'
|
|
87
|
+
? bridgeRow(parsed, readiness, postures)
|
|
88
|
+
: lensRow(parsed, surveyLens);
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const isReadyMember = (row) => READY_STATES.has(row.state);
|
|
93
|
+
|
|
94
|
+
export const skippedLine = (row, remedy = row.reason) =>
|
|
95
|
+
`skipped this round — ${safeLine(row.state)}: ${remedy ?? 'no remedy recorded'}`;
|
|
96
|
+
|
|
97
|
+
export const rosterLabel = (roster, { states = true } = {}) => roster.map((row) => {
|
|
98
|
+
if (!states || isReadyMember(row)) return row.member;
|
|
99
|
+
return `${row.member} (${safeLine(row.state)})`;
|
|
100
|
+
}).join(' + ');
|
|
101
|
+
|
|
102
|
+
export const activeLineCell = (roster, options) => `[${rosterLabel(roster, options)}]`;
|
|
103
|
+
|
|
104
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { REVIEW_CMD_ALIASES, receiptIdOfCmd, LENS_VERDICTS } from './carriers.mjs';
|
|
2
|
+
import { KNOWN_BACKENDS } from './detect-backends.mjs';
|
|
3
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
4
|
+
|
|
5
|
+
export { LENS_VERDICTS };
|
|
6
|
+
|
|
7
|
+
export const BUNDLED_LENS_TEMPLATES = Object.freeze(['review-lens']);
|
|
8
|
+
const MEMBER_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
9
|
+
const SUFFIX_TOKEN = /^[a-z0-9]+$/u;
|
|
10
|
+
|
|
11
|
+
const reviewCommands = () => KNOWN_BACKENDS
|
|
12
|
+
.map((backend) => backend.roleCmds?.review)
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
|
|
15
|
+
const failRoster = (message, code = null) => Object.assign(new Error(`review roster: ${message}`), { code });
|
|
16
|
+
|
|
17
|
+
export const parseSlotToken = (member) => {
|
|
18
|
+
if (typeof member !== 'string' || member.length === 0) {
|
|
19
|
+
throw failRoster('every member must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
const bridgeCommands = reviewCommands();
|
|
22
|
+
if (bridgeCommands.includes(member)) {
|
|
23
|
+
const alias = REVIEW_CMD_ALIASES[member];
|
|
24
|
+
if (!alias) throw failRoster(`bridge member "${member}" has no receipt alias`);
|
|
25
|
+
return {
|
|
26
|
+
member,
|
|
27
|
+
instrument: member,
|
|
28
|
+
kind: 'bridge',
|
|
29
|
+
stem: alias.receiptId,
|
|
30
|
+
model: null,
|
|
31
|
+
effort: null,
|
|
32
|
+
template: null,
|
|
33
|
+
derived: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const parts = member.split(':');
|
|
37
|
+
if (parts.length !== 1 && parts.length !== 3) {
|
|
38
|
+
if (bridgeCommands.includes(parts[0])) throw failRoster(`bridge member "${parts[0]}" takes no suffix`);
|
|
39
|
+
throw failRoster(`member "${member}" must be a bare stem or carry both model and effort`);
|
|
40
|
+
}
|
|
41
|
+
const [instrument, model = null, effort = null] = parts;
|
|
42
|
+
if (bridgeCommands.includes(instrument)) throw failRoster(`bridge member "${instrument}" takes no suffix`);
|
|
43
|
+
if (!MEMBER_TOKEN.test(instrument)) throw failRoster(`member instrument "${instrument}" is not a slug`);
|
|
44
|
+
if (model !== null && (!SUFFIX_TOKEN.test(model) || !SUFFIX_TOKEN.test(effort))) {
|
|
45
|
+
throw failRoster(`member "${member}" has an invalid model or effort token`);
|
|
46
|
+
}
|
|
47
|
+
if (model !== null && !BUNDLED_LENS_TEMPLATES.includes(instrument)) {
|
|
48
|
+
throw failRoster(`derived member "${member}" has no bundled lens template`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
member,
|
|
52
|
+
instrument,
|
|
53
|
+
kind: 'lens',
|
|
54
|
+
stem: model === null ? instrument : `${instrument}-${model}-${effort}`,
|
|
55
|
+
model,
|
|
56
|
+
effort,
|
|
57
|
+
template: BUNDLED_LENS_TEMPLATES.includes(instrument) ? instrument : null,
|
|
58
|
+
derived: model !== null,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const validateRoster = (value) => {
|
|
63
|
+
if (!Array.isArray(value)) throw failRoster('value must be an array');
|
|
64
|
+
if (value.length === 0) throw failRoster('array must not be empty');
|
|
65
|
+
const parsed = value.map(parseSlotToken);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const member of parsed) {
|
|
68
|
+
if (seen.has(member.stem)) throw failRoster(`duplicate resolved stem "${member.stem}"`);
|
|
69
|
+
seen.add(member.stem);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const expandShorthand = (value) => {
|
|
75
|
+
if (value === 'solo') return { lossless: true, members: [] };
|
|
76
|
+
if (value === 'council') return { lossless: true, members: reviewCommands() };
|
|
77
|
+
return { lossless: false, members: null };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const bridgeMembersOf = (value) => validateRoster(value)
|
|
81
|
+
.filter((member) => parseSlotToken(member).kind === 'bridge');
|
|
82
|
+
|
|
83
|
+
export const lensMembersOf = (value) => {
|
|
84
|
+
const values = Array.isArray(value)
|
|
85
|
+
? [value]
|
|
86
|
+
: Object.values(value ?? {}).flatMap((activity) => (
|
|
87
|
+
Array.isArray(activity?.review) ? [activity.review] : []
|
|
88
|
+
));
|
|
89
|
+
return values.flat().filter((member) => parseSlotToken(member).kind === 'lens');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const obligationsOf = (value) => {
|
|
93
|
+
const backends = bridgeMembersOf(value).map(receiptIdOfCmd);
|
|
94
|
+
if (backends.length === 0) return { recipe: 'solo', backends, minShip: 0, perBackend: false };
|
|
95
|
+
return {
|
|
96
|
+
recipe: backends.length === 1 ? 'reviewed' : 'council',
|
|
97
|
+
backends,
|
|
98
|
+
minShip: 1,
|
|
99
|
+
perBackend: true,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const explicitMembers = (value) => {
|
|
104
|
+
if (Array.isArray(value)) return [...value];
|
|
105
|
+
const expanded = expandShorthand(value);
|
|
106
|
+
if (!expanded.lossless) throw failRoster('reviewed has no lossless roster expansion');
|
|
107
|
+
return [...expanded.members];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const addReviewer = (value, member) => {
|
|
111
|
+
const next = explicitMembers(value);
|
|
112
|
+
const parsed = parseSlotToken(member);
|
|
113
|
+
if (next.some((entry) => parseSlotToken(entry).stem === parsed.stem)) return next;
|
|
114
|
+
next.push(member);
|
|
115
|
+
validateRoster(next);
|
|
116
|
+
return next;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const removeReviewer = (value, member) => {
|
|
120
|
+
const next = explicitMembers(value);
|
|
121
|
+
const stem = parseSlotToken(member).stem;
|
|
122
|
+
const filtered = next.filter((entry) => parseSlotToken(entry).stem !== stem);
|
|
123
|
+
if (next.length > 0 && filtered.length === 0) throw failRoster('removing the last member requires the solo shorthand', 'last-member');
|
|
124
|
+
if (filtered.length > 0) validateRoster(filtered);
|
|
125
|
+
return filtered;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
6
|
+
import { uncarriableArtifactByte } from './repo-lex.mjs';
|
|
7
|
+
import { readReceipts, resolveReceiptsPath } from './core-evidence.mjs';
|
|
8
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
9
|
+
import { ACTIVITIES, composeReadiness, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
|
|
10
|
+
import { groupRounds, renderRounds } from './review-rounds.mjs';
|
|
11
|
+
|
|
12
|
+
const REVIEW_ACTIVITIES = new Set(Object.entries(ACTIVITIES).filter(([, def]) => Object.hasOwn(def.slots, 'review')).map(([name]) => name));
|
|
13
|
+
|
|
14
|
+
const assertArtifactPathCarryable = (value) => {
|
|
15
|
+
const byte = uncarriableArtifactByte(value);
|
|
16
|
+
if (byte !== null) throw new Error(`artifact path contains ${byte} byte, which the receipt encoder cannot carry`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const gitTopLevel = (cwd, run = spawnSync) => {
|
|
20
|
+
const result = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
21
|
+
return result.status === 0 ? result.stdout.replace(/\r?\n$/u, '') : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const normalizeArtifactPath = (path, { cwd = process.cwd(), run = spawnSync, realpath = realpathSync, top = gitTopLevel(cwd, run) } = {}) => {
|
|
25
|
+
assertArtifactPathCarryable(path);
|
|
26
|
+
const absolute = realpath(resolve(cwd, path));
|
|
27
|
+
const root = top === null ? null : realpath(top);
|
|
28
|
+
const rel = root === null ? null : relative(root, absolute);
|
|
29
|
+
const contained = rel !== null && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
30
|
+
const normalized = contained ? rel.split(sep).join('/') : absolute.split(sep).join('/');
|
|
31
|
+
assertArtifactPathCarryable(normalized);
|
|
32
|
+
return normalized;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const parseArgs = (argv) => {
|
|
36
|
+
const values = { activity: 'plan-authoring', artifact: null };
|
|
37
|
+
for (const [index, arg] of argv.entries()) {
|
|
38
|
+
if (arg === '--artifact' || arg === '--activity') {
|
|
39
|
+
const value = argv[index + 1];
|
|
40
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
|
|
41
|
+
if (arg === '--artifact') values.artifact = value;
|
|
42
|
+
else values.activity = value;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (index > 0 && (argv[index - 1] === '--artifact' || argv[index - 1] === '--activity')) continue;
|
|
46
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
47
|
+
}
|
|
48
|
+
if (values.artifact === null) throw new Error('--artifact needs a <path>');
|
|
49
|
+
if (!REVIEW_ACTIVITIES.has(values.activity)) throw new Error(`--activity must be one of ${[...REVIEW_ACTIVITIES].join(', ')}, got ${values.activity}`);
|
|
50
|
+
return values;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Every refusal exits 2: usage, an unreadable store, a malformed config, a detection failure.
|
|
54
|
+
export const main = (argv, deps = {}) => {
|
|
55
|
+
try {
|
|
56
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
57
|
+
const env = deps.env ?? process.env;
|
|
58
|
+
const { artifact, activity } = parseArgs(argv);
|
|
59
|
+
const run = deps.run ?? spawnSync;
|
|
60
|
+
const top = gitTopLevel(cwd, run);
|
|
61
|
+
const artifactPath = normalizeArtifactPath(artifact, { cwd, run, top });
|
|
62
|
+
const root = top ?? cwd;
|
|
63
|
+
const { config } = loadConfig(root);
|
|
64
|
+
const detection = { failed: false };
|
|
65
|
+
const readiness = config?.[activity]?.review == null
|
|
66
|
+
? composeReadiness(root, { onDetectError: () => { detection.failed = true; }, ...(deps.readinessDeps ?? {}) })
|
|
67
|
+
: [];
|
|
68
|
+
const obligation = requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed: detection.failed, activity });
|
|
69
|
+
if (obligation.unknowable) throw new Error('backend detection failed — the review obligation is unknowable');
|
|
70
|
+
const receiptsPath = resolveReceiptsPath(root, env);
|
|
71
|
+
if (receiptsPath === null) throw new Error('the review receipts store cannot be resolved (not a git work tree and AW_REVIEW_RECEIPTS is unset)');
|
|
72
|
+
const read = readReceipts(receiptsPath);
|
|
73
|
+
if (read.readError !== undefined) throw new Error(`the review receipts store is unreadable: ${read.readError}`);
|
|
74
|
+
const selected = read.receipts.filter((receipt) => receipt.artifactPath === artifactPath);
|
|
75
|
+
const pathless = read.receipts.filter((receipt) => ['plan', 'diff'].includes(receipt.artifact) && !Object.hasOwn(receipt, 'artifactPath')).length;
|
|
76
|
+
const grouped = groupRounds(selected, obligation);
|
|
77
|
+
return {
|
|
78
|
+
code: 0,
|
|
79
|
+
stdout: renderRounds({ ...grouped, obligation, artifactPath, pathless, malformed: read.malformed }),
|
|
80
|
+
stderr: '',
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { code: 2, stdout: '', stderr: `review-rounds: ${err.message}` };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (isDirectRun(import.meta.url)) {
|
|
88
|
+
const result = main(process.argv.slice(2));
|
|
89
|
+
if (result.stdout) process.stdout.write(`${result.stdout}\n`);
|
|
90
|
+
if (result.stderr) process.stderr.write(`${result.stderr}\n`);
|
|
91
|
+
process.exitCode = result.code;
|
|
92
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isRenderableLine } from './repo-lex.mjs';
|
|
2
|
+
import { isShipVerdict, isRecognizedVerdict } from './core-evidence.mjs';
|
|
3
|
+
|
|
4
|
+
export const SIGNALS = Object.freeze({
|
|
5
|
+
NO_RECEIPTS: 'no receipts for <path>',
|
|
6
|
+
INCOMPLETE: 'incomplete round — <backend> missing: dispatch it',
|
|
7
|
+
CONVERGED: 'converged',
|
|
8
|
+
CROSSOVER: 'crossover — stop: diff-review',
|
|
9
|
+
CAP_REACHED: 'cap reached — classify each surviving finding: fixable-bug / inherent-layer-residual / escalate',
|
|
10
|
+
FOLD_AND_RE_REVIEW: 'round 1 — fold and re-review',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const isNonNegativeInteger = (value) => Number.isInteger(value) && value >= 0;
|
|
14
|
+
const isPlainLine = isRenderableLine;
|
|
15
|
+
|
|
16
|
+
const invalidFieldOf = (receipt) => {
|
|
17
|
+
if (typeof receipt?.artifactPath !== 'string') return 'artifactPath';
|
|
18
|
+
if (typeof receipt.fingerprint !== 'string' || !/^[0-9a-f]{64}$/u.test(receipt.fingerprint)) return 'fingerprint';
|
|
19
|
+
if (!isPlainLine(receipt.backend) || receipt.backend.length === 0) return 'backend';
|
|
20
|
+
if (receipt.probe !== false) return 'probe';
|
|
21
|
+
if (!isPlainLine(receipt.verdict)) return 'verdict';
|
|
22
|
+
if (!isNonNegativeInteger(receipt.durationS)) return 'durationS';
|
|
23
|
+
if (!isNonNegativeInteger(receipt.blocking)) return 'blocking';
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const groupRounds = (receipts, obligation) => {
|
|
28
|
+
const expected = new Set(obligation.backends);
|
|
29
|
+
const rounds = [];
|
|
30
|
+
const invalid = [];
|
|
31
|
+
const unexpected = [];
|
|
32
|
+
for (const receipt of receipts) {
|
|
33
|
+
const invalidField = invalidFieldOf(receipt);
|
|
34
|
+
if (invalidField !== null) {
|
|
35
|
+
invalid.push({ field: invalidField, receipt });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!expected.has(receipt.backend)) {
|
|
39
|
+
unexpected.push(receipt);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const previous = rounds.at(-1);
|
|
43
|
+
const round = previous?.fingerprint === receipt.fingerprint
|
|
44
|
+
? previous
|
|
45
|
+
: { fingerprint: receipt.fingerprint, byBackend: {} };
|
|
46
|
+
if (round !== previous) rounds.push(round);
|
|
47
|
+
round.byBackend[receipt.backend] = receipt;
|
|
48
|
+
}
|
|
49
|
+
return { rounds, invalid, unexpected };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const isComplete = (round, obligation) => {
|
|
53
|
+
const present = obligation.backends.filter((backend) => round.byBackend[backend] !== undefined);
|
|
54
|
+
return obligation.perBackend
|
|
55
|
+
? present.length === obligation.backends.length
|
|
56
|
+
: present.length >= obligation.minShip;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const isConverged = (round, obligation) => obligation.backends
|
|
60
|
+
.map((backend) => round.byBackend[backend])
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.every((receipt) => receipt.blocking === 0 && isShipVerdict(receipt.verdict));
|
|
63
|
+
|
|
64
|
+
const hasCrossover = (rounds, obligation) => {
|
|
65
|
+
const [earlier, latest] = rounds.slice(-2);
|
|
66
|
+
return obligation.backends.some((shipBackend) => {
|
|
67
|
+
const shipEarlier = earlier.byBackend[shipBackend];
|
|
68
|
+
const shipLatest = latest.byBackend[shipBackend];
|
|
69
|
+
if (!isShipVerdict(shipEarlier?.verdict) || !isShipVerdict(shipLatest?.verdict)) return false;
|
|
70
|
+
return obligation.backends.some((negativeBackend) => {
|
|
71
|
+
if (negativeBackend === shipBackend) return false;
|
|
72
|
+
const negativeEarlier = earlier.byBackend[negativeBackend]?.verdict;
|
|
73
|
+
const negativeLatest = latest.byBackend[negativeBackend]?.verdict;
|
|
74
|
+
return isRecognizedVerdict(negativeEarlier) && !isShipVerdict(negativeEarlier)
|
|
75
|
+
&& isRecognizedVerdict(negativeLatest) && !isShipVerdict(negativeLatest);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const signalFor = (rounds, obligation, artifactPath) => {
|
|
81
|
+
if (rounds.length === 0) return SIGNALS.NO_RECEIPTS.replace('<path>', () => artifactPath);
|
|
82
|
+
const latest = rounds.at(-1);
|
|
83
|
+
if (!isComplete(latest, obligation)) {
|
|
84
|
+
const missing = obligation.backends.find((backend) => latest.byBackend[backend] === undefined);
|
|
85
|
+
return SIGNALS.INCOMPLETE.replace('<backend>', () => missing);
|
|
86
|
+
}
|
|
87
|
+
if (isConverged(latest, obligation)) return SIGNALS.CONVERGED;
|
|
88
|
+
const complete = rounds.filter((round) => isComplete(round, obligation));
|
|
89
|
+
if (complete.length >= 2 && hasCrossover(complete, obligation)) return SIGNALS.CROSSOVER;
|
|
90
|
+
if (complete.length >= 2) return SIGNALS.CAP_REACHED;
|
|
91
|
+
return SIGNALS.FOLD_AND_RE_REVIEW;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const renderRounds = ({ rounds, invalid = [], unexpected = [], obligation, artifactPath, pathless = 0, malformed = 0 }) => {
|
|
95
|
+
const lines = [];
|
|
96
|
+
rounds.reduce((total, round, index) => {
|
|
97
|
+
const receipts = obligation.backends.map((backend) => round.byBackend[backend]).filter(Boolean);
|
|
98
|
+
const duration = receipts.reduce((sum, receipt) => sum + receipt.durationS, 0);
|
|
99
|
+
const cells = obligation.backends.map((backend) => {
|
|
100
|
+
const receipt = round.byBackend[backend];
|
|
101
|
+
return receipt === undefined
|
|
102
|
+
? `${backend}: missing`
|
|
103
|
+
: `${backend}: ${receipt.verdict} (${receipt.blocking} blocking, ${receipt.durationS}s)`;
|
|
104
|
+
});
|
|
105
|
+
const next = total + duration;
|
|
106
|
+
lines.push(`round ${index + 1} · ${cells.join(' · ')} · receipted duration: ${duration}s · cumulative: ${next}s`);
|
|
107
|
+
return next;
|
|
108
|
+
}, 0);
|
|
109
|
+
for (const entry of invalid) lines.push(`invalid: ${entry.field}`);
|
|
110
|
+
for (const receipt of unexpected) lines.push(`unexpected: ${receipt.backend}`);
|
|
111
|
+
lines.push(`pathless plan/diff receipts: ${pathless}`);
|
|
112
|
+
lines.push(`malformed receipt lines: ${malformed}`);
|
|
113
|
+
lines.push(`signal: ${signalFor(rounds, obligation, artifactPath)}`);
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
};
|
package/tools/review-state.mjs
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// `--check` turns the report into a gate exit code (declare it in docs/ai/gates.json).
|
|
10
10
|
//
|
|
11
11
|
// Normative `--check` exit contract (the single home of this list — SKILL.md points here):
|
|
12
|
+
// Before the review-recipe exits, configured plan-execution.execute=delegated with a plan and a
|
|
13
|
+
// dirty, fingerprintable tree audits the delegation ledger. An absent ledger is inert; an
|
|
14
|
+
// unavailable ledger, a HEAD error or a SUBSTITUTED held session refuses unless a codex-exec degrade at the
|
|
15
|
+
// substituted return's tree, standing after every later fold, accepts it, or the fold of that thread's
|
|
16
|
+
// retry lifts it (codex never grants it).
|
|
12
17
|
// exit 0 when the CONFIGURED plan-execution.review recipe is solo (or the computed default is —
|
|
13
18
|
// absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
|
|
14
19
|
// holds no top-level .md that is not queue.md and not scratch by the naming convention:
|
|
@@ -105,6 +110,10 @@ import { selectReliedOnReceipt, evaluateVetoOverride, evaluateInternalAttestatio
|
|
|
105
110
|
// graph; re-exported here so every historical consumer keeps its import site.
|
|
106
111
|
import { PLANS_REL, isScratchPlanName, plansInFlight } from './plan-files.mjs';
|
|
107
112
|
import { shellQuoteArg } from './repo-lex.mjs';
|
|
113
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
114
|
+
import {
|
|
115
|
+
decideHeldSession, HELD_EXECUTE_WRAPPER, HELD_RECEIPT_BACKEND, judgeLedger,
|
|
116
|
+
} from './held-session.mjs';
|
|
108
117
|
|
|
109
118
|
export { PLANS_REL, isScratchPlanName, plansInFlight, shellQuoteArg };
|
|
110
119
|
// The canonical review-domain primitives live in the core-evidence DAG bottom (ONE home for the
|
|
@@ -138,6 +147,7 @@ export {
|
|
|
138
147
|
|
|
139
148
|
const ACTIVITY = 'plan-execution';
|
|
140
149
|
const SLOT = 'review';
|
|
150
|
+
const CORE_EVIDENCE_TOOL = shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'core-evidence.mjs'));
|
|
141
151
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
|
|
142
152
|
// --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
|
|
143
153
|
// a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
|
|
@@ -296,10 +306,35 @@ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
|
|
|
296
306
|
const storePath = resolveEvidencePath(cwd, env);
|
|
297
307
|
const read = storePath ? readEvidence(storePath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
298
308
|
const unavailable = (read.malformed ?? 0) > 0 || read.readError != null;
|
|
309
|
+
const records = unavailable ? [] : authoritativeOfKind(read.records, 'degrade');
|
|
299
310
|
const set = unavailable || fingerprint == null
|
|
300
311
|
? new Set()
|
|
301
|
-
: new Set(
|
|
302
|
-
return { set, storePath, malformed: read.malformed ?? 0, readError: read.readError ?? null, unavailable };
|
|
312
|
+
: new Set(records.filter((r) => r.fingerprint === fingerprint).map((r) => r.backend));
|
|
313
|
+
return { set, records, storePath, malformed: read.malformed ?? 0, readError: read.readError ?? null, unavailable };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export const selectHeldSessionDegrades = (records) =>
|
|
317
|
+
records.filter((record) => record.kind === 'degrade' && record.backend === HELD_EXECUTE_WRAPPER);
|
|
318
|
+
|
|
319
|
+
export const shouldReadHeldSession = ({ configuredExecute, plans, fingerprint, clean }) =>
|
|
320
|
+
configuredExecute === 'delegated' && plans.length > 0 && fingerprint !== null && clean === false;
|
|
321
|
+
|
|
322
|
+
export const buildHeldSessionState = ({
|
|
323
|
+
cwd,
|
|
324
|
+
env,
|
|
325
|
+
configuredExecute,
|
|
326
|
+
plans,
|
|
327
|
+
fingerprint,
|
|
328
|
+
clean,
|
|
329
|
+
degrades,
|
|
330
|
+
resolveStore,
|
|
331
|
+
readStore,
|
|
332
|
+
audit,
|
|
333
|
+
readHead,
|
|
334
|
+
}) => {
|
|
335
|
+
if (!shouldReadHeldSession({ configuredExecute, plans, fingerprint, clean })) return null;
|
|
336
|
+
const ledger = readDelegationLedger(cwd, env, { resolveStore, readStore, audit, readHead });
|
|
337
|
+
return judgeLedger(ledger, { backend: HELD_RECEIPT_BACKEND, degrades });
|
|
303
338
|
};
|
|
304
339
|
|
|
305
340
|
// ── the check + report core ─────────────────────────────────────────────────────────
|
|
@@ -340,6 +375,15 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, su
|
|
|
340
375
|
const base = resolveBase(cwd);
|
|
341
376
|
const degrade = degradeRecordSet({ cwd, env, fingerprint });
|
|
342
377
|
const degradedExempt = requiredBackends.filter((b) => degrade.set.has(b));
|
|
378
|
+
const heldSession = buildHeldSessionState({
|
|
379
|
+
cwd,
|
|
380
|
+
env,
|
|
381
|
+
configuredExecute: config?.[ACTIVITY]?.execute,
|
|
382
|
+
plans,
|
|
383
|
+
fingerprint,
|
|
384
|
+
clean,
|
|
385
|
+
degrades: selectHeldSessionDegrades(degrade.records),
|
|
386
|
+
});
|
|
343
387
|
// ── the Phase-2 flow arms' state (two-tier activation, P3) ──
|
|
344
388
|
// Tier 1 is store-file PRESENCE: no file ⇒ every field below stays inert and the decision is
|
|
345
389
|
// byte-identical to the pre-flow checker (the unarmed fast path also reads NO plan file).
|
|
@@ -437,6 +481,7 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, su
|
|
|
437
481
|
evidenceReadError: degrade.readError,
|
|
438
482
|
evidenceUnavailable: degrade.unavailable,
|
|
439
483
|
degradedExempt,
|
|
484
|
+
heldSession,
|
|
440
485
|
maskedUntracked: countNeverCommittableUntracked(cwd, { lstat }),
|
|
441
486
|
detectionWarning,
|
|
442
487
|
flowPresent,
|
|
@@ -476,7 +521,27 @@ const backendFailurePart = (b, state) => {
|
|
|
476
521
|
if (b.state === 'probe') return `${b.backend}: only probe receipts for the current tree (CODEX_PROBE=1 / AGY_PROBE=1 relaxes the quality guards) — a probe review never attests; re-run a real one`;
|
|
477
522
|
if (b.state === 'rejected') return `${b.backend}: current-tree receipts rejected — ${rejectionCause(b)} (fail-closed); inspect ${state.receiptsPath}`;
|
|
478
523
|
if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
|
|
479
|
-
return `${b.backend}: no receipt — run its review wrapper, or record an explicit degrade (node ${
|
|
524
|
+
return `${b.backend}: no receipt — run its review wrapper, or record an explicit degrade (node ${CORE_EVIDENCE_TOOL} degrade --backend ${b.backend} --reason "...")`;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const describesHeldRecovery = (state) => {
|
|
528
|
+
const substitution = state.heldSession.substitution;
|
|
529
|
+
if (substitution === null) return '';
|
|
530
|
+
const canRetry = substitution.folded === false;
|
|
531
|
+
if (state.evidenceUnavailable === true) {
|
|
532
|
+
const retry = canRetry ? '; or fold the retry of that thread' : '';
|
|
533
|
+
return ` — the evidence store is unavailable, so the degrade lane cannot be judged: repair it (inspect ${state.evidenceStorePath}) and re-check${retry}`;
|
|
534
|
+
}
|
|
535
|
+
const canDegrade = typeof substitution.postTreeDigest === 'string'
|
|
536
|
+
&& state.fingerprint === substitution.postTreeDigest;
|
|
537
|
+
if (!canDegrade && !canRetry) {
|
|
538
|
+
return ' — no recovery lane remains: the tree moved after the substituted return and that thread is folded; re-dispatch the work under the held session';
|
|
539
|
+
}
|
|
540
|
+
const degrade = canDegrade
|
|
541
|
+
? ` — mint the accepted-replacement record now, before the next edit: node ${CORE_EVIDENCE_TOOL} degrade --backend ${HELD_EXECUTE_WRAPPER} --reason "..."`
|
|
542
|
+
: '';
|
|
543
|
+
const retry = canRetry ? `${canDegrade ? '; or' : ' —'} fold the retry of that thread` : '';
|
|
544
|
+
return `${degrade}${retry}`;
|
|
480
545
|
};
|
|
481
546
|
|
|
482
547
|
// The normative --check decision (the header contract, in order). → { code, reason }.
|
|
@@ -493,6 +558,11 @@ export const decideCheck = (state) => {
|
|
|
493
558
|
? ` — evidence store unavailable (${state.evidenceMalformed} malformed line(s)${state.evidenceReadError ? `, read error: ${state.evidenceReadError}` : ''}); the degrade escape is denied (fail-closed) — inspect ${state.evidenceStorePath}`
|
|
494
559
|
: '';
|
|
495
560
|
const earlyNotes = `${malformedNote}${evidenceNote}${state.receiptsReadError ? ` — receipts store unreadable (${state.receiptsReadError}); inspect ${state.receiptsPath}` : ''}`;
|
|
561
|
+
if (state.heldSession !== null && state.heldSession !== undefined) {
|
|
562
|
+
const held = decideHeldSession(state.heldSession);
|
|
563
|
+
const heldRecovery = describesHeldRecovery(state);
|
|
564
|
+
if (held.code !== 0) return { ...held, reason: `${held.reason}${heldRecovery}${earlyNotes}`, terminal: true, heldSession: true };
|
|
565
|
+
}
|
|
496
566
|
// Detector failure with NO configured recipe: the computed default is unknowable → fail closed.
|
|
497
567
|
// An EXPLICIT configured recipe needs no detector — its obligations are readiness-independent.
|
|
498
568
|
if (state.obligations.unknowable) {
|
|
@@ -649,6 +719,7 @@ const formatHuman = (state, check) => {
|
|
|
649
719
|
if (state.fingerprint == null) lines.push(' tree: not a git work tree');
|
|
650
720
|
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
651
721
|
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
722
|
+
if (state.heldSession != null) lines.push(` ${decideHeldSession(state.heldSession).line}`);
|
|
652
723
|
lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
|
|
653
724
|
if (state.receiptsReadError) lines.push(` ⚠ receipts store unreadable (${state.receiptsReadError}) — inspect ${state.receiptsPath}`);
|
|
654
725
|
if (state.evidenceUnavailable) lines.push(` ⚠ evidence store unavailable (${state.evidenceMalformed} malformed line(s)${state.evidenceReadError ? `, read error: ${state.evidenceReadError}` : ''}) — the degrade escape is denied (fail-closed)`);
|
|
@@ -700,7 +771,12 @@ attests; a malformed OR absent probe marker is rejected fail-closed — silence
|
|
|
700
771
|
The ONLY escape for an unavailable backend under council is an explicit current-tree degrade
|
|
701
772
|
record (node core-evidence.mjs degrade) — and never all backends.
|
|
702
773
|
|
|
703
|
-
--check exits 0/1 per the normative contract in the tool header
|
|
774
|
+
--check exits 0/1 per the normative contract in the tool header. FIRST, before any of the arms
|
|
775
|
+
below, a configured plan-execution.execute = delegated with a plan in flight and a dirty,
|
|
776
|
+
fingerprintable tree audits the delegation ledger: an absent ledger is inert; an unreadable,
|
|
777
|
+
malformed, foreign or audit-refused ledger, a failed HEAD read, or a SUBSTITUTED held session
|
|
778
|
+
exits 1 (a substitution is lifted only by a codex-exec degrade at the substituted return's own
|
|
779
|
+
tree, or the fold of that thread's retry). THEN: 0 for solo / no plan in flight /
|
|
704
780
|
a clean tree (under a non-solo review obligation the PASS names every plan in flight and its arm) /
|
|
705
781
|
not-a-git-tree / obligations satisfied (reviewed: >=1 ship-class attestation;
|
|
706
782
|
council: every backend ship-class or degrade-recorded, >=1 real ship); 1 on a veto, an
|
|
@@ -728,11 +804,12 @@ export const main = (argv, ctx = {}) => {
|
|
|
728
804
|
const cwd = ctx.cwd ?? process.cwd();
|
|
729
805
|
const env = ctx.env ?? process.env;
|
|
730
806
|
const detect = ctx.detect ?? detectBackends;
|
|
807
|
+
const build = ctx.buildState ?? buildState;
|
|
731
808
|
try {
|
|
732
809
|
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
733
810
|
const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
|
|
734
811
|
if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
|
|
735
|
-
const state =
|
|
812
|
+
const state = build({ cwd, env, detect, surveyVehicle: ctx.surveyVehicle, lstat: ctx.lstat, readFile: ctx.readFile });
|
|
736
813
|
const check = decideCheck(state);
|
|
737
814
|
// The mask advisory is NON-FAILING by contract: one notice line, never an exit-code arm.
|
|
738
815
|
const advisory = maskAdvisoryLine(state);
|
|
@@ -779,6 +856,7 @@ export const mainAwait = async (argv, ctx = {}) => {
|
|
|
779
856
|
const now = ctx.now ?? (() => Date.now());
|
|
780
857
|
const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
781
858
|
const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
|
|
859
|
+
const build = ctx.buildState ?? buildState;
|
|
782
860
|
try {
|
|
783
861
|
for (let i = 0; i < argv.length; i += 1) {
|
|
784
862
|
const a = argv[i];
|
|
@@ -798,9 +876,10 @@ export const mainAwait = async (argv, ctx = {}) => {
|
|
|
798
876
|
for (;;) {
|
|
799
877
|
const elapsed = now() - start;
|
|
800
878
|
if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
|
|
801
|
-
const check = decideCheck(
|
|
879
|
+
const check = decideCheck(build({ cwd, env, detect }));
|
|
802
880
|
lastReason = check.reason;
|
|
803
881
|
if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
|
|
882
|
+
if (check.heldSession === true) return { code: 1, stdout: '', stderr: `review-state --await: HELD SESSION — ${check.reason}` };
|
|
804
883
|
// Decision 4 (#50): an AUTHORITATIVE veto ends the wait loudly BEFORE the deadline — the
|
|
805
884
|
// dispatched review answered with a landed negative; classifying that as a timeout was the
|
|
806
885
|
// misclassification this amendment closes. Only a fresh review can move it, never waiting.
|