@tianhai/pi-workflow-kit 0.4.1 → 0.5.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.
@@ -0,0 +1,253 @@
1
+ # /workflow-next Handoff State Implementation Plan
2
+
3
+ > **REQUIRED SUB-SKILL:** Use the executing-tasks skill to implement this plan task-by-task.
4
+
5
+ **Goal:** Make `/workflow-next` preserve prior completed workflow history for same-feature handoffs, enforce immediate-next-only transitions, and rename the persisted local state file with legacy fallback.
6
+
7
+ **Architecture:** Add a small workflow-next state helper that validates allowed handoffs and derives the workflow snapshot for the new session. Update the workflow monitor to seed the new session through `ctx.newSession({ setup })` with derived workflow state plus fresh monitor state, and add focused tests for validation, state derivation, and file migration behavior.
8
+
9
+ **Tech Stack:** TypeScript, Vitest, pi extension API (`ctx.newSession({ setup })`, `SessionManager.appendCustomEntry`)
10
+
11
+ ---
12
+
13
+ ## Verification
14
+
15
+ All tasks completed. Final test results:
16
+ - `tests/extension/workflow-monitor/workflow-next-command.test.ts` — 17/17 pass
17
+ - `tests/extension/workflow-monitor/state-persistence.test.ts` — 25/25 pass
18
+ - `tests/extension/workflow-monitor/` (full suite) — 360/360 pass
19
+
20
+ No regressions. All acceptance criteria met.
21
+
22
+
23
+ ---
24
+
25
+ ### Task 1: Add failing tests for workflow-next handoff validation and state seeding
26
+
27
+ **Type:** code
28
+ **TDD scenario:** Modifying tested code — run existing tests first
29
+
30
+ **Files:**
31
+ - Modify: `tests/extension/workflow-monitor/workflow-next-command.test.ts`
32
+ - Test: `tests/extension/workflow-monitor/workflow-next-command.test.ts`
33
+
34
+ **Step 1: Write the failing tests**
35
+
36
+ Add tests covering:
37
+ - allows `plan -> execute` only when `plan` is complete
38
+ - rejects same-phase handoff
39
+ - rejects backward handoff
40
+ - rejects direct jump handoff
41
+ - rejects handoff when current phase is active
42
+ - seeds new session setup with derived workflow state preserving earlier completed phases, artifacts, and prompted flags
43
+ - resets TDD/debug/verification state in the seeded session snapshot
44
+
45
+ **Step 2: Run test to verify it fails**
46
+
47
+ Run: `npx vitest run tests/extension/workflow-monitor/workflow-next-command.test.ts`
48
+ Expected: FAIL with missing validation and missing setup-state assertions
49
+
50
+ **Step 3: Write minimal implementation support in test scaffolding only if needed**
51
+
52
+ If needed, extend the fake `ctx.newSession` stub in the test so it records the `setup` callback and lets the test invoke it with a fake session manager that captures appended custom entries.
53
+
54
+ **Step 4: Run test to verify it still fails for the intended production behavior gap**
55
+
56
+ Run: `npx vitest run tests/extension/workflow-monitor/workflow-next-command.test.ts`
57
+ Expected: FAIL only on the new assertions tied to unimplemented production code
58
+
59
+ **Step 5: Commit**
60
+
61
+ ```bash
62
+ git add tests/extension/workflow-monitor/workflow-next-command.test.ts
63
+ git commit -m "test: cover workflow-next handoff validation"
64
+ ```
65
+
66
+ ### Task 2: Add failing tests for state-file rename and legacy fallback
67
+
68
+ **Type:** code
69
+ **TDD scenario:** Modifying tested code — run existing tests first
70
+
71
+ **Files:**
72
+ - Modify: `tests/extension/workflow-monitor/state-persistence.test.ts`
73
+ - Test: `tests/extension/workflow-monitor/state-persistence.test.ts`
74
+
75
+ **Step 1: Write the failing tests**
76
+
77
+ Add tests covering:
78
+ - `getStateFilePath()` returns `.pi/workflow-kit-state.json`
79
+ - `reconstructState()` prefers `.pi/workflow-kit-state.json` when present
80
+ - `reconstructState()` falls back to `.pi/superpowers-state.json` when the new file is absent
81
+ - extension persistence writes the new filename only
82
+
83
+ **Step 2: Run test to verify it fails**
84
+
85
+ Run: `npx vitest run tests/extension/workflow-monitor/state-persistence.test.ts`
86
+ Expected: FAIL because current code still uses `.pi/superpowers-state.json`
87
+
88
+ **Step 3: Keep test fixtures minimal**
89
+
90
+ Reuse existing `withTempCwd()` and fake pi helpers. When testing persistence wiring, assert against files under `.pi/` in the temp directory rather than broad repo state.
91
+
92
+ **Step 4: Run test to verify it still fails for the intended production behavior gap**
93
+
94
+ Run: `npx vitest run tests/extension/workflow-monitor/state-persistence.test.ts`
95
+ Expected: FAIL only on filename/migration assertions
96
+
97
+ **Step 5: Commit**
98
+
99
+ ```bash
100
+ git add tests/extension/workflow-monitor/state-persistence.test.ts
101
+ git commit -m "test: cover workflow state file migration"
102
+ ```
103
+
104
+ ### Task 3: Implement workflow-next handoff validation and derived state helper
105
+
106
+ **Type:** code
107
+ **TDD scenario:** New feature — full TDD cycle
108
+
109
+ **Files:**
110
+ - Create: `extensions/workflow-monitor/workflow-next-state.ts`
111
+ - Modify: `extensions/workflow-monitor.ts`
112
+ - Test: `tests/extension/workflow-monitor/workflow-next-command.test.ts`
113
+
114
+ **Step 1: Write the helper module with pure functions**
115
+
116
+ Implement functions such as:
117
+ - `getImmediateNextPhase(currentPhase)`
118
+ - `validateWorkflowNextRequest(currentState, requestedPhase)`
119
+ - `deriveWorkflowHandoffState(currentState, requestedPhase)`
120
+
121
+ Behavior:
122
+ - require an existing current phase
123
+ - require current phase status to be exactly `complete`
124
+ - allow only the immediate next phase
125
+ - reject same/backward/direct-jump handoffs with precise messages
126
+ - derive workflow state with earlier phases `complete`, target `active`, later `pending`
127
+ - preserve earlier-phase artifacts and prompted flags
128
+
129
+ **Step 2: Update `/workflow-next` to use the helper and seed session state**
130
+
131
+ In `extensions/workflow-monitor.ts`:
132
+ - import the helper functions
133
+ - validate before calling `ctx.newSession(...)`
134
+ - use `ctx.newSession({ parentSession, setup })`
135
+ - inside `setup`, append a `superpowers_state` custom entry containing:
136
+ - derived `workflow`
137
+ - fresh `tdd` from `TDD_DEFAULTS`
138
+ - fresh `debug` from `DEBUG_DEFAULTS`
139
+ - fresh `verification` from `VERIFICATION_DEFAULTS`
140
+ - `savedAt: Date.now()`
141
+ - keep the editor prefill behavior unchanged
142
+
143
+ **Step 3: Run targeted tests**
144
+
145
+ Run: `npx vitest run tests/extension/workflow-monitor/workflow-next-command.test.ts`
146
+ Expected: PASS
147
+
148
+ **Step 4: Review for YAGNI and edge cases**
149
+
150
+ Verify:
151
+ - helper stays pure and focused
152
+ - no generic tracker semantics are changed outside `/workflow-next`
153
+ - invalid requests exit before session creation
154
+
155
+ **Step 5: Commit**
156
+
157
+ ```bash
158
+ git add extensions/workflow-monitor/workflow-next-state.ts extensions/workflow-monitor.ts tests/extension/workflow-monitor/workflow-next-command.test.ts
159
+ git commit -m "feat: preserve workflow state across workflow-next"
160
+ ```
161
+
162
+ ### Task 4: Implement state-file rename with legacy fallback
163
+
164
+ **Type:** code
165
+ **TDD scenario:** Modifying tested code — run existing tests first
166
+
167
+ **Files:**
168
+ - Modify: `extensions/workflow-monitor.ts`
169
+ - Test: `tests/extension/workflow-monitor/state-persistence.test.ts`
170
+
171
+ **Step 1: Update state file path helpers**
172
+
173
+ In `extensions/workflow-monitor.ts`:
174
+ - change `getStateFilePath()` to return `.pi/workflow-kit-state.json`
175
+ - add a legacy-path helper for `.pi/superpowers-state.json` if needed
176
+ - update `reconstructState()` to check new path first, then legacy path
177
+
178
+ **Step 2: Keep persistence write path singular**
179
+
180
+ Ensure `persistState()` writes only the new path and does not continue writing the legacy file.
181
+
182
+ **Step 3: Run targeted tests**
183
+
184
+ Run: `npx vitest run tests/extension/workflow-monitor/state-persistence.test.ts`
185
+ Expected: PASS
186
+
187
+ **Step 4: Verify no unintended regressions in reconstruction logic**
188
+
189
+ Confirm the existing session-entry reconstruction behavior still works when no file exists.
190
+
191
+ **Step 5: Commit**
192
+
193
+ ```bash
194
+ git add extensions/workflow-monitor.ts tests/extension/workflow-monitor/state-persistence.test.ts
195
+ git commit -m "refactor: rename workflow state file"
196
+ ```
197
+
198
+ ### Task 5: Update user-facing docs for the new workflow-next contract
199
+
200
+ **Type:** non-code
201
+
202
+ **Files:**
203
+ - Modify: `README.md`
204
+ - Modify: `docs/developer-usage-guide.md`
205
+ - Modify: `docs/workflow-phases.md`
206
+
207
+ **Acceptance criteria:**
208
+ - Criterion 1: `/workflow-next` docs describe immediate-next-only handoff semantics.
209
+ - Criterion 2: docs mention that the command preserves prior completed workflow history for the same feature.
210
+ - Criterion 3: docs do not claim arbitrary phase jumps are supported.
211
+
212
+ **Implementation notes:**
213
+ - Keep examples aligned with allowed transitions only.
214
+ - Mention the stricter behavior near existing `/workflow-next` examples rather than adding a long new section.
215
+ - If the local state file is mentioned anywhere, rename it to `.pi/workflow-kit-state.json`.
216
+
217
+ **Verification:**
218
+ - Review each acceptance criterion one-by-one.
219
+ - Confirm wording matches the implemented behavior and test coverage.
220
+
221
+ ### Task 6: Run focused verification and capture final status
222
+
223
+ **Type:** code
224
+ **TDD scenario:** Trivial change — use judgment
225
+
226
+ **Files:**
227
+ - Modify: `docs/plans/2026-04-09-workflow-next-handoff-state-implementation.md`
228
+ - Test: `tests/extension/workflow-monitor/workflow-next-command.test.ts`
229
+ - Test: `tests/extension/workflow-monitor/state-persistence.test.ts`
230
+
231
+ **Step 1: Run focused verification**
232
+
233
+ Run:
234
+ - `npx vitest run tests/extension/workflow-monitor/workflow-next-command.test.ts`
235
+ - `npx vitest run tests/extension/workflow-monitor/state-persistence.test.ts`
236
+
237
+ Expected: PASS
238
+
239
+ **Step 2: Run a broader confidence check**
240
+
241
+ Run: `npx vitest run tests/extension/workflow-monitor`
242
+ Expected: PASS
243
+
244
+ **Step 3: Update the implementation plan artifact with verification notes if useful**
245
+
246
+ Add a short note under the plan or in a small completion section summarizing which test commands passed.
247
+
248
+ **Step 4: Commit**
249
+
250
+ ```bash
251
+ git add docs/plans/2026-04-09-workflow-next-handoff-state-implementation.md
252
+ git commit -m "test: verify workflow-next handoff changes"
253
+ ```
@@ -7,3 +7,9 @@
7
7
  * This tool id intentionally remains unchanged across the rebrand.
8
8
  */
9
9
  export const PLAN_TRACKER_TOOL_NAME = "plan_tracker";
10
+
11
+ /**
12
+ * Custom entry type written by workflow-monitor's /workflow-reset so that
13
+ * plan-tracker's reconstructState picks up an empty task list.
14
+ */
15
+ export const PLAN_TRACKER_CLEARED_TYPE = "plan_tracker_cleared";
@@ -10,7 +10,7 @@ import { StringEnum } from "@mariozechner/pi-ai";
10
10
  import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
11
11
  import { Text } from "@mariozechner/pi-tui";
12
12
  import { type Static, Type } from "@sinclair/typebox";
13
- import { PLAN_TRACKER_TOOL_NAME } from "./constants.js";
13
+ import { PLAN_TRACKER_CLEARED_TYPE, PLAN_TRACKER_TOOL_NAME } from "./constants.js";
14
14
 
15
15
  export type TaskStatus = "pending" | "in_progress" | "complete" | "blocked";
16
16
  export type TaskPhase =
@@ -208,6 +208,12 @@ export default function (pi: ExtensionAPI) {
208
208
  const entries = ctx.sessionManager.getBranch();
209
209
  for (let i = entries.length - 1; i >= 0; i--) {
210
210
  const entry = entries[i];
211
+ // Check for explicit clear signal (written by /workflow-reset)
212
+ // biome-ignore lint/suspicious/noExplicitAny: pi SDK session entry type
213
+ if (entry.type === "custom" && (entry as any).customType === PLAN_TRACKER_CLEARED_TYPE) {
214
+ tasks = [];
215
+ break;
216
+ }
211
217
  if (entry.type !== "message") continue;
212
218
  const msg = entry.message;
213
219
  if (msg.role !== "toolResult" || msg.toolName !== PLAN_TRACKER_TOOL_NAME) continue;
@@ -153,6 +153,8 @@ interface SingleResult {
153
153
  stderr: string;
154
154
  usage: UsageStats;
155
155
  model?: string;
156
+ modelProvider?: string;
157
+ modelSource?: "agent" | "parent" | "default";
156
158
  stopReason?: string;
157
159
  errorMessage?: string;
158
160
  step?: number;
@@ -165,6 +167,32 @@ interface SubagentDetails {
165
167
  results: SingleResult[];
166
168
  }
167
169
 
170
+ interface ParentModelInfo {
171
+ id: string;
172
+ provider: string;
173
+ }
174
+
175
+ interface ResolvedModelSelection {
176
+ model: string;
177
+ provider?: string;
178
+ source: "agent" | "parent" | "default";
179
+ }
180
+
181
+ function resolveModelSelection(
182
+ agentModel: string | undefined,
183
+ parentModel: ParentModelInfo | undefined,
184
+ ): ResolvedModelSelection {
185
+ if (agentModel) {
186
+ return { model: agentModel, provider: undefined, source: "agent" };
187
+ }
188
+
189
+ if (parentModel?.id) {
190
+ return { model: parentModel.id, provider: parentModel.provider, source: "parent" };
191
+ }
192
+
193
+ return { model: DEFAULT_MODEL, provider: undefined, source: "default" };
194
+ }
195
+
168
196
  function getFinalOutput(messages: Message[]): string {
169
197
  for (let i = messages.length - 1; i >= 0; i--) {
170
198
  const msg = messages[i];
@@ -177,6 +205,36 @@ function getFinalOutput(messages: Message[]): string {
177
205
  return "";
178
206
  }
179
207
 
208
+ function buildModelArgs(selection: ResolvedModelSelection): string[] {
209
+ const args: string[] = [];
210
+ if (selection.provider) args.push("--provider", selection.provider);
211
+ args.push("--model", selection.model);
212
+ return args;
213
+ }
214
+
215
+ function formatModelSelection(
216
+ result: Pick<SingleResult, "model" | "modelProvider" | "modelSource">,
217
+ ): string | undefined {
218
+ if (!result.model) return undefined;
219
+ const modelLabel = result.modelProvider ? `${result.modelProvider}/${result.model}` : result.model;
220
+ switch (result.modelSource) {
221
+ case "parent":
222
+ return `${modelLabel} (inherited from parent session)`;
223
+ case "agent":
224
+ return `${modelLabel} (pinned by agent config)`;
225
+ case "default":
226
+ return `${modelLabel} (default fallback)`;
227
+ default:
228
+ return modelLabel;
229
+ }
230
+ }
231
+
232
+ function buildFailureMessage(prefix: string, result: SingleResult): string {
233
+ const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
234
+ const modelSelection = formatModelSelection(result);
235
+ return modelSelection ? `${prefix}: ${errorMsg}\nModel: ${modelSelection}` : `${prefix}: ${errorMsg}`;
236
+ }
237
+
180
238
  // biome-ignore lint/suspicious/noExplicitAny: pi SDK message content type
181
239
  type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
182
240
 
@@ -227,7 +285,7 @@ function collectSummary(messages: Message[]): { filesChanged: string[]; testsRan
227
285
  return { filesChanged: Array.from(files), testsRan };
228
286
  }
229
287
 
230
- export const __internal = { collectSummary };
288
+ export const __internal = { collectSummary, resolveModelSelection };
231
289
 
232
290
  async function mapWithConcurrencyLimit<TIn, TOut>(
233
291
  items: TIn[],
@@ -265,6 +323,7 @@ async function runSingleAgent(
265
323
  task: string,
266
324
  cwd: string | undefined,
267
325
  step: number | undefined,
326
+ parentModel: ParentModelInfo | undefined,
268
327
  signal: AbortSignal | undefined,
269
328
  onUpdate: OnUpdateCallback | undefined,
270
329
  makeDetails: (results: SingleResult[]) => SubagentDetails,
@@ -288,8 +347,8 @@ async function runSingleAgent(
288
347
  }
289
348
 
290
349
  const args: string[] = ["--mode", "json", "-p", "--no-session"];
291
- if (agent.model) args.push("--model", agent.model);
292
- else args.push("--model", DEFAULT_MODEL);
350
+ const selectedModel = resolveModelSelection(agent.model, parentModel);
351
+ args.push(...buildModelArgs(selectedModel));
293
352
  if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
294
353
  if (agent.extensions) {
295
354
  for (const ext of agent.extensions) {
@@ -307,7 +366,9 @@ async function runSingleAgent(
307
366
  messages: [],
308
367
  stderr: "",
309
368
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
310
- model: agent.model,
369
+ model: selectedModel.model,
370
+ modelProvider: selectedModel.provider,
371
+ modelSource: selectedModel.source,
311
372
  step,
312
373
  };
313
374
 
@@ -596,6 +657,7 @@ export default function (pi: ExtensionAPI) {
596
657
  projectAgentsDir: discovery.projectAgentsDir,
597
658
  results,
598
659
  });
660
+ const parentModel = ctx.model ? { id: ctx.model.id, provider: ctx.model.provider } : undefined;
599
661
 
600
662
  if (modeCount !== 1) {
601
663
  const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
@@ -665,6 +727,7 @@ export default function (pi: ExtensionAPI) {
665
727
  taskWithContext,
666
728
  step.cwd,
667
729
  i + 1,
730
+ parentModel,
668
731
  signal,
669
732
  chainUpdate,
670
733
  makeDetails("chain"),
@@ -675,9 +738,10 @@ export default function (pi: ExtensionAPI) {
675
738
 
676
739
  const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
677
740
  if (isError) {
678
- const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
679
741
  return {
680
- content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
742
+ content: [
743
+ { type: "text", text: buildFailureMessage(`Chain stopped at step ${i + 1} (${step.agent})`, result) },
744
+ ],
681
745
  details: makeDetails("chain")(results),
682
746
  isError: true,
683
747
  };
@@ -737,6 +801,7 @@ export default function (pi: ExtensionAPI) {
737
801
  t.task,
738
802
  t.cwd,
739
803
  undefined,
804
+ parentModel,
740
805
  signal,
741
806
  // Per-task update callback
742
807
  (partial) => {
@@ -779,6 +844,7 @@ export default function (pi: ExtensionAPI) {
779
844
  params.task,
780
845
  params.cwd,
781
846
  undefined,
847
+ parentModel,
782
848
  signal,
783
849
  onUpdate,
784
850
  makeDetails("single"),
@@ -800,9 +866,8 @@ export default function (pi: ExtensionAPI) {
800
866
  };
801
867
  const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
802
868
  if (isError) {
803
- const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
804
869
  return {
805
- content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
870
+ content: [{ type: "text", text: buildFailureMessage(`Agent ${result.stopReason || "failed"}`, result) }],
806
871
  details: stableDetails,
807
872
  isError: true,
808
873
  };
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
4
+
5
+ const WORKFLOW_NEXT_PHASES = ["brainstorm", "plan", "execute", "finalize"] as const;
6
+ const ARTIFACT_SUFFIX_BY_PHASE = {
7
+ brainstorm: null,
8
+ plan: "-design.md",
9
+ execute: "-implementation.md",
10
+ finalize: "-implementation.md",
11
+ } as const;
12
+
13
+ type WorkflowNextPhase = (typeof WORKFLOW_NEXT_PHASES)[number];
14
+
15
+ function getPhaseCompletions(prefix: string): AutocompleteItem[] | null {
16
+ const normalized = prefix.replace(/^\s+/, "");
17
+ const firstToken = normalized.split(/\s+/, 1)[0] ?? "";
18
+ const completingFirstArg = normalized.length === 0 || !/\s/.test(normalized);
19
+
20
+ if (completingFirstArg || !WORKFLOW_NEXT_PHASES.includes(firstToken as WorkflowNextPhase)) {
21
+ const phasePrefix = completingFirstArg ? normalized : firstToken;
22
+ const items = WORKFLOW_NEXT_PHASES.filter((phase) => phase.startsWith(phasePrefix)).map((phase) => ({
23
+ value: phase,
24
+ label: phase,
25
+ }));
26
+ return items.length > 0 ? items : null;
27
+ }
28
+
29
+ return null;
30
+ }
31
+
32
+ function listArtifactsForPhase(phase: WorkflowNextPhase, typedPrefix: string): AutocompleteItem[] | null {
33
+ const suffix = ARTIFACT_SUFFIX_BY_PHASE[phase];
34
+ if (!suffix) return null;
35
+
36
+ const plansDir = path.join(process.cwd(), "docs", "plans");
37
+ if (!fs.existsSync(plansDir)) return null;
38
+
39
+ try {
40
+ const items = fs
41
+ .readdirSync(plansDir)
42
+ .filter((name) => name.endsWith(suffix))
43
+ .map((name) => path.join("docs", "plans", name))
44
+ .filter((relPath) => relPath.startsWith(typedPrefix))
45
+ .map((relPath) => ({ value: `${phase} ${relPath}`, label: relPath }));
46
+
47
+ return items.length > 0 ? items : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export async function getWorkflowNextCompletions(prefix: string): Promise<AutocompleteItem[] | null> {
54
+ const phaseCompletions = getPhaseCompletions(prefix);
55
+ if (phaseCompletions) return phaseCompletions;
56
+
57
+ const normalized = prefix.replace(/^\s+/, "");
58
+ const match = normalized.match(/^(\S+)(?:\s+(.*))?$/);
59
+ const phase = match?.[1] as WorkflowNextPhase | undefined;
60
+ const artifactPrefix = match?.[2] ?? "";
61
+ const startingSecondArg = /\s$/.test(prefix) || artifactPrefix.length > 0;
62
+
63
+ if (phase && WORKFLOW_NEXT_PHASES.includes(phase) && startingSecondArg) {
64
+ return listArtifactsForPhase(phase, artifactPrefix);
65
+ }
66
+
67
+ return null;
68
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure helper functions for /workflow-next handoff validation and derived state.
3
+ *
4
+ * These functions have no side effects and no dependencies on the extension runtime,
5
+ * making them straightforward to test and reason about.
6
+ */
7
+
8
+ import { type Phase, type PhaseStatus, WORKFLOW_PHASES, type WorkflowTrackerState } from "./workflow-tracker";
9
+
10
+ /** Map of each phase to its immediate next phase (null for finalize). */
11
+ const NEXT_PHASE: Record<Phase, Phase | null> = {
12
+ brainstorm: "plan",
13
+ plan: "execute",
14
+ execute: "finalize",
15
+ finalize: null,
16
+ };
17
+
18
+ /**
19
+ * Validate whether a `/workflow-next` request is allowed.
20
+ *
21
+ * Rules:
22
+ * - A current phase must exist in the workflow state.
23
+ * - The current phase must have status exactly "complete".
24
+ * - The requested phase must be the immediate next phase.
25
+ *
26
+ * Returns `null` if the handoff is valid, or an error message string.
27
+ */
28
+ export function validateNextWorkflowPhase(currentState: WorkflowTrackerState, requestedPhase: Phase): string | null {
29
+ const current = currentState.currentPhase;
30
+
31
+ if (!current) {
32
+ return "No workflow phase is active. Start a workflow first or use /workflow-reset.";
33
+ }
34
+
35
+ const next = NEXT_PHASE[current];
36
+ if (next === null) {
37
+ return `Cannot hand off: ${current} is the final phase. Use /workflow-reset for a new task.`;
38
+ }
39
+
40
+ const currentStatus = currentState.phases[current];
41
+
42
+ // Same-phase handoff
43
+ if (requestedPhase === current) {
44
+ return `Cannot hand off to ${requestedPhase} from ${current}. Use /workflow-reset for a new task or continue in this session.`;
45
+ }
46
+
47
+ // Backward handoff
48
+ const currentIdx = WORKFLOW_PHASES.indexOf(current);
49
+ const requestedIdx = WORKFLOW_PHASES.indexOf(requestedPhase);
50
+ if (requestedIdx < currentIdx) {
51
+ return `Cannot hand off to ${requestedPhase} from ${current}: backward transitions are not allowed.`;
52
+ }
53
+
54
+ // Current phase not complete
55
+ if (currentStatus !== "complete") {
56
+ return `Cannot hand off to ${requestedPhase} because ${current} is not complete (status: ${currentStatus}).`;
57
+ }
58
+
59
+ // Direct jump (skipping intermediate phases)
60
+ if (requestedPhase !== next) {
61
+ return `Cannot hand off to ${requestedPhase} from ${current}. /workflow-next only supports the immediate next phase: ${next}.`;
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Derive the workflow state snapshot for a new session created by `/workflow-next`.
69
+ *
70
+ * Rules:
71
+ * - All phases before the requested phase are marked "complete".
72
+ * - The requested phase is marked "active".
73
+ * - All phases after the requested phase are marked "pending".
74
+ * - currentPhase is set to the requested phase.
75
+ * - Artifacts and prompted flags are preserved for earlier phases.
76
+ */
77
+ export function deriveWorkflowHandoffState(
78
+ currentState: WorkflowTrackerState,
79
+ requestedPhase: Phase,
80
+ ): WorkflowTrackerState {
81
+ const requestedIdx = WORKFLOW_PHASES.indexOf(requestedPhase);
82
+
83
+ const newPhases = { ...currentState.phases };
84
+ const newArtifacts = { ...currentState.artifacts };
85
+ const newPrompted = { ...currentState.prompted };
86
+
87
+ for (let i = 0; i < WORKFLOW_PHASES.length; i++) {
88
+ const phase = WORKFLOW_PHASES[i]!;
89
+
90
+ if (i < requestedIdx) {
91
+ // Earlier phases: mark complete, preserve artifacts/prompted
92
+ newPhases[phase] = "complete";
93
+ } else if (i === requestedIdx) {
94
+ // Target phase: active
95
+ newPhases[phase] = "active";
96
+ newArtifacts[phase] = currentState.artifacts[phase] ?? null;
97
+ newPrompted[phase] = false;
98
+ } else {
99
+ // Later phases: pending, clear artifacts/prompted
100
+ newPhases[phase] = "pending";
101
+ newArtifacts[phase] = null;
102
+ newPrompted[phase] = false;
103
+ }
104
+ }
105
+
106
+ return {
107
+ phases: newPhases as Record<Phase, PhaseStatus>,
108
+ currentPhase: requestedPhase,
109
+ artifacts: newArtifacts as Record<Phase, string | null>,
110
+ prompted: newPrompted as Record<Phase, boolean>,
111
+ };
112
+ }
@@ -194,15 +194,37 @@ export class WorkflowTracker {
194
194
  if (!PLANS_DIR_RE.test(path)) return false;
195
195
 
196
196
  if (DESIGN_RE.test(path)) {
197
- const changedArtifact = this.recordArtifact("brainstorm", path);
198
- const changedPhase = this.advanceTo("brainstorm");
199
- return changedArtifact || changedPhase;
197
+ // Only advance if we haven't already passed the brainstorm phase.
198
+ // Writing a design doc during plan/execute/finalize (e.g., updating
199
+ // the plan) must NOT reset workflow state.
200
+ const curIdx = this.state.currentPhase ? WORKFLOW_PHASES.indexOf(this.state.currentPhase) : -1;
201
+ if (curIdx > WORKFLOW_PHASES.indexOf("brainstorm")) {
202
+ return this.recordArtifact("brainstorm", path);
203
+ }
204
+ let changed = false;
205
+ changed = this.recordArtifact("brainstorm", path) || changed;
206
+ // Activating and immediately completing: the design doc is the
207
+ // deliverable that signals brainstorm is done. Do NOT mark prompted
208
+ // so the agent_end boundary prompt still fires to offer session handoff.
209
+ changed = this.advanceTo("brainstorm") || changed;
210
+ changed = this.completeCurrent() || changed;
211
+ return changed;
200
212
  }
201
213
 
202
214
  if (IMPLEMENTATION_RE.test(path)) {
203
- const changedArtifact = this.recordArtifact("plan", path);
204
- const changedPhase = this.advanceTo("plan");
205
- return changedArtifact || changedPhase;
215
+ // Only advance if we haven't already passed the plan phase.
216
+ const curIdx = this.state.currentPhase ? WORKFLOW_PHASES.indexOf(this.state.currentPhase) : -1;
217
+ if (curIdx > WORKFLOW_PHASES.indexOf("plan")) {
218
+ return this.recordArtifact("plan", path);
219
+ }
220
+ let changed = false;
221
+ changed = this.recordArtifact("plan", path) || changed;
222
+ // Activating and immediately completing: the implementation plan
223
+ // is the deliverable that signals plan phase is done. Do NOT mark
224
+ // prompted so the agent_end boundary prompt still fires.
225
+ changed = this.advanceTo("plan") || changed;
226
+ changed = this.completeCurrent() || changed;
227
+ return changed;
206
228
  }
207
229
 
208
230
  return false;