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.
@@ -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
  }
@@ -311,8 +334,10 @@ async function run() {
311
334
  );
312
335
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
313
336
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
337
+ assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
314
338
  await writeFile(join(globalAgentHome, "agents", "sdd-apply.md"), "stale global apply\n");
315
339
  await writeFile(join(globalAgentHome, "chains", "sdd-full.chain.md"), "stale global chain\n");
340
+ await writeFile(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md"), "stale global status contract\n");
316
341
  await mkdir(join(noUiCwd, ".pi", "agents"), { recursive: true });
317
342
  await writeFile(join(noUiCwd, ".pi", "agents", "sdd-apply.md"), "project override must stay\n");
318
343
  for (const handler of hooks.get("session_start")) {
@@ -328,6 +353,11 @@ async function run() {
328
353
  "stale global chain\n",
329
354
  "session_start must refresh stale global SDD chains",
330
355
  );
356
+ assert.notEqual(
357
+ await readFile(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md"), "utf8"),
358
+ "stale global status contract\n",
359
+ "session_start must refresh stale global SDD support files",
360
+ );
331
361
  assert.equal(
332
362
  await readFile(join(noUiCwd, ".pi", "agents", "sdd-apply.md"), "utf8"),
333
363
  "project override must stay\n",
@@ -402,7 +432,9 @@ async function run() {
402
432
  assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
403
433
  assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
404
434
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
435
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
405
436
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
437
+ assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
406
438
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
407
439
  assert.equal(ctx.ui.selections.length, 3);
408
440
  assert.equal(ctx.ui.selections[0].label, "SDD execution mode");
@@ -436,7 +468,9 @@ async function run() {
436
468
  assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
437
469
  assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
438
470
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
471
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
439
472
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
473
+ assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
440
474
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
441
475
  const globalSddApply = await readFile(
442
476
  join(globalAgentHome, "agents", "sdd-apply.md"),
@@ -605,6 +639,8 @@ async function run() {
605
639
  assert.match(ctx.ui.notifications.at(-1).message, /Global Gentle AI SDD assets installed/);
606
640
  assert.equal(existsSync(join(installCwd, ".pi", "agents", "sdd-apply.md")), false);
607
641
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
642
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
643
+ assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
608
644
  } finally {
609
645
  await rm(installCwd, { recursive: true, force: true });
610
646
  }
@@ -613,9 +649,11 @@ async function run() {
613
649
  try {
614
650
  await mkdir(join(staleAssetsCwd, ".pi", "agents"), { recursive: true });
615
651
  await mkdir(join(staleAssetsCwd, ".pi", "chains"), { recursive: true });
652
+ await mkdir(join(staleAssetsCwd, ".pi", "gentle-ai", "support"), { recursive: true });
616
653
  await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-apply.md"), "stale apply\n");
617
654
  await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-spec.md"), "stale spec\n");
618
655
  await writeFile(join(staleAssetsCwd, ".pi", "chains", "sdd-full.chain.md"), "stale chain\n");
656
+ await writeFile(join(staleAssetsCwd, ".pi", "gentle-ai", "support", "sdd-status-contract.md"), "stale status contract\n");
619
657
  const ctx = createCtx(staleAssetsCwd, true);
620
658
  await commands.get("gentle-ai:status").handler("", ctx);
621
659
  assert.match(ctx.ui.notifications.at(-1).message, /Project-local SDD override drift: \d+ file\(s\)/);
@@ -638,7 +676,9 @@ async function run() {
638
676
  assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), false);
639
677
  assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
640
678
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
679
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
641
680
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
681
+ assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
642
682
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
643
683
  assert.equal(ctx.ui.selections.length, 3);
644
684
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
@@ -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
+ });