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.
- package/assets/agents/sdd-apply.md +6 -2
- package/assets/agents/sdd-archive.md +4 -0
- package/assets/agents/sdd-status.md +2 -0
- package/assets/agents/sdd-sync.md +2 -0
- package/assets/agents/sdd-verify.md +5 -1
- package/assets/orchestrator.md +2 -1
- package/assets/support/sdd-status-contract.md +12 -4
- package/extensions/gentle-ai.ts +132 -39
- package/lib/sdd-preflight.ts +68 -2
- package/lib/sdd-status.ts +151 -17
- package/lib/terminal-theme.ts +16 -0
- package/package.json +5 -1
- package/tests/gentle-ai.test.ts +34 -0
- package/tests/gentle-theme.test.ts +221 -0
- package/tests/runtime-harness.mjs +16 -7
- package/tests/sdd-preflight.test.ts +113 -0
- package/tests/sdd-status.test.ts +320 -0
- package/tests/terminal-theme.test.ts +22 -0
- package/themes/Gentle.json +100 -0
|
@@ -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
|
|
package/assets/orchestrator.md
CHANGED
|
@@ -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:
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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
|
|
1291
|
-
|
|
1307
|
+
const horizontal = "─".repeat(innerWidth + 2);
|
|
1308
|
+
const border = (text: string) => this.renderText(text, "border");
|
|
1292
1309
|
return [
|
|
1293
|
-
`╭${
|
|
1294
|
-
...lines.map(
|
|
1295
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1530
|
-
lines.push(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1585
|
-
lines.push(
|
|
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(
|
|
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
|
|
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)}
|
|
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,
|
|
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),
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|