@cloverleaf/reference-impl 0.12.0 → 0.13.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +101 -37
- package/VERSION +1 -1
- package/config/council.json +10 -2
- package/dist/cli.mjs +29 -10
- package/dist/council.mjs +285 -64
- package/dist/events.mjs +3 -8
- package/dist/ids.mjs +7 -0
- package/dist/qa-rules.mjs +19 -3
- package/dist/task.mjs +6 -25
- package/dist/work-item.mjs +2 -3
- package/lib/cli.ts +30 -10
- package/lib/council-config.ts +1 -0
- package/lib/council.ts +343 -67
- package/lib/events.ts +4 -8
- package/lib/ids.ts +7 -0
- package/lib/qa-rules.ts +24 -3
- package/lib/task.ts +7 -33
- package/lib/work-item.ts +2 -4
- package/package.json +5 -3
- package/prompts/documenter.md +1 -1
- package/prompts/implementer.md +8 -2
- package/prompts/qa.md +6 -9
- package/prompts/reviewer.md +4 -2
- package/prompts/ui-reviewer.md +83 -11
- package/skills/cloverleaf-approve-baselines/SKILL.md +18 -26
- package/skills/cloverleaf-discover/SKILL.md +7 -1
- package/skills/cloverleaf-document/SKILL.md +5 -6
- package/skills/cloverleaf-implement/SKILL.md +24 -30
- package/skills/cloverleaf-merge/SKILL.md +14 -23
- package/skills/cloverleaf-new-task/SKILL.md +23 -4
- package/skills/cloverleaf-qa/SKILL.md +25 -24
- package/skills/cloverleaf-review/SKILL.md +24 -18
- package/skills/cloverleaf-run/SKILL.md +45 -106
- package/skills/cloverleaf-run-plan/SKILL.md +19 -26
- package/skills/cloverleaf-security-review/SKILL.md +22 -17
- package/skills/cloverleaf-ui-review/SKILL.md +59 -35
package/lib/cli.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Cloverleaf CLI dispatcher.
|
|
4
4
|
*
|
|
5
|
-
* Usage: cli
|
|
5
|
+
* Usage: cloverleaf-cli <command> [args...]
|
|
6
6
|
*
|
|
7
7
|
* Commands:
|
|
8
8
|
* load-task <repoRoot> <taskId>
|
|
9
9
|
* infer-project <repoRoot>
|
|
10
10
|
* next-task-id <repoRoot> [--project=<p>]
|
|
11
|
-
* advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]
|
|
11
|
+
* advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]
|
|
12
12
|
* write-feedback <repoRoot> <taskId> <envelopeJsonPath>
|
|
13
13
|
* latest-feedback <repoRoot> <taskId>
|
|
14
14
|
* emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
48
48
|
* chair-context <chairMemberInputsJson>
|
|
49
49
|
* chair-verdict <chairRawJson> <membersJson>
|
|
50
|
+
* validate-council <repoRoot>
|
|
50
51
|
*/
|
|
51
52
|
|
|
52
53
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
@@ -80,7 +81,9 @@ import type { SiblingScope } from './scope-check.js';
|
|
|
80
81
|
import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
|
|
81
82
|
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
|
|
82
83
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
83
|
-
import { resolveCouncilPlan, applyCouncilVerdict } from './council.js';
|
|
84
|
+
import { resolveCouncilPlan, applyCouncilVerdict, GATE_DESCRIPTORS } from './council.js';
|
|
85
|
+
import { loadCouncilConfigWithSource } from './council-config.js';
|
|
86
|
+
import { validateCouncilConfig } from '@cloverleaf/standard/validators/index.js';
|
|
84
87
|
import { aggregate, type MemberVerdict, type ThresholdRule, type CouncilVerdict } from './aggregation.js';
|
|
85
88
|
import { buildChairContext, finalizeChairVerdict, type ChairMemberInput, type ChairRawVerdict } from './chair.js';
|
|
86
89
|
|
|
@@ -92,12 +95,12 @@ function die(msg: string, code = 1): never {
|
|
|
92
95
|
function usage(msg?: string): never {
|
|
93
96
|
if (msg) process.stderr.write(msg + '\n');
|
|
94
97
|
process.stderr.write(
|
|
95
|
-
'Usage: cli
|
|
98
|
+
'Usage: cloverleaf-cli <command> [args...]\n' +
|
|
96
99
|
'Commands:\n' +
|
|
97
100
|
' load-task <repoRoot> <taskId>\n' +
|
|
98
101
|
' infer-project <repoRoot>\n' +
|
|
99
102
|
' next-task-id <repoRoot> [--project=<p>]\n' +
|
|
100
|
-
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]
|
|
103
|
+
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]\n' +
|
|
101
104
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
102
105
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
103
106
|
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
@@ -135,7 +138,8 @@ function usage(msg?: string): never {
|
|
|
135
138
|
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
136
139
|
' chair-context <chairMemberInputsJson>\n' +
|
|
137
140
|
' chair-verdict <chairRawJson> <membersJson>\n' +
|
|
138
|
-
' set-task-field <repoRoot> <taskId> <field> <value>\n'
|
|
141
|
+
' set-task-field <repoRoot> <taskId> <field> <value>\n' +
|
|
142
|
+
' validate-council <repoRoot>\n'
|
|
139
143
|
);
|
|
140
144
|
process.exit(2);
|
|
141
145
|
}
|
|
@@ -182,16 +186,15 @@ try {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
case 'advance-status': {
|
|
185
|
-
const [repoRoot, taskId, toStatus, actorArg, gate
|
|
189
|
+
const [repoRoot, taskId, toStatus, actorArg, gate] = rest;
|
|
186
190
|
if (!repoRoot || !taskId || !toStatus || !actorArg)
|
|
187
|
-
usage('advance-status requires <repoRoot> <taskId> <toStatus> <actor> [gate]
|
|
191
|
+
usage('advance-status requires <repoRoot> <taskId> <toStatus> <actor> [gate]');
|
|
188
192
|
if (actorArg !== 'agent' && actorArg !== 'human') {
|
|
189
193
|
die(`actor must be 'agent' or 'human' (got '${actorArg}')`, 2);
|
|
190
194
|
}
|
|
191
195
|
const actor: 'agent' | 'human' = actorArg;
|
|
192
|
-
const opts: { gate?: string
|
|
196
|
+
const opts: { gate?: string } = {};
|
|
193
197
|
if (gate) opts.gate = gate;
|
|
194
|
-
if (path === 'fast_lane' || path === 'full_pipeline') opts.path = path;
|
|
195
198
|
const updated = advanceStatus(repoRoot, taskId, toStatus, actor, opts);
|
|
196
199
|
process.stdout.write(updated.status + '\n');
|
|
197
200
|
break;
|
|
@@ -923,6 +926,23 @@ try {
|
|
|
923
926
|
break;
|
|
924
927
|
}
|
|
925
928
|
|
|
929
|
+
case 'validate-council': {
|
|
930
|
+
const [repoRoot] = rest;
|
|
931
|
+
if (!repoRoot) usage('validate-council requires <repoRoot>');
|
|
932
|
+
const { config } = loadCouncilConfigWithSource(repoRoot);
|
|
933
|
+
const gd = Object.fromEntries(
|
|
934
|
+
Object.entries(GATE_DESCRIPTORS).map(([k, d]) => [k, { kind: d.kind ?? 'code' }]),
|
|
935
|
+
);
|
|
936
|
+
const result = validateCouncilConfig(config as never, gd);
|
|
937
|
+
if (result.ok) {
|
|
938
|
+
process.stdout.write('council config OK\n');
|
|
939
|
+
} else {
|
|
940
|
+
for (const v of result.violations) process.stderr.write(`${v.rule}: ${v.message}\n`);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
|
|
926
946
|
default:
|
|
927
947
|
usage(`Unknown command: ${command}`);
|
|
928
948
|
}
|
package/lib/council-config.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type WhenPredicate = 'always' | 'security_class:high' | 'ui_changes';
|
|
|
11
11
|
export interface CouncilMember {
|
|
12
12
|
member: string; // built-in id ('reviewer' | 'security' | 'ui' | 'qa') or a custom role id
|
|
13
13
|
prompt?: string; // custom-role prompt filename, resolved under .cloverleaf/prompts/
|
|
14
|
+
kind?: 'code' | 'rfc' | 'plan'; // artifact kind this member reviews; default 'code'. A profile must be kind-homogeneous.
|
|
14
15
|
when?: WhenPredicate; // default 'always'
|
|
15
16
|
blocking?: boolean; // default true
|
|
16
17
|
weight?: number; // default 1
|
package/lib/council.ts
CHANGED
|
@@ -4,11 +4,15 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate, type CouncilMember } from './council-config.js';
|
|
5
5
|
import type { ThresholdRule, CouncilVerdict } from './aggregation.js';
|
|
6
6
|
import { loadTask, saveTask, advanceStatus } from './task.js';
|
|
7
|
+
import { loadPlan } from './plan.js';
|
|
8
|
+
import { loadRfc } from './rfc.js';
|
|
7
9
|
import { writeCouncilResult, type CouncilResult } from './council-result.js';
|
|
8
10
|
import { resolveChairPrompt } from './chair.js';
|
|
9
11
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
10
12
|
import { writeFeedback } from './feedback.js';
|
|
11
13
|
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
|
|
14
|
+
import { loadQaRulesDocument } from './qa-rules.js';
|
|
15
|
+
import { loadUiReviewConfig } from './ui-review-config.js';
|
|
12
16
|
import { getPluginRoot } from './plugin-path.js';
|
|
13
17
|
|
|
14
18
|
export interface ResolvedMember {
|
|
@@ -16,6 +20,14 @@ export interface ResolvedMember {
|
|
|
16
20
|
blocking: boolean;
|
|
17
21
|
weight: number;
|
|
18
22
|
promptPath: string;
|
|
23
|
+
/**
|
|
24
|
+
* Tokens this member's prompt declares beyond the five the runner always supplies
|
|
25
|
+
* (task, branch, base_branch, repo_root, diff), resolved here rather than described
|
|
26
|
+
* in skill prose. Values are the literal strings to substitute. Empty for members
|
|
27
|
+
* whose prompt needs nothing extra, and for any token planning cannot resolve
|
|
28
|
+
* without side effects (see MEMBER_TOKENS).
|
|
29
|
+
*/
|
|
30
|
+
substitutions: Record<string, string>;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export interface CouncilPlan {
|
|
@@ -77,7 +89,14 @@ export function resolveChangedFiles(repoRoot: string, taskId: string, opts: { ch
|
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The built-in council members and the shipped prompt each one resolves to.
|
|
94
|
+
* Exported as the single source of truth for "which members are built in":
|
|
95
|
+
* `tests/council.test.ts` walks it to assert every built-in prompt's declared
|
|
96
|
+
* tokens are covered by BASE_TOKENS ∪ MEMBER_TOKENS, so a prompt cannot gain a
|
|
97
|
+
* token that no one resolves.
|
|
98
|
+
*/
|
|
99
|
+
export const BUILTIN_PROMPTS: Record<string, string> = {
|
|
81
100
|
reviewer: 'reviewer.md',
|
|
82
101
|
security: 'security-reviewer.md',
|
|
83
102
|
ui: 'ui-reviewer.md',
|
|
@@ -85,23 +104,37 @@ const BUILTIN_PROMPTS: Record<string, string> = {
|
|
|
85
104
|
};
|
|
86
105
|
|
|
87
106
|
interface GateDescriptor {
|
|
88
|
-
state: string; // the
|
|
107
|
+
state: string; // the work-item status a gate's council runs at
|
|
89
108
|
advisoryOnly: boolean; // true when the gate's only legal transitions are human-driven
|
|
109
|
+
kind?: 'code' | 'rfc' | 'plan'; // artifact kind the gate reviews; default 'code'
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/**
|
|
93
|
-
* Council gate → FSM binding
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* of the binding — a fail-safe honoring "human gates are always advisory".
|
|
113
|
+
* Council gate → FSM binding. `task.review` is the collapsed delivery council
|
|
114
|
+
* (decisive); `task.plan_review` is decisive-capable (agent bounce to pending);
|
|
115
|
+
* `task.final_gate` and the two discovery gates are advisory (post-only, human
|
|
116
|
+
* drives the transition). Advisory-only gates route to postAdvisoryVerdict.
|
|
98
117
|
*/
|
|
99
118
|
export const GATE_DESCRIPTORS: Record<string, GateDescriptor> = {
|
|
100
|
-
'task.review': { state: '
|
|
101
|
-
'task.plan_review': { state: 'tactical-plan', advisoryOnly:
|
|
119
|
+
'task.review': { state: 'council', advisoryOnly: false },
|
|
120
|
+
'task.plan_review': { state: 'tactical-plan', advisoryOnly: false },
|
|
102
121
|
'task.final_gate': { state: 'final-gate', advisoryOnly: true },
|
|
122
|
+
'plan.task_batch': { state: 'task_batch_gate', advisoryOnly: true, kind: 'plan' },
|
|
123
|
+
'rfc.strategy_gate': { state: 'rfc_strategy_gate', advisoryOnly: true, kind: 'rfc' },
|
|
103
124
|
};
|
|
104
125
|
|
|
126
|
+
// Load any work item's status/doc by the gate's "type." prefix (task | plan | rfc).
|
|
127
|
+
function workItemTypeOf(gate: string): 'task' | 'plan' | 'rfc' {
|
|
128
|
+
const t = gate.split('.')[0];
|
|
129
|
+
if (t === 'task' || t === 'plan' || t === 'rfc') return t;
|
|
130
|
+
throw new Error(`council: gate '${gate}' has no task/plan/rfc type prefix`);
|
|
131
|
+
}
|
|
132
|
+
function loadWorkItemDoc(repoRoot: string, type: 'task' | 'plan' | 'rfc', id: string): Record<string, unknown> {
|
|
133
|
+
if (type === 'plan') return loadPlan(repoRoot, id) as unknown as Record<string, unknown>;
|
|
134
|
+
if (type === 'rfc') return loadRfc(repoRoot, id) as unknown as Record<string, unknown>;
|
|
135
|
+
return loadTask(repoRoot, id) as unknown as Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
/**
|
|
106
139
|
* Resolve a council member to the absolute path of its prompt. A member with a
|
|
107
140
|
* `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
|
|
@@ -124,16 +157,124 @@ export function resolveMemberPrompt(member: CouncilMember, repoRoot: string): st
|
|
|
124
157
|
return join(getPluginRoot(), 'prompts', builtin);
|
|
125
158
|
}
|
|
126
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Every extra token a built-in member's prompt may declare, beyond the five the
|
|
162
|
+
* runner always supplies. A union rather than `string` so `resolveSubstitutions`'s
|
|
163
|
+
* `default:` can be an exhaustiveness check: adding a token to MEMBER_TOKENS
|
|
164
|
+
* without a matching `case` is a typecheck failure, not a silently dropped key.
|
|
165
|
+
*/
|
|
166
|
+
export type MemberToken =
|
|
167
|
+
| 'test_rules'
|
|
168
|
+
| 'qa_rules'
|
|
169
|
+
| 'affected_routes'
|
|
170
|
+
| 'preview_port'
|
|
171
|
+
| 'ui_review_config'
|
|
172
|
+
| 'taskId';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extra tokens each built-in member's prompt declares, beyond the five the runner
|
|
176
|
+
* always supplies. Kept adjacent to the resolver so a prompt gaining a token is a
|
|
177
|
+
* one-line change in TS with a test behind it, rather than silent drift in skill prose.
|
|
178
|
+
*
|
|
179
|
+
* `preview_port` is listed because `ui-reviewer.md` genuinely declares it — but it is
|
|
180
|
+
* deliberately not resolved (see `resolveSubstitutions`). This map is the prompts'
|
|
181
|
+
* contract; the resolver is the subset planning can answer honestly.
|
|
182
|
+
*
|
|
183
|
+
* `tests/council.test.ts` pins this map against the prompts themselves: every
|
|
184
|
+
* `{{token}}` a built-in prompt declares must appear here or in BASE_TOKENS. That
|
|
185
|
+
* completeness check is what makes `cloverleaf-run` §7.2's "never dispatch with an
|
|
186
|
+
* unresolved token" rule safe — otherwise a prompt-only token stalls its member.
|
|
187
|
+
*/
|
|
188
|
+
export const MEMBER_TOKENS: Record<string, readonly MemberToken[]> = {
|
|
189
|
+
reviewer: ['test_rules'],
|
|
190
|
+
security: [],
|
|
191
|
+
qa: ['qa_rules'],
|
|
192
|
+
ui: ['affected_routes', 'preview_port', 'ui_review_config', 'taskId'],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
interface SubstitutionContext {
|
|
196
|
+
/**
|
|
197
|
+
* Id of the work item under review. Always known at planning time, so
|
|
198
|
+
* `{{taskId}}` — `ui-reviewer.md`'s run-artifact directory
|
|
199
|
+
* (`.cloverleaf/runs/<id>/ui-review/`) — is always resolvable.
|
|
200
|
+
*/
|
|
201
|
+
workItemId: string;
|
|
202
|
+
/**
|
|
203
|
+
* Routes this work item's diff affects, in the same `string[] | 'all'` encoding
|
|
204
|
+
* `cloverleaf-cli affected-routes` prints. Undefined for the discovery gates
|
|
205
|
+
* (plan/rfc), where no code diff is in scope.
|
|
206
|
+
*/
|
|
207
|
+
affectedRoutes?: string[] | 'all';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a member's extra prompt tokens. Side-effect free by contract: it reads
|
|
212
|
+
* config and reuses values the plan already computed, and never allocates, writes,
|
|
213
|
+
* or starts anything — `council-plan` is a query, and callers re-run it freely.
|
|
214
|
+
*
|
|
215
|
+
* A token that cannot be answered honestly at planning time is **omitted** rather
|
|
216
|
+
* than filled with a placeholder. An absent key leaves a visible `{{token}}` for
|
|
217
|
+
* whoever dispatches the member; a fabricated value is silently wrong, which is the
|
|
218
|
+
* exact failure mode this map exists to prevent.
|
|
219
|
+
*/
|
|
220
|
+
function resolveSubstitutions(
|
|
221
|
+
memberId: string,
|
|
222
|
+
repoRoot: string,
|
|
223
|
+
ctx: SubstitutionContext,
|
|
224
|
+
): Record<string, string> {
|
|
225
|
+
const out: Record<string, string> = {};
|
|
226
|
+
for (const token of MEMBER_TOKENS[memberId] ?? []) {
|
|
227
|
+
switch (token) {
|
|
228
|
+
// Both carry the qa-rules *document* (`{ rules: [...] }`) — the shape
|
|
229
|
+
// reviewer.md and qa.md document — not loadQaRulesConfig()'s bare array.
|
|
230
|
+
case 'test_rules':
|
|
231
|
+
case 'qa_rules':
|
|
232
|
+
out[token] = JSON.stringify(loadQaRulesDocument(repoRoot));
|
|
233
|
+
break;
|
|
234
|
+
case 'ui_review_config':
|
|
235
|
+
out[token] = JSON.stringify(loadUiReviewConfig(repoRoot));
|
|
236
|
+
break;
|
|
237
|
+
case 'affected_routes':
|
|
238
|
+
// Diff-dependent, so available only on a code gate; the plan already
|
|
239
|
+
// computed it to evaluate the `ui_changes` predicate.
|
|
240
|
+
if (ctx.affectedRoutes !== undefined) out[token] = JSON.stringify(ctx.affectedRoutes);
|
|
241
|
+
break;
|
|
242
|
+
case 'taskId':
|
|
243
|
+
// The run-artifact directory ui-reviewer.md writes its state.json sidecar
|
|
244
|
+
// into. A pure value already in hand, so it is always resolved: leaving it
|
|
245
|
+
// literal makes lib/ui-review-state.ts read a path that cannot exist, and
|
|
246
|
+
// the baselines-hold then fails open.
|
|
247
|
+
out[token] = ctx.workItemId;
|
|
248
|
+
break;
|
|
249
|
+
case 'preview_port':
|
|
250
|
+
// Deliberately unresolved: there is no configured preview port and no
|
|
251
|
+
// side-effect-free way to derive one — lib/ports.ts offers only
|
|
252
|
+
// getFreePort(), which *allocates*. Whoever dispatches the ui member
|
|
253
|
+
// allocates it there, as the standalone ui-review skill does.
|
|
254
|
+
break;
|
|
255
|
+
default: {
|
|
256
|
+
// Exhaustiveness guard. A token added to MEMBER_TOKENS with no case above
|
|
257
|
+
// would otherwise be dropped silently — the exact drift this map exists to
|
|
258
|
+
// prevent — so make it a compile error instead.
|
|
259
|
+
const unhandled: never = token;
|
|
260
|
+
throw new Error(`council: no resolver for member token '${String(unhandled)}'`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
127
267
|
export function resolveCouncilPlan(
|
|
128
268
|
repoRoot: string,
|
|
129
|
-
|
|
269
|
+
workItemId: string,
|
|
130
270
|
gateKey = 'task.review',
|
|
131
271
|
opts: { changedFiles?: string[] } = {},
|
|
132
272
|
): CouncilPlan {
|
|
133
273
|
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
134
|
-
const
|
|
274
|
+
const type = workItemTypeOf(gateKey);
|
|
275
|
+
const doc = loadWorkItemDoc(repoRoot, type, workItemId);
|
|
135
276
|
|
|
136
|
-
const binding = resolveBinding(config.gates[gateKey],
|
|
277
|
+
const binding = resolveBinding(config.gates[gateKey], doc);
|
|
137
278
|
const profileName = binding.profile;
|
|
138
279
|
const mode: 'decisive' | 'advisory' =
|
|
139
280
|
GATE_DESCRIPTORS[gateKey]?.advisoryOnly ? 'advisory' : binding.mode;
|
|
@@ -153,11 +294,17 @@ export function resolveCouncilPlan(
|
|
|
153
294
|
return empty; // unknown profile → fail toward today's behavior
|
|
154
295
|
}
|
|
155
296
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
297
|
+
// The when-context (security/ui) is a code-kind (task delivery) concern only.
|
|
298
|
+
// `affectedRoutes` outlives the predicate: the ui member's {{affected_routes}}
|
|
299
|
+
// token is the same value, so it is computed once and threaded to both.
|
|
300
|
+
let ctx: WhenContext = { securityHigh: false, uiChanges: false };
|
|
301
|
+
let affectedRoutes: string[] | 'all' | undefined;
|
|
302
|
+
if (type === 'task') {
|
|
303
|
+
const changed = resolveChangedFiles(repoRoot, workItemId, opts);
|
|
304
|
+
const securityHigh = classifyTaskSecurity(repoRoot, workItemId, { changedFiles: changed }).effective === 'high';
|
|
305
|
+
affectedRoutes = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
|
|
306
|
+
ctx = { securityHigh, uiChanges: affectedRoutes === 'all' || affectedRoutes.length > 0 };
|
|
307
|
+
}
|
|
161
308
|
|
|
162
309
|
const rounds: ResolvedMember[][] = [];
|
|
163
310
|
for (const round of profile.rounds) {
|
|
@@ -168,6 +315,7 @@ export function resolveCouncilPlan(
|
|
|
168
315
|
blocking: member.blocking !== false,
|
|
169
316
|
weight: member.weight ?? 1,
|
|
170
317
|
promptPath: resolveMemberPrompt(member, repoRoot),
|
|
318
|
+
substitutions: resolveSubstitutions(member.member, repoRoot, { workItemId, affectedRoutes }),
|
|
171
319
|
}));
|
|
172
320
|
if (active.length > 0) rounds.push(active);
|
|
173
321
|
}
|
|
@@ -189,14 +337,14 @@ export function resolveCouncilPlan(
|
|
|
189
337
|
|
|
190
338
|
/**
|
|
191
339
|
* Drive the FSM transition implied by a council verdict (the runner's terminal step).
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
340
|
+
* Routes by gate: `task.review` → the collapsed decisive delivery council; the
|
|
341
|
+
* decisive `task.plan_review` → the plan-review council; advisory-only gates
|
|
342
|
+
* (task.final_gate and the two discovery gates) → postAdvisoryVerdict, which
|
|
343
|
+
* records the verdict and drives no transition.
|
|
196
344
|
*/
|
|
197
345
|
export function applyCouncilVerdict(
|
|
198
346
|
repoRoot: string,
|
|
199
|
-
|
|
347
|
+
workItemId: string,
|
|
200
348
|
gate: string,
|
|
201
349
|
council: CouncilVerdict,
|
|
202
350
|
): CouncilResult {
|
|
@@ -207,16 +355,26 @@ export function applyCouncilVerdict(
|
|
|
207
355
|
);
|
|
208
356
|
}
|
|
209
357
|
if (desc.advisoryOnly) {
|
|
210
|
-
return postAdvisoryVerdict(repoRoot,
|
|
358
|
+
return postAdvisoryVerdict(repoRoot, workItemId, gate, desc.state, council);
|
|
211
359
|
}
|
|
212
|
-
|
|
360
|
+
if (gate === 'task.plan_review') {
|
|
361
|
+
return applyDecisivePlanReview(repoRoot, workItemId, gate, council);
|
|
362
|
+
}
|
|
363
|
+
return applyDeliveryCouncil(repoRoot, workItemId, gate, council); // task.review — collapsed council phase
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Decisive delivery council (task.review at the collapsed `council` state). */
|
|
367
|
+
function applyDeliveryCouncil(repoRoot: string, taskId: string, gate: string, council: CouncilVerdict): CouncilResult {
|
|
213
368
|
const task = loadTask(repoRoot, taskId);
|
|
214
|
-
if (task.status !== '
|
|
215
|
-
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected '
|
|
369
|
+
if (task.status !== 'council') {
|
|
370
|
+
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'council'`);
|
|
216
371
|
}
|
|
217
|
-
const lane: 'fast' | 'full' = task.risk_class === 'high' ? 'full' : 'fast';
|
|
218
372
|
const securityMember = council.members.find((m) => m.member === 'security');
|
|
219
|
-
const
|
|
373
|
+
const highSecurity = task.security_class === 'high';
|
|
374
|
+
// No security verdict to report? Ask the profile what it intended — before any
|
|
375
|
+
// transition mutates the task the gate binding selects a profile from.
|
|
376
|
+
const plannedSecurity = securityMember === undefined ? resolvePlannedSecurity(repoRoot, taskId, gate) : null;
|
|
377
|
+
const walk: string[] = ['council'];
|
|
220
378
|
|
|
221
379
|
if (council.verdict === 'escalate') {
|
|
222
380
|
advanceStatus(repoRoot, taskId, 'escalated', 'agent');
|
|
@@ -225,45 +383,30 @@ export function applyCouncilVerdict(
|
|
|
225
383
|
advanceStatus(repoRoot, taskId, 'implementing', 'agent');
|
|
226
384
|
walk.push('implementing');
|
|
227
385
|
} else {
|
|
228
|
-
// pass
|
|
229
|
-
//
|
|
230
|
-
advanceStatus(repoRoot, taskId, '
|
|
231
|
-
walk.push('
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
|
|
237
|
-
walk.push('qa');
|
|
238
|
-
advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
|
|
239
|
-
walk.push('final-gate');
|
|
386
|
+
// pass → council → final-gate; record the council's authoritative security verdict for high tasks
|
|
387
|
+
// (the v0.8.1 guarantee's backstop, now that the FSM no longer enforces it mechanically).
|
|
388
|
+
advanceStatus(repoRoot, taskId, 'final-gate', 'agent');
|
|
389
|
+
walk.push('final-gate');
|
|
390
|
+
if (highSecurity) {
|
|
391
|
+
const atGate = loadTask(repoRoot, taskId);
|
|
392
|
+
atGate.security_review_verdict = 'pass';
|
|
393
|
+
saveTask(repoRoot, atGate);
|
|
240
394
|
}
|
|
241
395
|
}
|
|
242
396
|
|
|
243
|
-
const qaTraversedAdministratively =
|
|
244
|
-
lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
|
|
245
|
-
|
|
246
397
|
const result: CouncilResult = {
|
|
247
398
|
gate,
|
|
248
399
|
final_verdict: council.verdict,
|
|
249
400
|
rule: council.rule,
|
|
250
401
|
rationale: council.rationale,
|
|
251
|
-
members: council.members.map((m) => ({
|
|
252
|
-
member: m.member,
|
|
253
|
-
verdict: m.verdict,
|
|
254
|
-
blocking: m.blocking !== false,
|
|
255
|
-
weight: m.weight ?? 1,
|
|
256
|
-
})),
|
|
402
|
+
members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
|
|
257
403
|
walk,
|
|
258
|
-
...(qaTraversedAdministratively
|
|
259
|
-
? { walk_note: 'qa state traversed administratively; no qa member ran' }
|
|
260
|
-
: {}),
|
|
261
404
|
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
262
405
|
security: {
|
|
263
406
|
member_verdict: securityMember ? securityMember.verdict : 'absent',
|
|
264
|
-
gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
|
|
407
|
+
gating_verdict_set: council.verdict === 'pass' && highSecurity ? 'pass' : null,
|
|
265
408
|
basis: !securityMember
|
|
266
|
-
?
|
|
409
|
+
? securityBasisWhenAbsent(plannedSecurity, council)
|
|
267
410
|
: securityMember.verdict === 'pass'
|
|
268
411
|
? 'security member passed'
|
|
269
412
|
: `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
|
|
@@ -273,33 +416,166 @@ export function applyCouncilVerdict(
|
|
|
273
416
|
return result;
|
|
274
417
|
}
|
|
275
418
|
|
|
419
|
+
/** What this gate's profile planned for the `security` member, for basis classification. */
|
|
420
|
+
interface PlannedSecurity {
|
|
421
|
+
/** Round the `security` member is planned in, or null when it is not in the plan at all. */
|
|
422
|
+
securityRound: number | null;
|
|
423
|
+
/**
|
|
424
|
+
* Earliest planned round per member id. A stop rule explains security's absence only if
|
|
425
|
+
* it fired in a round that could actually have preceded security's own, so the round
|
|
426
|
+
* ordering — not merely the presence of a bouncing member — is what the basis checks.
|
|
427
|
+
*/
|
|
428
|
+
memberRounds: Map<string, number>;
|
|
429
|
+
stopsOnRoundBounce: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Resolve what the profile bound to `gate` planned for the `security` member on this task.
|
|
434
|
+
*
|
|
435
|
+
* `council.members` names only the members that actually *ran*, so a missing security
|
|
436
|
+
* verdict is ambiguous on its own. The resolved plan disambiguates it — with one honest
|
|
437
|
+
* limitation: `resolveCouncilPlan` filters `when`-inactive members out, so a member
|
|
438
|
+
* excluded by its `when` predicate and a profile that never listed one both come back
|
|
439
|
+
* `planned: false`. Both are truthfully "no security member for this task", so they share
|
|
440
|
+
* one basis string rather than a distinction this cannot actually make.
|
|
441
|
+
*
|
|
442
|
+
* Two deliberate properties:
|
|
443
|
+
* - Returns null rather than throwing. Plan resolution reads consumer config and can fail
|
|
444
|
+
* on, say, a mistyped member id; an audit string is not worth failing a terminal apply
|
|
445
|
+
* step over, and a run whose plan will not resolve says exactly that.
|
|
446
|
+
* - Called only when there is no security verdict to explain: for a task-kind gate
|
|
447
|
+
* `resolveCouncilPlan` runs a `git diff`, so this adds one git invocation to the apply
|
|
448
|
+
* path, and it should stay at most one.
|
|
449
|
+
*/
|
|
450
|
+
function resolvePlannedSecurity(repoRoot: string, taskId: string, gate: string): PlannedSecurity | null {
|
|
451
|
+
try {
|
|
452
|
+
const plan = resolveCouncilPlan(repoRoot, taskId, gate);
|
|
453
|
+
// Earliest occurrence wins: if a member is planned twice, the first round is the
|
|
454
|
+
// earliest point the council could have run it.
|
|
455
|
+
const memberRounds = new Map<string, number>();
|
|
456
|
+
plan.rounds.forEach((round, index) => {
|
|
457
|
+
for (const m of round) if (!memberRounds.has(m.member)) memberRounds.set(m.member, index);
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
securityRound: memberRounds.get('security') ?? null,
|
|
461
|
+
memberRounds,
|
|
462
|
+
stopsOnRoundBounce: plan.on_round_bounce === 'stop',
|
|
463
|
+
};
|
|
464
|
+
} catch (err) {
|
|
465
|
+
process.stderr.write(
|
|
466
|
+
`apply-council-verdict: could not resolve the council plan for gate '${gate}' on ${taskId} (${err instanceof Error ? err.message : String(err)}); the result artifact will record the security basis as unknown.\n`,
|
|
467
|
+
);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The `security.basis` for a run that recorded no security verdict. Each string states only
|
|
474
|
+
* what that run can prove, so a cause is named only when the run satisfies one of the
|
|
475
|
+
* council's two stop rules *in a round that could have preceded security's own*:
|
|
476
|
+
*
|
|
477
|
+
* - an `escalate` from any member stops the council immediately, so an escalator in an
|
|
478
|
+
* earlier round — or in security's own round, cutting off a concurrently dispatched
|
|
479
|
+
* member — explains the absence; one in a *later* round cannot, since that round ran only
|
|
480
|
+
* because security's round completed;
|
|
481
|
+
* - a **blocking** `bounce` stops the council "before the next round" under
|
|
482
|
+
* `on_round_bounce: stop`, so it explains the absence only from a *strictly earlier*
|
|
483
|
+
* round. A same-round bounce cannot un-dispatch a member already running alongside it.
|
|
484
|
+
*
|
|
485
|
+
* Anything else — a non-blocking bounce, a stop rule that fired too late, a member the
|
|
486
|
+
* runner never dispatched or whose envelope was lost — leaves the absence unexplained, and
|
|
487
|
+
* the artifact says so rather than naming a cause that would point the auditor away from
|
|
488
|
+
* the real anomaly.
|
|
489
|
+
*/
|
|
490
|
+
function securityBasisWhenAbsent(planned: PlannedSecurity | null, council: CouncilVerdict): string {
|
|
491
|
+
if (planned === null) {
|
|
492
|
+
return 'no security member ran; the council plan could not be resolved, so whether one was configured is unknown';
|
|
493
|
+
}
|
|
494
|
+
const securityRound = planned.securityRound;
|
|
495
|
+
if (securityRound === null) {
|
|
496
|
+
// Covers both a profile with no security member and one excluded by its `when`
|
|
497
|
+
// predicate — indistinguishable here, and the wording claims no more than that.
|
|
498
|
+
const outcome =
|
|
499
|
+
council.verdict === 'bounce' ? 'council bounced to implementing'
|
|
500
|
+
: council.verdict === 'escalate' ? 'council escalated'
|
|
501
|
+
: 'advanced under council authority';
|
|
502
|
+
return `no security member in this task's resolved council plan; ${outcome}`;
|
|
503
|
+
}
|
|
504
|
+
// Did a stop rule fire in a round at/before `latestRound`? A member the plan does not
|
|
505
|
+
// name cannot be ordered against security's round, so it never explains the absence.
|
|
506
|
+
const stoppedBy = (verdict: CouncilVerdict['verdict'], latestRound: number, blockingOnly: boolean): boolean =>
|
|
507
|
+
council.members.some((m) => {
|
|
508
|
+
const round = planned.memberRounds.get(m.member);
|
|
509
|
+
return m.verdict === verdict && round !== undefined && round <= latestRound && (!blockingOnly || m.blocking !== false);
|
|
510
|
+
});
|
|
511
|
+
if (stoppedBy('escalate', securityRound, false)) {
|
|
512
|
+
return 'security member configured for this task but not reached: another member escalated and the council short-circuited; no security verdict recorded';
|
|
513
|
+
}
|
|
514
|
+
if (planned.stopsOnRoundBounce && stoppedBy('bounce', securityRound - 1, true)) {
|
|
515
|
+
return "security member configured for this task but not reached: an earlier round's blocking member bounced and the profile stops on a round bounce; no security verdict recorded";
|
|
516
|
+
}
|
|
517
|
+
return 'security member configured for this task but did not run; no security verdict recorded';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Decisive plan-review council (task.plan_review at tactical-plan). */
|
|
521
|
+
function applyDecisivePlanReview(repoRoot: string, taskId: string, gate: string, council: CouncilVerdict): CouncilResult {
|
|
522
|
+
const task = loadTask(repoRoot, taskId);
|
|
523
|
+
if (task.status !== 'tactical-plan') {
|
|
524
|
+
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'tactical-plan' for gate '${gate}'`);
|
|
525
|
+
}
|
|
526
|
+
const walk: string[] = ['tactical-plan'];
|
|
527
|
+
if (council.verdict === 'escalate') {
|
|
528
|
+
advanceStatus(repoRoot, taskId, 'escalated', 'agent');
|
|
529
|
+
walk.push('escalated');
|
|
530
|
+
} else if (council.verdict === 'bounce') {
|
|
531
|
+
advanceStatus(repoRoot, taskId, 'pending', 'agent', { gate: 'per_task_plan_review' });
|
|
532
|
+
walk.push('pending');
|
|
533
|
+
} else {
|
|
534
|
+
advanceStatus(repoRoot, taskId, 'implementing', 'agent');
|
|
535
|
+
walk.push('implementing');
|
|
536
|
+
}
|
|
537
|
+
const result: CouncilResult = {
|
|
538
|
+
gate,
|
|
539
|
+
final_verdict: council.verdict,
|
|
540
|
+
rule: council.rule,
|
|
541
|
+
rationale: council.rationale,
|
|
542
|
+
members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
|
|
543
|
+
walk,
|
|
544
|
+
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
545
|
+
};
|
|
546
|
+
writeCouncilResult(repoRoot, taskId, result);
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
276
550
|
/**
|
|
277
|
-
* Advisory-gate terminal step
|
|
278
|
-
*
|
|
279
|
-
*
|
|
551
|
+
* Advisory-gate terminal step: record the council verdict + post a feedback
|
|
552
|
+
* envelope, and drive NO transition — the human owns every transition at an
|
|
553
|
+
* advisory gate. The verdict (including an escalate) is recorded verbatim;
|
|
280
554
|
* because nothing is transitioned, the un-lowerable-escalate invariant holds
|
|
281
|
-
* trivially.
|
|
282
|
-
* (
|
|
555
|
+
* trivially. Generalized over work-item kind: the gate's `type.` prefix selects
|
|
556
|
+
* task | plan | rfc. Used for task.final_gate (final-gate) and the discovery
|
|
557
|
+
* gates plan.task_batch (task_batch_gate) / rfc.strategy_gate (rfc_strategy_gate).
|
|
283
558
|
*/
|
|
284
559
|
export function postAdvisoryVerdict(
|
|
285
560
|
repoRoot: string,
|
|
286
|
-
|
|
561
|
+
workItemId: string,
|
|
287
562
|
gate: string,
|
|
288
563
|
expectedState: string,
|
|
289
564
|
council: CouncilVerdict,
|
|
290
565
|
): CouncilResult {
|
|
291
|
-
const
|
|
292
|
-
|
|
566
|
+
const type = workItemTypeOf(gate);
|
|
567
|
+
const status = String(loadWorkItemDoc(repoRoot, type, workItemId).status ?? '');
|
|
568
|
+
if (status !== expectedState) {
|
|
293
569
|
throw new Error(
|
|
294
|
-
`apply-council-verdict:
|
|
570
|
+
`apply-council-verdict: ${type} ${workItemId} is '${status}', expected '${expectedState}' for advisory gate '${gate}'`,
|
|
295
571
|
);
|
|
296
572
|
}
|
|
297
|
-
const m =
|
|
298
|
-
if (!m) throw new Error(`apply-council-verdict: invalid
|
|
573
|
+
const m = workItemId.match(/^(.+)-(\d+)$/);
|
|
574
|
+
if (!m) throw new Error(`apply-council-verdict: invalid work-item id '${workItemId}'`);
|
|
299
575
|
const project = m[1];
|
|
300
576
|
writeFeedback(repoRoot, {
|
|
301
577
|
project,
|
|
302
|
-
taskId,
|
|
578
|
+
taskId: workItemId,
|
|
303
579
|
prefix: 'c',
|
|
304
580
|
envelope: { verdict: council.verdict, summary: council.rationale, findings: [] },
|
|
305
581
|
});
|
|
@@ -319,6 +595,6 @@ export function postAdvisoryVerdict(
|
|
|
319
595
|
walk_note: 'advisory: verdict posted; human drives the transition',
|
|
320
596
|
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
321
597
|
};
|
|
322
|
-
writeCouncilResult(repoRoot,
|
|
598
|
+
writeCouncilResult(repoRoot, workItemId, result);
|
|
323
599
|
return result;
|
|
324
600
|
}
|