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.
@@ -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.
@@ -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",
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
+ });