gentle-pi 2.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
package/lib/sdd-status.ts CHANGED
@@ -1,798 +1,26 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { basename, join, relative, resolve } from "node:path";
3
1
  import type { NativeSddStatusV2 } from "./native-review-cli.ts";
4
- import {
5
- detectActiveDomainCollisions,
6
- detectLegacyFlatSpec,
7
- type DomainCollision,
8
- } from "./openspec-guardrails.ts";
9
2
 
10
- // Canonical names match gentle-ai, which owns this contract. The dual-store
11
- // mode was called "both" here; "hybrid" is the provider's name for the same
12
- // thing, and normalizeSddArtifactStore keeps already-persisted "both" loading.
3
+ // Keep persisted legacy "both" normalization in sdd-preflight; native owns status.
13
4
  export type SddArtifactStore = "openspec" | "engram" | "hybrid" | "none";
14
- export type ArtifactState = "missing" | "done" | "partial";
15
- export type DependencyState = "blocked" | "ready" | "all_done" | "not_applicable";
16
- export type ApplyState = "blocked" | "ready" | "all_done" | "not_applicable";
17
- export type SddPhase = "apply" | "verify" | "sync" | "archive";
18
- export type SddNextRecommended =
19
- | `sdd-${"propose" | "spec" | "design" | "tasks" | SddPhase}`
20
- | "fix-task-ownership-marker"
21
- | "archived"
22
- | "resolve-via-engram"
23
- | "blocked";
5
+ export type SddPhase = "apply" | "verify" | "archive";
24
6
 
25
- export interface SddArtifactPaths {
26
- proposal: string[];
27
- specs: string[];
28
- design: string[];
29
- tasks: string[];
30
- applyProgress: string[];
31
- verifyReport: string[];
32
- syncReport: string[];
33
- }
34
-
35
- export interface SddTaskProgress {
36
- total: number;
37
- complete: number;
38
- remaining: number;
39
- unchecked: string[];
40
- }
41
-
42
- const SDD_TASK_OWNER = {
43
- IMPLEMENTATION: "implementation",
44
- PARENT: "parent",
45
- } as const;
46
-
47
- type SddTaskOwner = (typeof SDD_TASK_OWNER)[keyof typeof SDD_TASK_OWNER];
48
-
49
- interface SddTaskAccounting {
50
- implementation: SddTaskProgress;
51
- parent: SddTaskProgress;
52
- errors: string[];
53
- }
54
-
55
- const EMPTY_TASK_PROGRESS: SddTaskProgress = {
56
- total: 0,
57
- complete: 0,
58
- remaining: 0,
59
- unchecked: [],
60
- };
61
-
62
- export interface SddActionContext {
63
- mode: "repo-local";
64
- workspaceRoot: string;
65
- allowedEditRoots: string[];
66
- warnings: string[];
67
- }
68
-
69
- export interface SddDomainCollisionReport {
70
- domain: string;
71
- changes: DomainCollision[];
72
- }
73
-
74
- export interface SddPhaseInstructions {
75
- apply: string[];
76
- verify: string[];
77
- sync: string[];
78
- archive: string[];
79
- }
80
-
81
- export interface SddRelationships {
82
- dependsOn: string[];
83
- supersedes: string[];
84
- amends: string[];
85
- conflictsWith: string[];
86
- sameDomainActiveChanges: SddDomainCollisionReport[];
87
- }
88
-
89
- export interface SddStatus {
90
- schemaName: "gentle-pi.sdd-status";
91
- schemaVersion: 1;
92
- changeName: string | null;
93
- artifactStore: SddArtifactStore;
94
- planningHome: { root: string; changesDir: string };
95
- changeRoot: string | null;
96
- artifactPaths: SddArtifactPaths;
97
- contextFiles: SddArtifactPaths;
98
- artifacts: Record<keyof SddArtifactPaths, ArtifactState>;
99
- /** Implementation-owned progress; malformed markers remain unresolved here. */
100
- taskProgress: SddTaskProgress;
101
- /** Visible parent/orchestrator lifecycle actions, excluded from apply completion. */
102
- deferredParentActions: SddTaskProgress;
103
- /** Stable diagnostics for malformed ownership markers. */
104
- taskArtifactErrors: string[];
105
- applyState: ApplyState;
106
- dependencies: Record<SddPhase, DependencyState>;
107
- actionContext: SddActionContext;
108
- relationships: SddRelationships;
109
- collisions: SddDomainCollisionReport[];
110
- legacyFlatSpec?: { path: string; hasDomainSpecs: boolean };
111
- /**
112
- * Positive terminal projection: the change was archived. `path` is the
113
- * repo-relative archive folder (openspec/changes/archive/YYYY-MM-DD-<change>).
114
- * When set, `nextRecommended` is "archived" and no further phase is recommended.
115
- */
116
- archived?: { path: string };
117
- nextRecommended: SddNextRecommended;
118
- instructions?: SddPhaseInstructions;
119
- blockedReasons: string[];
120
- /**
121
- * True when the native status engine is not authoritative for the selected
122
- * artifact store (engram, none, or both without an openspec/ directory).
123
- * When true, `dependencies`, `applyState`, and `blockedReasons` must not be
124
- * treated as real blockers — resolve readiness from Engram instead.
125
- * Defaults to false on all authoritative (openspec / both-with-disk) paths.
126
- */
127
- isNonAuthoritative: boolean;
128
- }
129
-
130
- export interface ResolveSddStatusOptions {
131
- cwd: string;
132
- changeName?: string;
133
- includeInstructions?: boolean;
134
- workspaceRoot?: string;
135
- artifactStore?: SddArtifactStore;
136
- }
137
-
138
- const EMPTY_PATHS: SddArtifactPaths = {
139
- proposal: [],
140
- specs: [],
141
- design: [],
142
- tasks: [],
143
- applyProgress: [],
144
- verifyReport: [],
145
- syncReport: [],
146
- };
147
-
148
- function safeDirectories(path: string): string[] {
149
- try {
150
- return readdirSync(path)
151
- .filter((entry) => {
152
- try {
153
- return statSync(join(path, entry)).isDirectory();
154
- } catch {
155
- return false;
156
- }
157
- })
158
- .sort();
159
- } catch {
160
- return [];
161
- }
162
- }
163
-
164
- function safeRead(path: string): string {
165
- try {
166
- return readFileSync(path, "utf8");
167
- } catch {
168
- return "";
169
- }
170
- }
171
-
172
- function hasContent(path: string): boolean {
173
- return existsSync(path) && safeRead(path).trim().length > 0;
174
- }
175
-
176
- function singleFileState(paths: string[]): ArtifactState {
177
- if (paths.length === 0) return "missing";
178
- return paths.some((path) => !hasContent(path)) ? "partial" : "done";
179
- }
180
-
181
- function multiFileState(paths: string[], partial = false): ArtifactState {
182
- if (paths.length === 0) return partial ? "partial" : "missing";
183
- return paths.some((path) => !hasContent(path)) || partial ? "partial" : "done";
184
- }
185
-
186
- function findSpecFiles(specsDir: string): string[] {
187
- const files: string[] = [];
188
- function walk(dir: string): void {
189
- for (const entry of safeDirectories(dir)) {
190
- const path = join(dir, entry);
191
- const specPath = join(path, "spec.md");
192
- if (existsSync(specPath)) files.push(specPath);
193
- walk(path);
194
- }
195
- }
196
- walk(specsDir);
197
- return files.sort();
198
- }
199
-
200
- function domainFromSpecPath(changeRoot: string, specPath: string): string | undefined {
201
- const rel = relative(join(changeRoot, "specs"), specPath).split(/[\\/]/);
202
- return rel.length >= 2 && rel.at(-1) === "spec.md" ? rel.slice(0, -1).join("/") : undefined;
203
- }
204
-
205
- function taskProgress(lines: string[]): SddTaskProgress {
206
- const unchecked = lines.filter((line) => !/^\s*- \[[xX]\]/.test(line));
207
- return {
208
- total: lines.length,
209
- complete: lines.length - unchecked.length,
210
- remaining: unchecked.length,
211
- unchecked: unchecked.map((line) => line.trim()),
212
- };
213
- }
214
-
215
- function countTasks(tasksPath: string | undefined): SddTaskAccounting {
216
- if (!tasksPath || !existsSync(tasksPath)) {
217
- return { implementation: { ...EMPTY_TASK_PROGRESS }, parent: { ...EMPTY_TASK_PROGRESS }, errors: [] };
218
- }
219
- const implementation: string[] = [];
220
- const malformed: string[] = [];
221
- const parent: string[] = [];
222
- const errors: string[] = [];
223
- for (const rawLine of safeRead(tasksPath).split(/\r?\n/)) {
224
- const line = rawLine.trimEnd();
225
- if (!/^\s*- \[(?: |x|X)\]/.test(line)) continue;
226
- const markers = line.match(/<!--\s*sdd-owner\s*:[\s\S]*?-->/gi) ?? [];
227
- const hasOwnerToken = /sdd-owner/i.test(line);
228
- const canonical = /<!-- sdd-owner: (implementation|parent) -->\s*$/.exec(line);
229
- const owner: SddTaskOwner | undefined = canonical?.[1] as SddTaskOwner | undefined;
230
- if (!hasOwnerToken) {
231
- implementation.push(line);
232
- } else if (markers.length === 1 && canonical && canonical[0] === markers[0]) {
233
- (owner === SDD_TASK_OWNER.PARENT ? parent : implementation).push(line);
234
- } else {
235
- malformed.push(line);
236
- errors.push(`Malformed task ownership marker: ${line.trim()}`);
237
- }
238
- }
239
- const implementationProgress = taskProgress(implementation);
240
- return {
241
- implementation: {
242
- total: implementationProgress.total + malformed.length,
243
- complete: implementationProgress.complete,
244
- remaining: implementationProgress.remaining + malformed.length,
245
- unchecked: [...implementationProgress.unchecked, ...malformed.map((line) => line.trim())],
246
- },
247
- parent: taskProgress(parent),
248
- errors,
249
- };
250
- }
251
-
252
- function reportIsClearlyPassing(path: string | undefined): boolean {
253
- if (!path || !hasContent(path)) return false;
254
- const text = safeRead(path);
255
- 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);
256
- const hasPassSignal = text
257
- .split(/\r?\n/)
258
- .map((line) => line.trim())
259
- .some((line) =>
260
- /^(?:(?:status|verdict|result|verification|sync|final(?:\s+verdict)?)\s*:\s*)?(?:PASS|PASSED|SUCCESS|SUCCESSFUL)$/i.test(line) ||
261
- /^all checks passed\.?$/i.test(line) ||
262
- /^ready for archive\.?$/i.test(line) ||
263
- /^sync completed?\.?$/i.test(line),
264
- );
265
- return hasPassSignal && !hasBlocker;
266
- }
267
-
268
- // Planning routes repair the first incomplete prerequisite; diagnostics remain separate.
269
- function planningRecommendation(artifacts: SddStatus["artifacts"], taskTotal: number): SddNextRecommended {
270
- if (artifacts.proposal !== "done") return "sdd-propose";
271
- if (artifacts.specs !== "done") return "sdd-spec";
272
- if (artifacts.design !== "done") return "sdd-design";
273
- if (artifacts.tasks !== "done" || taskTotal === 0) return "sdd-tasks";
274
- return "blocked";
275
- }
276
-
277
- function emptyStatus(cwd: string, changeName: string | null, blockedReasons: string[], artifactStore: SddArtifactStore = "openspec", isNonAuthoritative = false): SddStatus {
278
- const root = resolve(cwd);
279
- const changesDir = join(root, "openspec", "changes");
280
- const actionContext: SddActionContext = {
281
- mode: "repo-local",
282
- workspaceRoot: root,
283
- allowedEditRoots: [root],
284
- warnings: [],
285
- };
286
- return {
287
- schemaName: "gentle-pi.sdd-status",
288
- schemaVersion: 1,
289
- changeName,
290
- artifactStore,
291
- planningHome: { root, changesDir },
292
- changeRoot: null,
293
- artifactPaths: { ...EMPTY_PATHS },
294
- contextFiles: { ...EMPTY_PATHS },
295
- artifacts: {
296
- proposal: "missing",
297
- specs: "missing",
298
- design: "missing",
299
- tasks: "missing",
300
- applyProgress: "missing",
301
- verifyReport: "missing",
302
- syncReport: "missing",
303
- },
304
- taskProgress: { ...EMPTY_TASK_PROGRESS },
305
- deferredParentActions: { ...EMPTY_TASK_PROGRESS },
306
- taskArtifactErrors: [],
307
- applyState: "blocked",
308
- dependencies: { apply: "blocked", verify: "blocked", sync: "blocked", archive: "blocked" },
309
- actionContext,
310
- relationships: {
311
- dependsOn: [],
312
- supersedes: [],
313
- amends: [],
314
- conflictsWith: [],
315
- sameDomainActiveChanges: [],
316
- },
317
- collisions: [],
318
- nextRecommended: "blocked",
319
- blockedReasons,
320
- isNonAuthoritative,
321
- };
322
- }
323
-
324
- /**
325
- * Find the newest archive entry for a change. Archive folders are named
326
- * `YYYY-MM-DD-<change>`; only an exact `-<change>` suffix after a full date
327
- * prefix matches (change "foo-bar" never matches "2026-01-01-foo-bar-baz").
328
- * safeDirectories sorts lexicographically, so the last match is the newest date.
329
- */
330
- function findArchivedChangeEntry(root: string, changeName: string): string | undefined {
331
- return safeDirectories(join(root, "openspec", "changes", "archive"))
332
- .filter(
333
- (entry) =>
334
- /^\d{4}-\d{2}-\d{2}-$/.test(entry.slice(0, 11)) && entry.slice(11) === changeName,
335
- )
336
- .at(-1);
337
- }
338
-
339
- /** Positive terminal projection for a change whose folder moved to the archive. */
340
- function archivedStatus(cwd: string, changeName: string, archiveEntry: string, artifactStore: SddArtifactStore): SddStatus {
341
- const status = emptyStatus(cwd, changeName, [], artifactStore);
342
- status.applyState = "all_done";
343
- status.dependencies = { apply: "all_done", verify: "all_done", sync: "all_done", archive: "all_done" };
344
- status.archived = { path: join("openspec", "changes", "archive", archiveEntry) };
345
- status.nextRecommended = "archived";
346
- return status;
347
- }
348
-
349
- export function listActiveOpenSpecChanges(cwd: string): string[] {
350
- return safeDirectories(join(cwd, "openspec", "changes")).filter(
351
- (change) => change !== "archive",
352
- );
353
- }
354
-
355
- export function renderPhaseInstructions(status: SddStatus): SddPhaseInstructions {
356
- const change = status.changeName ?? "<unresolved>";
357
- if (status.applyState === "not_applicable") {
358
- return {
359
- apply: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
360
- verify: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
361
- sync: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
362
- archive: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
363
- };
364
- }
365
- return {
366
- apply: [
367
- `Change: ${change}`,
368
- `State: ${status.dependencies.apply}`,
369
- status.applyState === "all_done"
370
- ? "All implementation tasks are checked complete; do not edit."
371
- : "Implement only unchecked implementation-owned tasks from the tasks artifact.",
372
- `Implementation tasks: ${status.taskProgress.complete}/${status.taskProgress.total} complete`,
373
- "Update persisted task checkboxes for implementation-owned tasks immediately after completing each task.",
374
- ...status.taskProgress.unchecked.map((line) => `Remaining implementation task: ${line}`),
375
- `Deferred parent lifecycle actions: ${status.deferredParentActions.complete}/${status.deferredParentActions.total} complete`,
376
- ...status.deferredParentActions.unchecked.map((line) => `Deferred parent action: ${line}`),
377
- ...status.taskArtifactErrors.map((error) => `Task artifact error: ${error}`),
378
- ],
379
- verify: [
380
- `Change: ${change}`,
381
- `State: ${status.dependencies.verify}`,
382
- "Verify task completion, spec coverage, implementation correctness, design coherence, and tests when available.",
383
- "Unchecked implementation tasks are CRITICAL archive blockers.",
384
- ...status.taskProgress.unchecked.map((line) => `Unchecked blocker: ${line}`),
385
- ],
386
- sync: [
387
- `Change: ${change}`,
388
- `State: ${status.dependencies.sync}`,
389
- "Sync delta specs into openspec/specs only after verification is clean.",
390
- ...status.artifactPaths.specs.map((path) => `Delta spec: ${path}`),
391
- ...status.collisions.flatMap((collision) =>
392
- collision.changes.map((item) =>
393
- `Same-domain collision: ${collision.domain} also touched by ${item.change} (${item.path})`,
394
- ),
395
- ),
396
- ],
397
- archive: [
398
- `Change: ${change}`,
399
- `State: ${status.dependencies.archive}`,
400
- "Archive only after clean verify, completed sync, and zero unchecked implementation tasks.",
401
- "CRITICAL verification issues have no override.",
402
- status.changeRoot
403
- ? `Archive target: ${join(status.planningHome.changesDir, "archive", `YYYY-MM-DD-${change}`)}`
404
- : "Archive target unavailable until change is resolved.",
405
- ],
406
- };
407
- }
408
-
409
- /**
410
- * Build the single canonical non-authoritative SddStatus.
411
- * All non-authoritative return sites must call this instead of constructing by hand.
412
- */
413
- function nonAuthoritativeStatus(cwd: string, changeName: string | null, store: SddArtifactStore, includeInstructions?: boolean): SddStatus {
414
- const root = resolve(cwd);
415
- const actionContext: SddActionContext = {
416
- mode: "repo-local",
417
- workspaceRoot: root,
418
- allowedEditRoots: [root],
419
- warnings: [],
420
- };
421
- const status: SddStatus = {
422
- schemaName: "gentle-pi.sdd-status",
423
- schemaVersion: 1,
424
- changeName,
425
- artifactStore: store,
426
- planningHome: { root, changesDir: "" },
427
- changeRoot: null,
428
- artifactPaths: { ...EMPTY_PATHS },
429
- contextFiles: { ...EMPTY_PATHS },
430
- artifacts: {
431
- proposal: "missing",
432
- specs: "missing",
433
- design: "missing",
434
- tasks: "missing",
435
- applyProgress: "missing",
436
- verifyReport: "missing",
437
- syncReport: "missing",
438
- },
439
- taskProgress: { ...EMPTY_TASK_PROGRESS },
440
- deferredParentActions: { ...EMPTY_TASK_PROGRESS },
441
- taskArtifactErrors: [],
442
- applyState: "not_applicable",
443
- dependencies: { apply: "not_applicable", verify: "not_applicable", sync: "not_applicable", archive: "not_applicable" },
444
- actionContext,
445
- relationships: {
446
- dependsOn: [],
447
- supersedes: [],
448
- amends: [],
449
- conflictsWith: [],
450
- sameDomainActiveChanges: [],
451
- },
452
- collisions: [],
453
- nextRecommended: "resolve-via-engram",
454
- blockedReasons: [],
455
- isNonAuthoritative: true,
456
- };
457
- if (includeInstructions) status.instructions = renderPhaseInstructions(status);
458
- return status;
459
- }
460
-
461
- export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
462
- // Safety net: when the store is unknown (undefined) and there is no openspec/ directory
463
- // on disk, don't emit the openspec "no changes / blocked" status — it would be a false
464
- // block for an engram or none session that hasn't been identified yet. Treat it as
465
- // non-authoritative instead. A genuine openspec session will have the directory.
466
- const hasOpenSpecDir = existsSync(join(resolve(options.cwd), "openspec"));
467
- const store: SddArtifactStore =
468
- options.artifactStore ?? (hasOpenSpecDir ? "openspec" : "none");
469
-
470
- // Single decision point: non-authoritative when the disk engine cannot resolve authoritatively.
471
- // Cases:
472
- // - store engram or none: always non-authoritative (no disk backing)
473
- // - store both, no openspec/ dir: non-authoritative (no disk to scan)
474
- // The both-with-openspec cases are handled below after listing active changes.
475
- if (store === "engram" || store === "none" || (store === "hybrid" && !hasOpenSpecDir)) {
476
- const changeName = options.changeName?.trim() || null;
477
- return nonAuthoritativeStatus(options.cwd, changeName, store, options.includeInstructions);
478
- }
479
-
480
- const root = resolve(options.cwd);
481
- const changesDir = join(root, "openspec", "changes");
482
- const activeChanges = listActiveOpenSpecChanges(root);
483
- let changeName = options.changeName?.trim() || "";
484
- const blockedReasons: string[] = [];
485
-
486
- if (!changeName) {
487
- if (activeChanges.length === 1) {
488
- changeName = activeChanges[0];
489
- } else if (activeChanges.length === 0) {
490
- // store both + openspec/ present + zero active changes + no changeName:
491
- // The change may live only in Engram — non-authoritative, not a false block.
492
- // Pure openspec with zero changes is a real block (run sdd-new).
493
- if (store === "hybrid") {
494
- return nonAuthoritativeStatus(options.cwd, null, store, options.includeInstructions);
495
- }
496
- return emptyStatus(root, null, ["No active SDD changes found."], store);
497
- } else {
498
- // Multiple active changes and no changeName: legit selection prompt (changes DO exist
499
- // on disk). Keep the existing authoritative ambiguous-selection behavior for both stores.
500
- return emptyStatus(root, null, [
501
- `Change selection is ambiguous: ${activeChanges.join(", ")}.`,
502
- ], store);
503
- }
504
- }
505
-
506
- if (!activeChanges.includes(changeName)) {
507
- // store both + openspec/ present + named change NOT found on disk:
508
- // The change may live only in Engram — non-authoritative.
509
- // Pure openspec still blocks (legit "run sdd-new").
510
- if (store === "hybrid") {
511
- return nonAuthoritativeStatus(options.cwd, changeName, store, options.includeInstructions);
512
- }
513
- // Issue #535: an absent active folder may mean the change was archived.
514
- // Project explicit completion instead of a false "run sdd-new" block.
515
- const archiveEntry = findArchivedChangeEntry(root, changeName);
516
- if (archiveEntry) {
517
- return archivedStatus(root, changeName, archiveEntry, store);
518
- }
519
- return emptyStatus(root, changeName, [`Active change not found: ${changeName}.`], store);
520
- }
521
-
522
- const changeRoot = join(changesDir, changeName);
523
- const proposal = join(changeRoot, "proposal.md");
524
- const design = join(changeRoot, "design.md");
525
- const tasks = join(changeRoot, "tasks.md");
526
- const applyProgress = join(changeRoot, "apply-progress.md");
527
- const verifyReport = join(changeRoot, "verify-report.md");
528
- const syncReport = join(changeRoot, "sync-report.md");
529
- const specFiles = findSpecFiles(join(changeRoot, "specs"));
530
- const legacyFlatSpec = detectLegacyFlatSpec(root, changeName);
531
- const flatOnly = Boolean(legacyFlatSpec && specFiles.length === 0);
532
-
533
- const artifactPaths: SddArtifactPaths = {
534
- proposal: existsSync(proposal) ? [proposal] : [],
535
- specs: specFiles,
536
- design: existsSync(design) ? [design] : [],
537
- tasks: existsSync(tasks) ? [tasks] : [],
538
- applyProgress: existsSync(applyProgress) ? [applyProgress] : [],
539
- verifyReport: existsSync(verifyReport) ? [verifyReport] : [],
540
- syncReport: existsSync(syncReport) ? [syncReport] : [],
541
- };
542
- const artifacts = {
543
- proposal: singleFileState(artifactPaths.proposal),
544
- specs: multiFileState(artifactPaths.specs, flatOnly),
545
- design: singleFileState(artifactPaths.design),
546
- tasks: singleFileState(artifactPaths.tasks),
547
- applyProgress: singleFileState(artifactPaths.applyProgress),
548
- verifyReport: singleFileState(artifactPaths.verifyReport),
549
- syncReport: singleFileState(artifactPaths.syncReport),
550
- } satisfies SddStatus["artifacts"];
551
- const taskAccounting = countTasks(artifactPaths.tasks[0]);
552
- const taskProgress = taskAccounting.implementation;
553
- const actionContext: SddActionContext = {
554
- mode: "repo-local",
555
- workspaceRoot: options.workspaceRoot ? resolve(options.workspaceRoot) : root,
556
- allowedEditRoots: [options.workspaceRoot ? resolve(options.workspaceRoot) : root],
557
- warnings: [],
558
- };
559
-
560
- const collisions = specFiles
561
- .map((path) => domainFromSpecPath(changeRoot, path))
562
- .filter((domain): domain is string => Boolean(domain))
563
- .map((domain) => ({
564
- domain,
565
- changes: detectActiveDomainCollisions(root, changeName, domain).sort((a, b) =>
566
- a.change.localeCompare(b.change),
567
- ),
568
- }))
569
- .filter((collision) => collision.changes.length > 0);
570
-
571
- if (artifacts.proposal === "missing") blockedReasons.push("proposal.md is missing.");
572
- if (artifacts.proposal === "partial") blockedReasons.push("proposal.md is empty or partial.");
573
- if (artifacts.specs !== "done") blockedReasons.push("domain specs are missing or partial.");
574
- if (artifacts.design === "missing") blockedReasons.push("design.md is missing.");
575
- if (artifacts.design === "partial") blockedReasons.push("design.md is empty or partial.");
576
- if (artifacts.tasks === "missing") blockedReasons.push("tasks.md is missing.");
577
- if (artifacts.tasks === "partial") blockedReasons.push("tasks.md is empty or partial.");
578
- if (artifacts.tasks === "done" && taskProgress.total === 0) {
579
- blockedReasons.push("tasks.md has no implementation task checkboxes.");
580
- }
581
- blockedReasons.push(...taskAccounting.errors);
582
- if (flatOnly && legacyFlatSpec) {
583
- blockedReasons.push(`Legacy flat spec is present without domain specs: ${legacyFlatSpec.path}.`);
584
- }
585
-
586
- const coreArtifactsReady = artifacts.proposal === "done" && artifacts.specs === "done" && artifacts.design === "done" && artifacts.tasks === "done" && taskProgress.total > 0 && !flatOnly;
587
- const taskArtifactBlocked = taskAccounting.errors.length > 0;
588
- const applyState: ApplyState = !coreArtifactsReady || taskArtifactBlocked
589
- ? "blocked"
590
- : taskProgress.remaining === 0
591
- ? "all_done"
592
- : "ready";
593
- const verifyClean = reportIsClearlyPassing(artifactPaths.verifyReport[0]);
594
- const syncClean = reportIsClearlyPassing(artifactPaths.syncReport[0]);
595
- const syncPrerequisitesReady = coreArtifactsReady && verifyClean && collisions.length === 0 && !flatOnly;
596
- const syncState: DependencyState = syncPrerequisitesReady
597
- ? syncClean
598
- ? "all_done"
599
- : "ready"
600
- : "blocked";
601
- const verifyState: DependencyState = verifyClean
602
- ? "all_done"
603
- : artifacts.tasks === "done" && taskProgress.total > 0 && (artifacts.applyProgress === "done" || applyState === "all_done")
604
- ? "ready"
605
- : "blocked";
606
- const dependencies: SddStatus["dependencies"] = {
607
- apply: applyState === "blocked" ? "blocked" : applyState,
608
- verify: verifyState,
609
- sync: syncState,
610
- archive: coreArtifactsReady && verifyClean && syncClean && taskProgress.remaining === 0 && !taskArtifactBlocked ? "ready" : "blocked",
611
- };
612
- const archiveReady = dependencies.archive === "ready";
613
- const nextRecommended: SddNextRecommended = taskArtifactBlocked
614
- ? "fix-task-ownership-marker"
615
- : dependencies.apply === "ready"
616
- ? "sdd-apply"
617
- : dependencies.verify === "ready"
618
- ? "sdd-verify"
619
- : dependencies.sync === "ready"
620
- ? "sdd-sync"
621
- : archiveReady
622
- ? "sdd-archive"
623
- : planningRecommendation(artifacts, taskProgress.total);
624
-
625
- const status: SddStatus = {
626
- schemaName: "gentle-pi.sdd-status",
627
- schemaVersion: 1,
628
- changeName,
629
- artifactStore: store,
630
- planningHome: { root, changesDir },
631
- changeRoot,
632
- artifactPaths,
633
- contextFiles: artifactPaths,
634
- artifacts,
635
- taskProgress,
636
- deferredParentActions: taskAccounting.parent,
637
- taskArtifactErrors: taskAccounting.errors,
638
- applyState,
639
- dependencies,
640
- actionContext,
641
- relationships: {
642
- dependsOn: [],
643
- supersedes: [],
644
- amends: [],
645
- conflictsWith: [],
646
- sameDomainActiveChanges: collisions,
647
- },
648
- collisions,
649
- legacyFlatSpec: legacyFlatSpec
650
- ? { path: legacyFlatSpec.path, hasDomainSpecs: specFiles.length > 0 }
651
- : undefined,
652
- nextRecommended,
653
- blockedReasons,
654
- isNonAuthoritative: false,
655
- };
656
- if (options.includeInstructions) status.instructions = renderPhaseInstructions(status);
657
- return status;
658
- }
659
-
660
- export function isNonAuthoritativeStatus(status: SddStatus): boolean {
661
- return status.isNonAuthoritative;
662
- }
663
-
664
- export function renderNativeSddPhasePrompt(status: SddStatus | NativeSddStatusV2, phase?: SddPhase | "remediate"): string {
665
- const native = status.schemaName === "gentle-ai.sdd-status";
666
- let selectedInstructions: readonly string[] | undefined;
667
- if (phase) {
668
- selectedInstructions = native
669
- ? phase === "sync" ? undefined : status.phaseInstructions?.[phase]
670
- : phase === "remediate" ? undefined : status.instructions?.[phase];
671
- }
672
- const isNonAuthoritative = !native && isNonAuthoritativeStatus(status);
673
- const authorityLine = isNonAuthoritative
674
- ? `This status is non-authoritative (artifact store: ${status.artifactStore}). The orchestrator must resolve readiness from Engram instead.`
675
- : "The parent/orchestrator resolved this status deterministically. Treat it as authoritative over prompt inference.";
676
- const blockLine = isNonAuthoritative
677
- ? `Do not block phase work based on this status — resolve readiness from Engram using the injected Engram memory read tools on the change topic keys (sdd/{change}/proposal, sdd/{change}/spec, sdd/{change}/design, sdd/{change}/tasks, etc.) instead.`
678
- : "Do not run phase work when this status marks the phase blocked; return the blockers instead.";
7
+ export function renderNativeSddPhasePrompt(status: NativeSddStatusV2, phase?: SddPhase | "remediate"): string {
8
+ const instructions = phase ? status.phaseInstructions?.[phase] : undefined;
679
9
  return [
680
10
  "## Native SDD Status Engine",
681
- authorityLine,
682
- blockLine,
683
- ...(phase && selectedInstructions
684
- ? ["", `### ${phase} instructions`, ...selectedInstructions.map((line) => `- ${line}`)]
685
- : []),
686
- "",
687
- "```json",
688
- JSON.stringify(status, null, 2),
689
- "```",
690
- ].join("\n");
691
- }
692
-
693
- export function renderSddDispatcherMarkdown(status: SddStatus): string {
694
- const isNonAuthoritative = isNonAuthoritativeStatus(status);
695
- const statusSection = isNonAuthoritative
696
- ? [
697
- "### Non-authoritative store — resolve via Engram",
698
- `This status is non-authoritative (artifact store: ${status.artifactStore}).`,
699
- "Resolve readiness directly from Engram using the injected Engram memory read tools on the change topic keys:",
700
- `- sdd/${status.changeName ?? "<change>"}/proposal`,
701
- `- sdd/${status.changeName ?? "<change>"}/spec`,
702
- `- sdd/${status.changeName ?? "<change>"}/design`,
703
- `- sdd/${status.changeName ?? "<change>"}/tasks`,
704
- `- sdd/${status.changeName ?? "<change>"}/apply-progress (if present)`,
705
- `- sdd/${status.changeName ?? "<change>"}/verify-report (if present)`,
706
- "Do not treat blockedReasons or dependency states from this status as real blockers.",
707
- ].join("\n")
708
- : status.blockedReasons.length > 0
709
- ? ["### Blocked", ...status.blockedReasons.map((reason) => `- ${reason}`)].join("\n")
710
- : "### Ready\nThe next phase may be delegated with the attached status JSON and phase instructions.";
711
- // For non-authoritative status, skip the unsafe SddPhase cast on nextRecommended
712
- const instructionsSection = isNonAuthoritative
713
- ? []
714
- : (status.instructions?.[status.nextRecommended.replace(/^sdd-/, "") as SddPhase] ?? []).map(
715
- (line) => `- ${line}`,
716
- );
717
- return [
718
- `## Native SDD Dispatcher: ${status.changeName ?? "unresolved"}`,
11
+ "Native status is the authoritative, read-only projection. Never re-derive readiness from artifacts or replace its recommended action.",
12
+ "Run only an admitted phase; report blocked dependencies and preserve every native blocker. Explicit optional verification does not change the recommended action.",
13
+ ...(phase && instructions ? ["", `### ${phase} instructions`, ...instructions.map((line) => `- ${line}`)] : []),
719
14
  "",
720
- `nextPhase: ${status.nextRecommended}`,
721
- `apply: ${status.dependencies.apply}`,
722
- `verify: ${status.dependencies.verify}`,
723
- `sync: ${status.dependencies.sync}`,
724
- `archive: ${status.dependencies.archive}`,
725
- "",
726
- statusSection,
727
- "",
728
- ...(instructionsSection.length > 0
729
- ? ["### Instructions for next phase", ...instructionsSection, ""]
730
- : []),
731
- "### Status JSON",
732
15
  "```json",
733
16
  JSON.stringify(status, null, 2),
734
17
  "```",
735
18
  ].join("\n");
736
19
  }
737
20
 
738
- export function renderSddStatusMarkdown(status: SddStatus): string {
739
- const title = status.changeName ?? "unresolved";
740
- const lines = [
741
- `## SDD Status: ${title}`,
742
- "",
743
- `schema: ${status.schemaName}@${status.schemaVersion}`,
744
- `store: ${status.artifactStore}`,
745
- `root: ${status.planningHome.root}`,
746
- `next: ${status.nextRecommended}`,
747
- "",
748
- "### Implementation tasks",
749
- `- complete: ${status.taskProgress.complete}/${status.taskProgress.total}`,
750
- `- remaining: ${status.taskProgress.remaining}`,
751
- ...status.taskProgress.unchecked.map((line) => `- unchecked: ${line}`),
752
- "",
753
- "### Deferred parent lifecycle actions",
754
- `- complete: ${status.deferredParentActions.complete}/${status.deferredParentActions.total}`,
755
- `- remaining: ${status.deferredParentActions.remaining}`,
756
- ...status.deferredParentActions.unchecked.map((line) => `- deferred: ${line}`),
757
- ...status.taskArtifactErrors.map((error) => `- task artifact error: ${error}`),
758
- "",
759
- "### Dependencies",
760
- ...Object.entries(status.dependencies).map(([phase, state]) => `- ${phase}: ${state}`),
761
- ];
762
- if (status.collisions.length > 0) {
763
- lines.push("", "### Same-domain active changes");
764
- for (const collision of status.collisions) {
765
- lines.push(
766
- `- ${collision.domain}: ${collision.changes.map((item) => item.change).join(", ")}`,
767
- );
768
- }
769
- }
770
- if (status.blockedReasons.length > 0) {
771
- lines.push("", "### Blockers", ...status.blockedReasons.map((reason) => `- ${reason}`));
772
- }
773
- lines.push("", "### JSON", "```json", JSON.stringify(status, null, 2), "```");
774
- return lines.join("\n");
775
- }
776
-
777
21
  export function parseSddStatusCommandArgs(args: string): { changeName?: string; json: boolean } {
778
22
  const parts = args.trim().split(/\s+/).filter(Boolean);
779
23
  const json = parts.includes("--json");
780
24
  const changeName = parts.find((part) => part !== "--json");
781
25
  return { changeName, json };
782
26
  }
783
-
784
- export function sddStatusSeverity(status: SddStatus): "info" | "warning" {
785
- // Non-authoritative status has no real blockers — always info
786
- if (isNonAuthoritativeStatus(status)) return "info";
787
- return status.blockedReasons.length > 0 || Object.values(status.dependencies).includes("blocked")
788
- ? "warning"
789
- : "info";
790
- }
791
-
792
- export function summarizeSddStatusForTitle(status: SddStatus): string {
793
- return `${status.changeName ?? "unresolved"}: ${status.nextRecommended} (${status.taskProgress.complete}/${status.taskProgress.total} tasks)`;
794
- }
795
-
796
- export function activeChangeLabel(cwd: string): string {
797
- return basename(resolve(cwd));
798
- }