gentle-pi 0.8.0 → 0.10.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.
@@ -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:
@@ -19,6 +19,8 @@ import { fileURLToPath } from "node:url";
19
19
  import type {
20
20
  ExtensionAPI,
21
21
  ExtensionContext,
22
+ Theme,
23
+ ThemeColor,
22
24
  ToolCallEventResult,
23
25
  } from "@earendil-works/pi-coding-agent";
24
26
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
@@ -45,6 +47,7 @@ import {
45
47
  type ChangedDiff,
46
48
  type TriggerEvent,
47
49
  } from "../lib/review-triggers.ts";
50
+ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
48
51
 
49
52
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
50
53
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -713,17 +716,8 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
713
716
  );
714
717
  }
715
718
 
716
- const ANSI_ESCAPE_PATTERN =
717
- /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
718
- const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
719
719
  const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
720
720
 
721
- function sanitizeTerminalText(value: string): string {
722
- return value
723
- .replace(ANSI_ESCAPE_PATTERN, "")
724
- .replace(CONTROL_CHAR_PATTERN, "");
725
- }
726
-
727
721
  function normalizeModelId(value: unknown): string | undefined {
728
722
  if (typeof value !== "string") return undefined;
729
723
  const model = value.trim();
@@ -1236,6 +1230,26 @@ type ModelPanelResult =
1236
1230
 
1237
1231
  const SET_ALL_AGENTS = "Set all agents";
1238
1232
 
1233
+ const PANEL_TONE = {
1234
+ BORDER: "border",
1235
+ MUTED: "muted",
1236
+ TEXT: "text",
1237
+ TITLE: "title",
1238
+ ACCENT: "accent",
1239
+ STATUS: "status",
1240
+ } as const;
1241
+
1242
+ type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
1243
+
1244
+ const PANEL_TONE_COLOR: Record<PanelTone, ThemeColor> = {
1245
+ border: "border",
1246
+ muted: "muted",
1247
+ text: "text",
1248
+ title: "accent",
1249
+ accent: "accent",
1250
+ status: "thinkingHigh",
1251
+ };
1252
+
1239
1253
  class SddModelPanel implements OverlayComponent {
1240
1254
  private cursor = 0;
1241
1255
  private mode: "agents" | "models" | "effort" = "agents";
@@ -1247,17 +1261,20 @@ class SddModelPanel implements OverlayComponent {
1247
1261
  private readonly rows: string[];
1248
1262
  private readonly modelOptions: string[];
1249
1263
  private readonly done: (result: ModelPanelResult) => void;
1264
+ private readonly theme: Theme | undefined;
1250
1265
 
1251
1266
  constructor(
1252
1267
  initialConfig: AgentModelConfig,
1253
1268
  modelOptions: string[],
1254
1269
  agents: string[],
1255
1270
  done: (result: ModelPanelResult) => void,
1271
+ theme?: Theme,
1256
1272
  ) {
1257
1273
  this.draft = cloneModelConfig(initialConfig);
1258
1274
  this.rows = [SET_ALL_AGENTS, ...agents];
1259
1275
  this.modelOptions = modelOptions;
1260
1276
  this.done = done;
1277
+ this.theme = theme;
1261
1278
  }
1262
1279
 
1263
1280
  invalidate(): void {}
@@ -1287,15 +1304,46 @@ class SddModelPanel implements OverlayComponent {
1287
1304
 
1288
1305
  private renderCard(lines: string[], width: number): string[] {
1289
1306
  const innerWidth = Math.max(1, width - 4);
1290
- const fit = (text = "") =>
1291
- truncateToWidth(sanitizeTerminalText(text), innerWidth, "", true).padEnd(innerWidth);
1307
+ const horizontal = "─".repeat(innerWidth + 2);
1308
+ const border = (text: string) => this.renderText(text, "border");
1292
1309
  return [
1293
- `╭${"─".repeat(innerWidth + 2)}╮`,
1294
- ...lines.map((line) => `│ ${fit(line)} │`),
1295
- `╰${"─".repeat(innerWidth + 2)}╯`,
1310
+ border(`╭${horizontal}╮`),
1311
+ ...lines.map(
1312
+ (line) =>
1313
+ `${border("│")} ${this.fitStyledLine(line, innerWidth)} ${border("│")}`,
1314
+ ),
1315
+ border(`╰${horizontal}╯`),
1296
1316
  ];
1297
1317
  }
1298
1318
 
1319
+ private fitStyledLine(line: string, width: number): string {
1320
+ const visible = stripAnsi(line);
1321
+ if (visible.length > width) {
1322
+ return truncateToWidth(visible, Math.max(1, width), "…", true);
1323
+ }
1324
+ return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
1325
+ }
1326
+
1327
+ private renderLine(text = "", width: number, tone?: PanelTone): string {
1328
+ const safe = truncateToWidth(
1329
+ sanitizeTerminalText(text),
1330
+ Math.max(1, width),
1331
+ "…",
1332
+ true,
1333
+ );
1334
+ return tone ? this.renderText(safe, tone) : safe;
1335
+ }
1336
+
1337
+ private renderText(text: string, tone: PanelTone): string {
1338
+ const safe = sanitizeTerminalText(text);
1339
+ if (!this.theme) return safe;
1340
+ return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
1341
+ }
1342
+
1343
+ private renderCursor(focused: boolean): string {
1344
+ return focused ? this.renderText("▸", "accent") : " ";
1345
+ }
1346
+
1299
1347
  private handleAgentInput(data: string): void {
1300
1348
  const maxCursor = this.rows.length + 1;
1301
1349
  if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
@@ -1478,11 +1526,11 @@ class SddModelPanel implements OverlayComponent {
1478
1526
 
1479
1527
  private renderAgentList(width: number): string[] {
1480
1528
  const lines: string[] = [];
1481
- const line = (text = "") =>
1482
- truncateToWidth(text, Math.max(1, width), "…", true);
1483
- lines.push(line("Assign Models and Effort to Agents"));
1529
+ const line = (text = "", tone?: PanelTone) =>
1530
+ this.renderLine(text, width, tone);
1531
+ lines.push(line("Assign Models and Effort to Agents", "title"));
1484
1532
  lines.push("");
1485
- lines.push(line("Current assignments:"));
1533
+ lines.push(line("Current assignments:", "muted"));
1486
1534
  lines.push("");
1487
1535
  const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length);
1488
1536
  const listCursor = Math.min(this.cursor, this.rows.length - 1);
@@ -1494,7 +1542,7 @@ class SddModelPanel implements OverlayComponent {
1494
1542
  ),
1495
1543
  );
1496
1544
  const end = Math.min(this.rows.length, start + visibleRows);
1497
- if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`));
1545
+ if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`, "muted"));
1498
1546
  for (let i = start; i < end; i++) {
1499
1547
  const row = this.rows[i] ?? SET_ALL_AGENTS;
1500
1548
  const focused = i === this.cursor;
@@ -1502,21 +1550,28 @@ class SddModelPanel implements OverlayComponent {
1502
1550
  row === SET_ALL_AGENTS
1503
1551
  ? this.renderSetAllLabel(row)
1504
1552
  : this.renderAgentLabel(row);
1505
- lines.push(line(`${focused ? "▸" : " "} ${label}`));
1553
+ lines.push(`${this.renderCursor(focused)} ${label}`);
1506
1554
  }
1507
1555
  if (end < this.rows.length)
1508
- lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`));
1556
+ lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`, "muted"));
1509
1557
  lines.push("");
1510
1558
  lines.push(
1511
- line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`),
1559
+ `${this.renderCursor(this.cursor === this.rows.length)} ${this.renderText(
1560
+ "Continue",
1561
+ this.cursor === this.rows.length ? "accent" : "text",
1562
+ )}`,
1512
1563
  );
1513
1564
  lines.push(
1514
- line(`${this.cursor === this.rows.length + 1 ? "▸" : " "} ← Back`),
1565
+ `${this.renderCursor(this.cursor === this.rows.length + 1)} ${this.renderText(
1566
+ "← Back",
1567
+ this.cursor === this.rows.length + 1 ? "accent" : "text",
1568
+ )}`,
1515
1569
  );
1516
1570
  lines.push("");
1517
1571
  lines.push(
1518
1572
  line(
1519
1573
  "j/k scroll • enter model/save • e effort • i inherit • c custom • x export • r restore • ctrl+s save • esc back",
1574
+ "muted",
1520
1575
  ),
1521
1576
  );
1522
1577
  return lines;
@@ -1525,11 +1580,15 @@ class SddModelPanel implements OverlayComponent {
1525
1580
  private renderModelPicker(width: number): string[] {
1526
1581
  const lines: string[] = [];
1527
1582
  const options = this.filteredModelOptions();
1528
- const line = (text = "") =>
1529
- truncateToWidth(text, Math.max(1, width), "…", true);
1530
- lines.push(line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`));
1583
+ const line = (text = "", tone?: PanelTone) =>
1584
+ this.renderLine(text, width, tone);
1585
+ lines.push(
1586
+ line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
1587
+ );
1531
1588
  lines.push("");
1532
- lines.push(line(`◎ ${this.query || "search..."}`));
1589
+ lines.push(
1590
+ `${this.renderText("◎", "accent")} ${this.renderText(this.query || "search...", "muted")}`,
1591
+ );
1533
1592
  lines.push("");
1534
1593
  const start = Math.max(
1535
1594
  0,
@@ -1541,12 +1600,17 @@ class SddModelPanel implements OverlayComponent {
1541
1600
  const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS);
1542
1601
  for (let i = start; i < end; i++) {
1543
1602
  const focused = i === this.modelCursor;
1544
- lines.push(line(`${focused ? "▸" : " "} ${sanitizeTerminalText(options[i] ?? "")}`));
1603
+ lines.push(
1604
+ `${this.renderCursor(focused)} ${this.renderText(
1605
+ options[i] ?? "",
1606
+ focused ? "status" : "text",
1607
+ )}`,
1608
+ );
1545
1609
  }
1546
- if (options.length === 0) lines.push(line(" No matching models"));
1610
+ if (options.length === 0) lines.push(line(" No matching models", "muted"));
1547
1611
  lines.push("");
1548
1612
  lines.push(
1549
- line("j/k: navigate • type: search • enter: select • esc: back"),
1613
+ line("j/k: navigate • type: search • enter: select • esc: back", "muted"),
1550
1614
  );
1551
1615
  return lines;
1552
1616
  }
@@ -1580,16 +1644,23 @@ class SddModelPanel implements OverlayComponent {
1580
1644
 
1581
1645
  private renderEffortPicker(width: number): string[] {
1582
1646
  const lines: string[] = [];
1583
- const line = (text = "") =>
1584
- truncateToWidth(text, Math.max(1, width), "…", true);
1585
- lines.push(line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`));
1647
+ const line = (text = "", tone?: PanelTone) =>
1648
+ this.renderLine(text, width, tone);
1649
+ lines.push(
1650
+ line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
1651
+ );
1586
1652
  lines.push("");
1587
1653
  for (let i = 0; i < THINKING_OPTIONS.length; i++) {
1588
1654
  const focused = i === this.effortCursor;
1589
- lines.push(line(`${focused ? "▸" : " "} ${THINKING_OPTIONS[i]}`));
1655
+ lines.push(
1656
+ `${this.renderCursor(focused)} ${this.renderText(
1657
+ THINKING_OPTIONS[i] ?? "",
1658
+ focused ? "status" : "text",
1659
+ )}`,
1660
+ );
1590
1661
  }
1591
1662
  lines.push("");
1592
- lines.push(line("j/k: navigate • enter: select • esc: back"));
1663
+ lines.push(line("j/k: navigate • enter: select • esc: back", "muted"));
1593
1664
  return lines;
1594
1665
  }
1595
1666
 
@@ -1608,16 +1679,34 @@ class SddModelPanel implements OverlayComponent {
1608
1679
  const effortLabel = efforts.every((value) => value === firstEffort)
1609
1680
  ? firstEffort
1610
1681
  : "mixed";
1611
- return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(modelLabel)}, effort=${sanitizeTerminalText(effortLabel)}`;
1682
+ return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(modelLabel, "status")}${this.renderText(
1683
+ ", effort=",
1684
+ "muted",
1685
+ )}${this.renderText(effortLabel, "status")}`;
1612
1686
  }
1613
1687
 
1614
1688
  private renderAgentLabel(row: string): string {
1615
1689
  const model = this.draft[row]?.model ?? "inherit";
1616
1690
  const effort = this.draft[row]?.thinking ?? "inherit";
1617
- return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(effort)}`;
1691
+ return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
1692
+ ", effort=",
1693
+ "muted",
1694
+ )}${this.renderText(effort, "status")}`;
1618
1695
  }
1619
1696
  }
1620
1697
 
1698
+ function renderSddModelPanelForTesting(
1699
+ initialConfig: AgentModelConfig,
1700
+ modelOptions: string[],
1701
+ agents: string[],
1702
+ width: number,
1703
+ theme?: Theme,
1704
+ ): string[] {
1705
+ return new SddModelPanel(initialConfig, modelOptions, agents, () => {}, theme).render(
1706
+ width,
1707
+ );
1708
+ }
1709
+
1621
1710
  async function showSddModelPanel(
1622
1711
  ctx: ExtensionContext,
1623
1712
  config: AgentModelConfig,
@@ -1625,8 +1714,8 @@ async function showSddModelPanel(
1625
1714
  const modelOptions = await getPiModelOptions(ctx);
1626
1715
  const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name);
1627
1716
  return ctx.ui.custom<ModelPanelResult>(
1628
- (_tui, _theme, _keybindings, done) =>
1629
- new SddModelPanel(config, modelOptions, agents, done),
1717
+ (_tui, theme, _keybindings, done) =>
1718
+ new SddModelPanel(config, modelOptions, agents, done, theme),
1630
1719
  {
1631
1720
  overlay: true,
1632
1721
  overlayOptions: {
@@ -1918,6 +2007,7 @@ export const __testing = {
1918
2007
  buildGentlePrompt,
1919
2008
  classifyReviewEvent,
1920
2009
  parseNumstat,
2010
+ renderSddModelPanel: renderSddModelPanelForTesting,
1921
2011
  };
1922
2012
 
1923
2013
  export default function gentleAi(pi: ExtensionAPI): void {
@@ -1982,6 +2072,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1982
2072
  ? `\n\n${renderNativeSddPhasePrompt(resolveSddStatus({
1983
2073
  cwd: ctx.cwd,
1984
2074
  includeInstructions: true,
2075
+ artifactStore: prefs?.artifactStore,
1985
2076
  }), phase)}`
1986
2077
  : "";
1987
2078
  const gentlePrompt = isNamedAgent || isSddAgent
@@ -2040,6 +2131,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2040
2131
  cwd: ctx.cwd,
2041
2132
  changeName: parsed.changeName,
2042
2133
  includeInstructions: true,
2134
+ artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
2043
2135
  });
2044
2136
  ctx.ui.notify(
2045
2137
  parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
@@ -2067,6 +2159,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2067
2159
  cwd: ctx.cwd,
2068
2160
  changeName: parsed.changeName,
2069
2161
  includeInstructions: true,
2162
+ artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
2070
2163
  });
2071
2164
  ctx.ui.notify(
2072
2165
  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
  }