@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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addReviewer,
|
|
3
|
+
expandShorthand,
|
|
4
|
+
lensMembersOf,
|
|
5
|
+
obligationsOf,
|
|
6
|
+
parseSlotToken,
|
|
7
|
+
removeReviewer,
|
|
8
|
+
} from './review-roster.mjs';
|
|
9
|
+
import { isReadyMember, resolveRoster, skippedLine } from './review-roster-resolve.mjs';
|
|
10
|
+
import { applySetOps, assertSlot, fail } from './orchestration-config.mjs';
|
|
11
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
12
|
+
|
|
13
|
+
const REVIEWER_KINDS = new Set(['add-reviewer', 'remove-reviewer']);
|
|
14
|
+
|
|
15
|
+
const parseQualified = (token, flag) => {
|
|
16
|
+
const equals = token.indexOf('=');
|
|
17
|
+
const qualified = equals < 0 ? token : token.slice(0, equals);
|
|
18
|
+
const dot = qualified.indexOf('.');
|
|
19
|
+
if (equals <= 0 || equals === token.length - 1 || dot <= 0 || dot === qualified.length - 1) {
|
|
20
|
+
throw fail(2, `--${flag} must be <activity>.review=<member> (got "${token}")`);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
activity: qualified.slice(0, dot),
|
|
24
|
+
slot: qualified.slice(dot + 1),
|
|
25
|
+
member: token.slice(equals + 1),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const parseReviewerOp = (kind, token) => {
|
|
30
|
+
if (!REVIEWER_KINDS.has(kind)) throw fail(2, `unknown reviewer op: ${kind}`);
|
|
31
|
+
const parsed = parseQualified(token, kind);
|
|
32
|
+
if (assertSlot(parsed.activity, parsed.slot) !== 'review') {
|
|
33
|
+
throw fail(2, `--${kind} requires a review slot (got "${parsed.activity}.${parsed.slot}")`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
parseSlotToken(parsed.member);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw fail(2, error.message);
|
|
39
|
+
}
|
|
40
|
+
return { kind, ...parsed };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const membersOf = (value) => {
|
|
44
|
+
if (Array.isArray(value)) return value;
|
|
45
|
+
const expanded = expandShorthand(value);
|
|
46
|
+
return expanded.lossless ? expanded.members : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const sameMembers = (left, right) => {
|
|
50
|
+
const a = membersOf(left);
|
|
51
|
+
const b = membersOf(right);
|
|
52
|
+
return a !== null && b !== null && a.length === b.length && a.every((member, index) => member === b[index]);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const reviewedRefusal = (activity) => fail(
|
|
56
|
+
2,
|
|
57
|
+
`reviewed has no lossless roster expansion — run --set ${activity}.review=council first, or use --add-reviewer ${activity}.review=codex-review / --add-reviewer ${activity}.review=agy-review on a solo slot`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const applyOne = (value, op) => {
|
|
61
|
+
if (membersOf(value) === null) throw reviewedRefusal(op.activity);
|
|
62
|
+
try {
|
|
63
|
+
return op.kind === 'add-reviewer'
|
|
64
|
+
? addReviewer(value, op.member)
|
|
65
|
+
: removeReviewer(value, op.member);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw fail(2, error.code === 'last-member' ? `${error.message} — run --set ${op.activity}.review=solo` : error.message);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const applyReviewerOps = (current, ops, { defaults = {}, seedReadme = null } = {}) => {
|
|
72
|
+
const states = new Map();
|
|
73
|
+
for (const op of ops) {
|
|
74
|
+
const key = `${op.activity}.${op.slot}`;
|
|
75
|
+
const raw = current?.[op.activity]?.[op.slot] ?? null;
|
|
76
|
+
const state = states.get(key) ?? {
|
|
77
|
+
activity: op.activity,
|
|
78
|
+
slot: op.slot,
|
|
79
|
+
from: raw,
|
|
80
|
+
beforeValue: raw ?? defaults[key],
|
|
81
|
+
value: raw ?? defaults[key],
|
|
82
|
+
named: new Set(),
|
|
83
|
+
};
|
|
84
|
+
if (state.value === undefined) throw fail(2, `no computed default supplied for ${key}`);
|
|
85
|
+
state.value = applyOne(state.value, op);
|
|
86
|
+
if (op.kind === 'add-reviewer') state.named.add(parseSlotToken(op.member).stem);
|
|
87
|
+
states.set(key, state);
|
|
88
|
+
}
|
|
89
|
+
const rows = [...states.values()].map((state) => {
|
|
90
|
+
const changed = !sameMembers(state.beforeValue, state.value);
|
|
91
|
+
return {
|
|
92
|
+
...state,
|
|
93
|
+
changed,
|
|
94
|
+
to: changed ? state.value : state.from,
|
|
95
|
+
afterValue: changed ? state.value : state.beforeValue,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
const changes = rows.filter((row) => row.changed).map((row) => ({
|
|
99
|
+
kind: 'set', activity: row.activity, slot: row.slot, recipe: row.to,
|
|
100
|
+
}));
|
|
101
|
+
return {
|
|
102
|
+
config: changes.length ? applySetOps(current, changes, { seedReadme }) : current,
|
|
103
|
+
rows,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const gateLabel = (value) => {
|
|
108
|
+
const members = membersOf(value) ?? [];
|
|
109
|
+
if (members.length === 0) return 'solo []';
|
|
110
|
+
const obligation = obligationsOf(members);
|
|
111
|
+
return `${obligation.recipe} [${obligation.backends.join(', ')}]`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const valueLabel = (value) => value == null
|
|
115
|
+
? '(computed default)'
|
|
116
|
+
: Array.isArray(value) ? JSON.stringify(value) : value;
|
|
117
|
+
|
|
118
|
+
const lensRemedy = (parsed, member, agentsApply) => {
|
|
119
|
+
if (parsed.kind !== 'lens' || member.state !== 'missing') return null;
|
|
120
|
+
if (parsed.template === null) return `HAND-APPLY: create .claude/agents/${parsed.stem}.md as a read-only vehicle`;
|
|
121
|
+
return agentsApply ? `to place it, run exactly: ${agentsApply}` : null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const persistedLensStems = (config) => new Set(lensMembersOf(config ?? {})
|
|
125
|
+
.map((member) => parseSlotToken(member))
|
|
126
|
+
.filter((parsed) => parsed.derived)
|
|
127
|
+
.map((parsed) => parsed.stem));
|
|
128
|
+
|
|
129
|
+
export const renderRosterPreview = (row, { agentsApply, wrote = false, persistedLenses = new Set() } = {}) => {
|
|
130
|
+
const lines = [row.changed
|
|
131
|
+
? ` ${row.activity}.${row.slot}: ${valueLabel(row.from)} → ${valueLabel(row.to)}`
|
|
132
|
+
: ` ${row.activity}.${row.slot}: already ${valueLabel(row.from)} (no change)`];
|
|
133
|
+
const persisted = new Set((membersOf(row.from) ?? []).map((member) => parseSlotToken(member).stem));
|
|
134
|
+
for (const member of row.roster) {
|
|
135
|
+
const parsed = parseSlotToken(member.member);
|
|
136
|
+
const apply = lensRemedy(parsed, member, agentsApply);
|
|
137
|
+
const remedy = [member.reason, apply].filter(Boolean).join('; ') || null;
|
|
138
|
+
const posture = member.posture == null ? '' : ` (${member.posture})`;
|
|
139
|
+
lines.push(` ↳ ${member.member}: ${isReadyMember(member) ? member.state : skippedLine(member, remedy)}${posture}`);
|
|
140
|
+
if (apply && parsed.derived && !wrote && !persisted.has(parsed.stem) && !persistedLenses.has(parsed.stem)) {
|
|
141
|
+
lines.push(' after --write — the agents writer derives this lens from what docs/ai/orchestration.json names');
|
|
142
|
+
}
|
|
143
|
+
if (parsed.kind === 'lens' && parsed.template === null && row.named.has(parsed.stem)) {
|
|
144
|
+
lines.push(` resolved as a lens with no bundled template — a hand-written vehicle; if you meant a bridge, the review cmds are ${expandShorthand('council').members.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
lines.push(` gate: ${gateLabel(row.beforeValue)} → ${gateLabel(row.afterValue)}`);
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const resolveReviewerRows = (rows, deps = {}) => rows.map((row) => {
|
|
152
|
+
const members = membersOf(row.afterValue);
|
|
153
|
+
return {
|
|
154
|
+
...row,
|
|
155
|
+
roster: members.length === 0 ? [] : resolveRoster({ value: members, ...deps }),
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const rosterJsonRows = (rows, changed) => rows.map((row) => changed ? ({
|
|
160
|
+
activity: row.activity, slot: row.slot, from: row.from, to: row.to,
|
|
161
|
+
effective: row.effective, degradedFrom: row.degradedFrom ?? null, reason: row.reason ?? null,
|
|
162
|
+
roster: row.roster ?? null,
|
|
163
|
+
}) : ({
|
|
164
|
+
activity: row.activity, slot: row.slot, recipe: row.from, roster: row.roster ?? null,
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
refuseDirectRun(import.meta.url);
|
package/tools/set-recipe.mjs
CHANGED
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
// Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
|
|
27
27
|
|
|
28
28
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
29
|
-
import { homedir } from 'node:os';
|
|
30
29
|
import { isDirectRun } from './direct-run.mjs';
|
|
30
|
+
import { settingsSnapshot } from './bridge-settings-read.mjs';
|
|
31
|
+
import { posturesByBackend } from './bridge-posture.mjs';
|
|
32
|
+
import { surveyVehicle } from './cheap-agents-read.mjs';
|
|
33
|
+
import { applyCheapAgentsCommand } from './cheap-agents.mjs';
|
|
31
34
|
import {
|
|
32
35
|
ACTIVITIES,
|
|
33
36
|
SLOT_RECIPES,
|
|
@@ -49,23 +52,36 @@ import {
|
|
|
49
52
|
CANON_README,
|
|
50
53
|
} from './orchestration-config.mjs';
|
|
51
54
|
import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
|
|
55
|
+
import {
|
|
56
|
+
applyReviewerOps,
|
|
57
|
+
parseReviewerOp,
|
|
58
|
+
persistedLensStems,
|
|
59
|
+
renderRosterPreview,
|
|
60
|
+
resolveReviewerRows,
|
|
61
|
+
rosterJsonRows,
|
|
62
|
+
} from './set-recipe-roster.mjs';
|
|
52
63
|
|
|
53
64
|
// ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
|
|
54
65
|
|
|
55
|
-
// Parse argv → { ops, write, json }.
|
|
56
|
-
//
|
|
57
|
-
// token → exit 2. `--set=<tok>` / `--unset=<tok>` inline forms are accepted too.
|
|
66
|
+
// Parse argv → { ops, write, json }. Fixed ops stay unique per slot; reviewer list ops accumulate.
|
|
67
|
+
// A `--write` with zero ops, an unknown flag, or a bad token → exit 2. Inline forms are accepted too.
|
|
58
68
|
const parseArgs = (argv) => {
|
|
59
69
|
const ops = [];
|
|
60
|
-
const
|
|
70
|
+
const fixed = new Set();
|
|
71
|
+
const reviewer = new Set();
|
|
61
72
|
let write = false;
|
|
62
73
|
let json = false;
|
|
63
74
|
const takeOp = (kind, tok) => {
|
|
64
|
-
|
|
65
|
-
const
|
|
75
|
+
const reviewerKind = kind === 'add-reviewer' || kind === 'remove-reviewer';
|
|
76
|
+
const form = reviewerKind ? '<activity>.review=<member>' : `<activity>.<slot>${kind === 'set' ? '=<value>' : ''}`;
|
|
77
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires ${form}`);
|
|
78
|
+
const op = reviewerKind ? parseReviewerOp(kind, tok) : parseOp(kind, tok);
|
|
66
79
|
const key = `${op.activity}.${op.slot}`;
|
|
67
|
-
if (
|
|
68
|
-
|
|
80
|
+
if (fixed.has(key) || (!reviewerKind && reviewer.has(key))) {
|
|
81
|
+
throw fail(2, `duplicate op for "${key}" — --set/--unset name each activity.slot at most once and cannot mix with reviewer list ops`);
|
|
82
|
+
}
|
|
83
|
+
if (reviewerKind) reviewer.add(key);
|
|
84
|
+
else fixed.add(key);
|
|
69
85
|
ops.push(op);
|
|
70
86
|
};
|
|
71
87
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -74,12 +90,16 @@ const parseArgs = (argv) => {
|
|
|
74
90
|
else if (a === '--write') write = true;
|
|
75
91
|
else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
|
|
76
92
|
else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
|
|
93
|
+
else if (a === '--add-reviewer') { takeOp('add-reviewer', argv[i + 1]); i += 1; }
|
|
94
|
+
else if (a === '--remove-reviewer') { takeOp('remove-reviewer', argv[i + 1]); i += 1; }
|
|
77
95
|
else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
|
|
78
96
|
else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
|
|
97
|
+
else if (a.startsWith('--add-reviewer=')) takeOp('add-reviewer', a.slice('--add-reviewer='.length));
|
|
98
|
+
else if (a.startsWith('--remove-reviewer=')) takeOp('remove-reviewer', a.slice('--remove-reviewer='.length));
|
|
79
99
|
else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
|
|
80
100
|
else throw fail(2, `unexpected argument: ${a}`);
|
|
81
101
|
}
|
|
82
|
-
if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset (a bare --write is a no-op)');
|
|
102
|
+
if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset/--add-reviewer/--remove-reviewer (a bare --write is a no-op)');
|
|
83
103
|
return { ops, write, json };
|
|
84
104
|
};
|
|
85
105
|
|
|
@@ -115,15 +135,21 @@ const effectiveLine = (e) =>
|
|
|
115
135
|
? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
|
|
116
136
|
: `effective here: ${e.effective}`;
|
|
117
137
|
|
|
118
|
-
const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
|
|
138
|
+
const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine, agentsApply, persistedLenses }) => {
|
|
119
139
|
const lines = [];
|
|
120
140
|
if (wrote) lines.push(`wrote ${CONFIG_REL}`);
|
|
121
141
|
else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
|
|
122
142
|
for (const e of changed) {
|
|
143
|
+
if (Array.isArray(e.roster)) {
|
|
144
|
+
lines.push(renderRosterPreview(e, { agentsApply, wrote, persistedLenses }));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
123
147
|
lines.push(` ${e.activity}.${e.slot}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
|
|
124
148
|
lines.push(` ↳ ${effectiveLine(e)}`);
|
|
125
149
|
}
|
|
126
|
-
for (const e of unchanged) lines.push(
|
|
150
|
+
for (const e of unchanged) lines.push(Array.isArray(e.roster)
|
|
151
|
+
? renderRosterPreview(e, { agentsApply, wrote, persistedLenses })
|
|
152
|
+
: ` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
|
|
127
153
|
for (const w of warnings) lines.push(` ⚠ ${w}`);
|
|
128
154
|
if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
|
|
129
155
|
// The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
|
|
@@ -140,8 +166,8 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody,
|
|
|
140
166
|
};
|
|
141
167
|
|
|
142
168
|
const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
|
|
143
|
-
changed: changed
|
|
144
|
-
unchanged: unchanged
|
|
169
|
+
changed: rosterJsonRows(changed, true),
|
|
170
|
+
unchanged: rosterJsonRows(unchanged, false),
|
|
145
171
|
writtenPath: writtenPath ?? null,
|
|
146
172
|
noop,
|
|
147
173
|
warnings,
|
|
@@ -167,10 +193,14 @@ const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
|
|
|
167
193
|
const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
|
|
168
194
|
|
|
169
195
|
Usage:
|
|
170
|
-
node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]...
|
|
196
|
+
node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]...
|
|
197
|
+
[--add-reviewer <activity>.review=<member>]...
|
|
198
|
+
[--remove-reviewer <activity>.review=<member>]... [--write] [--json]
|
|
171
199
|
|
|
172
200
|
--set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
|
|
173
201
|
--unset <activity>.<slot> return a slot to its computed default
|
|
202
|
+
--add-reviewer <activity>.review=<member> append a reviewer (same-slot ops accumulate in argv order)
|
|
203
|
+
--remove-reviewer <activity>.review=<member> remove a reviewer (same-slot ops accumulate in argv order)
|
|
174
204
|
--write apply the change (default: preview only — writes nothing)
|
|
175
205
|
--json machine-readable output
|
|
176
206
|
--help, -h this help
|
|
@@ -220,12 +250,37 @@ export const main = (argv, ctx = {}) => {
|
|
|
220
250
|
|
|
221
251
|
// The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
|
|
222
252
|
// is replaced by the current one on a touched write, while a customized note stays untouched.
|
|
223
|
-
const
|
|
224
|
-
|
|
253
|
+
const render = { agentsApply: applyCheapAgentsCommand(cwd), persistedLenses: persistedLensStems(current) };
|
|
225
254
|
const warnings = [];
|
|
226
255
|
const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
|
|
227
|
-
|
|
228
|
-
const
|
|
256
|
+
const reviewerOps = ops.filter((op) => op.kind === 'add-reviewer' || op.kind === 'remove-reviewer');
|
|
257
|
+
const fixedOps = ops.filter((op) => op.kind === 'set' || op.kind === 'unset');
|
|
258
|
+
const fixedAfter = fixedOps.length
|
|
259
|
+
? applySetOps(current, fixedOps, { seedReadme: CANON_README })
|
|
260
|
+
: current;
|
|
261
|
+
const defaults = Object.fromEntries(reviewerOps.map((op) => [
|
|
262
|
+
`${op.activity}.${op.slot}`,
|
|
263
|
+
resolveActivityRecipe({ config: {}, readiness, activity: op.activity, slot: op.slot }).recipe,
|
|
264
|
+
]));
|
|
265
|
+
const reviewerResult = applyReviewerOps(fixedAfter, reviewerOps, { defaults, seedReadme: CANON_README });
|
|
266
|
+
const after = refreshReadme(reviewerResult.config ?? {}).config;
|
|
267
|
+
const surveyLens = ctx.surveyLens ?? ((spec) => surveyVehicle(cwd, spec, ctx));
|
|
268
|
+
const hasRoster = reviewerOps.length > 0 || Object.values(after).some((activity) => Array.isArray(activity?.review));
|
|
269
|
+
const settings = hasRoster ? settingsSnapshot({
|
|
270
|
+
getenv: ctx.env, home: ctx.home, readFile: ctx.readFileSync, lstat: ctx.lstatSync,
|
|
271
|
+
}) : null;
|
|
272
|
+
const postures = ctx.postures ?? (hasRoster ? posturesByBackend({ settings }) : {});
|
|
273
|
+
const reviewerRows = resolveReviewerRows(reviewerResult.rows, { readiness, surveyLens, postures })
|
|
274
|
+
.map((row) => {
|
|
275
|
+
const resolved = resolveActivityRecipe({
|
|
276
|
+
config: after, readiness, activity: row.activity, slot: row.slot, surveyLens, postures,
|
|
277
|
+
});
|
|
278
|
+
return { ...row, effective: resolved.recipe, degradedFrom: null, reason: null };
|
|
279
|
+
});
|
|
280
|
+
const resolved = [
|
|
281
|
+
...fixedOps.map((op) => resolveOp(op, current, after, readiness)),
|
|
282
|
+
...reviewerRows,
|
|
283
|
+
];
|
|
229
284
|
const changed = resolved.filter((e) => e.from !== e.to);
|
|
230
285
|
const unchanged = resolved.filter((e) => e.from === e.to);
|
|
231
286
|
const noop = changed.length === 0;
|
|
@@ -233,7 +288,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
233
288
|
if (!write) {
|
|
234
289
|
const stdout = json
|
|
235
290
|
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop }), null, 2)
|
|
236
|
-
: formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false });
|
|
291
|
+
: formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false, ...render });
|
|
237
292
|
return { code: 0, stdout, stderr: '' };
|
|
238
293
|
}
|
|
239
294
|
|
|
@@ -241,7 +296,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
241
296
|
if (noop) {
|
|
242
297
|
const stdout = json
|
|
243
298
|
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop: true }), null, 2)
|
|
244
|
-
: formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false });
|
|
299
|
+
: formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false, ...render });
|
|
245
300
|
return { code: 0, stdout, stderr: '' };
|
|
246
301
|
}
|
|
247
302
|
|
|
@@ -259,10 +314,12 @@ export const main = (argv, ctx = {}) => {
|
|
|
259
314
|
return { error: (err && err.message) || String(err) };
|
|
260
315
|
}
|
|
261
316
|
})();
|
|
262
|
-
const activeLine = composeActiveRecipeLine(
|
|
317
|
+
const activeLine = composeActiveRecipeLine(
|
|
318
|
+
{ config: after, source: CONFIG_REL }, readiness, autonomyFacts, { surveyLens, postures },
|
|
319
|
+
);
|
|
263
320
|
const stdout = json
|
|
264
321
|
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
|
|
265
|
-
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
|
|
322
|
+
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine, ...render });
|
|
266
323
|
return { code: 0, stdout, stderr: '' };
|
|
267
324
|
} catch (err) {
|
|
268
325
|
return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };
|
|
@@ -10,6 +10,8 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
|
|
|
10
10
|
// The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
|
|
11
11
|
// for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
|
|
12
12
|
import { findOnPath } from './detect-backends.mjs';
|
|
13
|
+
// The seedable-path predicate lives in the pure leaf so the renders spell a path exactly as it is seeded.
|
|
14
|
+
import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from './repo-lex.mjs';
|
|
13
15
|
import { isDirectRun } from './direct-run.mjs';
|
|
14
16
|
import { compareSemver } from './semver-lite.mjs';
|
|
15
17
|
// The declared-path resolution + segment containment the allowWrite degrade shares with the
|
|
@@ -131,7 +133,7 @@ export const SHELL_READONLY = Object.freeze([
|
|
|
131
133
|
// seed time from the RUNNING tool's own location — resolved-absolute, so a moved or reinstalled
|
|
132
134
|
// skill leaves a stale rule that FAIL-SAFE prompts again (never a silent widening).
|
|
133
135
|
//
|
|
134
|
-
// Membership (
|
|
136
|
+
// Membership (13, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
|
|
135
137
|
// project-exec (it runs the project's OWN declared gates.json commands), so it seeds as ONE exact
|
|
136
138
|
// byte-string pinned to this project root (`--cwd <resolved root>`): a wildcard would be BROADER
|
|
137
139
|
// than the AD-037 hook boundary (`--cwd <dir>` executes an arbitrary OTHER project's gates.json)
|
|
@@ -158,6 +160,9 @@ export const KIT_READONLY_TOOLS = Object.freeze([
|
|
|
158
160
|
// useless approvals is mostly small path questions batched into a composed shell because no single
|
|
159
161
|
// call answered them, and a lane the agent must still ask about is not a lane.
|
|
160
162
|
'tools/path-inventory.mjs',
|
|
163
|
+
// The round table the procedures advisor names every review round; its path argument is resolved
|
|
164
|
+
// and compared, never opened as a pattern, so it rides the settings-level posture like the rest.
|
|
165
|
+
'tools/review-rounds-cli.mjs',
|
|
161
166
|
]);
|
|
162
167
|
// Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
|
|
163
168
|
// --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
|
|
@@ -269,10 +274,7 @@ export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}
|
|
|
269
274
|
// a sandbox. velocity never ADDS commit/push/publish as allow rules and keeps acceptEdits opt-in;
|
|
270
275
|
// runtime closure is not something settings-level allow rules can enforce — the residual guard
|
|
271
276
|
// ships as the opt-in PreToolUse hook (Mode: hook, tools/gate-hook.mjs; probe record in AD-037).
|
|
272
|
-
export
|
|
273
|
-
'&', '|', ';', '<', '>', '$', '`', '(', ')',
|
|
274
|
-
'\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
|
|
275
|
-
]);
|
|
277
|
+
export { SHELL_METACHARACTERS };
|
|
276
278
|
|
|
277
279
|
// The RUNTIME residual documented above, as data: the exact write-redirection / command-
|
|
278
280
|
// substitution / bounded write-flag forms a settings-level allow rule cannot see. Consumed by
|
|
@@ -484,8 +486,6 @@ const getDefaultMode = (data) => {
|
|
|
484
486
|
: { present: false, value: undefined };
|
|
485
487
|
};
|
|
486
488
|
|
|
487
|
-
const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
|
|
488
|
-
|
|
489
489
|
const tokenizeCommand = (cmd) => cmd.trim().split(WHITESPACE_PATTERN).filter(Boolean);
|
|
490
490
|
|
|
491
491
|
const getSubcommand = (tokens) => tokens.slice(1).join(' ');
|
|
@@ -504,20 +504,6 @@ const getBashExactCommand = (pattern) => {
|
|
|
504
504
|
return match ? match[1] : undefined;
|
|
505
505
|
};
|
|
506
506
|
|
|
507
|
-
// Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
|
|
508
|
-
// shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
|
|
509
|
-
// redirections/expansions — `*`/`?` globs included — but not these four).
|
|
510
|
-
const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
|
|
511
|
-
|
|
512
|
-
// A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
|
|
513
|
-
// whitespace, no shell metacharacter, no quoting/glob syntax.
|
|
514
|
-
const isSeedablePathToken = (token) =>
|
|
515
|
-
typeof token === 'string' &&
|
|
516
|
-
token.startsWith('/') &&
|
|
517
|
-
!/\s/u.test(token) &&
|
|
518
|
-
!hasShellMetacharacter(token) &&
|
|
519
|
-
!PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
|
|
520
|
-
|
|
521
507
|
// A tier path token must be SEEDABLE (a relative or shell-syntax-carrying spelling is a dead rule
|
|
522
508
|
// the screen refuses to bless) and end on a known tier-tool tail. Any seedable absolute prefix is
|
|
523
509
|
// accepted — the user's own kit copy elsewhere is legitimate shape-wise; entries OUTSIDE the
|
|
@@ -794,7 +780,7 @@ export const screenAllowlistEntry = (pattern) => {
|
|
|
794
780
|
};
|
|
795
781
|
|
|
796
782
|
/**
|
|
797
|
-
* Derive the opt-in kit-tools tier for a project:
|
|
783
|
+
* Derive the opt-in kit-tools tier for a project: 12 wildcard entries (resolved-absolute script
|
|
798
784
|
* path + args wildcard), ONE exact run-gates entry pinned to the resolved project root, and the
|
|
799
785
|
* writer-preview exact dry-run byte-strings. Pure derivation — a stale path simply prompts again.
|
|
800
786
|
*/
|