gentle-pi 0.4.3 → 0.4.4
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/assets/orchestrator.md +18 -0
- package/extensions/gentle-ai.ts +86 -1
- package/lib/sdd-status.ts +529 -0
- package/package.json +1 -1
- package/tests/runtime-harness.mjs +23 -0
- package/tests/sdd-status.test.ts +291 -0
package/assets/orchestrator.md
CHANGED
|
@@ -181,6 +181,24 @@ proposal → design ┘
|
|
|
181
181
|
|
|
182
182
|
`/sdd-status [change]` is the read-only status action for resolving the active change, artifact paths, task progress, dependency readiness, and action context before apply/verify/sync/archive.
|
|
183
183
|
|
|
184
|
+
## Native SDD Dispatcher
|
|
185
|
+
|
|
186
|
+
The user expresses intent; they should not have to administer phases manually. For natural-language SDD requests and `/sdd-continue`, the parent/orchestrator must use the native status engine as the state authority, decide the next phase, and delegate only the phase that status marks ready.
|
|
187
|
+
|
|
188
|
+
Flow:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
user intent → preflight/init guard → native status engine → phase decision → subagent gets status JSON + generated instructions → artifact/progress write → status recalculation → continue or stop
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rules:
|
|
195
|
+
|
|
196
|
+
- `/sdd-status` is a debug/status command, not the main UX.
|
|
197
|
+
- `/sdd-continue` is the native dispatcher command: resolve status, choose the next ready phase, and carry status/instructions into the subagent prompt.
|
|
198
|
+
- `sdd-apply`, `sdd-verify`, `sdd-sync`, and `sdd-archive` must obey parent-provided native status; they must not reconstruct readiness from prompt inference when status JSON is present.
|
|
199
|
+
- Do not launch a phase when native status marks that dependency `blocked`.
|
|
200
|
+
- `sdd-archive` cannot proceed unless native status says archive is ready.
|
|
201
|
+
|
|
184
202
|
## SDD Status Contract
|
|
185
203
|
|
|
186
204
|
Before `/sdd-continue`, `sdd-apply`, `sdd-verify`, `sdd-sync`, or `sdd-archive`, resolve and carry structured status. Lookup order: parent-provided status, then project override `.pi/gentle-ai/support/sdd-status-contract.md`, then globally installed `~/.pi/agent/gentle-ai/support/sdd-status-contract.md`, then the embedded `sdd-status` prompt contract. Do not use `assets/support/...` as a runtime path; that is only the package source path before installation.
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -29,6 +29,15 @@ import {
|
|
|
29
29
|
renderSddPreflightPrompt,
|
|
30
30
|
type SddPreflightPreferences,
|
|
31
31
|
} from "../lib/sdd-preflight.ts";
|
|
32
|
+
import {
|
|
33
|
+
parseSddStatusCommandArgs,
|
|
34
|
+
renderNativeSddPhasePrompt,
|
|
35
|
+
renderSddDispatcherMarkdown,
|
|
36
|
+
renderSddStatusMarkdown,
|
|
37
|
+
resolveSddStatus,
|
|
38
|
+
sddStatusSeverity,
|
|
39
|
+
type SddPhase,
|
|
40
|
+
} from "../lib/sdd-status.ts";
|
|
32
41
|
|
|
33
42
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
43
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
@@ -305,6 +314,21 @@ function isNamedAgentStartEvent(event: unknown): boolean {
|
|
|
305
314
|
return readAgentStartNames(event).length > 0;
|
|
306
315
|
}
|
|
307
316
|
|
|
317
|
+
function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
|
|
318
|
+
for (const name of readAgentStartNames(event)) {
|
|
319
|
+
if (name === "sdd-apply") return "apply";
|
|
320
|
+
if (name === "sdd-verify") return "verify";
|
|
321
|
+
if (name === "sdd-sync") return "sync";
|
|
322
|
+
if (name === "sdd-archive") return "archive";
|
|
323
|
+
}
|
|
324
|
+
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
325
|
+
if (/\bSDD apply executor\b/i.test(systemPrompt)) return "apply";
|
|
326
|
+
if (/\bSDD verify executor\b/i.test(systemPrompt)) return "verify";
|
|
327
|
+
if (/\bSDD sync executor\b/i.test(systemPrompt)) return "sync";
|
|
328
|
+
if (/\bSDD archive executor\b/i.test(systemPrompt)) return "archive";
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
308
332
|
function evaluateDeniedCommand(
|
|
309
333
|
command: string,
|
|
310
334
|
): ToolCallEventResult | undefined {
|
|
@@ -1608,11 +1632,18 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1608
1632
|
prefs && (!isNamedAgent || isSddAgent)
|
|
1609
1633
|
? `\n\n${renderSddPreflightPrompt(prefs)}`
|
|
1610
1634
|
: "";
|
|
1635
|
+
const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
|
|
1636
|
+
const nativeStatusPrompt = phase
|
|
1637
|
+
? `\n\n${renderNativeSddPhasePrompt(resolveSddStatus({
|
|
1638
|
+
cwd: ctx.cwd,
|
|
1639
|
+
includeInstructions: true,
|
|
1640
|
+
}), phase)}`
|
|
1641
|
+
: "";
|
|
1611
1642
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
1612
1643
|
? ""
|
|
1613
1644
|
: `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`;
|
|
1614
1645
|
return {
|
|
1615
|
-
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}`,
|
|
1646
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
|
|
1616
1647
|
};
|
|
1617
1648
|
});
|
|
1618
1649
|
|
|
@@ -1656,6 +1687,60 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1656
1687
|
},
|
|
1657
1688
|
});
|
|
1658
1689
|
|
|
1690
|
+
const handleSddStatusCommand = (args: string, ctx: ExtensionContext) => {
|
|
1691
|
+
const parsed = parseSddStatusCommandArgs(args);
|
|
1692
|
+
const status = resolveSddStatus({
|
|
1693
|
+
cwd: ctx.cwd,
|
|
1694
|
+
changeName: parsed.changeName,
|
|
1695
|
+
includeInstructions: true,
|
|
1696
|
+
});
|
|
1697
|
+
ctx.ui.notify(
|
|
1698
|
+
parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
|
|
1699
|
+
sddStatusSeverity(status),
|
|
1700
|
+
);
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
pi.registerCommand("sdd-status", {
|
|
1704
|
+
description: "Show deterministic SDD change status and instructions.",
|
|
1705
|
+
handler: async (args, ctx) => {
|
|
1706
|
+
handleSddStatusCommand(args, ctx);
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
pi.registerCommand("gentle-ai:sdd-status", {
|
|
1711
|
+
description: "Compatibility alias for /sdd-status.",
|
|
1712
|
+
handler: async (args, ctx) => {
|
|
1713
|
+
handleSddStatusCommand(args, ctx);
|
|
1714
|
+
},
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
const handleSddContinueCommand = (args: string, ctx: ExtensionContext) => {
|
|
1718
|
+
const parsed = parseSddStatusCommandArgs(args);
|
|
1719
|
+
const status = resolveSddStatus({
|
|
1720
|
+
cwd: ctx.cwd,
|
|
1721
|
+
changeName: parsed.changeName,
|
|
1722
|
+
includeInstructions: true,
|
|
1723
|
+
});
|
|
1724
|
+
ctx.ui.notify(
|
|
1725
|
+
parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
|
|
1726
|
+
sddStatusSeverity(status),
|
|
1727
|
+
);
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
pi.registerCommand("sdd-continue", {
|
|
1731
|
+
description: "Resolve SDD status and route the next phase deterministically.",
|
|
1732
|
+
handler: async (args, ctx) => {
|
|
1733
|
+
handleSddContinueCommand(args, ctx);
|
|
1734
|
+
},
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
pi.registerCommand("gentle-ai:sdd-continue", {
|
|
1738
|
+
description: "Compatibility alias for /sdd-continue.",
|
|
1739
|
+
handler: async (args, ctx) => {
|
|
1740
|
+
handleSddContinueCommand(args, ctx);
|
|
1741
|
+
},
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1659
1744
|
pi.registerCommand("gentle:models", {
|
|
1660
1745
|
description: "Configure global per-agent models for el Gentleman.",
|
|
1661
1746
|
handler: async (_args, ctx) => {
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
detectActiveDomainCollisions,
|
|
5
|
+
detectLegacyFlatSpec,
|
|
6
|
+
type DomainCollision,
|
|
7
|
+
} from "./openspec-guardrails.ts";
|
|
8
|
+
|
|
9
|
+
export type SddArtifactStore = "openspec";
|
|
10
|
+
export type ArtifactState = "missing" | "done" | "partial";
|
|
11
|
+
export type DependencyState = "blocked" | "ready" | "all_done" | "not_applicable";
|
|
12
|
+
export type ApplyState = "blocked" | "ready" | "all_done";
|
|
13
|
+
export type SddPhase = "apply" | "verify" | "sync" | "archive";
|
|
14
|
+
|
|
15
|
+
export interface SddArtifactPaths {
|
|
16
|
+
proposal: string[];
|
|
17
|
+
specs: string[];
|
|
18
|
+
design: string[];
|
|
19
|
+
tasks: string[];
|
|
20
|
+
applyProgress: string[];
|
|
21
|
+
verifyReport: string[];
|
|
22
|
+
syncReport: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SddTaskProgress {
|
|
26
|
+
total: number;
|
|
27
|
+
complete: number;
|
|
28
|
+
remaining: number;
|
|
29
|
+
unchecked: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SddActionContext {
|
|
33
|
+
mode: "repo-local";
|
|
34
|
+
workspaceRoot: string;
|
|
35
|
+
allowedEditRoots: string[];
|
|
36
|
+
warnings: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SddDomainCollisionReport {
|
|
40
|
+
domain: string;
|
|
41
|
+
changes: DomainCollision[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SddPhaseInstructions {
|
|
45
|
+
apply: string[];
|
|
46
|
+
verify: string[];
|
|
47
|
+
sync: string[];
|
|
48
|
+
archive: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SddRelationships {
|
|
52
|
+
dependsOn: string[];
|
|
53
|
+
supersedes: string[];
|
|
54
|
+
amends: string[];
|
|
55
|
+
conflictsWith: string[];
|
|
56
|
+
sameDomainActiveChanges: SddDomainCollisionReport[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SddStatus {
|
|
60
|
+
schemaName: "gentle-pi.sdd-status";
|
|
61
|
+
schemaVersion: 1;
|
|
62
|
+
changeName: string | null;
|
|
63
|
+
artifactStore: SddArtifactStore;
|
|
64
|
+
planningHome: { root: string; changesDir: string };
|
|
65
|
+
changeRoot: string | null;
|
|
66
|
+
artifactPaths: SddArtifactPaths;
|
|
67
|
+
contextFiles: SddArtifactPaths;
|
|
68
|
+
artifacts: Record<keyof SddArtifactPaths, ArtifactState>;
|
|
69
|
+
taskProgress: SddTaskProgress;
|
|
70
|
+
applyState: ApplyState;
|
|
71
|
+
dependencies: Record<SddPhase, DependencyState>;
|
|
72
|
+
actionContext: SddActionContext;
|
|
73
|
+
relationships: SddRelationships;
|
|
74
|
+
collisions: SddDomainCollisionReport[];
|
|
75
|
+
legacyFlatSpec?: { path: string; hasDomainSpecs: boolean };
|
|
76
|
+
nextRecommended: string;
|
|
77
|
+
instructions?: SddPhaseInstructions;
|
|
78
|
+
blockedReasons: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ResolveSddStatusOptions {
|
|
82
|
+
cwd: string;
|
|
83
|
+
changeName?: string;
|
|
84
|
+
includeInstructions?: boolean;
|
|
85
|
+
workspaceRoot?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const EMPTY_PATHS: SddArtifactPaths = {
|
|
89
|
+
proposal: [],
|
|
90
|
+
specs: [],
|
|
91
|
+
design: [],
|
|
92
|
+
tasks: [],
|
|
93
|
+
applyProgress: [],
|
|
94
|
+
verifyReport: [],
|
|
95
|
+
syncReport: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function safeDirectories(path: string): string[] {
|
|
99
|
+
try {
|
|
100
|
+
return readdirSync(path)
|
|
101
|
+
.filter((entry) => {
|
|
102
|
+
try {
|
|
103
|
+
return statSync(join(path, entry)).isDirectory();
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.sort();
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function safeRead(path: string): string {
|
|
115
|
+
try {
|
|
116
|
+
return readFileSync(path, "utf8");
|
|
117
|
+
} catch {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasContent(path: string): boolean {
|
|
123
|
+
return existsSync(path) && safeRead(path).trim().length > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function singleFileState(paths: string[]): ArtifactState {
|
|
127
|
+
if (paths.length === 0) return "missing";
|
|
128
|
+
return paths.some((path) => !hasContent(path)) ? "partial" : "done";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function multiFileState(paths: string[], partial = false): ArtifactState {
|
|
132
|
+
if (paths.length === 0) return partial ? "partial" : "missing";
|
|
133
|
+
return paths.some((path) => !hasContent(path)) || partial ? "partial" : "done";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findSpecFiles(specsDir: string): string[] {
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
function walk(dir: string): void {
|
|
139
|
+
for (const entry of safeDirectories(dir)) {
|
|
140
|
+
const path = join(dir, entry);
|
|
141
|
+
const specPath = join(path, "spec.md");
|
|
142
|
+
if (existsSync(specPath)) files.push(specPath);
|
|
143
|
+
walk(path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
walk(specsDir);
|
|
147
|
+
return files.sort();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function domainFromSpecPath(changeRoot: string, specPath: string): string | undefined {
|
|
151
|
+
const rel = relative(join(changeRoot, "specs"), specPath).split(/[\\/]/);
|
|
152
|
+
return rel.length >= 2 && rel.at(-1) === "spec.md" ? rel.slice(0, -1).join("/") : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function countTasks(tasksPath: string | undefined): SddTaskProgress {
|
|
156
|
+
if (!tasksPath || !existsSync(tasksPath)) {
|
|
157
|
+
return { total: 0, complete: 0, remaining: 0, unchecked: [] };
|
|
158
|
+
}
|
|
159
|
+
const unchecked: string[] = [];
|
|
160
|
+
let complete = 0;
|
|
161
|
+
for (const rawLine of safeRead(tasksPath).split(/\r?\n/)) {
|
|
162
|
+
const line = rawLine.trimEnd();
|
|
163
|
+
if (/^\s*- \[[xX]\]/.test(line)) complete += 1;
|
|
164
|
+
if (/^\s*- \[ \]/.test(line)) unchecked.push(line.trim());
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
total: complete + unchecked.length,
|
|
168
|
+
complete,
|
|
169
|
+
remaining: unchecked.length,
|
|
170
|
+
unchecked,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function reportIsClearlyPassing(path: string | undefined): boolean {
|
|
175
|
+
if (!path || !hasContent(path)) return false;
|
|
176
|
+
const text = safeRead(path);
|
|
177
|
+
const hasBlocker = /(^|\b)(FAIL|FAILED|BLOCKED|CRITICAL|PENDING|TODO)(\b|:)|verification blockers?|not\s+(?:pass|passed|passing|successful|complete|completed)|(?:pass|passed|success|successful|complete|completed)\s*:\s*no\b/i.test(text);
|
|
178
|
+
const hasPassSignal = text
|
|
179
|
+
.split(/\r?\n/)
|
|
180
|
+
.map((line) => line.trim())
|
|
181
|
+
.some((line) =>
|
|
182
|
+
/^(?:(?:status|verdict|result|verification|sync|final(?:\s+verdict)?)\s*:\s*)?(?:PASS|PASSED|SUCCESS|SUCCESSFUL)$/i.test(line) ||
|
|
183
|
+
/^all checks passed\.?$/i.test(line) ||
|
|
184
|
+
/^ready for archive\.?$/i.test(line) ||
|
|
185
|
+
/^sync completed?\.?$/i.test(line),
|
|
186
|
+
);
|
|
187
|
+
return hasPassSignal && !hasBlocker;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function emptyStatus(cwd: string, changeName: string | null, blockedReasons: string[]): SddStatus {
|
|
191
|
+
const root = resolve(cwd);
|
|
192
|
+
const changesDir = join(root, "openspec", "changes");
|
|
193
|
+
const actionContext: SddActionContext = {
|
|
194
|
+
mode: "repo-local",
|
|
195
|
+
workspaceRoot: root,
|
|
196
|
+
allowedEditRoots: [root],
|
|
197
|
+
warnings: [],
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
schemaName: "gentle-pi.sdd-status",
|
|
201
|
+
schemaVersion: 1,
|
|
202
|
+
changeName,
|
|
203
|
+
artifactStore: "openspec",
|
|
204
|
+
planningHome: { root, changesDir },
|
|
205
|
+
changeRoot: null,
|
|
206
|
+
artifactPaths: { ...EMPTY_PATHS },
|
|
207
|
+
contextFiles: { ...EMPTY_PATHS },
|
|
208
|
+
artifacts: {
|
|
209
|
+
proposal: "missing",
|
|
210
|
+
specs: "missing",
|
|
211
|
+
design: "missing",
|
|
212
|
+
tasks: "missing",
|
|
213
|
+
applyProgress: "missing",
|
|
214
|
+
verifyReport: "missing",
|
|
215
|
+
syncReport: "missing",
|
|
216
|
+
},
|
|
217
|
+
taskProgress: { total: 0, complete: 0, remaining: 0, unchecked: [] },
|
|
218
|
+
applyState: "blocked",
|
|
219
|
+
dependencies: { apply: "blocked", verify: "blocked", sync: "blocked", archive: "blocked" },
|
|
220
|
+
actionContext,
|
|
221
|
+
relationships: {
|
|
222
|
+
dependsOn: [],
|
|
223
|
+
supersedes: [],
|
|
224
|
+
amends: [],
|
|
225
|
+
conflictsWith: [],
|
|
226
|
+
sameDomainActiveChanges: [],
|
|
227
|
+
},
|
|
228
|
+
collisions: [],
|
|
229
|
+
nextRecommended: blockedReasons[0] ?? "Start an SDD change.",
|
|
230
|
+
blockedReasons,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function listActiveOpenSpecChanges(cwd: string): string[] {
|
|
235
|
+
return safeDirectories(join(cwd, "openspec", "changes")).filter(
|
|
236
|
+
(change) => change !== "archive",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderPhaseInstructions(status: SddStatus): SddPhaseInstructions {
|
|
241
|
+
const change = status.changeName ?? "<unresolved>";
|
|
242
|
+
return {
|
|
243
|
+
apply: [
|
|
244
|
+
`Change: ${change}`,
|
|
245
|
+
`State: ${status.dependencies.apply}`,
|
|
246
|
+
status.applyState === "all_done"
|
|
247
|
+
? "All tasks are already checked complete; do not edit."
|
|
248
|
+
: "Implement only unchecked tasks from the tasks artifact.",
|
|
249
|
+
`Tasks: ${status.taskProgress.complete}/${status.taskProgress.total} complete`,
|
|
250
|
+
"Update persisted task checkboxes immediately after completing each task.",
|
|
251
|
+
...status.taskProgress.unchecked.map((line) => `Remaining: ${line}`),
|
|
252
|
+
],
|
|
253
|
+
verify: [
|
|
254
|
+
`Change: ${change}`,
|
|
255
|
+
`State: ${status.dependencies.verify}`,
|
|
256
|
+
"Verify task completion, spec coverage, implementation correctness, design coherence, and tests when available.",
|
|
257
|
+
"Unchecked implementation tasks are CRITICAL archive blockers.",
|
|
258
|
+
...status.taskProgress.unchecked.map((line) => `Unchecked blocker: ${line}`),
|
|
259
|
+
],
|
|
260
|
+
sync: [
|
|
261
|
+
`Change: ${change}`,
|
|
262
|
+
`State: ${status.dependencies.sync}`,
|
|
263
|
+
"Sync delta specs into openspec/specs only after verification is clean.",
|
|
264
|
+
...status.artifactPaths.specs.map((path) => `Delta spec: ${path}`),
|
|
265
|
+
...status.collisions.flatMap((collision) =>
|
|
266
|
+
collision.changes.map((item) =>
|
|
267
|
+
`Same-domain collision: ${collision.domain} also touched by ${item.change} (${item.path})`,
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
],
|
|
271
|
+
archive: [
|
|
272
|
+
`Change: ${change}`,
|
|
273
|
+
`State: ${status.dependencies.archive}`,
|
|
274
|
+
"Archive only after clean verify, completed sync, and zero unchecked implementation tasks.",
|
|
275
|
+
"CRITICAL verification issues have no override.",
|
|
276
|
+
status.changeRoot
|
|
277
|
+
? `Archive target: ${join(status.planningHome.changesDir, "archive", `YYYY-MM-DD-${change}`)}`
|
|
278
|
+
: "Archive target unavailable until change is resolved.",
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
|
|
284
|
+
const root = resolve(options.cwd);
|
|
285
|
+
const changesDir = join(root, "openspec", "changes");
|
|
286
|
+
const activeChanges = listActiveOpenSpecChanges(root);
|
|
287
|
+
let changeName = options.changeName?.trim() || "";
|
|
288
|
+
const blockedReasons: string[] = [];
|
|
289
|
+
|
|
290
|
+
if (!changeName) {
|
|
291
|
+
if (activeChanges.length === 1) {
|
|
292
|
+
changeName = activeChanges[0];
|
|
293
|
+
} else if (activeChanges.length === 0) {
|
|
294
|
+
return emptyStatus(root, null, ["No active SDD changes found."]);
|
|
295
|
+
} else {
|
|
296
|
+
return emptyStatus(root, null, [
|
|
297
|
+
`Change selection is ambiguous: ${activeChanges.join(", ")}.`,
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!activeChanges.includes(changeName)) {
|
|
303
|
+
return emptyStatus(root, changeName, [`Active change not found: ${changeName}.`]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const changeRoot = join(changesDir, changeName);
|
|
307
|
+
const proposal = join(changeRoot, "proposal.md");
|
|
308
|
+
const design = join(changeRoot, "design.md");
|
|
309
|
+
const tasks = join(changeRoot, "tasks.md");
|
|
310
|
+
const applyProgress = join(changeRoot, "apply-progress.md");
|
|
311
|
+
const verifyReport = join(changeRoot, "verify-report.md");
|
|
312
|
+
const syncReport = join(changeRoot, "sync-report.md");
|
|
313
|
+
const specFiles = findSpecFiles(join(changeRoot, "specs"));
|
|
314
|
+
const legacyFlatSpec = detectLegacyFlatSpec(root, changeName);
|
|
315
|
+
const flatOnly = Boolean(legacyFlatSpec && specFiles.length === 0);
|
|
316
|
+
|
|
317
|
+
const artifactPaths: SddArtifactPaths = {
|
|
318
|
+
proposal: existsSync(proposal) ? [proposal] : [],
|
|
319
|
+
specs: specFiles,
|
|
320
|
+
design: existsSync(design) ? [design] : [],
|
|
321
|
+
tasks: existsSync(tasks) ? [tasks] : [],
|
|
322
|
+
applyProgress: existsSync(applyProgress) ? [applyProgress] : [],
|
|
323
|
+
verifyReport: existsSync(verifyReport) ? [verifyReport] : [],
|
|
324
|
+
syncReport: existsSync(syncReport) ? [syncReport] : [],
|
|
325
|
+
};
|
|
326
|
+
const artifacts = {
|
|
327
|
+
proposal: singleFileState(artifactPaths.proposal),
|
|
328
|
+
specs: multiFileState(artifactPaths.specs, flatOnly),
|
|
329
|
+
design: singleFileState(artifactPaths.design),
|
|
330
|
+
tasks: singleFileState(artifactPaths.tasks),
|
|
331
|
+
applyProgress: singleFileState(artifactPaths.applyProgress),
|
|
332
|
+
verifyReport: singleFileState(artifactPaths.verifyReport),
|
|
333
|
+
syncReport: singleFileState(artifactPaths.syncReport),
|
|
334
|
+
} satisfies SddStatus["artifacts"];
|
|
335
|
+
const taskProgress = countTasks(artifactPaths.tasks[0]);
|
|
336
|
+
const actionContext: SddActionContext = {
|
|
337
|
+
mode: "repo-local",
|
|
338
|
+
workspaceRoot: options.workspaceRoot ? resolve(options.workspaceRoot) : root,
|
|
339
|
+
allowedEditRoots: [options.workspaceRoot ? resolve(options.workspaceRoot) : root],
|
|
340
|
+
warnings: [],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const collisions = specFiles
|
|
344
|
+
.map((path) => domainFromSpecPath(changeRoot, path))
|
|
345
|
+
.filter((domain): domain is string => Boolean(domain))
|
|
346
|
+
.map((domain) => ({
|
|
347
|
+
domain,
|
|
348
|
+
changes: detectActiveDomainCollisions(root, changeName, domain).sort((a, b) =>
|
|
349
|
+
a.change.localeCompare(b.change),
|
|
350
|
+
),
|
|
351
|
+
}))
|
|
352
|
+
.filter((collision) => collision.changes.length > 0);
|
|
353
|
+
|
|
354
|
+
if (artifacts.proposal === "missing") blockedReasons.push("proposal.md is missing.");
|
|
355
|
+
if (artifacts.proposal === "partial") blockedReasons.push("proposal.md is empty or partial.");
|
|
356
|
+
if (artifacts.specs !== "done") blockedReasons.push("domain specs are missing or partial.");
|
|
357
|
+
if (artifacts.design === "missing") blockedReasons.push("design.md is missing.");
|
|
358
|
+
if (artifacts.design === "partial") blockedReasons.push("design.md is empty or partial.");
|
|
359
|
+
if (artifacts.tasks === "missing") blockedReasons.push("tasks.md is missing.");
|
|
360
|
+
if (artifacts.tasks === "partial") blockedReasons.push("tasks.md is empty or partial.");
|
|
361
|
+
if (artifacts.tasks === "done" && taskProgress.total === 0) {
|
|
362
|
+
blockedReasons.push("tasks.md has no implementation task checkboxes.");
|
|
363
|
+
}
|
|
364
|
+
if (flatOnly && legacyFlatSpec) {
|
|
365
|
+
blockedReasons.push(`Legacy flat spec is present without domain specs: ${legacyFlatSpec.path}.`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const coreArtifactsReady = artifacts.proposal === "done" && artifacts.specs === "done" && artifacts.design === "done" && artifacts.tasks === "done" && taskProgress.total > 0 && !flatOnly;
|
|
369
|
+
const applyState: ApplyState = !coreArtifactsReady
|
|
370
|
+
? "blocked"
|
|
371
|
+
: taskProgress.remaining === 0
|
|
372
|
+
? "all_done"
|
|
373
|
+
: "ready";
|
|
374
|
+
const verifyClean = reportIsClearlyPassing(artifactPaths.verifyReport[0]);
|
|
375
|
+
const syncClean = reportIsClearlyPassing(artifactPaths.syncReport[0]);
|
|
376
|
+
const syncPrerequisitesReady = coreArtifactsReady && verifyClean && collisions.length === 0 && !flatOnly;
|
|
377
|
+
const syncState: DependencyState = syncPrerequisitesReady
|
|
378
|
+
? syncClean
|
|
379
|
+
? "all_done"
|
|
380
|
+
: "ready"
|
|
381
|
+
: "blocked";
|
|
382
|
+
const verifyState: DependencyState = verifyClean
|
|
383
|
+
? "all_done"
|
|
384
|
+
: artifacts.tasks === "done" && taskProgress.total > 0 && (artifacts.applyProgress === "done" || applyState === "all_done")
|
|
385
|
+
? "ready"
|
|
386
|
+
: "blocked";
|
|
387
|
+
const dependencies: SddStatus["dependencies"] = {
|
|
388
|
+
apply: applyState === "blocked" ? "blocked" : applyState,
|
|
389
|
+
verify: verifyState,
|
|
390
|
+
sync: syncState,
|
|
391
|
+
archive: coreArtifactsReady && verifyClean && syncClean && taskProgress.remaining === 0 ? "ready" : "blocked",
|
|
392
|
+
};
|
|
393
|
+
const archiveReady = dependencies.archive === "ready";
|
|
394
|
+
const nextRecommended = dependencies.apply === "ready"
|
|
395
|
+
? "sdd-apply"
|
|
396
|
+
: dependencies.verify === "ready"
|
|
397
|
+
? "sdd-verify"
|
|
398
|
+
: dependencies.sync === "ready"
|
|
399
|
+
? "sdd-sync"
|
|
400
|
+
: archiveReady
|
|
401
|
+
? "sdd-archive"
|
|
402
|
+
: blockedReasons[0] ?? "Resolve blockers.";
|
|
403
|
+
|
|
404
|
+
const status: SddStatus = {
|
|
405
|
+
schemaName: "gentle-pi.sdd-status",
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
changeName,
|
|
408
|
+
artifactStore: "openspec",
|
|
409
|
+
planningHome: { root, changesDir },
|
|
410
|
+
changeRoot,
|
|
411
|
+
artifactPaths,
|
|
412
|
+
contextFiles: artifactPaths,
|
|
413
|
+
artifacts,
|
|
414
|
+
taskProgress,
|
|
415
|
+
applyState,
|
|
416
|
+
dependencies,
|
|
417
|
+
actionContext,
|
|
418
|
+
relationships: {
|
|
419
|
+
dependsOn: [],
|
|
420
|
+
supersedes: [],
|
|
421
|
+
amends: [],
|
|
422
|
+
conflictsWith: [],
|
|
423
|
+
sameDomainActiveChanges: collisions,
|
|
424
|
+
},
|
|
425
|
+
collisions,
|
|
426
|
+
legacyFlatSpec: legacyFlatSpec
|
|
427
|
+
? { path: legacyFlatSpec.path, hasDomainSpecs: specFiles.length > 0 }
|
|
428
|
+
: undefined,
|
|
429
|
+
nextRecommended,
|
|
430
|
+
blockedReasons,
|
|
431
|
+
};
|
|
432
|
+
if (options.includeInstructions) status.instructions = renderPhaseInstructions(status);
|
|
433
|
+
return status;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function renderNativeSddPhasePrompt(status: SddStatus, phase?: SddPhase): string {
|
|
437
|
+
const selectedInstructions = phase ? status.instructions?.[phase] : undefined;
|
|
438
|
+
return [
|
|
439
|
+
"## Native SDD Status Engine",
|
|
440
|
+
"The parent/orchestrator resolved this status deterministically. Treat it as authoritative over prompt inference.",
|
|
441
|
+
"Do not run phase work when this status marks the phase blocked; return the blockers instead.",
|
|
442
|
+
...(phase && selectedInstructions
|
|
443
|
+
? ["", `### ${phase} instructions`, ...selectedInstructions.map((line) => `- ${line}`)]
|
|
444
|
+
: []),
|
|
445
|
+
"",
|
|
446
|
+
"```json",
|
|
447
|
+
JSON.stringify(status, null, 2),
|
|
448
|
+
"```",
|
|
449
|
+
].join("\n");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function renderSddDispatcherMarkdown(status: SddStatus): string {
|
|
453
|
+
return [
|
|
454
|
+
`## Native SDD Dispatcher: ${status.changeName ?? "unresolved"}`,
|
|
455
|
+
"",
|
|
456
|
+
`nextPhase: ${status.nextRecommended}`,
|
|
457
|
+
`apply: ${status.dependencies.apply}`,
|
|
458
|
+
`verify: ${status.dependencies.verify}`,
|
|
459
|
+
`sync: ${status.dependencies.sync}`,
|
|
460
|
+
`archive: ${status.dependencies.archive}`,
|
|
461
|
+
"",
|
|
462
|
+
status.blockedReasons.length > 0
|
|
463
|
+
? ["### Blocked", ...status.blockedReasons.map((reason) => `- ${reason}`)].join("\n")
|
|
464
|
+
: "### Ready\nThe next phase may be delegated with the attached status JSON and phase instructions.",
|
|
465
|
+
"",
|
|
466
|
+
"### Instructions for next phase",
|
|
467
|
+
...((status.instructions?.[status.nextRecommended.replace(/^sdd-/, "") as SddPhase] ?? [])
|
|
468
|
+
.map((line) => `- ${line}`)),
|
|
469
|
+
"",
|
|
470
|
+
"### Status JSON",
|
|
471
|
+
"```json",
|
|
472
|
+
JSON.stringify(status, null, 2),
|
|
473
|
+
"```",
|
|
474
|
+
].join("\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function renderSddStatusMarkdown(status: SddStatus): string {
|
|
478
|
+
const title = status.changeName ?? "unresolved";
|
|
479
|
+
const lines = [
|
|
480
|
+
`## SDD Status: ${title}`,
|
|
481
|
+
"",
|
|
482
|
+
`schema: ${status.schemaName}@${status.schemaVersion}`,
|
|
483
|
+
`store: ${status.artifactStore}`,
|
|
484
|
+
`root: ${status.planningHome.root}`,
|
|
485
|
+
`next: ${status.nextRecommended}`,
|
|
486
|
+
"",
|
|
487
|
+
"### Tasks",
|
|
488
|
+
`- complete: ${status.taskProgress.complete}/${status.taskProgress.total}`,
|
|
489
|
+
`- remaining: ${status.taskProgress.remaining}`,
|
|
490
|
+
...status.taskProgress.unchecked.map((line) => `- unchecked: ${line}`),
|
|
491
|
+
"",
|
|
492
|
+
"### Dependencies",
|
|
493
|
+
...Object.entries(status.dependencies).map(([phase, state]) => `- ${phase}: ${state}`),
|
|
494
|
+
];
|
|
495
|
+
if (status.collisions.length > 0) {
|
|
496
|
+
lines.push("", "### Same-domain active changes");
|
|
497
|
+
for (const collision of status.collisions) {
|
|
498
|
+
lines.push(
|
|
499
|
+
`- ${collision.domain}: ${collision.changes.map((item) => item.change).join(", ")}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (status.blockedReasons.length > 0) {
|
|
504
|
+
lines.push("", "### Blockers", ...status.blockedReasons.map((reason) => `- ${reason}`));
|
|
505
|
+
}
|
|
506
|
+
lines.push("", "### JSON", "```json", JSON.stringify(status, null, 2), "```");
|
|
507
|
+
return lines.join("\n");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function parseSddStatusCommandArgs(args: string): { changeName?: string; json: boolean } {
|
|
511
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
512
|
+
const json = parts.includes("--json");
|
|
513
|
+
const changeName = parts.find((part) => part !== "--json");
|
|
514
|
+
return { changeName, json };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function sddStatusSeverity(status: SddStatus): "info" | "warning" {
|
|
518
|
+
return status.blockedReasons.length > 0 || Object.values(status.dependencies).includes("blocked")
|
|
519
|
+
? "warning"
|
|
520
|
+
: "info";
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function summarizeSddStatusForTitle(status: SddStatus): string {
|
|
524
|
+
return `${status.changeName ?? "unresolved"}: ${status.nextRecommended} (${status.taskProgress.complete}/${status.taskProgress.total} tasks)`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function activeChangeLabel(cwd: string): string {
|
|
528
|
+
return basename(resolve(cwd));
|
|
529
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,10 @@ const EXPECTED_COMMANDS = [
|
|
|
31
31
|
"gentle-ai:install-sdd",
|
|
32
32
|
"gentle-ai:sdd-preflight",
|
|
33
33
|
"gentle:sdd-preflight",
|
|
34
|
+
"sdd-status",
|
|
35
|
+
"gentle-ai:sdd-status",
|
|
36
|
+
"sdd-continue",
|
|
37
|
+
"gentle-ai:sdd-continue",
|
|
34
38
|
"gentle:models",
|
|
35
39
|
"gentle-ai:models",
|
|
36
40
|
"gentleman:models",
|
|
@@ -246,6 +250,25 @@ async function run() {
|
|
|
246
250
|
assert.match(onboardPromptResult.systemPrompt, /onboard base/);
|
|
247
251
|
assert.match(onboardPromptResult.systemPrompt, /## SDD Session Preflight/);
|
|
248
252
|
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-onboard.md")), true);
|
|
253
|
+
await mkdir(join(promptCwd, "openspec", "changes", "status-demo", "specs", "demo"), { recursive: true });
|
|
254
|
+
await writeFile(join(promptCwd, "openspec", "changes", "status-demo", "proposal.md"), "# Proposal\n");
|
|
255
|
+
await writeFile(join(promptCwd, "openspec", "changes", "status-demo", "specs", "demo", "spec.md"), "# Spec\n");
|
|
256
|
+
await writeFile(join(promptCwd, "openspec", "changes", "status-demo", "design.md"), "# Design\n");
|
|
257
|
+
await writeFile(join(promptCwd, "openspec", "changes", "status-demo", "tasks.md"), "# Tasks\n\n- [ ] 1.1 Implement demo\n");
|
|
258
|
+
const applyPromptResult = await promptHook(
|
|
259
|
+
{ agentName: "sdd-apply", systemPrompt: "apply base" },
|
|
260
|
+
createCtx(promptCwd, true, "sdd-apply-session"),
|
|
261
|
+
);
|
|
262
|
+
assert.match(applyPromptResult.systemPrompt, /## Native SDD Status Engine/);
|
|
263
|
+
assert.match(applyPromptResult.systemPrompt, /"changeName": "status-demo"/);
|
|
264
|
+
assert.match(applyPromptResult.systemPrompt, /### apply instructions/);
|
|
265
|
+
const statusCtx = createCtx(promptCwd, true);
|
|
266
|
+
await commands.get("sdd-status").handler("status-demo --json", statusCtx);
|
|
267
|
+
assert.match(statusCtx.ui.notifications.at(-1).message, /"schemaName": "gentle-pi\.sdd-status"/);
|
|
268
|
+
const continueCtx = createCtx(promptCwd, true);
|
|
269
|
+
await commands.get("sdd-continue").handler("status-demo", continueCtx);
|
|
270
|
+
assert.match(continueCtx.ui.notifications.at(-1).message, /Native SDD Dispatcher/);
|
|
271
|
+
assert.match(continueCtx.ui.notifications.at(-1).message, /nextPhase: sdd-apply/);
|
|
249
272
|
} finally {
|
|
250
273
|
await rm(promptCwd, { recursive: true, force: true });
|
|
251
274
|
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
listActiveOpenSpecChanges,
|
|
9
|
+
parseSddStatusCommandArgs,
|
|
10
|
+
renderPhaseInstructions,
|
|
11
|
+
renderSddStatusMarkdown,
|
|
12
|
+
resolveSddStatus,
|
|
13
|
+
} from "../lib/sdd-status.ts";
|
|
14
|
+
|
|
15
|
+
async function workspace(): Promise<string> {
|
|
16
|
+
return mkdtemp(join(tmpdir(), "gentle-pi-sdd-status-"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function write(path: string, content: string): void {
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
21
|
+
writeFileSync(path, content);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function seedChange(cwd: string, change = "add-auth"): string {
|
|
25
|
+
const root = join(cwd, "openspec", "changes", change);
|
|
26
|
+
write(join(root, "proposal.md"), "# Proposal\n");
|
|
27
|
+
write(join(root, "specs", "auth", "spec.md"), "# Auth Spec\n");
|
|
28
|
+
write(join(root, "design.md"), "# Design\n");
|
|
29
|
+
write(
|
|
30
|
+
join(root, "tasks.md"),
|
|
31
|
+
`# Tasks
|
|
32
|
+
|
|
33
|
+
- [x] 1.1 Build foundation
|
|
34
|
+
- [ ] 1.2 Wire routes
|
|
35
|
+
`,
|
|
36
|
+
);
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("listActiveOpenSpecChanges excludes archive and sorts active changes", async () => {
|
|
41
|
+
const cwd = await workspace();
|
|
42
|
+
mkdirSync(join(cwd, "openspec", "changes", "b-change"), { recursive: true });
|
|
43
|
+
mkdirSync(join(cwd, "openspec", "changes", "a-change"), { recursive: true });
|
|
44
|
+
mkdirSync(join(cwd, "openspec", "changes", "archive", "2026-01-01-old"), { recursive: true });
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(listActiveOpenSpecChanges(cwd), ["a-change", "b-change"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("resolveSddStatus blocks when there are no active changes", async () => {
|
|
50
|
+
const cwd = await workspace();
|
|
51
|
+
mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
|
|
52
|
+
|
|
53
|
+
const status = resolveSddStatus({ cwd });
|
|
54
|
+
|
|
55
|
+
assert.equal(status.changeName, null);
|
|
56
|
+
assert.match(status.blockedReasons[0], /No active SDD changes/);
|
|
57
|
+
assert.equal(status.dependencies.apply, "blocked");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("resolveSddStatus blocks when change selection is ambiguous", async () => {
|
|
61
|
+
const cwd = await workspace();
|
|
62
|
+
mkdirSync(join(cwd, "openspec", "changes", "first"), { recursive: true });
|
|
63
|
+
mkdirSync(join(cwd, "openspec", "changes", "second"), { recursive: true });
|
|
64
|
+
|
|
65
|
+
const status = resolveSddStatus({ cwd });
|
|
66
|
+
|
|
67
|
+
assert.equal(status.changeName, null);
|
|
68
|
+
assert.match(status.blockedReasons[0], /ambiguous/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("resolveSddStatus selects the only active change and counts task progress", async () => {
|
|
72
|
+
const cwd = await workspace();
|
|
73
|
+
const root = seedChange(cwd);
|
|
74
|
+
|
|
75
|
+
const status = resolveSddStatus({ cwd, includeInstructions: true });
|
|
76
|
+
|
|
77
|
+
assert.equal(status.changeName, "add-auth");
|
|
78
|
+
assert.equal(status.changeRoot, root);
|
|
79
|
+
assert.equal(status.artifacts.proposal, "done");
|
|
80
|
+
assert.equal(status.artifacts.specs, "done");
|
|
81
|
+
assert.deepEqual(status.taskProgress, {
|
|
82
|
+
total: 2,
|
|
83
|
+
complete: 1,
|
|
84
|
+
remaining: 1,
|
|
85
|
+
unchecked: ["- [ ] 1.2 Wire routes"],
|
|
86
|
+
});
|
|
87
|
+
assert.equal(status.applyState, "ready");
|
|
88
|
+
assert.equal(status.dependencies.apply, "ready");
|
|
89
|
+
assert.match(status.instructions?.apply.join("\n") ?? "", /persisted task checkboxes/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("resolveSddStatus marks apply all_done and verify ready when tasks are checked", async () => {
|
|
93
|
+
const cwd = await workspace();
|
|
94
|
+
const root = seedChange(cwd);
|
|
95
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Build foundation\n");
|
|
96
|
+
|
|
97
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
98
|
+
|
|
99
|
+
assert.equal(status.applyState, "all_done");
|
|
100
|
+
assert.equal(status.dependencies.apply, "all_done");
|
|
101
|
+
assert.equal(status.dependencies.verify, "ready");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resolveSddStatus blocks sync when verify report is not clearly passing", async () => {
|
|
105
|
+
const cwd = await workspace();
|
|
106
|
+
const root = seedChange(cwd);
|
|
107
|
+
write(join(root, "apply-progress.md"), "# Apply\n\nSome work completed.\n");
|
|
108
|
+
write(join(root, "verify-report.md"), "# Verify\n\nTODO: tests not run yet\n");
|
|
109
|
+
|
|
110
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
111
|
+
|
|
112
|
+
assert.equal(status.dependencies.verify, "ready");
|
|
113
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
114
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("resolveSddStatus rejects negated pass and sync-complete phrases", async () => {
|
|
118
|
+
const cwd = await workspace();
|
|
119
|
+
const root = seedChange(cwd);
|
|
120
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
121
|
+
write(join(root, "verify-report.md"), "# Verify\n\nStatus: not passed\n");
|
|
122
|
+
write(join(root, "sync-report.md"), "# Sync\n\nSync complete: no\n");
|
|
123
|
+
|
|
124
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
125
|
+
|
|
126
|
+
assert.equal(status.dependencies.verify, "ready");
|
|
127
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
128
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
129
|
+
assert.notEqual(status.nextRecommended, "sdd-archive");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("resolveSddStatus blocks sync when verify report contains critical text", async () => {
|
|
133
|
+
const cwd = await workspace();
|
|
134
|
+
const root = seedChange(cwd);
|
|
135
|
+
write(join(root, "verify-report.md"), "# Verify\n\nCRITICAL: missing tests\n");
|
|
136
|
+
|
|
137
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
138
|
+
|
|
139
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
140
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("resolveSddStatus reports same-domain collisions", async () => {
|
|
144
|
+
const cwd = await workspace();
|
|
145
|
+
const root = seedChange(cwd, "current");
|
|
146
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
147
|
+
write(join(root, "verify-report.md"), "# Verify\n\nPASS\n");
|
|
148
|
+
write(join(cwd, "openspec", "changes", "other", "specs", "auth", "spec.md"), "# Other\n");
|
|
149
|
+
|
|
150
|
+
const status = resolveSddStatus({ cwd, changeName: "current" });
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(status.collisions.map((collision) => collision.domain), ["auth"]);
|
|
153
|
+
assert.equal(status.collisions[0].changes[0].change, "other");
|
|
154
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("resolveSddStatus blocks apply when tasks has no checkboxes", async () => {
|
|
158
|
+
const cwd = await workspace();
|
|
159
|
+
const root = seedChange(cwd);
|
|
160
|
+
write(join(root, "tasks.md"), "# Tasks\n\nImplementation notes only.\n");
|
|
161
|
+
|
|
162
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
163
|
+
|
|
164
|
+
assert.equal(status.taskProgress.total, 0);
|
|
165
|
+
assert.equal(status.applyState, "blocked");
|
|
166
|
+
assert.equal(status.dependencies.apply, "blocked");
|
|
167
|
+
assert.match(status.blockedReasons.join("\n"), /no implementation task checkboxes/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("resolveSddStatus marks legacy flat specs partial and blocks sync", async () => {
|
|
171
|
+
const cwd = await workspace();
|
|
172
|
+
write(join(cwd, "openspec", "changes", "legacy", "proposal.md"), "# Proposal\n");
|
|
173
|
+
write(join(cwd, "openspec", "changes", "legacy", "spec.md"), "# Flat\n");
|
|
174
|
+
write(join(cwd, "openspec", "changes", "legacy", "design.md"), "# Design\n");
|
|
175
|
+
write(join(cwd, "openspec", "changes", "legacy", "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
176
|
+
write(join(cwd, "openspec", "changes", "legacy", "verify-report.md"), "# Verify\n\nPASS\n");
|
|
177
|
+
|
|
178
|
+
const status = resolveSddStatus({ cwd, changeName: "legacy" });
|
|
179
|
+
|
|
180
|
+
assert.equal(status.artifacts.specs, "partial");
|
|
181
|
+
assert.match(status.blockedReasons.join("\n"), /Legacy flat spec/);
|
|
182
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("resolveSddStatus accepts nested domain specs even when a legacy flat spec also exists", async () => {
|
|
186
|
+
const cwd = await workspace();
|
|
187
|
+
write(join(cwd, "openspec", "changes", "mixed", "proposal.md"), "# Proposal\n");
|
|
188
|
+
write(join(cwd, "openspec", "changes", "mixed", "spec.md"), "# Flat\n");
|
|
189
|
+
write(join(cwd, "openspec", "changes", "mixed", "specs", "parent", "child", "spec.md"), "# Nested\n");
|
|
190
|
+
write(join(cwd, "openspec", "changes", "mixed", "design.md"), "# Design\n");
|
|
191
|
+
write(join(cwd, "openspec", "changes", "mixed", "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
192
|
+
|
|
193
|
+
const status = resolveSddStatus({ cwd, changeName: "mixed" });
|
|
194
|
+
|
|
195
|
+
assert.equal(status.artifacts.specs, "done");
|
|
196
|
+
assert.equal(status.legacyFlatSpec?.hasDomainSpecs, true);
|
|
197
|
+
assert.doesNotMatch(status.blockedReasons.join("\n"), /Legacy flat spec/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("resolveSddStatus blocks sync when core artifacts are missing even with clean verify", async () => {
|
|
201
|
+
const cwd = await workspace();
|
|
202
|
+
write(join(cwd, "openspec", "changes", "thin", "proposal.md"), "# Proposal\n");
|
|
203
|
+
write(join(cwd, "openspec", "changes", "thin", "design.md"), "# Design\n");
|
|
204
|
+
write(join(cwd, "openspec", "changes", "thin", "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
205
|
+
write(join(cwd, "openspec", "changes", "thin", "verify-report.md"), "# Verify\n\nPASS\n");
|
|
206
|
+
|
|
207
|
+
const status = resolveSddStatus({ cwd, changeName: "thin" });
|
|
208
|
+
|
|
209
|
+
assert.match(status.blockedReasons.join("\n"), /domain specs are missing or partial/);
|
|
210
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
211
|
+
assert.notEqual(status.nextRecommended, "sdd-sync");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("resolveSddStatus blocks stale sync report when current verify is not passing", async () => {
|
|
215
|
+
const cwd = await workspace();
|
|
216
|
+
const root = seedChange(cwd);
|
|
217
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
218
|
+
write(join(root, "verify-report.md"), "# Verify\n\nStatus: not passed\n");
|
|
219
|
+
write(join(root, "sync-report.md"), "# Sync\n\nPASS\n");
|
|
220
|
+
|
|
221
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
222
|
+
|
|
223
|
+
assert.equal(status.dependencies.verify, "ready");
|
|
224
|
+
assert.equal(status.dependencies.sync, "blocked");
|
|
225
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
226
|
+
assert.notEqual(status.nextRecommended, "sdd-archive");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("resolveSddStatus blocks archive when required artifacts are missing", async () => {
|
|
230
|
+
const cwd = await workspace();
|
|
231
|
+
write(join(cwd, "openspec", "changes", "thin", "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
232
|
+
write(join(cwd, "openspec", "changes", "thin", "verify-report.md"), "# Verify\n\nPASS\n");
|
|
233
|
+
write(join(cwd, "openspec", "changes", "thin", "sync-report.md"), "# Sync\n\nPASS\n");
|
|
234
|
+
|
|
235
|
+
const status = resolveSddStatus({ cwd, changeName: "thin" });
|
|
236
|
+
|
|
237
|
+
assert.match(status.blockedReasons.join("\n"), /proposal\.md is missing/);
|
|
238
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
239
|
+
assert.notEqual(status.nextRecommended, "sdd-archive");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("resolveSddStatus reports partial core artifacts as blockers", async () => {
|
|
243
|
+
const cwd = await workspace();
|
|
244
|
+
const root = seedChange(cwd);
|
|
245
|
+
write(join(root, "proposal.md"), "");
|
|
246
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
247
|
+
write(join(root, "verify-report.md"), "# Verify\n\nPASS\n");
|
|
248
|
+
write(join(root, "sync-report.md"), "# Sync\n\nPASS\n");
|
|
249
|
+
|
|
250
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
251
|
+
|
|
252
|
+
assert.equal(status.artifacts.proposal, "partial");
|
|
253
|
+
assert.match(status.blockedReasons.join("\n"), /proposal\.md is empty or partial/);
|
|
254
|
+
assert.equal(status.dependencies.archive, "blocked");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("resolveSddStatus marks archive ready only after clean verify, sync, and complete tasks", async () => {
|
|
258
|
+
const cwd = await workspace();
|
|
259
|
+
const root = seedChange(cwd);
|
|
260
|
+
write(join(root, "tasks.md"), "# Tasks\n\n- [x] 1.1 Done\n");
|
|
261
|
+
write(join(root, "verify-report.md"), "# Verify\n\nPASS\n");
|
|
262
|
+
write(join(root, "sync-report.md"), "# Sync\n\nPASS\n");
|
|
263
|
+
|
|
264
|
+
const status = resolveSddStatus({ cwd, changeName: "add-auth" });
|
|
265
|
+
|
|
266
|
+
assert.equal(status.dependencies.archive, "ready");
|
|
267
|
+
assert.equal(status.nextRecommended, "sdd-archive");
|
|
268
|
+
assert.match(renderPhaseInstructions(status).archive.join("\n"), /CRITICAL verification issues have no override/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("renderSddStatusMarkdown includes structured JSON", async () => {
|
|
272
|
+
const cwd = await workspace();
|
|
273
|
+
seedChange(cwd);
|
|
274
|
+
|
|
275
|
+
const markdown = renderSddStatusMarkdown(resolveSddStatus({ cwd }));
|
|
276
|
+
|
|
277
|
+
assert.match(markdown, /## SDD Status: add-auth/);
|
|
278
|
+
assert.match(markdown, /```json/);
|
|
279
|
+
assert.match(markdown, /"schemaName": "gentle-pi.sdd-status"/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("parseSddStatusCommandArgs extracts change and json flag", () => {
|
|
283
|
+
assert.deepEqual(parseSddStatusCommandArgs("add-auth --json"), {
|
|
284
|
+
changeName: "add-auth",
|
|
285
|
+
json: true,
|
|
286
|
+
});
|
|
287
|
+
assert.deepEqual(parseSddStatusCommandArgs("--json"), {
|
|
288
|
+
changeName: undefined,
|
|
289
|
+
json: true,
|
|
290
|
+
});
|
|
291
|
+
});
|