gentle-pi 0.4.2 → 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/agents/sdd-apply.md +39 -5
- package/assets/agents/sdd-archive.md +39 -2
- package/assets/agents/sdd-spec.md +7 -0
- package/assets/agents/sdd-status.md +100 -0
- package/assets/agents/sdd-sync.md +14 -1
- package/assets/agents/sdd-verify.md +32 -2
- package/assets/orchestrator.md +37 -2
- package/assets/support/sdd-status-contract.md +100 -0
- package/extensions/gentle-ai.ts +89 -1
- package/lib/sdd-status.ts +529 -0
- package/package.json +1 -1
- package/scripts/verify-package-files.mjs +2 -0
- package/tests/runtime-harness.mjs +40 -0
- package/tests/sdd-status.test.ts +291 -0
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");
|
|
@@ -42,6 +51,7 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
42
51
|
for (const [assetSubdir, installedSubdir] of [
|
|
43
52
|
["agents", "agents"],
|
|
44
53
|
["chains", "chains"],
|
|
54
|
+
["support", join("gentle-ai", "support")],
|
|
45
55
|
] as const) {
|
|
46
56
|
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
47
57
|
if (!existsSync(assetDir)) continue;
|
|
@@ -79,6 +89,7 @@ function sddLocalOverrideDriftCount(cwd: string): number {
|
|
|
79
89
|
for (const [assetSubdir, installedSubdir] of [
|
|
80
90
|
["agents", join(".pi", "agents")],
|
|
81
91
|
["chains", join(".pi", "chains")],
|
|
92
|
+
["support", join(".pi", "gentle-ai", "support")],
|
|
82
93
|
] as const) {
|
|
83
94
|
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
84
95
|
const installedDir = join(cwd, installedSubdir);
|
|
@@ -216,6 +227,7 @@ const SDD_AGENT_NAMES = [
|
|
|
216
227
|
"sdd-spec",
|
|
217
228
|
"sdd-design",
|
|
218
229
|
"sdd-tasks",
|
|
230
|
+
"sdd-status",
|
|
219
231
|
"sdd-apply",
|
|
220
232
|
"sdd-verify",
|
|
221
233
|
"sdd-sync",
|
|
@@ -302,6 +314,21 @@ function isNamedAgentStartEvent(event: unknown): boolean {
|
|
|
302
314
|
return readAgentStartNames(event).length > 0;
|
|
303
315
|
}
|
|
304
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
|
+
|
|
305
332
|
function evaluateDeniedCommand(
|
|
306
333
|
command: string,
|
|
307
334
|
): ToolCallEventResult | undefined {
|
|
@@ -1605,11 +1632,18 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1605
1632
|
prefs && (!isNamedAgent || isSddAgent)
|
|
1606
1633
|
? `\n\n${renderSddPreflightPrompt(prefs)}`
|
|
1607
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
|
+
: "";
|
|
1608
1642
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
1609
1643
|
? ""
|
|
1610
1644
|
: `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`;
|
|
1611
1645
|
return {
|
|
1612
|
-
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}`,
|
|
1646
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
|
|
1613
1647
|
};
|
|
1614
1648
|
});
|
|
1615
1649
|
|
|
@@ -1653,6 +1687,60 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1653
1687
|
},
|
|
1654
1688
|
});
|
|
1655
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
|
+
|
|
1656
1744
|
pi.registerCommand("gentle:models", {
|
|
1657
1745
|
description: "Configure global per-agent models for el Gentleman.",
|
|
1658
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",
|
|
@@ -15,12 +15,14 @@ const requiredPaths = [
|
|
|
15
15
|
"assets/agents/sdd-onboard.md",
|
|
16
16
|
"assets/agents/sdd-proposal.md",
|
|
17
17
|
"assets/agents/sdd-spec.md",
|
|
18
|
+
"assets/agents/sdd-status.md",
|
|
18
19
|
"assets/agents/sdd-sync.md",
|
|
19
20
|
"assets/agents/sdd-tasks.md",
|
|
20
21
|
"assets/agents/sdd-verify.md",
|
|
21
22
|
"assets/chains/sdd-full.chain.md",
|
|
22
23
|
"assets/chains/sdd-plan.chain.md",
|
|
23
24
|
"assets/chains/sdd-verify.chain.md",
|
|
25
|
+
"assets/support/sdd-status-contract.md",
|
|
24
26
|
"assets/support/strict-tdd.md",
|
|
25
27
|
"assets/support/strict-tdd-verify.md",
|
|
26
28
|
"extensions/gentle-ai.ts",
|