gentle-pi 0.7.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.
@@ -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.7.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
+ });