@xzzpig/pi-openspec-x 0.1.0 → 0.2.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/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
 
3
6
  import { loadOpsxConfig, type OpsxConfig } from "./config.ts";
4
7
  import {
@@ -15,6 +18,9 @@ import {
15
18
  } from "./dependencies.ts";
16
19
  import { createResourcesDiscoverHandler } from "./discover.ts";
17
20
  import { createOpsxRecoverySessionStartHandler } from "./goal-base.ts";
21
+ import { readGoalBaseSummaries } from "./goal-base.ts";
22
+ import { registerChildReportIngestion } from "./child-report-ingestion.ts";
23
+ import { childReportFlowId } from "./child-report-ingestion.ts";
18
24
  import {
19
25
  registerOpsxAuditorResolver,
20
26
  buildOpsxAuditorOverride,
@@ -27,13 +33,17 @@ import {
27
33
  import {
28
34
  registerImplementFlow,
29
35
  activeImplementFlow,
36
+ rebindImplementFlow,
30
37
  } from "./implement-command.ts";
31
38
  import { createOpsxSessionStartHandler } from "./mode.ts";
32
- import { registerPlanFlow, activePlanSession } from "./plan-command.ts";
39
+ import {
40
+ registerPlanFlow,
41
+ activePlanSession,
42
+ rebindPlanFlow,
43
+ } from "./plan-command.ts";
33
44
  import { registerRollbackFlow } from "./rollback-command.ts";
34
45
  import { registerPlanGateTools } from "./plan-gate.ts";
35
- import { registerProgressObserver } from "./progress-observer.ts";
36
- import { registerReportTools } from "./report-tools.ts";
46
+ import { recordChildReportProgress } from "./progress-observer.ts";
37
47
  import { buildSandboxProfiles } from "./sandbox-profiles.ts";
38
48
 
39
49
  // Read once at extension load (merge semantics in loadOpsxConfig).
@@ -65,8 +75,10 @@ const config: OpsxConfig = loadOpsxConfig();
65
75
  * semantics; see mode.ts).
66
76
  *
67
77
  * Registers the three child reporting tools (report_gap_analysis,
68
- * report_plan_review, report_work) that the opsx subagents submit their
69
- * structured results through.
78
+ * report_plan_review, report_work) through the role-specific child-only
79
+ * extensions declared in agents.ts (design D1): each tool is registered into
80
+ * its own subagent's process and never into the main session, so the parent's
81
+ * tool declaration and prompt rules stay free of them.
70
82
  *
71
83
  * pi-subagents is probed the same lazy way: on success the four opsx
72
84
  * subagents (opsx-gap-analysis / opsx-plan-review / opsx-worker / opsx-reviewer) are
@@ -95,10 +107,12 @@ export default async function piOpenspecX(pi: ExtensionAPI): Promise<void> {
95
107
  }
96
108
  }
97
109
 
98
- registerReportTools(pi);
99
- // Parent-side projection of the subagents' structured progress (task 9.2).
100
- registerProgressObserver(pi);
101
110
  // Progress lines for the flow entries (task 9.1).
111
+ //
112
+ // The progress board is no longer fed by a main-session `tool_result`
113
+ // listener: the report tools are not registered here any more (design D1),
114
+ // so the only honest source is an accepted child report, delivered through
115
+ // the ingestion callback below (task 4.4).
102
116
  registerFlowRenderers(pi);
103
117
  // `/opsx:plan`: planner mode + append-only phase injection (task 7.1).
104
118
  registerPlanFlow(pi);
@@ -170,4 +184,94 @@ export default async function piOpenspecX(pi: ExtensionAPI): Promise<void> {
170
184
  void disposeSessionStart;
171
185
  void disposeRecoverySessionStart;
172
186
  void disposeStatusSnapshot;
187
+
188
+ // Flow recovery (design D5): the flow registries are process memory, so a
189
+ // cold start leaves them empty while the branch still holds the flow's own
190
+ // entries. Re-bind on every session boundary and, as a lazy fallback, on the
191
+ // first tool call of a session that missed them — otherwise a restored
192
+ // session would silently skip the completion gate.
193
+ const rebindKeys = new Set<string>();
194
+ const rebindFlows = async (ctx: ExtensionContext): Promise<void> => {
195
+ const sessionId = ctx.sessionManager.getSessionId();
196
+ const key = `${sessionId}:${ctx.sessionManager.getLeafId() ?? "?"}`;
197
+ if (rebindKeys.has(key)) return;
198
+ rebindKeys.add(key);
199
+ try {
200
+ rebindPlanFlow(sessionId, ctx);
201
+ } catch {
202
+ // Recovery is best-effort; an unbound flow stays unbound rather than
203
+ // being bound to the wrong thing.
204
+ }
205
+ try {
206
+ const result = await rebindImplementFlow(ctx, {
207
+ readGoals: (innerCtx: ExtensionContext) =>
208
+ readGoalBaseSummaries({
209
+ cwd: innerCtx.cwd,
210
+ sessionManager: innerCtx.sessionManager,
211
+ }),
212
+ });
213
+ if (!result.ok) {
214
+ pi.sendMessage(
215
+ {
216
+ customType: "pi-openspec-x/recovery-notice",
217
+ content: result.reason,
218
+ display: true,
219
+ },
220
+ { deliverAs: "followUp" },
221
+ );
222
+ }
223
+ } catch {
224
+ // Best-effort: never break a session boundary.
225
+ }
226
+ };
227
+ const disposeRebindStart = pi.on(
228
+ "session_start",
229
+ (_event, ctx: ExtensionContext) => rebindFlows(ctx),
230
+ );
231
+ const disposeRebindSwitch = pi.on(
232
+ "session_before_switch",
233
+ (_event, ctx: ExtensionContext) => rebindFlows(ctx),
234
+ );
235
+ const disposeRebindTree = pi.on(
236
+ "session_tree",
237
+ (_event, ctx: ExtensionContext) => rebindFlows(ctx),
238
+ );
239
+ const disposeRebindToolCall = pi.on(
240
+ "tool_call",
241
+ (_event, ctx: ExtensionContext) => rebindFlows(ctx),
242
+ );
243
+ void disposeRebindStart;
244
+ void disposeRebindSwitch;
245
+ void disposeRebindTree;
246
+ void disposeRebindToolCall;
247
+
248
+ // Automatic child-report ingestion (design D3): the report tools live only in
249
+ // their own child processes, so the parent reconstructs delivery from its own
250
+ // launch records plus the child's canonical session record.
251
+ registerChildReportIngestion(pi, {
252
+ activeFlow: (sessionId) => {
253
+ const flow = activeImplementFlow(sessionId);
254
+ if (!flow) return undefined;
255
+ return {
256
+ flowId: childReportFlowId(sessionId, flow.changeId),
257
+ changeId: flow.changeId,
258
+ goalId: flow.goalId,
259
+ };
260
+ },
261
+ // Display-only projection of an accepted report's structured progress.
262
+ // It never gates, completes, or approves anything (task 4.4).
263
+ onProgress: ({ sessionId, toolName, report }) => {
264
+ recordChildReportProgress(pi, sessionId, { toolName, report });
265
+ },
266
+ // A durable record that cannot be confirmed is a real gap — the receipt
267
+ // will not survive a restart. Surfacing it keeps a silent persistence
268
+ // failure from looking like persistence was never wired (design D5).
269
+ onDiagnostic: (message) => {
270
+ pi.sendMessage({
271
+ customType: "pi-openspec-x/recovery-notice",
272
+ content: `[OPSX CHILD REPORT] ${message}`,
273
+ display: true,
274
+ });
275
+ },
276
+ });
173
277
  }
package/src/objective.ts CHANGED
@@ -125,14 +125,14 @@ function agentBoundaries(writable: readonly string[]): string[] {
125
125
  "- openspec/ plan files (tasks.md, proposal.md, specs/, design.md) are read-only for opsx-worker; only the main agent ticks tasks.md, after review.",
126
126
  "",
127
127
  "Worker dispatch contract (task 8.2): every opsx-worker dispatch carries these six sections, in this order:",
128
- "1. Task — the task id and title.",
128
+ "1. Task — the task id and title, and on its own line the exact marker `[OPSX WORKER DISPATCH task=<taskId>]` with the task id percent-encoded. Every dispatch, and every resume of it, must carry the marker: it is what attributes the worker's report to the task.",
129
129
  "2. Expected output — the concrete files/behavior the dispatch must produce.",
130
130
  "3. Tools — the tools the worker needs for this task.",
131
131
  "4. Must do — the task-specific obligations.",
132
132
  "5. Must not do — the task-specific prohibitions, plus:",
133
133
  ...STANDARD_WORKER_MUST_NOT_DO.map((rule) => ` - ${rule}`),
134
134
  "6. Context — the files/behavior/constraints the worker needs to know.",
135
- "After the dispatch: review its full diff and its verification output, then call report_work with taskId, changedFiles and the verification command/result. Ticking a task before that report exists is blocked by the tick gate, and a report that claims files the dispatch did not change (or omits files it did) is held for reconciliation.",
135
+ "After the dispatch: the worker's own report is recorded automatically from its run, so never transcribe or re-submit it yourself and do not look for a tool to file it with. Review the full diff and the verification output against that report, then tick the task. Ticking a task whose dispatch produced no accepted report is blocked by the gate, and a report that claims files the dispatch did not change (or omits files it did) is held for reconciliation.",
136
136
  "",
137
137
  "Don'ts:",
138
138
  "- Do not edit source files directly, not even for a one-line fix; dispatch opsx-worker.",
@@ -100,6 +100,52 @@ export function endPlanFlow(sessionId: string): string | undefined {
100
100
  return session.changeId;
101
101
  }
102
102
 
103
+ /**
104
+ * Restore a plan binding from the current branch (design D5).
105
+ *
106
+ * Read-only: it re-establishes which plan flow this session is in so the
107
+ * phase injection and status snapshots keep working after a restart. It never
108
+ * re-runs the command, re-approves a plan, or changes a mode.
109
+ */
110
+ export function rebindPlanFlow(
111
+ sessionId: string,
112
+ ctx: { sessionManager?: { getBranch(): readonly unknown[] } },
113
+ ): { changeId: string } | undefined {
114
+ const existing = planSessions.get(sessionId);
115
+ if (existing) return { changeId: existing.changeId };
116
+
117
+ let branch: Array<{ customType?: string; data?: unknown }> = [];
118
+ try {
119
+ branch = (ctx.sessionManager?.getBranch() ?? []) as typeof branch;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ let changeId: string | undefined;
124
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
125
+ const entry = branch[index];
126
+ if (entry?.customType === OPSX_ENTRY_TYPES.modeExited) break;
127
+ if (entry?.customType !== OPSX_ENTRY_TYPES.modeEntered) continue;
128
+ const data = entry.data as
129
+ | { changeId?: unknown; mode?: unknown }
130
+ | undefined;
131
+ if (data?.mode === "plan" && typeof data.changeId === "string") {
132
+ changeId = data.changeId;
133
+ }
134
+ break;
135
+ }
136
+ if (!changeId) return undefined;
137
+
138
+ planSessions.set(sessionId, {
139
+ changeId,
140
+ injected: new Set<string>(),
141
+ // Suppress the one-shot notices for a recovered flow: the user already saw
142
+ // them before the restart, and re-sending them would be noise.
143
+ degradedNotified: true,
144
+ reviewNotified: true,
145
+ });
146
+ return { changeId };
147
+ }
148
+
103
149
  function defaultFetchStatus(
104
150
  changeId: string,
105
151
  options: OpenspecCliOptions,
@@ -3,76 +3,70 @@
3
3
  * MUST 经结构化上报(阶段标签 + 百分比,或等效字段),由主会话投影为看板展示).
4
4
  *
5
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.
6
+ * tool call runs in the child's own session, so the parent never sees the call
7
+ * itself — and since the report tools are no longer registered in the main
8
+ * session at all (design D1), the parent cannot observe them as tool results
9
+ * either. Progress therefore arrives from the ingestion layer, which is the one
10
+ * component that holds an accepted child report (design D3).
12
11
  *
13
- * Progress is display-only: it never gates anything, and a session with no
14
- * reports appends nothing.
12
+ * This module only folds those accepted updates into one row per agent+phase
13
+ * and appends a `progress` flow entry whose renderer draws the board.
14
+ *
15
+ * Progress is display-only: it never gates anything, it never completes a task,
16
+ * and a session with no reports appends nothing.
15
17
  */
16
- import type {
17
- ExtensionAPI,
18
- ExtensionContext,
19
- ExtensionHandler,
20
- ToolResultEvent,
21
- ToolResultEventResult,
22
- } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
19
 
24
20
  import { appendFlowEntry, OPSX_ENTRY_TYPES } from "./flow-entries.ts";
25
21
  import {
26
22
  progressUpdateFrom,
27
23
  projectProgress,
28
- type ProgressUpdate,
24
+ type ProjectedProgress,
29
25
  } from "./progress-projection.ts";
30
26
 
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;
27
+ /**
28
+ * The projected board per session.
29
+ *
30
+ * Only the folded rows are retained — one per agent+phase — so a chatty
31
+ * subagent cannot grow the parent's memory without bound (design D4).
32
+ */
33
+ const boardsBySession = new Map<string, ProjectedProgress[]>();
37
34
 
38
- const updatesBySession = new Map<string, ProgressUpdate[]>();
35
+ /** Reset the per-session progress board. Test-only. */
36
+ export function resetProgressObserverForTests(): void {
37
+ boardsBySession.clear();
38
+ }
39
39
 
40
- function sessionKey(ctx: ExtensionContext): string {
41
- try {
42
- return ctx.sessionManager?.getSessionId() ?? "unknown";
43
- } catch {
44
- return "unknown";
45
- }
40
+ /** The current board for a session. Test-observable. */
41
+ export function progressBoardFor(sessionId: string): ProjectedProgress[] {
42
+ return boardsBySession.get(sessionId) ?? [];
46
43
  }
47
44
 
48
45
  /**
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.
46
+ * Record the structured progress carried by an accepted child report.
47
+ *
48
+ * Returns whether a board entry was appended. A report without progress leaves
49
+ * the board untouched, and a repeated update folds into the existing row rather
50
+ * than appending a second entry, so duplicates never reach the interface.
51
+ *
52
+ * The caller is the ingestion layer, which only ever passes a report that
53
+ * already reached a successful terminal state with a matching attempt nonce —
54
+ * so a failed run or a stale nonce is never projected.
51
55
  */
52
- export function createProgressObserverHandler(
56
+ export function recordChildReportProgress(
53
57
  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;
58
+ sessionId: string,
59
+ input: { toolName: string; report: Record<string, unknown> },
60
+ ): boolean {
61
+ const update = progressUpdateFrom(input.toolName, input.report);
62
+ if (!update) return false;
64
63
 
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
- }
64
+ const current = boardsBySession.get(sessionId) ?? [];
65
+ const next = projectProgress([...current, update]);
66
+ const changed = JSON.stringify(next) !== JSON.stringify(current);
67
+ boardsBySession.set(sessionId, next);
68
+ if (!changed) return false;
75
69
 
76
- export function registerProgressObserver(pi: ExtensionAPI): void {
77
- pi.on("tool_result", createProgressObserverHandler(pi));
70
+ appendFlowEntry(pi, OPSX_ENTRY_TYPES.progress, { rows: next });
71
+ return true;
78
72
  }
@@ -23,7 +23,7 @@
23
23
  * number of times; projection of the calls is a later concern.
24
24
  */
25
25
  import { Type } from "@earendil-works/pi-ai";
26
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { defineTool } from "@earendil-works/pi-coding-agent";
27
27
 
28
28
  export const REPORT_GAP_ANALYSIS_TOOL_NAME = "report_gap_analysis";
29
29
  export const REPORT_PLAN_REVIEW_TOOL_NAME = "report_plan_review";
@@ -304,14 +304,15 @@ export const reportWorkTool = defineTool({
304
304
  },
305
305
  });
306
306
 
307
- /** The three reporting tools, in registration order. */
307
+ /**
308
+ * The three reporting tools, in registration order.
309
+ *
310
+ * These are pure definitions: nothing here registers them into a session.
311
+ * Each role's child-only extension (design D1,
312
+ * `src/child-report-extensions/<role>.ts`) wraps exactly one of them with the
313
+ * attempt metadata envelope and registers that. The main session registers
314
+ * none of them (design D1, spec "结构化裁决上报").
315
+ */
308
316
  export function createReportTools() {
309
317
  return [reportGapAnalysisTool, reportPlanReviewTool, reportWorkTool];
310
318
  }
311
-
312
- /** Register the three reporting tools into the running pi process. */
313
- export function registerReportTools(pi: ExtensionAPI): void {
314
- for (const tool of createReportTools()) {
315
- pi.registerTool(tool);
316
- }
317
- }
@@ -62,7 +62,7 @@ function normalize(filePath: string): string {
62
62
 
63
63
  /**
64
64
  * Decide whether a task may be ticked. Holds (never ticks) when:
65
- * - no report was submitted;
65
+ * - no accepted worker report was recorded;
66
66
  * - the report claims files the diff does not contain (phantom work);
67
67
  * - the diff contains files the report omitted (unclaimed work);
68
68
  * - the verification did not run or did not pass.
@@ -77,7 +77,7 @@ export function decideTaskTick(
77
77
  kind: "hold",
78
78
  taskId,
79
79
  reason:
80
- "No report_work was submitted; review the dispatch output before ticking.",
80
+ "No accepted worker report was recorded for this dispatch; let the worker run to completion, then tick.",
81
81
  };
82
82
  }
83
83