gentle-pi 0.8.0 → 0.9.2

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.
@@ -34,11 +34,15 @@ Never claim persistence you did not perform.
34
34
 
35
35
  Before writing code, consume structured SDD status from the parent prompt. If missing, produce the same fields using this lookup order: 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 status contract. Do not use `assets/support/...` as a runtime path; that is only the package source path before installation.
36
36
 
37
+ **Non-authoritative store carve-out:** when the native status JSON shows `nextRecommended: "resolve-via-engram"` (covers `artifactStore: engram`, `artifactStore: none`, and `artifactStore: both` without an `openspec/` directory), the status is non-authoritative. Do not treat `applyState`, `dependencies`, or `blockedReasons` from that status as real blockers. Resolve readiness as follows:
38
+ - `engram` (or `both` without openspec/): search Engram for `sdd/{change}/tasks`, `sdd/{change}/spec`, and `sdd/{change}/design` using `mem_search` + `mem_get_observation`. Proceed with implementation once those artifacts are confirmed present.
39
+ - `none`: there is no persistent backend. Return artifacts inline and ask the user to provide required inputs (tasks, spec, design) or acknowledge that no persistent artifact store is available.
40
+
37
41
  Stop with `blocked` before editing if:
38
42
 
39
43
  - active change selection is missing or ambiguous;
40
- - `applyState: blocked`;
41
- - required apply artifacts are missing;
44
+ - `applyState: blocked` **and the status is authoritative** (openspec or both store);
45
+ - required apply artifacts are missing (confirmed by artifact store);
42
46
  - `actionContext.mode: workspace-planning` and no `allowedEditRoots` are provided;
43
47
  - any target file is outside the authoritative workspace or allowed edit roots.
44
48
 
@@ -34,6 +34,10 @@ Archive a completed SDD change. In file-backed modes, this requires canonical sp
34
34
 
35
35
  Before archive work, consume structured SDD status from the parent prompt. If missing, produce the same fields using this lookup order: 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 status contract. Do not use `assets/support/...` as a runtime path; that is only the package source path before installation.
36
36
 
37
+ **Non-authoritative store carve-out:** when the native status JSON shows `nextRecommended: "resolve-via-engram"` (covers `artifactStore: engram`, `artifactStore: none`, and `artifactStore: both` without an `openspec/` directory), the status is non-authoritative. Do not treat `dependencies` or `blockedReasons` (including `not_applicable` dependency states) from that status as real blockers. Resolve readiness as follows:
38
+ - `engram` (or `both` without openspec/): refer to the Artifact Store Modes section — resolve readiness by checking Engram for `sdd/{change}/verify-report` using `mem_search` + `mem_get_observation`, then record the archive report in Engram without filesystem sync or folder moves.
39
+ - `none`: there is no persistent backend. Return a closure summary inline and ask the user to confirm that verification has passed before proceeding.
40
+
37
41
  Stop with `blocked` if:
38
42
 
39
43
  - active change selection is missing or ambiguous;
@@ -98,6 +98,8 @@ If parent context reports `workspace-planning` and no `allowedEditRoots`, mark a
98
98
  - `sync` is `ready` when verify-report exists and has no unresolved `FAIL`, `BLOCKED`, `CRITICAL`, or verification blockers; it is `not_applicable` for `engram`/`none` modes.
99
99
  - `archive` is `ready` only when verify-report is passing, sync-report exists or sync is not applicable, and no unchecked implementation tasks remain. CRITICAL verification issues have no override. Explicit recorded exceptions are limited to non-critical partial archives or stale-checkbox reconciliation when apply-progress/verify-report prove completion.
100
100
 
101
+ **Non-authoritative carve-out:** when `nextRecommended: "resolve-via-engram"` or `isNonAuthoritative: true` is set on the status object, the `dependencies`, `applyState`, and `blockedReasons` fields are non-authoritative — they must not be treated as real blockers. This condition applies when the artifact store is `engram`, `none`, or `both` without an `openspec/` directory present on disk. For `engram`/`both-without-openspec`, resolve readiness directly from Engram using `mem_search` + `mem_get_observation` on the change topic keys (`sdd/{change}/proposal`, `sdd/{change}/spec`, `sdd/{change}/design`, `sdd/{change}/tasks`, etc.). For `none`, return inline status or ask the user — do not use the engine's `not_applicable`/`blockedReasons` as real gate failures.
102
+
101
103
  ## Output
102
104
 
103
105
  Return the standard phase envelope with status, executive_summary, artifacts, next_recommended, risks, and skill_resolution. Include the structured status block in `artifacts` or `executive_summary`.
@@ -37,6 +37,8 @@ Sync file-backed SDD change specs into canonical `openspec/specs/` without movin
37
37
 
38
38
  Before syncing, consume structured SDD status from the parent prompt. If missing, produce the same fields using this lookup order: 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 status contract. Do not use `assets/support/...` as a runtime path; that is only the package source path before installation.
39
39
 
40
+ **Non-authoritative carve-out:** when native status JSON shows `nextRecommended: "resolve-via-engram"` (covers `artifactStore: engram`, `artifactStore: none`, and `artifactStore: both` without an `openspec/` directory), the status is non-authoritative. Do not treat `dependencies` or `blockedReasons` from that status as real blockers. For `engram` store, refer to the Artifact Store Modes section — sync is not applicable; return a report explaining that canonical spec merge is not supported in Engram-only mode.
41
+
40
42
  Stop with `blocked` if:
41
43
 
42
44
  - active change selection is missing or ambiguous;
@@ -32,10 +32,14 @@ Never claim persistence you did not perform.
32
32
 
33
33
  Before verification, consume structured SDD status from the parent prompt. If missing, produce the same fields using this lookup order: 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 status contract. Do not use `assets/support/...` as a runtime path; that is only the package source path before installation.
34
34
 
35
+ **Non-authoritative store carve-out:** when the native status JSON shows `nextRecommended: "resolve-via-engram"` (covers `artifactStore: engram`, `artifactStore: none`, and `artifactStore: both` without an `openspec/` directory), the status is non-authoritative. Do not treat `dependencies` or `blockedReasons` from that status as real blockers. Resolve readiness as follows:
36
+ - `engram` (or `both` without openspec/): check Engram for `sdd/{change}/tasks` and `sdd/{change}/apply-progress` using `mem_search` + `mem_get_observation`. Proceed with verification once those artifacts are confirmed present.
37
+ - `none`: there is no persistent backend. Return the verification report inline and ask the user to provide required inputs (tasks, apply-progress) or acknowledge that no persistent artifact store is available.
38
+
35
39
  Stop with `blocked` if:
36
40
 
37
41
  - active change selection is missing or ambiguous;
38
- - `tasks.md` / the tasks artifact is missing or empty;
42
+ - `tasks.md` / the tasks artifact is missing or empty (confirmed by artifact store);
39
43
  - `actionContext.mode: workspace-planning` and no `allowedEditRoots` are provided;
40
44
  - implementation ownership or target files cannot be proven inside the authoritative workspace or allowed edit roots.
41
45
 
@@ -197,7 +197,8 @@ Rules:
197
197
  - `/sdd-continue` is the native dispatcher command: resolve status, choose the next ready phase, and carry status/instructions into the subagent prompt.
198
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
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.
200
+ - `sdd-archive` cannot proceed unless native status says `dependencies.archive` is `ready` or `all_done` — UNLESS the store carve-out is active (`nextRecommended: "resolve-via-engram"`), in which case resolve archive readiness from Engram instead of treating `not_applicable` as a gate failure.
201
+ - **Non-authoritative store carve-out:** when `nextRecommended: "resolve-via-engram"` is set, native status is **not authoritative**. This applies to `artifactStore: engram`, `artifactStore: none`, and `artifactStore: both` when the `openspec/` directory does not exist. For non-authoritative stores: resolve readiness from Engram using `mem_search` + `mem_get_observation` on the change topic keys (`sdd/{change-name}/proposal`, `sdd/{change-name}/spec`, `sdd/{change-name}/design`, `sdd/{change-name}/tasks`, etc.). Do **not** treat `blockedReasons` or `not_applicable` dependency states from the native engine as real blockers when the store carve-out is active.
201
202
 
202
203
  ## SDD Status Contract
203
204
 
@@ -54,18 +54,19 @@ taskProgress:
54
54
  complete: 0
55
55
  remaining: 0
56
56
  unchecked: []
57
- applyState: blocked | all_done | ready
57
+ applyState: blocked | all_done | ready | not_applicable
58
58
  dependencies:
59
- apply: blocked | ready | all_done
60
- verify: blocked | ready | all_done
59
+ apply: blocked | ready | all_done | not_applicable
60
+ verify: blocked | ready | all_done | not_applicable
61
61
  sync: blocked | ready | all_done | not_applicable
62
- archive: blocked | ready | all_done
62
+ archive: blocked | ready | all_done | not_applicable
63
63
  actionContext:
64
64
  mode: repo-local | workspace-planning
65
65
  workspaceRoot: <absolute path>
66
66
  allowedEditRoots: [<absolute paths>]
67
67
  warnings: []
68
68
  nextRecommended: <command-or-action>
69
+ isNonAuthoritative: false # boolean; true when the native engine is not authoritative for the store
69
70
  ```
70
71
 
71
72
  ## Apply State
@@ -73,6 +74,7 @@ nextRecommended: <command-or-action>
73
74
  - `blocked`: required apply artifacts are missing, task selection is ambiguous, or action context makes edits unsafe.
74
75
  - `all_done`: tasks artifact exists and every implementation task is checked `[x]`.
75
76
  - `ready`: tasks artifact exists, at least one implementation task remains unchecked, and edit scope is safe.
77
+ - `not_applicable`: emitted for non-authoritative stores (see Engine Authority by Store). This is NOT a blocker.
76
78
 
77
79
  ## Dependency States
78
80
 
@@ -80,6 +82,7 @@ nextRecommended: <command-or-action>
80
82
  - `verify` is `ready` when tasks exist and either apply-progress exists or the tasks artifact shows all intended implementation work complete. Unchecked implementation tasks remain CRITICAL blockers for full archive readiness.
81
83
  - `sync` is `ready` only when verify-report exists and has no unresolved `FAIL`, `BLOCKED`, `CRITICAL`, or verification blockers. `engram`/`none` modes may mark sync `not_applicable`.
82
84
  - `archive` is `ready` only when verify-report exists, sync is complete or not applicable, and tasks are complete. CRITICAL verification issues have no override. Explicit recorded exceptions are limited to non-critical partial archives or stale-checkbox reconciliation when apply-progress/verify-report prove completion.
85
+ - `not_applicable`: emitted for non-authoritative stores (engram, none, and both when no `openspec/` directory exists) when `nextRecommended: "resolve-via-engram"` is active. `not_applicable` is NOT a gate failure — readiness must be resolved from Engram instead of from these fields.
83
86
 
84
87
  ## Action Context Guard
85
88
 
@@ -89,6 +92,11 @@ The orchestrator MUST carry `actionContext` into any phase launch.
89
92
  - If `allowedEditRoots` is present, only edit or move files within those roots.
90
93
  - If a phase cannot prove a file is inside the authoritative workspace or allowed edit roots, stop and ask for clarification.
91
94
 
95
+ ## Engine Authority by Store
96
+
97
+ - `openspec` and `both` (when `openspec/` directory exists): the native status engine resolves artifact state from disk and is authoritative. Phase executors must obey it.
98
+ - `engram`, `none`, and `both` (when `openspec/` directory does NOT exist): the native status engine cannot read Engram artifacts. It returns `nextRecommended: "resolve-via-engram"` and empty `blockedReasons`. This output is **non-authoritative**. The orchestrator must resolve readiness directly from Engram using `mem_search` + `mem_get_observation` on the change topic keys (`sdd/{change-name}/proposal`, `sdd/{change-name}/spec`, etc.) instead of relying on the engine's dependency states. The `artifactStore` field still reflects the real chosen store value (e.g. `"both"`) and must not be rewritten.
99
+
92
100
  ## Status Output
93
101
 
94
102
  Every command or agent that acts on a change MUST show or consume status before doing phase work:
@@ -1982,6 +1982,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1982
1982
  ? `\n\n${renderNativeSddPhasePrompt(resolveSddStatus({
1983
1983
  cwd: ctx.cwd,
1984
1984
  includeInstructions: true,
1985
+ artifactStore: prefs?.artifactStore,
1985
1986
  }), phase)}`
1986
1987
  : "";
1987
1988
  const gentlePrompt = isNamedAgent || isSddAgent
@@ -2040,6 +2041,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2040
2041
  cwd: ctx.cwd,
2041
2042
  changeName: parsed.changeName,
2042
2043
  includeInstructions: true,
2044
+ artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
2043
2045
  });
2044
2046
  ctx.ui.notify(
2045
2047
  parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
@@ -2067,6 +2069,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2067
2069
  cwd: ctx.cwd,
2068
2070
  changeName: parsed.changeName,
2069
2071
  includeInstructions: true,
2072
+ artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
2070
2073
  });
2071
2074
  ctx.ui.notify(
2072
2075
  parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
@@ -3,6 +3,9 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { SddArtifactStore } from "./sdd-status.ts";
7
+
8
+ export type { SddArtifactStore };
6
9
 
7
10
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
11
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -12,7 +15,6 @@ function gentlePiAgentHome(): string {
12
15
  }
13
16
 
14
17
  export type SddExecutionMode = "interactive" | "auto";
15
- export type SddArtifactStore = "openspec" | "engram" | "both";
16
18
  export type SddChainedPrStrategy =
17
19
  | "auto-forecast"
18
20
  | "ask-always"
@@ -66,6 +68,60 @@ function isRecord(value: unknown): value is Record<string, unknown> {
66
68
  return typeof value === "object" && value !== null && !Array.isArray(value);
67
69
  }
68
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Durable store — survives restarts, resumed sessions, and non-SDD agent starts
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function sddPreflightDiskPath(cwd: string): string {
76
+ return join(cwd, ".pi", "gentle-ai", "sdd-preflight.json");
77
+ }
78
+
79
+ export function readSddPreflightFromDisk(cwd: string): SddPreflightPreferences | undefined {
80
+ const path = sddPreflightDiskPath(cwd);
81
+ if (!existsSync(path)) return undefined;
82
+ try {
83
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
84
+ if (!isRecord(parsed)) return undefined;
85
+ // Validate required fields to guard against stale/corrupt writes
86
+ const { executionMode, artifactStore, chainedPrStrategy, reviewBudgetLines, engramAvailable, prompted } = parsed;
87
+ if (
88
+ (executionMode !== "interactive" && executionMode !== "auto") ||
89
+ (artifactStore !== "openspec" && artifactStore !== "engram" && artifactStore !== "both" && artifactStore !== "none") ||
90
+ typeof reviewBudgetLines !== "number" ||
91
+ typeof engramAvailable !== "boolean" ||
92
+ typeof prompted !== "boolean"
93
+ ) {
94
+ return undefined;
95
+ }
96
+ const normalizedChain: SddChainedPrStrategy =
97
+ chainedPrStrategy === "ask-always" ||
98
+ chainedPrStrategy === "single-pr-default" ||
99
+ chainedPrStrategy === "force-chained"
100
+ ? (chainedPrStrategy as SddChainedPrStrategy)
101
+ : "auto-forecast";
102
+ return {
103
+ executionMode,
104
+ artifactStore,
105
+ chainedPrStrategy: normalizedChain,
106
+ reviewBudgetLines,
107
+ engramAvailable,
108
+ prompted,
109
+ };
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ export function writeSddPreflightToDisk(cwd: string, prefs: SddPreflightPreferences): void {
116
+ try {
117
+ const path = sddPreflightDiskPath(cwd);
118
+ mkdirSync(dirname(path), { recursive: true });
119
+ writeFileSync(path, JSON.stringify(prefs, null, 2));
120
+ } catch {
121
+ // Disk write failures are non-fatal; in-memory cache is the primary store
122
+ }
123
+ }
124
+
69
125
  function copyDirectoryFiles(
70
126
  sourceDir: string,
71
127
  targetDir: string,
@@ -295,6 +351,7 @@ export async function ensureSddPreflight(
295
351
  );
296
352
  }
297
353
  sddPreflightBySession.set(sessionKey, prefs);
354
+ writeSddPreflightToDisk(ctx.cwd, prefs);
298
355
  return prefs;
299
356
  })();
300
357
  sddPreflightInFlight.set(sessionKey, promise);
@@ -308,5 +365,14 @@ export async function ensureSddPreflight(
308
365
  export function getSddPreflightPreferences(
309
366
  ctx: ExtensionContext,
310
367
  ): SddPreflightPreferences | undefined {
311
- return sddPreflightBySession.get(sddPreflightSessionKey(ctx));
368
+ const sessionKey = sddPreflightSessionKey(ctx);
369
+ const cached = sddPreflightBySession.get(sessionKey);
370
+ if (cached) return cached;
371
+ // Cache miss: check the durable disk store (survives restarts and non-SDD agent starts)
372
+ const persisted = readSddPreflightFromDisk(ctx.cwd);
373
+ if (persisted) {
374
+ sddPreflightBySession.set(sessionKey, persisted);
375
+ return persisted;
376
+ }
377
+ return undefined;
312
378
  }
package/lib/sdd-status.ts CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  type DomainCollision,
7
7
  } from "./openspec-guardrails.ts";
8
8
 
9
- export type SddArtifactStore = "openspec";
9
+ export type SddArtifactStore = "openspec" | "engram" | "both" | "none";
10
10
  export type ArtifactState = "missing" | "done" | "partial";
11
11
  export type DependencyState = "blocked" | "ready" | "all_done" | "not_applicable";
12
- export type ApplyState = "blocked" | "ready" | "all_done";
12
+ export type ApplyState = "blocked" | "ready" | "all_done" | "not_applicable";
13
13
  export type SddPhase = "apply" | "verify" | "sync" | "archive";
14
14
 
15
15
  export interface SddArtifactPaths {
@@ -76,6 +76,14 @@ export interface SddStatus {
76
76
  nextRecommended: string;
77
77
  instructions?: SddPhaseInstructions;
78
78
  blockedReasons: string[];
79
+ /**
80
+ * True when the native status engine is not authoritative for the selected
81
+ * artifact store (engram, none, or both without an openspec/ directory).
82
+ * When true, `dependencies`, `applyState`, and `blockedReasons` must not be
83
+ * treated as real blockers — resolve readiness from Engram instead.
84
+ * Defaults to false on all authoritative (openspec / both-with-disk) paths.
85
+ */
86
+ isNonAuthoritative: boolean;
79
87
  }
80
88
 
81
89
  export interface ResolveSddStatusOptions {
@@ -83,6 +91,7 @@ export interface ResolveSddStatusOptions {
83
91
  changeName?: string;
84
92
  includeInstructions?: boolean;
85
93
  workspaceRoot?: string;
94
+ artifactStore?: SddArtifactStore;
86
95
  }
87
96
 
88
97
  const EMPTY_PATHS: SddArtifactPaths = {
@@ -187,7 +196,7 @@ function reportIsClearlyPassing(path: string | undefined): boolean {
187
196
  return hasPassSignal && !hasBlocker;
188
197
  }
189
198
 
190
- function emptyStatus(cwd: string, changeName: string | null, blockedReasons: string[]): SddStatus {
199
+ function emptyStatus(cwd: string, changeName: string | null, blockedReasons: string[], artifactStore: SddArtifactStore = "openspec", isNonAuthoritative = false): SddStatus {
191
200
  const root = resolve(cwd);
192
201
  const changesDir = join(root, "openspec", "changes");
193
202
  const actionContext: SddActionContext = {
@@ -200,7 +209,7 @@ function emptyStatus(cwd: string, changeName: string | null, blockedReasons: str
200
209
  schemaName: "gentle-pi.sdd-status",
201
210
  schemaVersion: 1,
202
211
  changeName,
203
- artifactStore: "openspec",
212
+ artifactStore,
204
213
  planningHome: { root, changesDir },
205
214
  changeRoot: null,
206
215
  artifactPaths: { ...EMPTY_PATHS },
@@ -228,6 +237,7 @@ function emptyStatus(cwd: string, changeName: string | null, blockedReasons: str
228
237
  collisions: [],
229
238
  nextRecommended: blockedReasons[0] ?? "Start an SDD change.",
230
239
  blockedReasons,
240
+ isNonAuthoritative,
231
241
  };
232
242
  }
233
243
 
@@ -239,6 +249,14 @@ export function listActiveOpenSpecChanges(cwd: string): string[] {
239
249
 
240
250
  export function renderPhaseInstructions(status: SddStatus): SddPhaseInstructions {
241
251
  const change = status.changeName ?? "<unresolved>";
252
+ if (status.applyState === "not_applicable") {
253
+ return {
254
+ apply: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
255
+ verify: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
256
+ sync: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
257
+ archive: ["Readiness is resolved from Engram; per-phase instructions not applicable."],
258
+ };
259
+ }
242
260
  return {
243
261
  apply: [
244
262
  `Change: ${change}`,
@@ -280,7 +298,75 @@ export function renderPhaseInstructions(status: SddStatus): SddPhaseInstructions
280
298
  };
281
299
  }
282
300
 
301
+ /**
302
+ * Build the single canonical non-authoritative SddStatus.
303
+ * All non-authoritative return sites must call this instead of constructing by hand.
304
+ */
305
+ function nonAuthoritativeStatus(cwd: string, changeName: string | null, store: SddArtifactStore, includeInstructions?: boolean): SddStatus {
306
+ const root = resolve(cwd);
307
+ const actionContext: SddActionContext = {
308
+ mode: "repo-local",
309
+ workspaceRoot: root,
310
+ allowedEditRoots: [root],
311
+ warnings: [],
312
+ };
313
+ const status: SddStatus = {
314
+ schemaName: "gentle-pi.sdd-status",
315
+ schemaVersion: 1,
316
+ changeName,
317
+ artifactStore: store,
318
+ planningHome: { root, changesDir: "" },
319
+ changeRoot: null,
320
+ artifactPaths: { ...EMPTY_PATHS },
321
+ contextFiles: { ...EMPTY_PATHS },
322
+ artifacts: {
323
+ proposal: "missing",
324
+ specs: "missing",
325
+ design: "missing",
326
+ tasks: "missing",
327
+ applyProgress: "missing",
328
+ verifyReport: "missing",
329
+ syncReport: "missing",
330
+ },
331
+ taskProgress: { total: 0, complete: 0, remaining: 0, unchecked: [] },
332
+ applyState: "not_applicable",
333
+ dependencies: { apply: "not_applicable", verify: "not_applicable", sync: "not_applicable", archive: "not_applicable" },
334
+ actionContext,
335
+ relationships: {
336
+ dependsOn: [],
337
+ supersedes: [],
338
+ amends: [],
339
+ conflictsWith: [],
340
+ sameDomainActiveChanges: [],
341
+ },
342
+ collisions: [],
343
+ nextRecommended: "resolve-via-engram",
344
+ blockedReasons: [],
345
+ isNonAuthoritative: true,
346
+ };
347
+ if (includeInstructions) status.instructions = renderPhaseInstructions(status);
348
+ return status;
349
+ }
350
+
283
351
  export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
352
+ // Safety net: when the store is unknown (undefined) and there is no openspec/ directory
353
+ // on disk, don't emit the openspec "no changes / blocked" status — it would be a false
354
+ // block for an engram or none session that hasn't been identified yet. Treat it as
355
+ // non-authoritative instead. A genuine openspec session will have the directory.
356
+ const hasOpenSpecDir = existsSync(join(resolve(options.cwd), "openspec"));
357
+ const store: SddArtifactStore =
358
+ options.artifactStore ?? (hasOpenSpecDir ? "openspec" : "none");
359
+
360
+ // Single decision point: non-authoritative when the disk engine cannot resolve authoritatively.
361
+ // Cases:
362
+ // - store engram or none: always non-authoritative (no disk backing)
363
+ // - store both, no openspec/ dir: non-authoritative (no disk to scan)
364
+ // The both-with-openspec cases are handled below after listing active changes.
365
+ if (store === "engram" || store === "none" || (store === "both" && !hasOpenSpecDir)) {
366
+ const changeName = options.changeName?.trim() || null;
367
+ return nonAuthoritativeStatus(options.cwd, changeName, store, options.includeInstructions);
368
+ }
369
+
284
370
  const root = resolve(options.cwd);
285
371
  const changesDir = join(root, "openspec", "changes");
286
372
  const activeChanges = listActiveOpenSpecChanges(root);
@@ -291,16 +377,30 @@ export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
291
377
  if (activeChanges.length === 1) {
292
378
  changeName = activeChanges[0];
293
379
  } else if (activeChanges.length === 0) {
294
- return emptyStatus(root, null, ["No active SDD changes found."]);
380
+ // store both + openspec/ present + zero active changes + no changeName:
381
+ // The change may live only in Engram — non-authoritative, not a false block.
382
+ // Pure openspec with zero changes is a real block (run sdd-new).
383
+ if (store === "both") {
384
+ return nonAuthoritativeStatus(options.cwd, null, store, options.includeInstructions);
385
+ }
386
+ return emptyStatus(root, null, ["No active SDD changes found."], store);
295
387
  } else {
388
+ // Multiple active changes and no changeName: legit selection prompt (changes DO exist
389
+ // on disk). Keep the existing authoritative ambiguous-selection behavior for both stores.
296
390
  return emptyStatus(root, null, [
297
391
  `Change selection is ambiguous: ${activeChanges.join(", ")}.`,
298
- ]);
392
+ ], store);
299
393
  }
300
394
  }
301
395
 
302
396
  if (!activeChanges.includes(changeName)) {
303
- return emptyStatus(root, changeName, [`Active change not found: ${changeName}.`]);
397
+ // store both + openspec/ present + named change NOT found on disk:
398
+ // The change may live only in Engram — non-authoritative.
399
+ // Pure openspec still blocks (legit "run sdd-new").
400
+ if (store === "both") {
401
+ return nonAuthoritativeStatus(options.cwd, changeName, store, options.includeInstructions);
402
+ }
403
+ return emptyStatus(root, changeName, [`Active change not found: ${changeName}.`], store);
304
404
  }
305
405
 
306
406
  const changeRoot = join(changesDir, changeName);
@@ -405,7 +505,7 @@ export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
405
505
  schemaName: "gentle-pi.sdd-status",
406
506
  schemaVersion: 1,
407
507
  changeName,
408
- artifactStore: "openspec",
508
+ artifactStore: store,
409
509
  planningHome: { root, changesDir },
410
510
  changeRoot,
411
511
  artifactPaths,
@@ -428,17 +528,29 @@ export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
428
528
  : undefined,
429
529
  nextRecommended,
430
530
  blockedReasons,
531
+ isNonAuthoritative: false,
431
532
  };
432
533
  if (options.includeInstructions) status.instructions = renderPhaseInstructions(status);
433
534
  return status;
434
535
  }
435
536
 
537
+ export function isNonAuthoritativeStatus(status: SddStatus): boolean {
538
+ return status.isNonAuthoritative;
539
+ }
540
+
436
541
  export function renderNativeSddPhasePrompt(status: SddStatus, phase?: SddPhase): string {
437
542
  const selectedInstructions = phase ? status.instructions?.[phase] : undefined;
543
+ const isNonAuthoritative = isNonAuthoritativeStatus(status);
544
+ const authorityLine = isNonAuthoritative
545
+ ? `This status is non-authoritative (artifact store: ${status.artifactStore}). The orchestrator must resolve readiness from Engram instead.`
546
+ : "The parent/orchestrator resolved this status deterministically. Treat it as authoritative over prompt inference.";
547
+ const blockLine = isNonAuthoritative
548
+ ? `Do not block phase work based on this status — resolve readiness from Engram using mem_search + mem_get_observation on the change topic keys (sdd/{change}/proposal, sdd/{change}/spec, sdd/{change}/design, sdd/{change}/tasks, etc.) instead.`
549
+ : "Do not run phase work when this status marks the phase blocked; return the blockers instead.";
438
550
  return [
439
551
  "## 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.",
552
+ authorityLine,
553
+ blockLine,
442
554
  ...(phase && selectedInstructions
443
555
  ? ["", `### ${phase} instructions`, ...selectedInstructions.map((line) => `- ${line}`)]
444
556
  : []),
@@ -450,6 +562,29 @@ export function renderNativeSddPhasePrompt(status: SddStatus, phase?: SddPhase):
450
562
  }
451
563
 
452
564
  export function renderSddDispatcherMarkdown(status: SddStatus): string {
565
+ const isNonAuthoritative = isNonAuthoritativeStatus(status);
566
+ const statusSection = isNonAuthoritative
567
+ ? [
568
+ "### Non-authoritative store — resolve via Engram",
569
+ `This status is non-authoritative (artifact store: ${status.artifactStore}).`,
570
+ "Resolve readiness directly from Engram using mem_search + mem_get_observation on the change topic keys:",
571
+ `- sdd/${status.changeName ?? "<change>"}/proposal`,
572
+ `- sdd/${status.changeName ?? "<change>"}/spec`,
573
+ `- sdd/${status.changeName ?? "<change>"}/design`,
574
+ `- sdd/${status.changeName ?? "<change>"}/tasks`,
575
+ `- sdd/${status.changeName ?? "<change>"}/apply-progress (if present)`,
576
+ `- sdd/${status.changeName ?? "<change>"}/verify-report (if present)`,
577
+ "Do not treat blockedReasons or dependency states from this status as real blockers.",
578
+ ].join("\n")
579
+ : status.blockedReasons.length > 0
580
+ ? ["### Blocked", ...status.blockedReasons.map((reason) => `- ${reason}`)].join("\n")
581
+ : "### Ready\nThe next phase may be delegated with the attached status JSON and phase instructions.";
582
+ // For non-authoritative status, skip the unsafe SddPhase cast on nextRecommended
583
+ const instructionsSection = isNonAuthoritative
584
+ ? []
585
+ : (status.instructions?.[status.nextRecommended.replace(/^sdd-/, "") as SddPhase] ?? []).map(
586
+ (line) => `- ${line}`,
587
+ );
453
588
  return [
454
589
  `## Native SDD Dispatcher: ${status.changeName ?? "unresolved"}`,
455
590
  "",
@@ -459,14 +594,11 @@ export function renderSddDispatcherMarkdown(status: SddStatus): string {
459
594
  `sync: ${status.dependencies.sync}`,
460
595
  `archive: ${status.dependencies.archive}`,
461
596
  "",
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}`)),
597
+ statusSection,
469
598
  "",
599
+ ...(instructionsSection.length > 0
600
+ ? ["### Instructions for next phase", ...instructionsSection, ""]
601
+ : []),
470
602
  "### Status JSON",
471
603
  "```json",
472
604
  JSON.stringify(status, null, 2),
@@ -515,6 +647,8 @@ export function parseSddStatusCommandArgs(args: string): { changeName?: string;
515
647
  }
516
648
 
517
649
  export function sddStatusSeverity(status: SddStatus): "info" | "warning" {
650
+ // Non-authoritative status has no real blockers — always info
651
+ if (isNonAuthoritativeStatus(status)) return "info";
518
652
  return status.blockedReasons.length > 0 || Object.values(status.dependencies).includes("blocked")
519
653
  ? "warning"
520
654
  : "info";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.8.0",
3
+ "version": "0.9.2",
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",
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import {
8
+ readSddPreflightFromDisk,
9
+ sddPreflightDiskPath,
10
+ writeSddPreflightToDisk,
11
+ type SddPreflightPreferences,
12
+ } from "../lib/sdd-preflight.ts";
13
+
14
+ async function workspace(): Promise<string> {
15
+ return mkdtemp(join(tmpdir(), "gentle-pi-sdd-preflight-"));
16
+ }
17
+
18
+ const SAMPLE_PREFS: SddPreflightPreferences = {
19
+ executionMode: "auto",
20
+ artifactStore: "engram",
21
+ chainedPrStrategy: "auto-forecast",
22
+ reviewBudgetLines: 400,
23
+ engramAvailable: true,
24
+ prompted: true,
25
+ };
26
+
27
+ test("sddPreflightDiskPath returns project-local .pi/gentle-ai/sdd-preflight.json", async () => {
28
+ const cwd = await workspace();
29
+ const path = sddPreflightDiskPath(cwd);
30
+ assert.equal(path, join(cwd, ".pi", "gentle-ai", "sdd-preflight.json"));
31
+ });
32
+
33
+ test("writeSddPreflightToDisk creates parent dirs and writes valid JSON", async () => {
34
+ const cwd = await workspace();
35
+ writeSddPreflightToDisk(cwd, SAMPLE_PREFS);
36
+
37
+ const path = sddPreflightDiskPath(cwd);
38
+ assert.ok(existsSync(path));
39
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
40
+ assert.deepEqual(parsed, SAMPLE_PREFS);
41
+ });
42
+
43
+ test("readSddPreflightFromDisk returns undefined when no file exists", async () => {
44
+ const cwd = await workspace();
45
+ assert.equal(readSddPreflightFromDisk(cwd), undefined);
46
+ });
47
+
48
+ test("readSddPreflightFromDisk returns persisted prefs after write", async () => {
49
+ const cwd = await workspace();
50
+ writeSddPreflightToDisk(cwd, SAMPLE_PREFS);
51
+
52
+ const loaded = readSddPreflightFromDisk(cwd);
53
+ assert.deepEqual(loaded, SAMPLE_PREFS);
54
+ });
55
+
56
+ test("persisted store survives a simulated cache miss (write then cold read)", async () => {
57
+ const cwd = await workspace();
58
+
59
+ // Simulate ensureSddPreflight writing to disk
60
+ writeSddPreflightToDisk(cwd, SAMPLE_PREFS);
61
+
62
+ // Simulate a fresh process where in-memory Map is empty — only disk store exists
63
+ const loaded = readSddPreflightFromDisk(cwd);
64
+ assert.ok(loaded !== undefined);
65
+ assert.equal(loaded.artifactStore, "engram");
66
+ assert.equal(loaded.executionMode, "auto");
67
+ assert.equal(loaded.prompted, true);
68
+ });
69
+
70
+ test("readSddPreflightFromDisk returns undefined for corrupt JSON", async () => {
71
+ const cwd = await workspace();
72
+ const path = sddPreflightDiskPath(cwd);
73
+ mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true });
74
+ writeFileSync(path, "not-json{{{");
75
+
76
+ assert.equal(readSddPreflightFromDisk(cwd), undefined);
77
+ });
78
+
79
+ test("readSddPreflightFromDisk returns undefined for JSON with invalid fields", async () => {
80
+ const cwd = await workspace();
81
+ const path = sddPreflightDiskPath(cwd);
82
+ mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true });
83
+ writeFileSync(path, JSON.stringify({ executionMode: "invalid", artifactStore: "openspec", chainedPrStrategy: "auto-forecast", reviewBudgetLines: 400, engramAvailable: false, prompted: false }));
84
+
85
+ // executionMode "invalid" is not "interactive" | "auto" → should reject
86
+ assert.equal(readSddPreflightFromDisk(cwd), undefined);
87
+ });
88
+
89
+ test("readSddPreflightFromDisk normalizes unknown chainedPrStrategy to auto-forecast", async () => {
90
+ const cwd = await workspace();
91
+ const path = sddPreflightDiskPath(cwd);
92
+ mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true });
93
+ writeFileSync(path, JSON.stringify({
94
+ executionMode: "interactive",
95
+ artifactStore: "openspec",
96
+ chainedPrStrategy: "unknown-strategy",
97
+ reviewBudgetLines: 400,
98
+ engramAvailable: false,
99
+ prompted: true,
100
+ }));
101
+
102
+ const loaded = readSddPreflightFromDisk(cwd);
103
+ assert.ok(loaded !== undefined);
104
+ assert.equal(loaded.chainedPrStrategy, "auto-forecast");
105
+ });
106
+
107
+ test("writeSddPreflightToDisk is non-fatal when directory is not writable (no throw)", async () => {
108
+ // Can only test the no-throw guarantee; the actual write failure is swallowed
109
+ // We verify that calling with a deeply nested path doesn't throw
110
+ assert.doesNotThrow(() => {
111
+ writeSddPreflightToDisk("/nonexistent/path/that/cannot/be/created/gently", SAMPLE_PREFS);
112
+ });
113
+ });
@@ -5,9 +5,12 @@ import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import test from "node:test";
7
7
  import {
8
+ isNonAuthoritativeStatus,
8
9
  listActiveOpenSpecChanges,
9
10
  parseSddStatusCommandArgs,
11
+ renderNativeSddPhasePrompt,
10
12
  renderPhaseInstructions,
13
+ renderSddDispatcherMarkdown,
11
14
  renderSddStatusMarkdown,
12
15
  resolveSddStatus,
13
16
  } from "../lib/sdd-status.ts";
@@ -279,6 +282,76 @@ test("renderSddStatusMarkdown includes structured JSON", async () => {
279
282
  assert.match(markdown, /"schemaName": "gentle-pi.sdd-status"/);
280
283
  });
281
284
 
285
+ test("resolveSddStatus with artifactStore engram returns non-authoritative status without disk scan", async () => {
286
+ const cwd = await workspace();
287
+ // No openspec directory — simulates an engram-only session
288
+
289
+ const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "my-change" });
290
+
291
+ assert.equal(status.artifactStore, "engram");
292
+ assert.equal(status.changeName, "my-change");
293
+ assert.deepEqual(status.blockedReasons, []);
294
+ assert.equal(status.nextRecommended, "resolve-via-engram");
295
+ assert.equal(status.dependencies.apply, "not_applicable");
296
+ assert.equal(status.applyState, "not_applicable");
297
+ });
298
+
299
+ test("resolveSddStatus with artifactStore none returns non-authoritative status without disk scan", async () => {
300
+ const cwd = await workspace();
301
+ // No openspec directory
302
+
303
+ const status = resolveSddStatus({ cwd, artifactStore: "none", changeName: "my-change" });
304
+
305
+ assert.equal(status.artifactStore, "none");
306
+ assert.deepEqual(status.blockedReasons, []);
307
+ assert.equal(status.nextRecommended, "resolve-via-engram");
308
+ });
309
+
310
+ test("resolveSddStatus with artifactStore both uses disk scan and reflects store", async () => {
311
+ const cwd = await workspace();
312
+ seedChange(cwd);
313
+
314
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
315
+
316
+ assert.equal(status.artifactStore, "both");
317
+ assert.equal(status.changeName, "add-auth");
318
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
319
+ });
320
+
321
+ test("resolveSddStatus with undefined store and existing openspec dir defaults to openspec and blocks", async () => {
322
+ const cwd = await workspace();
323
+ // Create openspec/ directory to signal an openspec workspace
324
+ mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
325
+
326
+ const status = resolveSddStatus({ cwd });
327
+
328
+ // openspec workspace with no active changes → blocked (back-compat)
329
+ assert.equal(status.artifactStore, "openspec");
330
+ assert.match(status.blockedReasons[0] ?? "", /No active SDD changes/);
331
+ });
332
+
333
+ test("resolveSddStatus with undefined store and NO openspec dir returns non-authoritative status", async () => {
334
+ const cwd = await workspace();
335
+ // No openspec directory at all — unknown store, no disk evidence
336
+
337
+ const status = resolveSddStatus({ cwd });
338
+
339
+ // Safety net: should not emit the openspec false-block
340
+ assert.equal(status.artifactStore, "none");
341
+ assert.deepEqual(status.blockedReasons, []);
342
+ assert.equal(status.nextRecommended, "resolve-via-engram");
343
+ assert.equal(status.applyState, "not_applicable");
344
+ });
345
+
346
+ test("resolveSddStatus non-authoritative status has neutral planningHome (no misleading openspec path)", async () => {
347
+ const cwd = await workspace();
348
+
349
+ const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "fix-auth" });
350
+
351
+ assert.equal(status.planningHome.changesDir, "");
352
+ assert.equal(status.planningHome.root, status.actionContext.workspaceRoot);
353
+ });
354
+
282
355
  test("parseSddStatusCommandArgs extracts change and json flag", () => {
283
356
  assert.deepEqual(parseSddStatusCommandArgs("add-auth --json"), {
284
357
  changeName: "add-auth",
@@ -289,3 +362,250 @@ test("parseSddStatusCommandArgs extracts change and json flag", () => {
289
362
  json: true,
290
363
  });
291
364
  });
365
+
366
+ test("resolveSddStatus with artifactStore both and NO openspec dir returns non-authoritative status", async () => {
367
+ const cwd = await workspace();
368
+ // No openspec directory — both store without disk backing is non-authoritative
369
+
370
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "my-change" });
371
+
372
+ assert.equal(status.artifactStore, "both");
373
+ assert.equal(status.changeName, "my-change");
374
+ assert.deepEqual(status.blockedReasons, []);
375
+ assert.equal(status.nextRecommended, "resolve-via-engram");
376
+ assert.equal(status.applyState, "not_applicable");
377
+ assert.equal(status.dependencies.apply, "not_applicable");
378
+ assert.equal(status.dependencies.archive, "not_applicable");
379
+ });
380
+
381
+ test("resolveSddStatus with artifactStore both and existing openspec dir runs authoritative disk scan", async () => {
382
+ const cwd = await workspace();
383
+ seedChange(cwd);
384
+
385
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
386
+
387
+ assert.equal(status.artifactStore, "both");
388
+ assert.equal(status.changeName, "add-auth");
389
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
390
+ assert.equal(status.artifacts.proposal, "done");
391
+ });
392
+
393
+ // Renamed: previously "returns true only when nextRecommended is resolve-via-engram" — now
394
+ // explicitly asserts BOTH the typed isNonAuthoritative field and the sentinel together.
395
+ test("isNonAuthoritativeStatus reads typed isNonAuthoritative field and matches resolve-via-engram sentinel", async () => {
396
+ const cwd = await workspace();
397
+
398
+ const engram = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "x" });
399
+ assert.equal(engram.isNonAuthoritative, true);
400
+ assert.equal(isNonAuthoritativeStatus(engram), true);
401
+ assert.equal(engram.nextRecommended, "resolve-via-engram");
402
+
403
+ const none = resolveSddStatus({ cwd, artifactStore: "none", changeName: "x" });
404
+ assert.equal(none.isNonAuthoritative, true);
405
+ assert.equal(isNonAuthoritativeStatus(none), true);
406
+ assert.equal(none.nextRecommended, "resolve-via-engram");
407
+
408
+ const bothWithoutOpenspec = resolveSddStatus({ cwd, artifactStore: "both", changeName: "x" });
409
+ assert.equal(bothWithoutOpenspec.isNonAuthoritative, true);
410
+ assert.equal(isNonAuthoritativeStatus(bothWithoutOpenspec), true);
411
+ assert.equal(bothWithoutOpenspec.nextRecommended, "resolve-via-engram");
412
+
413
+ seedChange(cwd);
414
+ const bothWithOpenspec = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
415
+ assert.equal(bothWithOpenspec.isNonAuthoritative, false);
416
+ assert.equal(isNonAuthoritativeStatus(bothWithOpenspec), false);
417
+ assert.notEqual(bothWithOpenspec.nextRecommended, "resolve-via-engram");
418
+ });
419
+
420
+ // Fix 4 item 4 — isNonAuthoritative boolean is set correctly on the typed field
421
+ test("isNonAuthoritative boolean field is set correctly across all store/disk combinations", async () => {
422
+ const cwd = await workspace();
423
+
424
+ // engram → non-authoritative
425
+ const engram = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "x" });
426
+ assert.equal(engram.isNonAuthoritative, true);
427
+
428
+ // none → non-authoritative
429
+ const none = resolveSddStatus({ cwd, artifactStore: "none", changeName: "x" });
430
+ assert.equal(none.isNonAuthoritative, true);
431
+
432
+ // both without openspec/ → non-authoritative
433
+ const bothWithout = resolveSddStatus({ cwd, artifactStore: "both", changeName: "x" });
434
+ assert.equal(bothWithout.isNonAuthoritative, true);
435
+
436
+ // both WITH openspec/ and seeded change → authoritative
437
+ seedChange(cwd);
438
+ const bothWith = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
439
+ assert.equal(bothWith.isNonAuthoritative, false);
440
+
441
+ // openspec (default disk scan, seeded) → authoritative
442
+ const openspec = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "add-auth" });
443
+ assert.equal(openspec.isNonAuthoritative, false);
444
+ });
445
+
446
+ // Fix 4 item 1 — both + openspec/ dir present + change NOT on disk → non-authoritative
447
+ test("resolveSddStatus with artifactStore both, openspec dir present but change not on disk returns non-authoritative", async () => {
448
+ const cwd = await workspace();
449
+ // Create an openspec/changes dir with a different change — not the requested one
450
+ mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
451
+
452
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "missing-change" });
453
+
454
+ assert.equal(status.isNonAuthoritative, true);
455
+ assert.equal(status.nextRecommended, "resolve-via-engram");
456
+ assert.deepEqual(status.blockedReasons, []);
457
+ assert.equal(status.applyState, "not_applicable");
458
+ assert.equal(status.artifactStore, "both");
459
+ // Must NOT be treated as blocked
460
+ assert.notEqual(status.applyState, "blocked");
461
+ });
462
+
463
+ // Fix 4 item 2 — strengthen existing both-with-openspec-and-seeded-change test
464
+ test("resolveSddStatus with artifactStore both, openspec dir present and change on disk is authoritative", async () => {
465
+ const cwd = await workspace();
466
+ seedChange(cwd);
467
+
468
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
469
+
470
+ assert.equal(status.artifactStore, "both");
471
+ assert.equal(status.changeName, "add-auth");
472
+ // Must be authoritative
473
+ assert.equal(isNonAuthoritativeStatus(status), false);
474
+ assert.equal(status.isNonAuthoritative, false);
475
+ // Must not be not_applicable — real disk scan ran
476
+ assert.notEqual(status.applyState, "not_applicable");
477
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
478
+ assert.equal(status.artifacts.proposal, "done");
479
+ });
480
+
481
+ // Fix 4 item 3 — pure openspec store + change not found STILL blocks (guard against over-broadening Fix 2)
482
+ test("resolveSddStatus with artifactStore openspec and change not found still blocks", async () => {
483
+ const cwd = await workspace();
484
+ // Create openspec dir with a different change — simulate openspec store with no matching change
485
+ mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
486
+
487
+ const status = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "nonexistent" });
488
+
489
+ // Must block, not return non-authoritative
490
+ assert.equal(status.isNonAuthoritative, false);
491
+ assert.match(status.blockedReasons.join("\n"), /Active change not found/);
492
+ assert.equal(status.applyState, "blocked");
493
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
494
+ });
495
+
496
+ test("renderSddDispatcherMarkdown for both-without-openspec does NOT render Ready", async () => {
497
+ const cwd = await workspace();
498
+ // No openspec directory — both store is non-authoritative
499
+
500
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
501
+ const markdown = renderSddDispatcherMarkdown(status);
502
+
503
+ assert.doesNotMatch(markdown, /### Ready/);
504
+ assert.match(markdown, /resolve via Engram/i);
505
+ });
506
+
507
+ test("renderNativeSddPhasePrompt for both-without-openspec emits non-authoritative line", async () => {
508
+ const cwd = await workspace();
509
+
510
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
511
+ const prompt = renderNativeSddPhasePrompt(status, "apply");
512
+
513
+ assert.match(prompt, /non-authoritative/);
514
+ assert.doesNotMatch(prompt, /deterministically/);
515
+ });
516
+
517
+ test("renderPhaseInstructions for not_applicable applyState emits neutral line", async () => {
518
+ const cwd = await workspace();
519
+
520
+ const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "fix-x" });
521
+ const instructions = renderPhaseInstructions(status);
522
+
523
+ assert.match(instructions.apply.join("\n"), /Readiness is resolved from Engram/);
524
+ assert.match(instructions.archive.join("\n"), /Readiness is resolved from Engram/);
525
+ });
526
+
527
+ // Fix 4 item 1 — both + openspec/ + ZERO changes + no changeName → non-authoritative
528
+ test("resolveSddStatus both + openspec/ dir + zero active changes + no changeName returns non-authoritative", async () => {
529
+ const cwd = await workspace();
530
+ // openspec/ dir exists but holds no active changes (only the changes/ subdir)
531
+ mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
532
+
533
+ const status = resolveSddStatus({ cwd, artifactStore: "both" });
534
+
535
+ assert.equal(status.isNonAuthoritative, true);
536
+ assert.equal(status.nextRecommended, "resolve-via-engram");
537
+ assert.deepEqual(status.blockedReasons, []);
538
+ assert.equal(status.artifactStore, "both");
539
+ assert.equal(status.applyState, "not_applicable");
540
+ assert.equal(status.dependencies.apply, "not_applicable");
541
+ assert.equal(status.dependencies.archive, "not_applicable");
542
+ // Must NOT be treated as blocked
543
+ assert.notEqual(status.applyState, "blocked");
544
+ });
545
+
546
+ // Fix 4 item 2 — both + openspec/ + MULTIPLE changes + no changeName → authoritative select-change
547
+ test("resolveSddStatus both + openspec/ dir + multiple active changes + no changeName stays authoritative", async () => {
548
+ const cwd = await workspace();
549
+ mkdirSync(join(cwd, "openspec", "changes", "alpha"), { recursive: true });
550
+ mkdirSync(join(cwd, "openspec", "changes", "beta"), { recursive: true });
551
+
552
+ const status = resolveSddStatus({ cwd, artifactStore: "both" });
553
+
554
+ // Authoritative ambiguous-selection behavior must be preserved
555
+ assert.equal(status.isNonAuthoritative, false);
556
+ assert.match(status.blockedReasons.join("\n"), /ambiguous/);
557
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
558
+ });
559
+
560
+ // Fix 4 item 3 — both + openspec/ + ONE resolvable change → authoritative
561
+ test("resolveSddStatus both + openspec/ dir + exactly one active change is authoritative", async () => {
562
+ const cwd = await workspace();
563
+ seedChange(cwd);
564
+
565
+ // No changeName supplied — should auto-select the single change
566
+ const status = resolveSddStatus({ cwd, artifactStore: "both" });
567
+
568
+ assert.equal(status.isNonAuthoritative, false);
569
+ assert.equal(status.changeName, "add-auth");
570
+ assert.equal(status.artifactStore, "both");
571
+ assert.notEqual(status.applyState, "not_applicable");
572
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
573
+ assert.equal(status.artifacts.proposal, "done");
574
+ });
575
+
576
+ // Fix 4 item 4 — pure openspec + zero/missing change STILL blocks (guard against over-broadening)
577
+ test("resolveSddStatus openspec + zero active changes still blocks", async () => {
578
+ const cwd = await workspace();
579
+ mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
580
+
581
+ const status = resolveSddStatus({ cwd, artifactStore: "openspec" });
582
+
583
+ assert.equal(status.isNonAuthoritative, false);
584
+ assert.match(status.blockedReasons.join("\n"), /No active SDD changes/);
585
+ assert.equal(status.applyState, "blocked");
586
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
587
+ });
588
+
589
+ test("resolveSddStatus openspec + named change missing still blocks", async () => {
590
+ const cwd = await workspace();
591
+ mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
592
+
593
+ const status = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "nonexistent" });
594
+
595
+ assert.equal(status.isNonAuthoritative, false);
596
+ assert.match(status.blockedReasons.join("\n"), /Active change not found/);
597
+ assert.equal(status.applyState, "blocked");
598
+ assert.notEqual(status.nextRecommended, "resolve-via-engram");
599
+ });
600
+
601
+ // Fix 4 render test — non-authoritative both status → dispatcher shows "both" not "Engram or none"
602
+ test("renderSddDispatcherMarkdown for non-authoritative both status shows artifact store 'both'", async () => {
603
+ const cwd = await workspace();
604
+
605
+ const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
606
+ const markdown = renderSddDispatcherMarkdown(status);
607
+
608
+ assert.match(markdown, /artifact store: both/);
609
+ assert.doesNotMatch(markdown, /Engram or none/);
610
+ assert.match(markdown, /resolve via Engram/i);
611
+ });