@xzzpig/pi-openspec-x 0.1.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/index.ts +1 -0
- package/package.json +55 -0
- package/src/agent-dir.ts +48 -0
- package/src/agent-prompts.ts +115 -0
- package/src/agents.ts +262 -0
- package/src/approval-gate.ts +139 -0
- package/src/cli.ts +442 -0
- package/src/command-util.ts +33 -0
- package/src/config.ts +89 -0
- package/src/dependencies.ts +445 -0
- package/src/discover.ts +112 -0
- package/src/dispatch-gate.ts +257 -0
- package/src/final-gate.ts +158 -0
- package/src/flow-entries.ts +258 -0
- package/src/flow-recovery.ts +227 -0
- package/src/goal-base.ts +339 -0
- package/src/implement-command.ts +1151 -0
- package/src/index.ts +173 -0
- package/src/lifecycle.ts +156 -0
- package/src/mode.ts +285 -0
- package/src/objective.ts +246 -0
- package/src/plan-command.ts +285 -0
- package/src/plan-flow.ts +133 -0
- package/src/plan-gate.ts +279 -0
- package/src/plan-review.ts +452 -0
- package/src/progress-observer.ts +78 -0
- package/src/progress-projection.ts +118 -0
- package/src/report-tools.ts +317 -0
- package/src/review-scope.ts +172 -0
- package/src/rollback-command.ts +241 -0
- package/src/sandbox-profiles.ts +73 -0
- package/src/skills.ts +386 -0
- package/src/static-conflict.ts +89 -0
- package/src/task-sync.ts +226 -0
- package/src/tasks-md.ts +136 -0
- package/src/worker-dispatch.ts +152 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan review loop: verdict-driven decisions, the append-only plan ledger, and
|
|
3
|
+
* the `reviews.md` projection (design D3/D6; openspec change add-pi-openspec-x,
|
|
4
|
+
* task 7.2).
|
|
5
|
+
*
|
|
6
|
+
* The plan flow's facts live in an append-only JSONL ledger under the change
|
|
7
|
+
* directory (`.opsx-plan-review.jsonl`): gap analyses, plan-review verdicts
|
|
8
|
+
* with their rounds and blockers, delegation usage, and escalations.
|
|
9
|
+
* `reviews.md` is a human-readable projection of that ledger — the ledger is
|
|
10
|
+
* the source of truth, the markdown is derived.
|
|
11
|
+
*
|
|
12
|
+
* Gate decisions are purely structural (never parsed from free text):
|
|
13
|
+
* - OKAY passes.
|
|
14
|
+
* - ITERATE revises, but at most {@link MAX_ITERATE_ROUNDS} consecutive
|
|
15
|
+
* iterations before the user takes over.
|
|
16
|
+
* - REJECT revises; a blocker that repeats from the previous round escalates
|
|
17
|
+
* immediately, because a review that raises the same blocker is not making
|
|
18
|
+
* progress.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { type ApprovalVia } from "./approval-gate.ts";
|
|
24
|
+
|
|
25
|
+
export type PlanVerdict = "OKAY" | "ITERATE" | "REJECT";
|
|
26
|
+
|
|
27
|
+
/** Structural view of pi-subagents' terminal delegation usage. */
|
|
28
|
+
export interface DelegationUsage {
|
|
29
|
+
input: number;
|
|
30
|
+
output: number;
|
|
31
|
+
cacheRead: number;
|
|
32
|
+
cacheWrite: number;
|
|
33
|
+
turns: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlanReviewRound {
|
|
37
|
+
/** 1-based review round. */
|
|
38
|
+
round: number;
|
|
39
|
+
verdict: PlanVerdict;
|
|
40
|
+
blockers: string[];
|
|
41
|
+
summary?: string;
|
|
42
|
+
usage?: DelegationUsage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const MAX_ITERATE_ROUNDS = 2;
|
|
46
|
+
|
|
47
|
+
export type ReviewAction =
|
|
48
|
+
| { kind: "dispatch"; reason: string }
|
|
49
|
+
| { kind: "pass"; round: number }
|
|
50
|
+
| { kind: "revise"; round: number; reason: string }
|
|
51
|
+
| { kind: "escalate"; round: number; reason: string };
|
|
52
|
+
|
|
53
|
+
function consecutiveIterates(history: readonly PlanReviewRound[]): number {
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
56
|
+
if (history[index]!.verdict !== "ITERATE") break;
|
|
57
|
+
count += 1;
|
|
58
|
+
}
|
|
59
|
+
return count;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a blocker for the repeat check. Verdicts come from an LLM reviewer,
|
|
64
|
+
* so the same finding routinely returns with different casing, padding, or
|
|
65
|
+
* spacing between rounds; exact string equality would under-report repeats and
|
|
66
|
+
* let a stuck loop keep "progressing". Comparison is therefore case-insensitive
|
|
67
|
+
* on trimmed, whitespace-collapsed text.
|
|
68
|
+
*/
|
|
69
|
+
function normalizeBlocker(blocker: string): string {
|
|
70
|
+
return blocker.trim().replace(/\s+/g, " ").toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide what the plan flow does after the recorded verdicts. An empty history
|
|
75
|
+
* means the first plan review still has to be dispatched.
|
|
76
|
+
*/
|
|
77
|
+
export function nextReviewAction(
|
|
78
|
+
history: readonly PlanReviewRound[],
|
|
79
|
+
): ReviewAction {
|
|
80
|
+
if (history.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "dispatch",
|
|
83
|
+
reason: "No plan review has run yet; dispatch opsx-plan-review.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const last = history[history.length - 1]!;
|
|
87
|
+
|
|
88
|
+
if (last.verdict === "OKAY") {
|
|
89
|
+
return { kind: "pass", round: last.round };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (last.verdict === "ITERATE") {
|
|
93
|
+
const iterations = consecutiveIterates(history);
|
|
94
|
+
if (iterations >= MAX_ITERATE_ROUNDS) {
|
|
95
|
+
return {
|
|
96
|
+
kind: "escalate",
|
|
97
|
+
round: last.round,
|
|
98
|
+
reason: `${iterations} consecutive ITERATE rounds reached the limit (${MAX_ITERATE_ROUNDS}); the user takes over.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
kind: "revise",
|
|
103
|
+
round: last.round,
|
|
104
|
+
reason: `ITERATE round ${last.round} of at most ${MAX_ITERATE_ROUNDS}; revise the plan and re-review.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// REJECT: a blocker repeated from the previous round means the loop is stuck.
|
|
109
|
+
const previous = history[history.length - 2];
|
|
110
|
+
const previousBlockers = new Set(
|
|
111
|
+
previous?.blockers.map(normalizeBlocker) ?? [],
|
|
112
|
+
);
|
|
113
|
+
const repeated = previous
|
|
114
|
+
? last.blockers.filter((blocker) =>
|
|
115
|
+
previousBlockers.has(normalizeBlocker(blocker)),
|
|
116
|
+
)
|
|
117
|
+
: [];
|
|
118
|
+
if (repeated.length > 0) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "escalate",
|
|
121
|
+
round: last.round,
|
|
122
|
+
reason: `REJECT repeated blocker(s) from round ${previous!.round}: ${repeated.join("; ")}. The user takes over.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
kind: "revise",
|
|
127
|
+
round: last.round,
|
|
128
|
+
reason: `REJECT round ${last.round} with new blocker(s); revise the plan and re-review.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── append-only ledger ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export type PlanReviewEntryType =
|
|
135
|
+
| "gap_analysis"
|
|
136
|
+
| "plan_review_verdict"
|
|
137
|
+
| "plan_approval"
|
|
138
|
+
| "escalation";
|
|
139
|
+
|
|
140
|
+
/** The user's plan decision, mirrored from the approval gate. */
|
|
141
|
+
export type PlanApprovalDecision =
|
|
142
|
+
| "approved"
|
|
143
|
+
| "revise"
|
|
144
|
+
| "rejected"
|
|
145
|
+
| "pending";
|
|
146
|
+
|
|
147
|
+
export interface PlanReviewEntry {
|
|
148
|
+
/** ISO timestamp. */
|
|
149
|
+
at: string;
|
|
150
|
+
type: PlanReviewEntryType;
|
|
151
|
+
round?: number;
|
|
152
|
+
verdict?: PlanVerdict;
|
|
153
|
+
/** The user's decision on a `plan_approval` entry. */
|
|
154
|
+
decision?: PlanApprovalDecision;
|
|
155
|
+
/** How the approval was requested (pi-ask / select / text). */
|
|
156
|
+
via?: string;
|
|
157
|
+
/** Gap-analysis findings, or the escalation reason. */
|
|
158
|
+
findings?: string[];
|
|
159
|
+
blockers?: string[];
|
|
160
|
+
summary?: string;
|
|
161
|
+
usage?: DelegationUsage;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Ledger file name inside the change directory. */
|
|
165
|
+
export const PLAN_REVIEW_LEDGER_FILE = ".opsx-plan-review.jsonl";
|
|
166
|
+
/** Human-readable projection file name inside the change directory. */
|
|
167
|
+
export const PLAN_REVIEWS_FILE = "reviews.md";
|
|
168
|
+
|
|
169
|
+
export function planReviewLedgerPath(changeDir: string): string {
|
|
170
|
+
return path.join(changeDir, PLAN_REVIEW_LEDGER_FILE);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function planReviewsPath(changeDir: string): string {
|
|
174
|
+
return path.join(changeDir, PLAN_REVIEWS_FILE);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Append one entry to the change's plan ledger. Creates the directory. */
|
|
178
|
+
export function appendPlanReviewEntry(
|
|
179
|
+
changeDir: string,
|
|
180
|
+
entry: PlanReviewEntry,
|
|
181
|
+
): void {
|
|
182
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
183
|
+
fs.appendFileSync(
|
|
184
|
+
planReviewLedgerPath(changeDir),
|
|
185
|
+
`${JSON.stringify(entry)}\n`,
|
|
186
|
+
"utf-8",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the plan ledger. Malformed lines are skipped (an interrupted append
|
|
192
|
+
* must not take the flow down); a missing file yields an empty ledger.
|
|
193
|
+
*/
|
|
194
|
+
export function readPlanReviewLedger(changeDir: string): PlanReviewEntry[] {
|
|
195
|
+
let raw: string;
|
|
196
|
+
try {
|
|
197
|
+
raw = fs.readFileSync(planReviewLedgerPath(changeDir), "utf-8");
|
|
198
|
+
} catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const entries: PlanReviewEntry[] = [];
|
|
202
|
+
for (const line of raw.split("\n")) {
|
|
203
|
+
const trimmed = line.trim();
|
|
204
|
+
if (!trimmed) continue;
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(trimmed) as PlanReviewEntry;
|
|
207
|
+
if (
|
|
208
|
+
parsed &&
|
|
209
|
+
typeof parsed === "object" &&
|
|
210
|
+
typeof parsed.type === "string"
|
|
211
|
+
) {
|
|
212
|
+
entries.push(parsed);
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Skip the damaged line; the ledger stays usable.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The review rounds in the ledger, in order. */
|
|
222
|
+
export function planReviewRounds(
|
|
223
|
+
entries: readonly PlanReviewEntry[],
|
|
224
|
+
): PlanReviewRound[] {
|
|
225
|
+
return entries
|
|
226
|
+
.filter((entry) => entry.type === "plan_review_verdict" && entry.verdict)
|
|
227
|
+
.map((entry) => ({
|
|
228
|
+
round: entry.round ?? 0,
|
|
229
|
+
verdict: entry.verdict!,
|
|
230
|
+
blockers: entry.blockers ?? [],
|
|
231
|
+
...(entry.summary ? { summary: entry.summary } : {}),
|
|
232
|
+
...(entry.usage ? { usage: entry.usage } : {}),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Sum delegation usage across the ledger's verdict rounds. */
|
|
237
|
+
export function totalDelegationUsage(
|
|
238
|
+
entries: readonly PlanReviewEntry[],
|
|
239
|
+
): DelegationUsage {
|
|
240
|
+
const total: DelegationUsage = {
|
|
241
|
+
input: 0,
|
|
242
|
+
output: 0,
|
|
243
|
+
cacheRead: 0,
|
|
244
|
+
cacheWrite: 0,
|
|
245
|
+
turns: 0,
|
|
246
|
+
};
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
if (!entry.usage) continue;
|
|
249
|
+
total.input += entry.usage.input;
|
|
250
|
+
total.output += entry.usage.output;
|
|
251
|
+
total.cacheRead += entry.usage.cacheRead;
|
|
252
|
+
total.cacheWrite += entry.usage.cacheWrite;
|
|
253
|
+
total.turns += entry.usage.turns;
|
|
254
|
+
}
|
|
255
|
+
return total;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Render the `reviews.md` projection of the ledger. */
|
|
259
|
+
export function renderReviewsMarkdown(
|
|
260
|
+
entries: readonly PlanReviewEntry[],
|
|
261
|
+
): string {
|
|
262
|
+
const lines = [
|
|
263
|
+
"# Plan review record",
|
|
264
|
+
"",
|
|
265
|
+
"Projected from `.opsx-plan-review.jsonl` (append-only).",
|
|
266
|
+
"",
|
|
267
|
+
];
|
|
268
|
+
if (entries.length === 0) {
|
|
269
|
+
lines.push("_No review entries yet._", "");
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
if (entry.type === "gap_analysis") {
|
|
274
|
+
lines.push(`## Gap analysis — ${entry.at}`, "");
|
|
275
|
+
if (entry.summary) lines.push(entry.summary, "");
|
|
276
|
+
for (const finding of entry.findings ?? []) lines.push(`- ${finding}`);
|
|
277
|
+
lines.push("");
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (entry.type === "plan_review_verdict") {
|
|
281
|
+
lines.push(
|
|
282
|
+
`## Plan review verdict — round ${entry.round ?? "?"} — ${entry.verdict ?? "?"} — ${entry.at}`,
|
|
283
|
+
"",
|
|
284
|
+
);
|
|
285
|
+
if (entry.summary) lines.push(entry.summary, "");
|
|
286
|
+
for (const blocker of entry.blockers ?? [])
|
|
287
|
+
lines.push(`- Blocker: ${blocker}`);
|
|
288
|
+
if (entry.usage) {
|
|
289
|
+
lines.push(
|
|
290
|
+
"",
|
|
291
|
+
`Usage: input ${entry.usage.input}, output ${entry.usage.output}, turns ${entry.usage.turns}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
lines.push("");
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (entry.type === "plan_approval") {
|
|
298
|
+
lines.push(
|
|
299
|
+
`## Plan approval — ${entry.decision ?? "?"} — ${entry.at}`,
|
|
300
|
+
"",
|
|
301
|
+
`Requested via: ${entry.via ?? "?"}`,
|
|
302
|
+
"",
|
|
303
|
+
);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
lines.push(`## Escalation — ${entry.at}`, "");
|
|
307
|
+
for (const finding of entry.findings ?? []) lines.push(`- ${finding}`);
|
|
308
|
+
lines.push("");
|
|
309
|
+
}
|
|
310
|
+
const total = totalDelegationUsage(entries);
|
|
311
|
+
lines.push(
|
|
312
|
+
"## Delegation usage (total)",
|
|
313
|
+
"",
|
|
314
|
+
`input ${total.input}, output ${total.output}, cacheRead ${total.cacheRead}, cacheWrite ${total.cacheWrite}, turns ${total.turns}`,
|
|
315
|
+
"",
|
|
316
|
+
);
|
|
317
|
+
return lines.join("\n");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Project the ledger to `reviews.md` in the change directory. Returns the
|
|
322
|
+
* written path. The projection is derived state: callers may regenerate it at
|
|
323
|
+
* any time.
|
|
324
|
+
*/
|
|
325
|
+
export function writeReviewsProjection(changeDir: string): string {
|
|
326
|
+
const entries = readPlanReviewLedger(changeDir);
|
|
327
|
+
const target = planReviewsPath(changeDir);
|
|
328
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
329
|
+
fs.writeFileSync(target, renderReviewsMarkdown(entries), "utf-8");
|
|
330
|
+
return target;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Record one gap analysis and refresh the projection. Gap analysis is advisory:
|
|
335
|
+
* it never gates, so no decision is returned.
|
|
336
|
+
*/
|
|
337
|
+
export function recordGapAnalysis(
|
|
338
|
+
changeDir: string,
|
|
339
|
+
input: { findings: string[]; summary?: string; at?: string },
|
|
340
|
+
): PlanReviewEntry {
|
|
341
|
+
const entry: PlanReviewEntry = {
|
|
342
|
+
at: input.at ?? new Date().toISOString(),
|
|
343
|
+
type: "gap_analysis",
|
|
344
|
+
findings: [...input.findings],
|
|
345
|
+
...(input.summary ? { summary: input.summary } : {}),
|
|
346
|
+
};
|
|
347
|
+
appendPlanReviewEntry(changeDir, entry);
|
|
348
|
+
writeReviewsProjection(changeDir);
|
|
349
|
+
return entry;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Record one plan-review verdict (round derived from the ledger), refresh the
|
|
354
|
+
* projection, and return the gate decision the orchestrator must follow. The
|
|
355
|
+
* caller never recomputes the round or the escalation policy.
|
|
356
|
+
*/
|
|
357
|
+
export function recordPlanReviewVerdict(
|
|
358
|
+
changeDir: string,
|
|
359
|
+
input: {
|
|
360
|
+
verdict: PlanVerdict;
|
|
361
|
+
blockers: string[];
|
|
362
|
+
summary?: string;
|
|
363
|
+
usage?: DelegationUsage;
|
|
364
|
+
at?: string;
|
|
365
|
+
},
|
|
366
|
+
): { entry: PlanReviewEntry; action: ReviewAction } {
|
|
367
|
+
const rounds = planReviewRounds(readPlanReviewLedger(changeDir));
|
|
368
|
+
const round = rounds.length + 1;
|
|
369
|
+
const entry: PlanReviewEntry = {
|
|
370
|
+
at: input.at ?? new Date().toISOString(),
|
|
371
|
+
type: "plan_review_verdict",
|
|
372
|
+
round,
|
|
373
|
+
verdict: input.verdict,
|
|
374
|
+
blockers: [...input.blockers],
|
|
375
|
+
...(input.summary ? { summary: input.summary } : {}),
|
|
376
|
+
...(input.usage ? { usage: input.usage } : {}),
|
|
377
|
+
};
|
|
378
|
+
appendPlanReviewEntry(changeDir, entry);
|
|
379
|
+
const action = nextReviewAction([
|
|
380
|
+
...rounds,
|
|
381
|
+
{ round, verdict: input.verdict, blockers: input.blockers },
|
|
382
|
+
]);
|
|
383
|
+
writeReviewsProjection(changeDir);
|
|
384
|
+
return { entry, action };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Record one escalation (the loop gave up and the user takes over). */
|
|
388
|
+
export function recordEscalation(
|
|
389
|
+
changeDir: string,
|
|
390
|
+
input: { reason: string; round?: number; at?: string },
|
|
391
|
+
): PlanReviewEntry {
|
|
392
|
+
const entry: PlanReviewEntry = {
|
|
393
|
+
at: input.at ?? new Date().toISOString(),
|
|
394
|
+
type: "escalation",
|
|
395
|
+
...(input.round === undefined ? {} : { round: input.round }),
|
|
396
|
+
findings: [input.reason],
|
|
397
|
+
};
|
|
398
|
+
appendPlanReviewEntry(changeDir, entry);
|
|
399
|
+
writeReviewsProjection(changeDir);
|
|
400
|
+
return entry;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Record the user's plan decision in the ledger and refresh the projection.
|
|
405
|
+
*/
|
|
406
|
+
export function recordPlanApproval(
|
|
407
|
+
changeDir: string,
|
|
408
|
+
input: {
|
|
409
|
+
changeId: string;
|
|
410
|
+
decision: PlanApprovalDecision;
|
|
411
|
+
via: string;
|
|
412
|
+
at?: string;
|
|
413
|
+
},
|
|
414
|
+
): PlanReviewEntry {
|
|
415
|
+
const entry: PlanReviewEntry = {
|
|
416
|
+
at: input.at ?? new Date().toISOString(),
|
|
417
|
+
type: "plan_approval",
|
|
418
|
+
decision: input.decision,
|
|
419
|
+
via: input.via,
|
|
420
|
+
};
|
|
421
|
+
appendPlanReviewEntry(changeDir, entry);
|
|
422
|
+
writeReviewsProjection(changeDir);
|
|
423
|
+
return entry;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Narrow a stored `via` string to the approval gate's closed union. */
|
|
427
|
+
function asApprovalVia(value: string | undefined): ApprovalVia {
|
|
428
|
+
return value === "pi-ask" || value === "select" || value === "text"
|
|
429
|
+
? value
|
|
430
|
+
: "text";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The most recent recorded plan approval, if any (change id is the caller's). */
|
|
434
|
+
export function readPlanApproval(changeDir: string):
|
|
435
|
+
| {
|
|
436
|
+
decision: PlanApprovalDecision;
|
|
437
|
+
via: ApprovalVia;
|
|
438
|
+
at: string;
|
|
439
|
+
}
|
|
440
|
+
| undefined {
|
|
441
|
+
const entries = readPlanReviewLedger(changeDir);
|
|
442
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
443
|
+
const entry = entries[index]!;
|
|
444
|
+
if (entry.type !== "plan_approval" || !entry.decision) continue;
|
|
445
|
+
return {
|
|
446
|
+
decision: entry.decision,
|
|
447
|
+
via: asApprovalVia(entry.via),
|
|
448
|
+
at: entry.at,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side subagent progress projection (task 9.2; spec: 子 agent 的过程进度
|
|
3
|
+
* MUST 经结构化上报(阶段标签 + 百分比,或等效字段),由主会话投影为看板展示).
|
|
4
|
+
*
|
|
5
|
+
* The reporting tools carry an optional structured `progress` field. A child's
|
|
6
|
+
* tool call runs in the child's own session, so the parent does not see it
|
|
7
|
+
* directly; what the parent does see is every report that reaches ITS session —
|
|
8
|
+
* the main agent's own `report_work` (the tick gate requires it in agent mode)
|
|
9
|
+
* and any report relayed up. This observer folds whatever it observes into one
|
|
10
|
+
* row per agent+phase and appends a `progress` flow entry whose renderer draws
|
|
11
|
+
* the board.
|
|
12
|
+
*
|
|
13
|
+
* Progress is display-only: it never gates anything, and a session with no
|
|
14
|
+
* reports appends nothing.
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
ExtensionAPI,
|
|
18
|
+
ExtensionContext,
|
|
19
|
+
ExtensionHandler,
|
|
20
|
+
ToolResultEvent,
|
|
21
|
+
ToolResultEventResult,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
import { appendFlowEntry, OPSX_ENTRY_TYPES } from "./flow-entries.ts";
|
|
25
|
+
import {
|
|
26
|
+
progressUpdateFrom,
|
|
27
|
+
projectProgress,
|
|
28
|
+
type ProgressUpdate,
|
|
29
|
+
} from "./progress-projection.ts";
|
|
30
|
+
|
|
31
|
+
/** The report tools that can carry a structured progress field. */
|
|
32
|
+
const REPORT_TOOL_NAMES = [
|
|
33
|
+
"report_gap_analysis",
|
|
34
|
+
"report_plan_review",
|
|
35
|
+
"report_work",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const updatesBySession = new Map<string, ProgressUpdate[]>();
|
|
39
|
+
|
|
40
|
+
function sessionKey(ctx: ExtensionContext): string {
|
|
41
|
+
try {
|
|
42
|
+
return ctx.sessionManager?.getSessionId() ?? "unknown";
|
|
43
|
+
} catch {
|
|
44
|
+
return "unknown";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the `tool_result` handler that folds report-tool progress into the
|
|
50
|
+
* session's board. Returns a value only when a board entry was appended.
|
|
51
|
+
*/
|
|
52
|
+
export function createProgressObserverHandler(
|
|
53
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
54
|
+
): ExtensionHandler<ToolResultEvent, ToolResultEventResult> {
|
|
55
|
+
return (event, ctx) => {
|
|
56
|
+
if (
|
|
57
|
+
!(REPORT_TOOL_NAMES as readonly string[]).includes(event.toolName) ||
|
|
58
|
+
event.isError
|
|
59
|
+
) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const update = progressUpdateFrom(event.toolName, event.input);
|
|
63
|
+
if (!update) return;
|
|
64
|
+
|
|
65
|
+
const sessionId = sessionKey(ctx);
|
|
66
|
+
const updates = updatesBySession.get(sessionId) ?? [];
|
|
67
|
+
updates.push(update);
|
|
68
|
+
updatesBySession.set(sessionId, updates);
|
|
69
|
+
|
|
70
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.progress, {
|
|
71
|
+
rows: projectProgress(updates),
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerProgressObserver(pi: ExtensionAPI): void {
|
|
77
|
+
pi.on("tool_result", createProgressObserverHandler(pi));
|
|
78
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent progress projection (design D8; openspec change add-pi-openspec-x,
|
|
3
|
+
* task 9.2).
|
|
4
|
+
*
|
|
5
|
+
* The reporting tools carry a phase label and a percentage alongside their
|
|
6
|
+
* structured result. The parent projects those calls into a bounded progress
|
|
7
|
+
* board: repeated updates for the same agent and phase fold into one row
|
|
8
|
+
* (keeping the highest percentage), so a chatty subagent cannot flood the
|
|
9
|
+
* interface. Progress is display-only — it never influences a gate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ProgressUpdate {
|
|
13
|
+
agent: string;
|
|
14
|
+
/** The phase the subagent is in (e.g. "plan compliance"). */
|
|
15
|
+
phase: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** 0–100; clamped on projection. */
|
|
18
|
+
percentage: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProjectedProgress {
|
|
22
|
+
agent: string;
|
|
23
|
+
phase: string;
|
|
24
|
+
label: string;
|
|
25
|
+
percentage: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clampPercentage(value: number): number {
|
|
29
|
+
if (!Number.isFinite(value)) return 0;
|
|
30
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fold a progress stream into one row per agent+phase, in first-seen order.
|
|
35
|
+
* A later update for the same agent+phase replaces the row only when its
|
|
36
|
+
* percentage is at least the current one, so a regression cannot make the
|
|
37
|
+
* board go backwards.
|
|
38
|
+
*/
|
|
39
|
+
export function projectProgress(
|
|
40
|
+
updates: readonly ProgressUpdate[],
|
|
41
|
+
): ProjectedProgress[] {
|
|
42
|
+
const rows = new Map<string, ProjectedProgress>();
|
|
43
|
+
const order: string[] = [];
|
|
44
|
+
for (const update of updates) {
|
|
45
|
+
const key = `${update.agent}\u0000${update.phase}`;
|
|
46
|
+
if (!rows.has(key)) order.push(key);
|
|
47
|
+
const existing = rows.get(key);
|
|
48
|
+
const percentage = clampPercentage(update.percentage);
|
|
49
|
+
if (!existing || percentage >= existing.percentage) {
|
|
50
|
+
rows.set(key, {
|
|
51
|
+
agent: update.agent,
|
|
52
|
+
phase: update.phase,
|
|
53
|
+
label: update.label,
|
|
54
|
+
percentage,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const result: ProjectedProgress[] = [];
|
|
59
|
+
for (const key of order) {
|
|
60
|
+
const row = rows.get(key);
|
|
61
|
+
if (row) result.push(row);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Which opsx agent a reporting tool belongs to. */
|
|
67
|
+
const REPORT_AGENT_BY_TOOL: Record<string, string> = {
|
|
68
|
+
report_gap_analysis: "opsx-gap-analysis",
|
|
69
|
+
report_plan_review: "opsx-plan-review",
|
|
70
|
+
report_work: "opsx-worker",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the structured progress out of a report tool's payload. `undefined`
|
|
75
|
+
* when the report carries none, so a report without progress simply leaves the
|
|
76
|
+
* board unchanged. Structural read on purpose: the parent sees the raw tool
|
|
77
|
+
* input, not a typed schema instance.
|
|
78
|
+
*/
|
|
79
|
+
export function progressUpdateFrom(
|
|
80
|
+
toolName: string,
|
|
81
|
+
input: Record<string, unknown>,
|
|
82
|
+
): ProgressUpdate | undefined {
|
|
83
|
+
const agent = REPORT_AGENT_BY_TOOL[toolName];
|
|
84
|
+
if (!agent) return undefined;
|
|
85
|
+
const progress = input.progress;
|
|
86
|
+
if (typeof progress !== "object" || progress === null) return undefined;
|
|
87
|
+
const { phase, percentage, label } = progress as {
|
|
88
|
+
phase?: unknown;
|
|
89
|
+
percentage?: unknown;
|
|
90
|
+
label?: unknown;
|
|
91
|
+
};
|
|
92
|
+
if (typeof phase !== "string" || phase.trim() === "") return undefined;
|
|
93
|
+
if (typeof percentage !== "number" || !Number.isFinite(percentage)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const text = phase.trim();
|
|
97
|
+
return {
|
|
98
|
+
agent,
|
|
99
|
+
phase: text,
|
|
100
|
+
label:
|
|
101
|
+
typeof label === "string" && label.trim() !== "" ? label.trim() : text,
|
|
102
|
+
percentage,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Render the projected progress as a compact board. */
|
|
107
|
+
export function renderProgressBoard(
|
|
108
|
+
projected: readonly ProjectedProgress[],
|
|
109
|
+
): string {
|
|
110
|
+
if (projected.length === 0) return "opsx progress: (no subagent activity)";
|
|
111
|
+
const lines = ["opsx progress:"];
|
|
112
|
+
for (const row of projected) {
|
|
113
|
+
lines.push(
|
|
114
|
+
` ${row.agent} [${row.phase}] ${row.label} — ${row.percentage}%`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|