@tianhai/pi-workflow-kit 0.4.1 → 0.5.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/README.md +29 -5
- package/docs/developer-usage-guide.md +3 -4
- package/docs/plans/completed/2026-04-09-workflow-next-autocomplete-design.md +185 -0
- package/docs/plans/completed/2026-04-09-workflow-next-autocomplete-implementation.md +334 -0
- package/docs/plans/completed/2026-04-09-workflow-next-handoff-state-design.md +251 -0
- package/docs/plans/completed/2026-04-09-workflow-next-handoff-state-implementation.md +253 -0
- package/extensions/subagent/index.ts +73 -8
- package/extensions/workflow-monitor/workflow-next-completions.ts +68 -0
- package/extensions/workflow-monitor/workflow-next-state.ts +112 -0
- package/extensions/workflow-monitor.ts +57 -6
- package/package.json +1 -1
|
@@ -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: 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
|
+
}
|
|
@@ -27,7 +27,16 @@ import {
|
|
|
27
27
|
getTddViolationWarning,
|
|
28
28
|
getVerificationViolationWarning,
|
|
29
29
|
} from "./workflow-monitor/warnings";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
createWorkflowHandler,
|
|
32
|
+
DEBUG_DEFAULTS,
|
|
33
|
+
TDD_DEFAULTS,
|
|
34
|
+
VERIFICATION_DEFAULTS,
|
|
35
|
+
type Violation,
|
|
36
|
+
type WorkflowHandler,
|
|
37
|
+
} from "./workflow-monitor/workflow-handler";
|
|
38
|
+
import { getWorkflowNextCompletions } from "./workflow-monitor/workflow-next-completions";
|
|
39
|
+
import { deriveWorkflowHandoffState, validateNextWorkflowPhase } from "./workflow-monitor/workflow-next-state";
|
|
31
40
|
import {
|
|
32
41
|
computeBoundaryToPrompt,
|
|
33
42
|
type Phase,
|
|
@@ -55,10 +64,14 @@ async function selectValue<T extends string>(
|
|
|
55
64
|
|
|
56
65
|
const SUPERPOWERS_STATE_ENTRY_TYPE = "superpowers_state";
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
function getLegacyStateFilePath(): string {
|
|
59
68
|
return path.join(process.cwd(), ".pi", "superpowers-state.json");
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
export function getStateFilePath(): string {
|
|
72
|
+
return path.join(process.cwd(), ".pi", "workflow-kit-state.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
export function reconstructState(ctx: ExtensionContext, handler: WorkflowHandler, stateFilePath?: string | false) {
|
|
63
76
|
handler.resetState();
|
|
64
77
|
|
|
@@ -68,10 +81,17 @@ export function reconstructState(ctx: ExtensionContext, handler: WorkflowHandler
|
|
|
68
81
|
|
|
69
82
|
if (stateFilePath !== false) {
|
|
70
83
|
try {
|
|
71
|
-
const
|
|
72
|
-
if (fs.existsSync(
|
|
73
|
-
const raw = fs.readFileSync(
|
|
84
|
+
const newPath = stateFilePath ?? getStateFilePath();
|
|
85
|
+
if (fs.existsSync(newPath)) {
|
|
86
|
+
const raw = fs.readFileSync(newPath, "utf-8");
|
|
74
87
|
fileData = JSON.parse(raw);
|
|
88
|
+
} else if (stateFilePath === undefined) {
|
|
89
|
+
// Legacy fallback: try the old filename only when no explicit path is given.
|
|
90
|
+
const legacyPath = getLegacyStateFilePath();
|
|
91
|
+
if (fs.existsSync(legacyPath)) {
|
|
92
|
+
const raw = fs.readFileSync(legacyPath, "utf-8");
|
|
93
|
+
fileData = JSON.parse(raw);
|
|
94
|
+
}
|
|
75
95
|
}
|
|
76
96
|
} catch (err) {
|
|
77
97
|
log.warn(
|
|
@@ -812,6 +832,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
812
832
|
|
|
813
833
|
pi.registerCommand("workflow-next", {
|
|
814
834
|
description: "Start a fresh session for the next workflow phase (optionally referencing an artifact path)",
|
|
835
|
+
getArgumentCompletions: getWorkflowNextCompletions,
|
|
815
836
|
async handler(args, ctx) {
|
|
816
837
|
if (!ctx.hasUI) {
|
|
817
838
|
ctx.ui.notify("workflow-next requires interactive mode", "error");
|
|
@@ -828,8 +849,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
828
849
|
return;
|
|
829
850
|
}
|
|
830
851
|
|
|
852
|
+
// Validate handoff against current workflow state
|
|
853
|
+
const currentWorkflowState = handler.getWorkflowState();
|
|
854
|
+
if (currentWorkflowState?.currentPhase) {
|
|
855
|
+
const validationError = validateNextWorkflowPhase(currentWorkflowState, phase as Phase);
|
|
856
|
+
if (validationError) {
|
|
857
|
+
ctx.ui.notify(validationError, "error");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Derive handoff state for session seeding
|
|
863
|
+
const derivedWorkflow = currentWorkflowState
|
|
864
|
+
? deriveWorkflowHandoffState(currentWorkflowState, phase as Phase)
|
|
865
|
+
: undefined;
|
|
866
|
+
|
|
831
867
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
832
|
-
const res = await ctx.newSession({
|
|
868
|
+
const res = await ctx.newSession({
|
|
869
|
+
parentSession,
|
|
870
|
+
setup: derivedWorkflow
|
|
871
|
+
? async (sm) => {
|
|
872
|
+
const fullState = handler.getFullState();
|
|
873
|
+
sm.appendCustomEntry(SUPERPOWERS_STATE_ENTRY_TYPE, {
|
|
874
|
+
...fullState,
|
|
875
|
+
workflow: derivedWorkflow,
|
|
876
|
+
tdd: { ...TDD_DEFAULTS, testFiles: [], sourceFiles: [] },
|
|
877
|
+
debug: { ...DEBUG_DEFAULTS },
|
|
878
|
+
verification: { ...VERIFICATION_DEFAULTS },
|
|
879
|
+
savedAt: Date.now(),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
: undefined,
|
|
883
|
+
});
|
|
833
884
|
if (res.cancelled) return;
|
|
834
885
|
|
|
835
886
|
const lines: string[] = [];
|