@abdullahsahmad/work-kit 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/README.md +147 -0
- package/cli/bin/work-kit.mjs +18 -0
- package/cli/src/commands/complete.ts +123 -0
- package/cli/src/commands/completions.ts +137 -0
- package/cli/src/commands/context.ts +41 -0
- package/cli/src/commands/doctor.ts +79 -0
- package/cli/src/commands/init.test.ts +116 -0
- package/cli/src/commands/init.ts +184 -0
- package/cli/src/commands/loopback.ts +64 -0
- package/cli/src/commands/next.ts +172 -0
- package/cli/src/commands/observe.ts +144 -0
- package/cli/src/commands/setup.ts +159 -0
- package/cli/src/commands/status.ts +50 -0
- package/cli/src/commands/uninstall.ts +89 -0
- package/cli/src/commands/upgrade.ts +12 -0
- package/cli/src/commands/validate.ts +34 -0
- package/cli/src/commands/workflow.ts +125 -0
- package/cli/src/config/agent-map.ts +62 -0
- package/cli/src/config/loopback-routes.ts +45 -0
- package/cli/src/config/phases.ts +119 -0
- package/cli/src/context/extractor.test.ts +77 -0
- package/cli/src/context/extractor.ts +73 -0
- package/cli/src/context/prompt-builder.ts +70 -0
- package/cli/src/engine/loopbacks.test.ts +33 -0
- package/cli/src/engine/loopbacks.ts +32 -0
- package/cli/src/engine/parallel.ts +60 -0
- package/cli/src/engine/phases.ts +23 -0
- package/cli/src/engine/transitions.test.ts +117 -0
- package/cli/src/engine/transitions.ts +97 -0
- package/cli/src/index.ts +248 -0
- package/cli/src/observer/data.ts +237 -0
- package/cli/src/observer/renderer.ts +316 -0
- package/cli/src/observer/watcher.ts +99 -0
- package/cli/src/state/helpers.test.ts +91 -0
- package/cli/src/state/helpers.ts +65 -0
- package/cli/src/state/schema.ts +113 -0
- package/cli/src/state/store.ts +82 -0
- package/cli/src/state/validators.test.ts +105 -0
- package/cli/src/state/validators.ts +81 -0
- package/cli/src/utils/colors.ts +12 -0
- package/package.json +49 -0
- package/skills/auto-kit/SKILL.md +214 -0
- package/skills/build/SKILL.md +88 -0
- package/skills/build/stages/commit.md +43 -0
- package/skills/build/stages/core.md +48 -0
- package/skills/build/stages/integration.md +44 -0
- package/skills/build/stages/migration.md +41 -0
- package/skills/build/stages/red.md +44 -0
- package/skills/build/stages/refactor.md +48 -0
- package/skills/build/stages/setup.md +42 -0
- package/skills/build/stages/ui.md +51 -0
- package/skills/deploy/SKILL.md +62 -0
- package/skills/deploy/stages/merge.md +47 -0
- package/skills/deploy/stages/monitor.md +39 -0
- package/skills/deploy/stages/remediate.md +54 -0
- package/skills/full-kit/SKILL.md +195 -0
- package/skills/plan/SKILL.md +77 -0
- package/skills/plan/stages/architecture.md +53 -0
- package/skills/plan/stages/audit.md +58 -0
- package/skills/plan/stages/blueprint.md +60 -0
- package/skills/plan/stages/clarify.md +61 -0
- package/skills/plan/stages/investigate.md +47 -0
- package/skills/plan/stages/scope.md +46 -0
- package/skills/plan/stages/sketch.md +44 -0
- package/skills/plan/stages/ux-flow.md +49 -0
- package/skills/review/SKILL.md +104 -0
- package/skills/review/stages/compliance.md +48 -0
- package/skills/review/stages/handoff.md +59 -0
- package/skills/review/stages/performance.md +45 -0
- package/skills/review/stages/security.md +49 -0
- package/skills/review/stages/self-review.md +41 -0
- package/skills/test/SKILL.md +83 -0
- package/skills/test/stages/e2e.md +44 -0
- package/skills/test/stages/validate.md +51 -0
- package/skills/test/stages/verify.md +41 -0
- package/skills/wrap-up/SKILL.md +107 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PhaseName,
|
|
3
|
+
SUBSTAGES_BY_PHASE,
|
|
4
|
+
Classification,
|
|
5
|
+
WorkflowStep,
|
|
6
|
+
} from "../state/schema.js";
|
|
7
|
+
|
|
8
|
+
// ── Phase Order ──────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export const PHASE_ORDER: PhaseName[] = ["plan", "build", "test", "review", "deploy", "wrap-up"];
|
|
11
|
+
|
|
12
|
+
// ── Prerequisites ────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const PHASE_PREREQUISITES: Record<PhaseName, PhaseName | null> = {
|
|
15
|
+
plan: null,
|
|
16
|
+
build: "plan",
|
|
17
|
+
test: "build",
|
|
18
|
+
review: "test",
|
|
19
|
+
deploy: "review",
|
|
20
|
+
"wrap-up": "review", // or deploy if deploy was included
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ── Skill File Paths ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function skillFilePath(phase: PhaseName, subStage?: string): string {
|
|
26
|
+
if (phase === "wrap-up") return ".claude/skills/wrap-up/SKILL.md";
|
|
27
|
+
if (!subStage) return `.claude/skills/${phase}/SKILL.md`;
|
|
28
|
+
return `.claude/skills/${phase}/stages/${subStage}.md`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Auto-kit Default Workflows ───────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
type InclusionRule = "YES" | "skip" | "if UI" | "if DB" | "optional";
|
|
34
|
+
|
|
35
|
+
const WORKFLOW_MATRIX: Record<Classification, Record<string, InclusionRule>> = {
|
|
36
|
+
"bug-fix": {
|
|
37
|
+
"plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "skip", "plan/scope": "skip",
|
|
38
|
+
"plan/ux-flow": "skip", "plan/architecture": "skip", "plan/blueprint": "skip", "plan/audit": "skip",
|
|
39
|
+
"build/setup": "skip", "build/migration": "skip", "build/red": "YES", "build/core": "YES",
|
|
40
|
+
"build/ui": "if UI", "build/refactor": "skip", "build/integration": "skip", "build/commit": "YES",
|
|
41
|
+
"test/verify": "YES", "test/e2e": "skip", "test/validate": "YES",
|
|
42
|
+
"review/self-review": "YES", "review/security": "skip", "review/performance": "skip",
|
|
43
|
+
"review/compliance": "skip", "review/handoff": "YES",
|
|
44
|
+
"deploy/merge": "optional", "deploy/monitor": "optional", "deploy/remediate": "optional",
|
|
45
|
+
"wrap-up/wrap-up": "YES",
|
|
46
|
+
},
|
|
47
|
+
"small-change": {
|
|
48
|
+
"plan/clarify": "YES", "plan/investigate": "skip", "plan/sketch": "skip", "plan/scope": "skip",
|
|
49
|
+
"plan/ux-flow": "skip", "plan/architecture": "skip", "plan/blueprint": "skip", "plan/audit": "skip",
|
|
50
|
+
"build/setup": "skip", "build/migration": "skip", "build/red": "skip", "build/core": "YES",
|
|
51
|
+
"build/ui": "if UI", "build/refactor": "skip", "build/integration": "skip", "build/commit": "YES",
|
|
52
|
+
"test/verify": "YES", "test/e2e": "skip", "test/validate": "skip",
|
|
53
|
+
"review/self-review": "YES", "review/security": "skip", "review/performance": "skip",
|
|
54
|
+
"review/compliance": "skip", "review/handoff": "YES",
|
|
55
|
+
"deploy/merge": "optional", "deploy/monitor": "optional", "deploy/remediate": "optional",
|
|
56
|
+
"wrap-up/wrap-up": "YES",
|
|
57
|
+
},
|
|
58
|
+
refactor: {
|
|
59
|
+
"plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "skip", "plan/scope": "skip",
|
|
60
|
+
"plan/ux-flow": "skip", "plan/architecture": "skip", "plan/blueprint": "skip", "plan/audit": "skip",
|
|
61
|
+
"build/setup": "skip", "build/migration": "skip", "build/red": "skip", "build/core": "YES",
|
|
62
|
+
"build/ui": "if UI", "build/refactor": "YES", "build/integration": "skip", "build/commit": "YES",
|
|
63
|
+
"test/verify": "YES", "test/e2e": "skip", "test/validate": "skip",
|
|
64
|
+
"review/self-review": "YES", "review/security": "skip", "review/performance": "YES",
|
|
65
|
+
"review/compliance": "skip", "review/handoff": "YES",
|
|
66
|
+
"deploy/merge": "optional", "deploy/monitor": "optional", "deploy/remediate": "optional",
|
|
67
|
+
"wrap-up/wrap-up": "YES",
|
|
68
|
+
},
|
|
69
|
+
feature: {
|
|
70
|
+
"plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "YES", "plan/scope": "YES",
|
|
71
|
+
"plan/ux-flow": "if UI", "plan/architecture": "YES", "plan/blueprint": "YES", "plan/audit": "skip",
|
|
72
|
+
"build/setup": "YES", "build/migration": "if DB", "build/red": "YES", "build/core": "YES",
|
|
73
|
+
"build/ui": "if UI", "build/refactor": "skip", "build/integration": "YES", "build/commit": "YES",
|
|
74
|
+
"test/verify": "YES", "test/e2e": "if UI", "test/validate": "YES",
|
|
75
|
+
"review/self-review": "YES", "review/security": "YES", "review/performance": "skip",
|
|
76
|
+
"review/compliance": "YES", "review/handoff": "YES",
|
|
77
|
+
"deploy/merge": "optional", "deploy/monitor": "optional", "deploy/remediate": "optional",
|
|
78
|
+
"wrap-up/wrap-up": "YES",
|
|
79
|
+
},
|
|
80
|
+
"large-feature": {
|
|
81
|
+
"plan/clarify": "YES", "plan/investigate": "YES", "plan/sketch": "YES", "plan/scope": "YES",
|
|
82
|
+
"plan/ux-flow": "if UI", "plan/architecture": "YES", "plan/blueprint": "YES", "plan/audit": "YES",
|
|
83
|
+
"build/setup": "YES", "build/migration": "if DB", "build/red": "YES", "build/core": "YES",
|
|
84
|
+
"build/ui": "if UI", "build/refactor": "YES", "build/integration": "YES", "build/commit": "YES",
|
|
85
|
+
"test/verify": "YES", "test/e2e": "YES", "test/validate": "YES",
|
|
86
|
+
"review/self-review": "YES", "review/security": "YES", "review/performance": "YES",
|
|
87
|
+
"review/compliance": "YES", "review/handoff": "YES",
|
|
88
|
+
"deploy/merge": "optional", "deploy/monitor": "optional", "deploy/remediate": "optional",
|
|
89
|
+
"wrap-up/wrap-up": "YES",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function buildDefaultWorkflow(classification: Classification): WorkflowStep[] {
|
|
94
|
+
const matrix = WORKFLOW_MATRIX[classification];
|
|
95
|
+
const steps: WorkflowStep[] = [];
|
|
96
|
+
|
|
97
|
+
for (const [key, rule] of Object.entries(matrix)) {
|
|
98
|
+
const [phase, subStage] = key.split("/") as [PhaseName, string];
|
|
99
|
+
// "YES" always included, "skip" excluded, conditional ones included by default (user can remove)
|
|
100
|
+
const included = rule === "YES" || rule === "if UI" || rule === "if DB";
|
|
101
|
+
if (rule !== "skip") {
|
|
102
|
+
steps.push({ phase, subStage, included });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return steps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildFullWorkflow(): WorkflowStep[] {
|
|
110
|
+
const steps: WorkflowStep[] = [];
|
|
111
|
+
for (const phase of PHASE_ORDER) {
|
|
112
|
+
for (const subStage of SUBSTAGES_BY_PHASE[phase]) {
|
|
113
|
+
// Deploy is optional by default
|
|
114
|
+
const included = phase !== "deploy";
|
|
115
|
+
steps.push({ phase, subStage, included });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return steps;
|
|
119
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import * as assert from "node:assert/strict";
|
|
3
|
+
import { extractSection, extractTopSection } from "./extractor.js";
|
|
4
|
+
|
|
5
|
+
const SAMPLE_MD = `# Title
|
|
6
|
+
|
|
7
|
+
## Description
|
|
8
|
+
This is the description content.
|
|
9
|
+
It spans multiple lines.
|
|
10
|
+
|
|
11
|
+
## Criteria
|
|
12
|
+
- Criterion one
|
|
13
|
+
- Criterion two
|
|
14
|
+
|
|
15
|
+
### Sub Heading A
|
|
16
|
+
Content A here.
|
|
17
|
+
More content A.
|
|
18
|
+
|
|
19
|
+
### Sub Heading B
|
|
20
|
+
Content B here.
|
|
21
|
+
|
|
22
|
+
## Decisions
|
|
23
|
+
Some decisions.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
describe("extractSection", () => {
|
|
27
|
+
it("finds ### section by heading", () => {
|
|
28
|
+
const result = extractSection(SAMPLE_MD, "Sub Heading A");
|
|
29
|
+
assert.notEqual(result, null);
|
|
30
|
+
assert.ok(result!.includes("Content A here."));
|
|
31
|
+
assert.ok(result!.includes("More content A."));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("stops at the next ### heading", () => {
|
|
35
|
+
const result = extractSection(SAMPLE_MD, "Sub Heading A");
|
|
36
|
+
assert.notEqual(result, null);
|
|
37
|
+
assert.ok(!result!.includes("Content B here."));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null for missing section", () => {
|
|
41
|
+
const result = extractSection(SAMPLE_MD, "Nonexistent");
|
|
42
|
+
assert.equal(result, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("works when heading already includes ###", () => {
|
|
46
|
+
const result = extractSection(SAMPLE_MD, "### Sub Heading B");
|
|
47
|
+
assert.notEqual(result, null);
|
|
48
|
+
assert.ok(result!.includes("Content B here."));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("extractTopSection", () => {
|
|
53
|
+
it("finds ## section", () => {
|
|
54
|
+
const result = extractTopSection(SAMPLE_MD, "Description");
|
|
55
|
+
assert.notEqual(result, null);
|
|
56
|
+
assert.ok(result!.includes("This is the description content."));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("stops at next ## section", () => {
|
|
60
|
+
const result = extractTopSection(SAMPLE_MD, "Description");
|
|
61
|
+
assert.notEqual(result, null);
|
|
62
|
+
assert.ok(!result!.includes("Criterion one"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null for missing section", () => {
|
|
66
|
+
const result = extractTopSection(SAMPLE_MD, "Nonexistent");
|
|
67
|
+
assert.equal(result, null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("includes ### sub-sections within the ## section", () => {
|
|
71
|
+
const result = extractTopSection(SAMPLE_MD, "Criteria");
|
|
72
|
+
assert.notEqual(result, null);
|
|
73
|
+
assert.ok(result!.includes("Criterion one"));
|
|
74
|
+
assert.ok(result!.includes("Sub Heading A"));
|
|
75
|
+
assert.ok(result!.includes("Content A here."));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readStateMd } from "../state/store.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract a specific ### section from state.md by heading.
|
|
5
|
+
* Returns the content between the heading and the next ### heading (or end of file).
|
|
6
|
+
*/
|
|
7
|
+
export function extractSection(stateMd: string, heading: string): string | null {
|
|
8
|
+
// Normalize heading — ensure it starts with ###
|
|
9
|
+
const prefix = heading.startsWith("###") ? heading : `### ${heading}`;
|
|
10
|
+
const lines = stateMd.split("\n");
|
|
11
|
+
let capturing = false;
|
|
12
|
+
const captured: string[] = [];
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (line.trim().startsWith(prefix)) {
|
|
16
|
+
capturing = true;
|
|
17
|
+
captured.push(line);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (capturing && line.trim().startsWith("### ")) {
|
|
21
|
+
break; // Hit next section
|
|
22
|
+
}
|
|
23
|
+
if (capturing) {
|
|
24
|
+
captured.push(line);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return captured.length > 0 ? captured.join("\n").trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract a ## section (top-level section like Description, Criteria).
|
|
33
|
+
*/
|
|
34
|
+
export function extractTopSection(stateMd: string, heading: string): string | null {
|
|
35
|
+
const prefix = heading.startsWith("##") ? heading : `## ${heading}`;
|
|
36
|
+
const lines = stateMd.split("\n");
|
|
37
|
+
let capturing = false;
|
|
38
|
+
const captured: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.trim().startsWith(prefix) && !line.trim().startsWith("### ")) {
|
|
42
|
+
capturing = true;
|
|
43
|
+
captured.push(line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (capturing && line.trim().startsWith("## ") && !line.trim().startsWith("### ")) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (capturing) {
|
|
50
|
+
captured.push(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return captured.length > 0 ? captured.join("\n").trim() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract multiple sections from state.md.
|
|
59
|
+
*/
|
|
60
|
+
export function extractSections(worktreeRoot: string, sectionNames: string[]): Record<string, string | null> {
|
|
61
|
+
const stateMd = readStateMd(worktreeRoot);
|
|
62
|
+
if (!stateMd) return {};
|
|
63
|
+
|
|
64
|
+
const result: Record<string, string | null> = {};
|
|
65
|
+
for (const name of sectionNames) {
|
|
66
|
+
if (name.startsWith("### ") || !name.startsWith("## ")) {
|
|
67
|
+
result[name] = extractSection(stateMd, name);
|
|
68
|
+
} else {
|
|
69
|
+
result[name] = extractTopSection(stateMd, name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { WorkKitState, PhaseName } from "../state/schema.js";
|
|
2
|
+
import { getContextFor } from "../config/agent-map.js";
|
|
3
|
+
import { extractSection, extractTopSection } from "./extractor.js";
|
|
4
|
+
import { readStateMd } from "../state/store.js";
|
|
5
|
+
import { skillFilePath } from "../config/phases.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a complete agent prompt for a given phase/sub-stage.
|
|
9
|
+
* Accepts optional pre-read stateMd to avoid repeated file reads in parallel scenarios.
|
|
10
|
+
*/
|
|
11
|
+
export function buildAgentPrompt(
|
|
12
|
+
worktreeRoot: string,
|
|
13
|
+
state: WorkKitState,
|
|
14
|
+
phase: PhaseName,
|
|
15
|
+
subStage: string,
|
|
16
|
+
stateMd?: string | null
|
|
17
|
+
): string {
|
|
18
|
+
const ctx = getContextFor(phase, subStage);
|
|
19
|
+
const md = stateMd ?? readStateMd(worktreeRoot);
|
|
20
|
+
const skill = skillFilePath(phase, subStage);
|
|
21
|
+
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
|
|
24
|
+
parts.push(`# Agent: ${phase}/${subStage}`);
|
|
25
|
+
parts.push(`**Worktree:** ${worktreeRoot}`);
|
|
26
|
+
parts.push(`**Slug:** ${state.slug}`);
|
|
27
|
+
parts.push(`**Branch:** ${state.branch}`);
|
|
28
|
+
parts.push(`**Mode:** ${state.mode}`);
|
|
29
|
+
parts.push("");
|
|
30
|
+
|
|
31
|
+
parts.push(`## Instructions`);
|
|
32
|
+
parts.push(`Read and follow the skill file: \`${skill}\``);
|
|
33
|
+
parts.push("");
|
|
34
|
+
|
|
35
|
+
if (phase === "wrap-up" && md) {
|
|
36
|
+
parts.push(`## Full State`);
|
|
37
|
+
parts.push(md);
|
|
38
|
+
parts.push("");
|
|
39
|
+
} else if (md && ctx.sections.length > 0) {
|
|
40
|
+
parts.push(`## Context from state.md`);
|
|
41
|
+
parts.push("");
|
|
42
|
+
|
|
43
|
+
for (const sectionName of ctx.sections) {
|
|
44
|
+
const content = sectionName.startsWith("### ")
|
|
45
|
+
? extractSection(md, sectionName)
|
|
46
|
+
: extractTopSection(md, sectionName);
|
|
47
|
+
if (content) {
|
|
48
|
+
parts.push(content);
|
|
49
|
+
parts.push("");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (ctx.needsGitDiff) {
|
|
55
|
+
parts.push(`## Git Diff`);
|
|
56
|
+
parts.push(`Run \`git diff main...HEAD\` to review the changes.`);
|
|
57
|
+
parts.push("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
parts.push(`## Output`);
|
|
61
|
+
parts.push(`Write your outputs to \`.work-kit/state.md\` under a section for this sub-stage.`);
|
|
62
|
+
|
|
63
|
+
if (subStage === "wrap-up") {
|
|
64
|
+
parts.push(`Follow the wrap-up skill file instructions for archiving and cleanup.`);
|
|
65
|
+
} else {
|
|
66
|
+
parts.push(`When done, report your outcome so the orchestrator can run: \`npx work-kit complete ${phase}/${subStage} --outcome <outcome>\``);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parts.join("\n");
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import * as assert from "node:assert/strict";
|
|
3
|
+
import { checkLoopback } from "./loopbacks.js";
|
|
4
|
+
|
|
5
|
+
describe("checkLoopback", () => {
|
|
6
|
+
it("plan/audit with 'revise' loops back to plan/blueprint", () => {
|
|
7
|
+
const result = checkLoopback("plan", "audit", "revise");
|
|
8
|
+
assert.notEqual(result, null);
|
|
9
|
+
assert.deepStrictEqual(result!.to, { phase: "plan", subStage: "blueprint" });
|
|
10
|
+
assert.ok(result!.reason.length > 0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("plan/audit with 'proceed' returns null (no loopback)", () => {
|
|
14
|
+
const result = checkLoopback("plan", "audit", "proceed");
|
|
15
|
+
assert.equal(result, null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("review/handoff with 'changes_requested' loops back to build/core", () => {
|
|
19
|
+
const result = checkLoopback("review", "handoff", "changes_requested");
|
|
20
|
+
assert.notEqual(result, null);
|
|
21
|
+
assert.deepStrictEqual(result!.to, { phase: "build", subStage: "core" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("build/core with 'done' returns null", () => {
|
|
25
|
+
const result = checkLoopback("build", "core", "done");
|
|
26
|
+
assert.equal(result, null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns null when outcome is undefined", () => {
|
|
30
|
+
const result = checkLoopback("plan", "audit");
|
|
31
|
+
assert.equal(result, null);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PhaseName, Location } from "../state/schema.js";
|
|
2
|
+
import { LOOPBACK_ROUTES } from "../config/loopback-routes.js";
|
|
3
|
+
|
|
4
|
+
interface LoopbackResult {
|
|
5
|
+
to: Location;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if completing a sub-stage with a given outcome should trigger a loop-back.
|
|
11
|
+
*/
|
|
12
|
+
export function checkLoopback(
|
|
13
|
+
phase: PhaseName,
|
|
14
|
+
subStage: string,
|
|
15
|
+
outcome?: string
|
|
16
|
+
): LoopbackResult | null {
|
|
17
|
+
if (!outcome) return null;
|
|
18
|
+
|
|
19
|
+
const route = LOOPBACK_ROUTES.find(
|
|
20
|
+
(r) =>
|
|
21
|
+
r.from.phase === phase &&
|
|
22
|
+
r.from.subStage === subStage &&
|
|
23
|
+
r.triggerOutcome === outcome
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!route) return null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
to: route.to,
|
|
30
|
+
reason: route.reason,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PhaseName, WorkKitState } from "../state/schema.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defines which sub-stages run in parallel and which runs sequentially after.
|
|
5
|
+
*/
|
|
6
|
+
export interface ParallelGroup {
|
|
7
|
+
parallel: string[]; // sub-stages that run concurrently
|
|
8
|
+
thenSequential?: string; // sub-stage that runs after all parallel complete
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parallel group definitions per phase.
|
|
13
|
+
*/
|
|
14
|
+
const PARALLEL_GROUPS: Record<string, ParallelGroup> = {
|
|
15
|
+
test: {
|
|
16
|
+
parallel: ["verify", "e2e"],
|
|
17
|
+
thenSequential: "validate",
|
|
18
|
+
},
|
|
19
|
+
review: {
|
|
20
|
+
parallel: ["self-review", "security", "performance", "compliance"],
|
|
21
|
+
thenSequential: "handoff",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a sub-stage triggers a parallel group.
|
|
27
|
+
* Triggers on any parallel member that is the first non-skipped one in the group.
|
|
28
|
+
* Returns null if the sub-stage is not a parallel trigger or the group doesn't apply.
|
|
29
|
+
*/
|
|
30
|
+
export function getParallelGroup(phase: PhaseName, subStage: string, state?: WorkKitState): ParallelGroup | null {
|
|
31
|
+
const group = PARALLEL_GROUPS[phase];
|
|
32
|
+
if (!group) return null;
|
|
33
|
+
|
|
34
|
+
if (!group.parallel.includes(subStage)) return null;
|
|
35
|
+
|
|
36
|
+
// Find the first non-skipped parallel member
|
|
37
|
+
if (state) {
|
|
38
|
+
const phaseState = state.phases[phase];
|
|
39
|
+
const firstActive = group.parallel.find((ss) => {
|
|
40
|
+
const ssState = phaseState?.subStages[ss];
|
|
41
|
+
return ssState && ssState.status !== "skipped" && ssState.status !== "completed";
|
|
42
|
+
});
|
|
43
|
+
// Only trigger if this sub-stage is the first active parallel member
|
|
44
|
+
if (firstActive !== subStage) return null;
|
|
45
|
+
} else {
|
|
46
|
+
// No state provided — fall back to first-member trigger
|
|
47
|
+
if (group.parallel[0] !== subStage) return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return group;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a sub-stage is a parallel member (part of a group, not necessarily trigger).
|
|
55
|
+
*/
|
|
56
|
+
export function isParallelMember(phase: PhaseName, subStage: string): boolean {
|
|
57
|
+
const group = PARALLEL_GROUPS[phase];
|
|
58
|
+
if (!group) return false;
|
|
59
|
+
return group.parallel.includes(subStage);
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PhaseName } from "../state/schema.js";
|
|
2
|
+
|
|
3
|
+
// ── Phase-level Wait Points ──────────────────────────────────────────
|
|
4
|
+
// After completing these phases, the orchestrator should wait for user
|
|
5
|
+
// confirmation before proceeding to the next phase.
|
|
6
|
+
|
|
7
|
+
export const WAIT_AFTER_PHASE: Set<PhaseName> = new Set([
|
|
8
|
+
"plan", // User reviews the blueprint before build
|
|
9
|
+
"build", // User reviews the PR before test
|
|
10
|
+
"test", // User reviews test results before review
|
|
11
|
+
"review", // User reviews the ship decision before deploy
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// ── Phase Display Names ──────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const PHASE_DISPLAY_NAMES: Record<PhaseName, string> = {
|
|
17
|
+
plan: "Plan",
|
|
18
|
+
build: "Build",
|
|
19
|
+
test: "Test",
|
|
20
|
+
review: "Review",
|
|
21
|
+
deploy: "Deploy",
|
|
22
|
+
"wrap-up": "Wrap-up",
|
|
23
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import * as assert from "node:assert/strict";
|
|
3
|
+
import { nextSubStageInPhase, isPhaseComplete, determineNextStep } from "./transitions.js";
|
|
4
|
+
import type { WorkKitState, PhaseName, PhaseState, SubStageState } from "../state/schema.js";
|
|
5
|
+
import { PHASE_NAMES, SUBSTAGES_BY_PHASE } from "../state/schema.js";
|
|
6
|
+
|
|
7
|
+
function makeState(): WorkKitState {
|
|
8
|
+
const phases = {} as Record<PhaseName, PhaseState>;
|
|
9
|
+
for (const phase of PHASE_NAMES) {
|
|
10
|
+
const subStages: Record<string, SubStageState> = {};
|
|
11
|
+
for (const ss of SUBSTAGES_BY_PHASE[phase]) {
|
|
12
|
+
subStages[ss] = { status: "pending" };
|
|
13
|
+
}
|
|
14
|
+
phases[phase] = { status: "pending", subStages };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
version: 1,
|
|
18
|
+
slug: "test",
|
|
19
|
+
branch: "feature/test",
|
|
20
|
+
started: "2026-01-01",
|
|
21
|
+
mode: "full-kit",
|
|
22
|
+
status: "in-progress",
|
|
23
|
+
currentPhase: "plan",
|
|
24
|
+
currentSubStage: "clarify",
|
|
25
|
+
phases,
|
|
26
|
+
loopbacks: [],
|
|
27
|
+
metadata: { worktreeRoot: "/tmp/test", mainRepoRoot: "/tmp/test" },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("nextSubStageInPhase", () => {
|
|
32
|
+
it("returns first pending sub-stage", () => {
|
|
33
|
+
const state = makeState();
|
|
34
|
+
const result = nextSubStageInPhase(state, "plan");
|
|
35
|
+
assert.equal(result, "clarify");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null when all complete or skipped", () => {
|
|
39
|
+
const state = makeState();
|
|
40
|
+
for (const ss of Object.values(state.phases.plan.subStages)) {
|
|
41
|
+
ss.status = "completed";
|
|
42
|
+
}
|
|
43
|
+
const result = nextSubStageInPhase(state, "plan");
|
|
44
|
+
assert.equal(result, null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("skips completed sub-stages and returns next pending", () => {
|
|
48
|
+
const state = makeState();
|
|
49
|
+
state.phases.plan.subStages.clarify.status = "completed";
|
|
50
|
+
state.phases.plan.subStages.investigate.status = "completed";
|
|
51
|
+
const result = nextSubStageInPhase(state, "plan");
|
|
52
|
+
assert.equal(result, "sketch");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("isPhaseComplete", () => {
|
|
57
|
+
it("returns true when all complete or skipped", () => {
|
|
58
|
+
const state = makeState();
|
|
59
|
+
for (const ss of Object.values(state.phases.plan.subStages)) {
|
|
60
|
+
ss.status = "completed";
|
|
61
|
+
}
|
|
62
|
+
assert.equal(isPhaseComplete(state, "plan"), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns true with mix of completed and skipped", () => {
|
|
66
|
+
const state = makeState();
|
|
67
|
+
let first = true;
|
|
68
|
+
for (const ss of Object.values(state.phases.plan.subStages)) {
|
|
69
|
+
ss.status = first ? "skipped" : "completed";
|
|
70
|
+
first = false;
|
|
71
|
+
}
|
|
72
|
+
assert.equal(isPhaseComplete(state, "plan"), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false when some sub-stages are pending", () => {
|
|
76
|
+
const state = makeState();
|
|
77
|
+
assert.equal(isPhaseComplete(state, "plan"), false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("determineNextStep", () => {
|
|
82
|
+
it("returns complete when state is completed", () => {
|
|
83
|
+
const state = makeState();
|
|
84
|
+
state.status = "completed";
|
|
85
|
+
const step = determineNextStep(state);
|
|
86
|
+
assert.equal(step.type, "complete");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns phase-boundary when no current phase", () => {
|
|
90
|
+
const state = makeState();
|
|
91
|
+
state.currentPhase = null;
|
|
92
|
+
const step = determineNextStep(state);
|
|
93
|
+
assert.equal(step.type, "phase-boundary");
|
|
94
|
+
assert.equal(step.phase, "plan");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns sub-stage for current phase with pending work", () => {
|
|
98
|
+
const state = makeState();
|
|
99
|
+
state.currentPhase = "plan";
|
|
100
|
+
state.phases.plan.status = "in-progress";
|
|
101
|
+
const step = determineNextStep(state);
|
|
102
|
+
assert.equal(step.type, "sub-stage");
|
|
103
|
+
assert.equal(step.phase, "plan");
|
|
104
|
+
assert.equal(step.subStage, "clarify");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns wait-for-user when current phase is complete", () => {
|
|
108
|
+
const state = makeState();
|
|
109
|
+
state.currentPhase = "plan";
|
|
110
|
+
for (const ss of Object.values(state.phases.plan.subStages)) {
|
|
111
|
+
ss.status = "completed";
|
|
112
|
+
}
|
|
113
|
+
const step = determineNextStep(state);
|
|
114
|
+
assert.equal(step.type, "wait-for-user");
|
|
115
|
+
assert.equal(step.phase, "build");
|
|
116
|
+
});
|
|
117
|
+
});
|