@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
package/src/objective.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objective assembler for the `/opsx:implement` execution base (design D10;
|
|
3
|
+
* openspec change add-pi-openspec-x, task 6.2).
|
|
4
|
+
*
|
|
5
|
+
* The implementation flow runs on top of a persistent pi-goal-x goal, and the
|
|
6
|
+
* goal's objective is where the opsx orchestration contract lives. This module
|
|
7
|
+
* turns the authoritative `tasks.md` into:
|
|
8
|
+
*
|
|
9
|
+
* - the objective text handed to goal-x's `create_goal`: a `Steps` section (the
|
|
10
|
+
* task list), a `Boundaries`/`Don'ts` section (the read-only boundary and the
|
|
11
|
+
* flow discipline), and the single `Verification contract:` line goal-x
|
|
12
|
+
* extracts;
|
|
13
|
+
* - the flat task list handed to goal-x's `set_goal_tasks` (mirror of the same
|
|
14
|
+
* tasks, ids preserved 1:1 so the bidirectional sync in task 6.3 can follow);
|
|
15
|
+
* - the task count gate: `set_goal_tasks` accepts at most 50 nodes, so a
|
|
16
|
+
* larger tasks.md is refused with guidance instead of silently truncated.
|
|
17
|
+
*
|
|
18
|
+
* Flow discipline is written into the objective text on purpose (design D10:
|
|
19
|
+
* "策略调校同受流程隔离约束"). Global goal policy settings are never touched
|
|
20
|
+
* — a global switch would change every plain `/goal`, and the objective is the
|
|
21
|
+
* per-goal carrier that cannot leak.
|
|
22
|
+
*
|
|
23
|
+
* The assembler is pure: it reads the tasks.md string it is given and returns
|
|
24
|
+
* data. Callers own file reading and goal-tool invocation.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
duplicateTaskIds,
|
|
28
|
+
parseTasksMarkdown,
|
|
29
|
+
type OpsxTask,
|
|
30
|
+
} from "./tasks-md.ts";
|
|
31
|
+
|
|
32
|
+
/** `set_goal_tasks` hard limit (goal-task-tools.ts MAX_TASKS). */
|
|
33
|
+
export const MAX_GOAL_TASKS = 50;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The objective's `Verification contract:` value. goal-x extracts this line
|
|
37
|
+
* into the goal's own verificationContract, and the completion audit reads it.
|
|
38
|
+
*/
|
|
39
|
+
export const OPSX_VERIFICATION_CONTRACT =
|
|
40
|
+
"Implementation completion requires the goal completion audit to APPROVE; a fully ticked tasks.md is not completion.";
|
|
41
|
+
|
|
42
|
+
/** Which implementation mode the objective describes. */
|
|
43
|
+
export type OpsxImplementationMode = "agent" | "direct";
|
|
44
|
+
|
|
45
|
+
/** The read-only boundary the objective encodes, per implementation mode. */
|
|
46
|
+
export interface OpsxBoundaries {
|
|
47
|
+
mode: OpsxImplementationMode;
|
|
48
|
+
/**
|
|
49
|
+
* Write allowlist globs for the agent sandbox (design D4). Ignored in
|
|
50
|
+
* direct mode, where the main session keeps normal permissions.
|
|
51
|
+
*/
|
|
52
|
+
writable: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AssembleObjectiveInput {
|
|
56
|
+
/** The openspec change id the goal implements. */
|
|
57
|
+
changeId: string;
|
|
58
|
+
/** Raw tasks.md content; `undefined` when the file does not exist. */
|
|
59
|
+
tasksMarkdown: string | undefined;
|
|
60
|
+
boundaries: OpsxBoundaries;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One `set_goal_tasks` entry (flat, parent-linked shape; all roots here). */
|
|
64
|
+
export interface FlatGoalTaskInput {
|
|
65
|
+
id: string;
|
|
66
|
+
title: string;
|
|
67
|
+
verification_contract?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Why the assembler refused to build an objective. */
|
|
71
|
+
export type ObjectiveAssemblyFailureCode =
|
|
72
|
+
| "no_tasks_file"
|
|
73
|
+
| "no_tasks"
|
|
74
|
+
| "too_many_tasks"
|
|
75
|
+
| "duplicate_ids";
|
|
76
|
+
|
|
77
|
+
export type ObjectiveAssembly =
|
|
78
|
+
| {
|
|
79
|
+
ok: true;
|
|
80
|
+
objective: string;
|
|
81
|
+
tasks: OpsxTask[];
|
|
82
|
+
/** The flat task list to mirror through `set_goal_tasks`. */
|
|
83
|
+
goalTasks: FlatGoalTaskInput[];
|
|
84
|
+
verificationContract: string;
|
|
85
|
+
/**
|
|
86
|
+
* The `create_goal` invocation the main agent makes to start the flow:
|
|
87
|
+
* sisyphus in agent mode (patient ordered execution), a regular
|
|
88
|
+
* autoContinue goal in direct mode (task 6.4).
|
|
89
|
+
*/
|
|
90
|
+
goalStart: { objective: string; mode: "regular" | "sisyphus" };
|
|
91
|
+
}
|
|
92
|
+
| {
|
|
93
|
+
ok: false;
|
|
94
|
+
code: ObjectiveAssemblyFailureCode;
|
|
95
|
+
message: string;
|
|
96
|
+
guidance: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the flat goal task list from the parsed tasks. Ids are preserved
|
|
101
|
+
* verbatim (the sync key), and the per-task verification contract travels
|
|
102
|
+
* along so the goal completion gate can demand evidence.
|
|
103
|
+
*/
|
|
104
|
+
export function toFlatGoalTasks(
|
|
105
|
+
tasks: readonly OpsxTask[],
|
|
106
|
+
): FlatGoalTaskInput[] {
|
|
107
|
+
return tasks.map((task) => ({
|
|
108
|
+
id: task.id,
|
|
109
|
+
title: task.title,
|
|
110
|
+
...(task.verificationContract
|
|
111
|
+
? { verification_contract: task.verificationContract }
|
|
112
|
+
: {}),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
import { STANDARD_WORKER_MUST_NOT_DO } from "./worker-dispatch.ts";
|
|
117
|
+
|
|
118
|
+
function agentBoundaries(writable: readonly string[]): string[] {
|
|
119
|
+
const writableText = writable.length > 0 ? writable.join(", ") : "(none)";
|
|
120
|
+
return [
|
|
121
|
+
"Boundaries:",
|
|
122
|
+
`- Writable: ${writableText}. Nothing else is writable.`,
|
|
123
|
+
"- Everything else, including the source tree, is read-only.",
|
|
124
|
+
"- Source changes happen only through an opsx-worker dispatch; the main agent never edits a source file itself.",
|
|
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
|
+
"",
|
|
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.",
|
|
129
|
+
"2. Expected output — the concrete files/behavior the dispatch must produce.",
|
|
130
|
+
"3. Tools — the tools the worker needs for this task.",
|
|
131
|
+
"4. Must do — the task-specific obligations.",
|
|
132
|
+
"5. Must not do — the task-specific prohibitions, plus:",
|
|
133
|
+
...STANDARD_WORKER_MUST_NOT_DO.map((rule) => ` - ${rule}`),
|
|
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.",
|
|
136
|
+
"",
|
|
137
|
+
"Don'ts:",
|
|
138
|
+
"- Do not edit source files directly, not even for a one-line fix; dispatch opsx-worker.",
|
|
139
|
+
"- Do not tick a task in tasks.md before reviewing the worker's full diff and running its verification.",
|
|
140
|
+
"- Do not idle-wait between tasks; after a reviewed dispatch, immediately start the next pending task.",
|
|
141
|
+
"- Do not change global goal policy settings; the flow discipline lives in this objective.",
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function directBoundaries(): string[] {
|
|
146
|
+
return [
|
|
147
|
+
"Boundaries:",
|
|
148
|
+
"- The main session implements directly: no role switch and no sandbox restriction.",
|
|
149
|
+
"- tasks.md is the task authority and openspec/ is the plan contract.",
|
|
150
|
+
"- The completion audit still gates completion: every task being ticked is not completion.",
|
|
151
|
+
"",
|
|
152
|
+
"Don'ts:",
|
|
153
|
+
"- Do not skip a task's stated verification.",
|
|
154
|
+
"- Do not tick a task in tasks.md before its verification passes.",
|
|
155
|
+
"- Do not idle-wait between tasks; after finishing one, immediately start the next pending task.",
|
|
156
|
+
"- Do not change global goal policy settings; the flow discipline lives in this objective.",
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Assemble the objective text and goal task mirror for one implementation
|
|
162
|
+
* flow. Refuses (never truncates) when there is no usable task list:
|
|
163
|
+
*
|
|
164
|
+
* - no tasks.md → run `/opsx:plan` first (or point at the change's tasks.md);
|
|
165
|
+
* - no tracked task → the change has nothing to implement;
|
|
166
|
+
* - more than {@link MAX_GOAL_TASKS} tasks → split the change, because
|
|
167
|
+
* `set_goal_tasks` cannot mirror a longer list;
|
|
168
|
+
* - duplicate ids → the mirror would silently drop or collide tasks.
|
|
169
|
+
*/
|
|
170
|
+
export function assembleOpsxObjective(
|
|
171
|
+
input: AssembleObjectiveInput,
|
|
172
|
+
): ObjectiveAssembly {
|
|
173
|
+
if (input.tasksMarkdown === undefined) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
code: "no_tasks_file",
|
|
177
|
+
message: `No tasks.md was found for change "${input.changeId}".`,
|
|
178
|
+
guidance:
|
|
179
|
+
"Run /opsx:plan to produce the change plan (proposal/specs/design/tasks) first, then retry /opsx:implement.",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const tasks = parseTasksMarkdown(input.tasksMarkdown);
|
|
184
|
+
if (tasks.length === 0) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
code: "no_tasks",
|
|
188
|
+
message: `tasks.md for change "${input.changeId}" contains no tracked tasks.`,
|
|
189
|
+
guidance:
|
|
190
|
+
"tasks.md must list checkbox tasks (`- [ ] 1.1 ...`); add them (or re-run /opsx:plan) before implementing.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const duplicates = duplicateTaskIds(tasks);
|
|
195
|
+
if (duplicates.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: "duplicate_ids",
|
|
199
|
+
message: `tasks.md for change "${input.changeId}" repeats task id(s): ${duplicates.join(", ")}.`,
|
|
200
|
+
guidance:
|
|
201
|
+
"Task ids must be unique because the goal task mirror is keyed by id; fix the duplicate ids in tasks.md and retry.",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (tasks.length > MAX_GOAL_TASKS) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
code: "too_many_tasks",
|
|
209
|
+
message: `tasks.md for change "${input.changeId}" has ${tasks.length} tasks; set_goal_tasks accepts at most ${MAX_GOAL_TASKS}.`,
|
|
210
|
+
guidance:
|
|
211
|
+
"Split the change into smaller changes (or merge trivial tasks) so tasks.md stays within the limit, then retry /opsx:implement.",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const lines: string[] = [
|
|
216
|
+
`Implement the OpenSpec change "${input.changeId}".`,
|
|
217
|
+
"",
|
|
218
|
+
"Steps:",
|
|
219
|
+
];
|
|
220
|
+
tasks.forEach((task, index) => {
|
|
221
|
+
const contract = task.verificationContract
|
|
222
|
+
? ` — verification: ${task.verificationContract}`
|
|
223
|
+
: "";
|
|
224
|
+
lines.push(`${index + 1}. [${task.id}] ${task.title}${contract}`);
|
|
225
|
+
});
|
|
226
|
+
lines.push("");
|
|
227
|
+
lines.push(
|
|
228
|
+
...(input.boundaries.mode === "agent"
|
|
229
|
+
? agentBoundaries(input.boundaries.writable)
|
|
230
|
+
: directBoundaries()),
|
|
231
|
+
);
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push(`Verification contract: ${OPSX_VERIFICATION_CONTRACT}`);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
objective: lines.join("\n"),
|
|
238
|
+
tasks,
|
|
239
|
+
goalTasks: toFlatGoalTasks(tasks),
|
|
240
|
+
verificationContract: OPSX_VERIFICATION_CONTRACT,
|
|
241
|
+
goalStart: {
|
|
242
|
+
objective: lines.join("\n"),
|
|
243
|
+
mode: input.boundaries.mode === "agent" ? "sisyphus" : "regular",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/opsx:plan` command and turn_end injection wiring (design D5; openspec
|
|
3
|
+
* change add-pi-openspec-x, task 7.1).
|
|
4
|
+
*
|
|
5
|
+
* Entering the plan mode appends one mode-contract message (never a system
|
|
6
|
+
* prompt rewrite), and each turn_end appends at most one new phase block
|
|
7
|
+
* through the BoundaryResult `entries` channel — the append-only discipline
|
|
8
|
+
* that keeps the prompt-prefix cache intact. Skill registration is untouched.
|
|
9
|
+
*
|
|
10
|
+
* All CLI access is injectable: the default fetchers run the real `openspec
|
|
11
|
+
* status` / `openspec instructions` commands, while tests supply data or
|
|
12
|
+
* failures. A CLI failure degrades to the built-in skeleton and a notice; it
|
|
13
|
+
* never stops the flow.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
ExtensionAPI,
|
|
17
|
+
ExtensionCommandContext,
|
|
18
|
+
ExtensionContext,
|
|
19
|
+
ExtensionHandler,
|
|
20
|
+
TurnEndEvent,
|
|
21
|
+
TurnEndEventResult,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
getOpenspecArtifactInstructions,
|
|
26
|
+
runOpenspecJson,
|
|
27
|
+
type OpenspecArtifactInstructions,
|
|
28
|
+
type OpenspecCliOptions,
|
|
29
|
+
} from "./cli.ts";
|
|
30
|
+
import { describeError } from "./dependencies.ts";
|
|
31
|
+
import { sendNotice, sessionKey } from "./command-util.ts";
|
|
32
|
+
import { appendFlowEntry, OPSX_ENTRY_TYPES } from "./flow-entries.ts";
|
|
33
|
+
import { publishLifecycle, type EventEmitterLike } from "./lifecycle.ts";
|
|
34
|
+
import { enterOpsxMode } from "./mode.ts";
|
|
35
|
+
import {
|
|
36
|
+
nextPlanPhase,
|
|
37
|
+
renderPlanModeContract,
|
|
38
|
+
renderPlanPhaseBlock,
|
|
39
|
+
type PlanStatus,
|
|
40
|
+
} from "./plan-flow.ts";
|
|
41
|
+
|
|
42
|
+
/** customType of the appended mode-contract message. */
|
|
43
|
+
export const PLAN_MODE_CONTRACT_CUSTOM_TYPE =
|
|
44
|
+
"pi-openspec-x/plan-mode-contract";
|
|
45
|
+
/** customType of an appended phase instruction block. */
|
|
46
|
+
export const PLAN_PHASE_CUSTOM_TYPE = "pi-openspec-x/plan-phase";
|
|
47
|
+
/** customType of a plan-flow notice. */
|
|
48
|
+
export const PLAN_NOTICE_CUSTOM_TYPE = "pi-openspec-x/plan-notice";
|
|
49
|
+
|
|
50
|
+
export interface PlanFlowDeps {
|
|
51
|
+
/** CLI options (env/cwd/spawn) forwarded to the default fetchers. */
|
|
52
|
+
cliOptions?: OpenspecCliOptions;
|
|
53
|
+
/** Injectable status fetcher; defaults to `openspec status --json`. */
|
|
54
|
+
fetchStatus?: (changeId: string) => PlanStatus;
|
|
55
|
+
/** Injectable instructions fetcher; defaults to `openspec instructions`. */
|
|
56
|
+
fetchInstructions?: (
|
|
57
|
+
artifact: string,
|
|
58
|
+
changeId: string,
|
|
59
|
+
) => OpenspecArtifactInstructions | undefined;
|
|
60
|
+
/** Injectable restricted-mode entry; defaults to the planner sandbox mode. */
|
|
61
|
+
enterMode?: (
|
|
62
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
63
|
+
ctx: ExtensionCommandContext,
|
|
64
|
+
) => Promise<void>;
|
|
65
|
+
/** Lifecycle bus; defaults to `pi.events` at call time. */
|
|
66
|
+
events?: EventEmitterLike;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PlanSession {
|
|
70
|
+
changeId: string;
|
|
71
|
+
/** Artifacts whose block was already appended (once per phase). */
|
|
72
|
+
injected: Set<string>;
|
|
73
|
+
degradedNotified: boolean;
|
|
74
|
+
reviewNotified: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const planSessions = new Map<string, PlanSession>();
|
|
78
|
+
|
|
79
|
+
/** Reset the per-session plan-flow state. Test-only. */
|
|
80
|
+
export function resetPlanFlowStateForTests(): void {
|
|
81
|
+
planSessions.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The active plan session for a session id (status snapshot wiring). */
|
|
85
|
+
export function activePlanSession(
|
|
86
|
+
sessionId: string,
|
|
87
|
+
): { changeId: string } | undefined {
|
|
88
|
+
const session = planSessions.get(sessionId);
|
|
89
|
+
return session ? { changeId: session.changeId } : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* End the plan session because the implementation flow takes over. Returns
|
|
94
|
+
* the change id of the ended session, or undefined when none was active.
|
|
95
|
+
*/
|
|
96
|
+
export function endPlanFlow(sessionId: string): string | undefined {
|
|
97
|
+
const session = planSessions.get(sessionId);
|
|
98
|
+
if (!session) return undefined;
|
|
99
|
+
planSessions.delete(sessionId);
|
|
100
|
+
return session.changeId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defaultFetchStatus(
|
|
104
|
+
changeId: string,
|
|
105
|
+
options: OpenspecCliOptions,
|
|
106
|
+
): PlanStatus {
|
|
107
|
+
return runOpenspecJson<PlanStatus>(
|
|
108
|
+
["status", "--change", changeId, "--json"],
|
|
109
|
+
options,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function defaultFetchInstructions(
|
|
114
|
+
artifact: string,
|
|
115
|
+
changeId: string,
|
|
116
|
+
options: OpenspecCliOptions,
|
|
117
|
+
): OpenspecArtifactInstructions | undefined {
|
|
118
|
+
try {
|
|
119
|
+
return getOpenspecArtifactInstructions(artifact, changeId, options);
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the turn_end handler that appends the next phase block. Returns an
|
|
127
|
+
* empty result while the change is unchanged, the block once per artifact,
|
|
128
|
+
* and `continue: true` so the agent picks the block up in the same run.
|
|
129
|
+
*/
|
|
130
|
+
function createPlanTurnEndHandler(
|
|
131
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
132
|
+
deps: Required<Pick<PlanFlowDeps, "fetchStatus" | "fetchInstructions">>,
|
|
133
|
+
): ExtensionHandler<TurnEndEvent, TurnEndEventResult> {
|
|
134
|
+
return (
|
|
135
|
+
_event: TurnEndEvent,
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
): TurnEndEventResult | void => {
|
|
138
|
+
const session = planSessions.get(sessionKey(ctx));
|
|
139
|
+
if (!session) return;
|
|
140
|
+
|
|
141
|
+
let status: PlanStatus;
|
|
142
|
+
try {
|
|
143
|
+
status = deps.fetchStatus(session.changeId);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
sendNotice(
|
|
146
|
+
pi,
|
|
147
|
+
PLAN_NOTICE_CUSTOM_TYPE,
|
|
148
|
+
`Could not read the change status for "${session.changeId}": ${describeError(error)}. Fix the CLI/change id, then continue the plan flow.`,
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const phase = nextPlanPhase(status);
|
|
154
|
+
if (phase.kind === "review") {
|
|
155
|
+
if (!session.reviewNotified) {
|
|
156
|
+
session.reviewNotified = true;
|
|
157
|
+
sendNotice(
|
|
158
|
+
pi,
|
|
159
|
+
PLAN_NOTICE_CUSTOM_TYPE,
|
|
160
|
+
`Every artifact for change "${session.changeId}" is done. Dispatch the read-only opsx-plan-review plan reviewer and record each verdict with the opsx_plan_verdict tool; it applies the review-loop policy and opens the user approval dialog on OKAY.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (session.injected.has(phase.artifact)) return;
|
|
167
|
+
const instructions = deps.fetchInstructions(
|
|
168
|
+
phase.artifact,
|
|
169
|
+
session.changeId,
|
|
170
|
+
);
|
|
171
|
+
const block = renderPlanPhaseBlock(phase.artifact, instructions);
|
|
172
|
+
session.injected.add(phase.artifact);
|
|
173
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.phaseChanged, {
|
|
174
|
+
changeId: session.changeId,
|
|
175
|
+
phase: phase.artifact,
|
|
176
|
+
});
|
|
177
|
+
publishLifecycle(pi.events, {
|
|
178
|
+
type: "phase_changed",
|
|
179
|
+
change: session.changeId,
|
|
180
|
+
mode: "plan",
|
|
181
|
+
phase: phase.artifact,
|
|
182
|
+
});
|
|
183
|
+
if (block.degraded && !session.degradedNotified) {
|
|
184
|
+
session.degradedNotified = true;
|
|
185
|
+
sendNotice(
|
|
186
|
+
pi,
|
|
187
|
+
PLAN_NOTICE_CUSTOM_TYPE,
|
|
188
|
+
`Live instructions for artifact "${phase.artifact}" were unavailable; the built-in skeleton was injected instead. Re-run \`openspec instructions ${phase.artifact} --change "${session.changeId}" --json\` when the CLI answers.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
entries: [
|
|
193
|
+
{
|
|
194
|
+
type: "custom_message",
|
|
195
|
+
customType: PLAN_PHASE_CUSTOM_TYPE,
|
|
196
|
+
content: block.content,
|
|
197
|
+
display: true,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
continue: true,
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Register the `/opsx:plan` command and the turn_end injection handler. The
|
|
207
|
+
* command enters the restricted planner mode (fail-closed) before appending
|
|
208
|
+
* the mode contract.
|
|
209
|
+
*/
|
|
210
|
+
export function registerPlanFlow(
|
|
211
|
+
pi: ExtensionAPI,
|
|
212
|
+
deps: PlanFlowDeps = {},
|
|
213
|
+
): void {
|
|
214
|
+
const cliOptions = deps.cliOptions ?? {};
|
|
215
|
+
const fetchStatus =
|
|
216
|
+
deps.fetchStatus ??
|
|
217
|
+
((changeId: string) => defaultFetchStatus(changeId, cliOptions));
|
|
218
|
+
const fetchInstructions =
|
|
219
|
+
deps.fetchInstructions ??
|
|
220
|
+
((artifact: string, changeId: string) =>
|
|
221
|
+
defaultFetchInstructions(artifact, changeId, cliOptions));
|
|
222
|
+
const enterMode =
|
|
223
|
+
deps.enterMode ??
|
|
224
|
+
((
|
|
225
|
+
target: Pick<ExtensionAPI, "appendEntry">,
|
|
226
|
+
ctx: ExtensionCommandContext,
|
|
227
|
+
) =>
|
|
228
|
+
enterOpsxMode(target as ExtensionAPI, "planner", {
|
|
229
|
+
sessionId: () => ctx.sessionManager.getSessionId(),
|
|
230
|
+
}).then(() => undefined));
|
|
231
|
+
|
|
232
|
+
pi.registerCommand("opsx:plan", {
|
|
233
|
+
description:
|
|
234
|
+
"Run the enhanced OpenSpec planning flow for a change (planner mode, gap-analysis / plan-review gates, user approval).",
|
|
235
|
+
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
236
|
+
const changeId = args.trim();
|
|
237
|
+
if (!changeId) {
|
|
238
|
+
sendNotice(
|
|
239
|
+
pi,
|
|
240
|
+
PLAN_NOTICE_CUSTOM_TYPE,
|
|
241
|
+
"Usage: /opsx:plan <change-id>",
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
await enterMode(pi, ctx);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
sendNotice(
|
|
249
|
+
pi,
|
|
250
|
+
PLAN_NOTICE_CUSTOM_TYPE,
|
|
251
|
+
`Cannot enter plan mode: ${describeError(error)}`,
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
planSessions.set(sessionKey(ctx), {
|
|
256
|
+
changeId,
|
|
257
|
+
injected: new Set(),
|
|
258
|
+
degradedNotified: false,
|
|
259
|
+
reviewNotified: false,
|
|
260
|
+
});
|
|
261
|
+
pi.sendMessage(
|
|
262
|
+
{
|
|
263
|
+
customType: PLAN_MODE_CONTRACT_CUSTOM_TYPE,
|
|
264
|
+
content: renderPlanModeContract(changeId),
|
|
265
|
+
display: true,
|
|
266
|
+
},
|
|
267
|
+
{ deliverAs: "steer" },
|
|
268
|
+
);
|
|
269
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.modeEntered, {
|
|
270
|
+
changeId,
|
|
271
|
+
mode: "plan",
|
|
272
|
+
});
|
|
273
|
+
publishLifecycle(deps.events ?? pi.events, {
|
|
274
|
+
type: "plan_started",
|
|
275
|
+
change: changeId,
|
|
276
|
+
mode: "plan",
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
pi.on(
|
|
282
|
+
"turn_end",
|
|
283
|
+
createPlanTurnEndHandler(pi, { fetchStatus, fetchInstructions }),
|
|
284
|
+
);
|
|
285
|
+
}
|
package/src/plan-flow.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/opsx:plan` phase model and append-only injection blocks (design D5; openspec
|
|
3
|
+
* change add-pi-openspec-x, task 7.1).
|
|
4
|
+
*
|
|
5
|
+
* The plan flow never rewrites the system prompt and never adds or removes a
|
|
6
|
+
* skill mid-session (that would break the prompt-prefix cache): the mode
|
|
7
|
+
* contract is one appended message, and each phase appends exactly one
|
|
8
|
+
* instruction block, built live from
|
|
9
|
+
* `openspec instructions <artifact> --change <id> --json`. When the CLI cannot
|
|
10
|
+
* answer (no change yet, CLI error, malformed output) the block degrades to a
|
|
11
|
+
* built-in skeleton and the caller raises a notice — the flow never stops.
|
|
12
|
+
*
|
|
13
|
+
* This module is pure: phase selection and block rendering take data and
|
|
14
|
+
* return data. The command/turn_end wiring lives in ./plan-command.ts.
|
|
15
|
+
*/
|
|
16
|
+
import type { OpenspecArtifactInstructions } from "./cli.ts";
|
|
17
|
+
|
|
18
|
+
/** The spec-driven artifact order, used only for the built-in skeleton text. */
|
|
19
|
+
const PLAN_ARTIFACT_ORDER = ["proposal", "specs", "design", "tasks"] as const;
|
|
20
|
+
|
|
21
|
+
type PlanArtifact = string;
|
|
22
|
+
|
|
23
|
+
/** The validated slice of `openspec status --change <id> --json`. */
|
|
24
|
+
export interface PlanStatusArtifact {
|
|
25
|
+
id: string;
|
|
26
|
+
status: string;
|
|
27
|
+
requires?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlanStatus {
|
|
31
|
+
artifacts?: PlanStatusArtifact[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The next thing the plan flow must do. */
|
|
35
|
+
export type PlanPhase =
|
|
36
|
+
| { kind: "artifact"; artifact: PlanArtifact }
|
|
37
|
+
| { kind: "review" };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The first artifact that is neither done nor skipped, in the status output's
|
|
41
|
+
* own order (which is the schema's dependency order). Every artifact done
|
|
42
|
+
* means the plan is ready for the plan-review gate.
|
|
43
|
+
*/
|
|
44
|
+
export function nextPlanPhase(status: PlanStatus): PlanPhase {
|
|
45
|
+
const pending = (status.artifacts ?? []).find(
|
|
46
|
+
(artifact) => artifact.status !== "done" && artifact.status !== "skipped",
|
|
47
|
+
);
|
|
48
|
+
return pending
|
|
49
|
+
? { kind: "artifact", artifact: pending.id }
|
|
50
|
+
: { kind: "review" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The decision-complete slice every phase block carries. */
|
|
54
|
+
const DECISION_COMPLETE_RULES = [
|
|
55
|
+
"Decision-complete rules for this artifact:",
|
|
56
|
+
'- Name exact file paths and symbols; never write "the relevant file".',
|
|
57
|
+
"- Reference every dependency exhaustively: what to read, what to change.",
|
|
58
|
+
"- State Must-NOT-Have explicitly: what is out of scope.",
|
|
59
|
+
'- Every acceptance criterion must be executable by an agent (a command, a file check, a test run); no "the user confirms".',
|
|
60
|
+
"- Leave the implementer zero judgment calls.",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const ARTIFACT_SKELETONS: Record<string, string> = {
|
|
64
|
+
proposal:
|
|
65
|
+
"Write proposal.md: Why, What Changes, Capabilities (New/Modified), Impact. Keep it to the change's intent and boundaries.",
|
|
66
|
+
specs:
|
|
67
|
+
"Write the spec deltas under specs/: ADDED/MODIFIED/REMOVED requirements with WHEN/THEN scenarios. Keep capability paths stable.",
|
|
68
|
+
design:
|
|
69
|
+
"Write design.md: Context, Goals/Non-Goals, Decisions (including rejected alternatives), Risks, Migration, Open Questions.",
|
|
70
|
+
tasks:
|
|
71
|
+
"Write tasks.md: `## N. Group` headings and `- [ ] X.Y ...` checkbox tasks; every task states how to verify completion.",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const GENERIC_SKELETON =
|
|
75
|
+
"Follow the schema's artifact requirements; the CLI instructions are authoritative whenever they are available.";
|
|
76
|
+
|
|
77
|
+
/** The mode-contract message appended once when the plan mode is entered. */
|
|
78
|
+
export function renderPlanModeContract(changeId: string): string {
|
|
79
|
+
return [
|
|
80
|
+
`[OPSX PLAN MODE change=${changeId}]`,
|
|
81
|
+
`You are now in the opsx planning flow for change "${changeId}". Your writes are restricted to openspec/ (the opsx-planner sandbox profile); everything else is read-only.`,
|
|
82
|
+
"Flow: write the change artifacts in dependency order, each following its live `openspec instructions <artifact> --change <id> --json` output. Dispatch the read-only opsx-gap-analysis gap analyst mid-way, absorb its findings into the artifacts, and record them with the `opsx_plan_gap_analysis` tool. When every artifact is done, dispatch the read-only opsx-plan-review plan reviewer, record each verdict with `opsx_plan_verdict`, and follow that tool's instruction: revise and re-review, hand over to the user when it escalates, or — only after OKAY — let it open the blocking user approval dialog.",
|
|
83
|
+
"Discipline: decision-complete writing (exact paths, exhaustive references, explicit Must-NOT-Have, agent-executable acceptance criteria). Do not implement code in this mode. Only the user's explicit approval unlocks implementation; an OKAY verdict is not approval.",
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PlanPhaseBlock {
|
|
88
|
+
artifact: PlanArtifact;
|
|
89
|
+
content: string;
|
|
90
|
+
/** True when the live CLI instructions were unavailable and the skeleton was used. */
|
|
91
|
+
degraded: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Render one phase's append-only instruction block. `instructions === undefined`
|
|
96
|
+
* is the explicit degradation path (no change yet, CLI error, malformed
|
|
97
|
+
* output): the block says so and falls back to the built-in skeleton.
|
|
98
|
+
*/
|
|
99
|
+
export function renderPlanPhaseBlock(
|
|
100
|
+
artifact: PlanArtifact,
|
|
101
|
+
instructions: OpenspecArtifactInstructions | undefined,
|
|
102
|
+
): PlanPhaseBlock {
|
|
103
|
+
const skeleton = ARTIFACT_SKELETONS[artifact] ?? GENERIC_SKELETON;
|
|
104
|
+
if (!instructions) {
|
|
105
|
+
return {
|
|
106
|
+
artifact,
|
|
107
|
+
degraded: true,
|
|
108
|
+
content: [
|
|
109
|
+
`[OPSX PLAN PHASE: ${artifact}] (live CLI instructions unavailable — built-in skeleton)`,
|
|
110
|
+
"Re-run `openspec instructions " +
|
|
111
|
+
artifact +
|
|
112
|
+
' --change "<change-id>" --json` once the CLI answers, and follow that output over this skeleton.',
|
|
113
|
+
"",
|
|
114
|
+
skeleton,
|
|
115
|
+
"",
|
|
116
|
+
...DECISION_COMPLETE_RULES,
|
|
117
|
+
].join("\n"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lines = [`[OPSX PLAN PHASE: ${artifact}]`, instructions.instruction];
|
|
122
|
+
if (instructions.resolvedOutputPath) {
|
|
123
|
+
lines.push("", `Write to: ${instructions.resolvedOutputPath}`);
|
|
124
|
+
}
|
|
125
|
+
if (instructions.template) {
|
|
126
|
+
lines.push("", "Template:", instructions.template);
|
|
127
|
+
}
|
|
128
|
+
if (instructions.rules && instructions.rules.length > 0) {
|
|
129
|
+
lines.push("", "Rules:", ...instructions.rules.map((rule) => `- ${rule}`));
|
|
130
|
+
}
|
|
131
|
+
lines.push("", ...DECISION_COMPLETE_RULES);
|
|
132
|
+
return { artifact, degraded: false, content: lines.join("\n") };
|
|
133
|
+
}
|