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.
- package/assets/agents/sdd-apply.md +20 -5
- package/assets/agents/sdd-archive.md +15 -3
- package/assets/agents/sdd-design.md +11 -3
- package/assets/agents/sdd-explore.md +12 -4
- package/assets/agents/sdd-init.md +11 -3
- package/assets/agents/sdd-onboard.md +11 -3
- package/assets/agents/sdd-proposal.md +12 -4
- package/assets/agents/sdd-spec.md +11 -3
- package/assets/agents/sdd-status.md +8 -3
- package/assets/agents/sdd-sync.md +13 -3
- package/assets/agents/sdd-tasks.md +11 -3
- package/assets/agents/sdd-verify.md +17 -4
- package/assets/orchestrator.md +26 -5
- package/assets/support/sdd-status-contract.md +12 -4
- package/extensions/gentle-ai.ts +3 -0
- package/lib/sdd-preflight.ts +68 -2
- package/lib/sdd-status.ts +151 -17
- package/package.json +1 -1
- package/tests/sdd-preflight.test.ts +113 -0
- package/tests/sdd-status.test.ts +320 -0
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
|
}
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|