gentle-pi 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +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
|
@@ -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
|
@@ -1982,6 +1982,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1982
1982
|
? `\n\n${renderNativeSddPhasePrompt(resolveSddStatus({
|
|
1983
1983
|
cwd: ctx.cwd,
|
|
1984
1984
|
includeInstructions: true,
|
|
1985
|
+
artifactStore: prefs?.artifactStore,
|
|
1985
1986
|
}), phase)}`
|
|
1986
1987
|
: "";
|
|
1987
1988
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
@@ -2040,6 +2041,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
2040
2041
|
cwd: ctx.cwd,
|
|
2041
2042
|
changeName: parsed.changeName,
|
|
2042
2043
|
includeInstructions: true,
|
|
2044
|
+
artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
|
|
2043
2045
|
});
|
|
2044
2046
|
ctx.ui.notify(
|
|
2045
2047
|
parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
|
|
@@ -2067,6 +2069,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
2067
2069
|
cwd: ctx.cwd,
|
|
2068
2070
|
changeName: parsed.changeName,
|
|
2069
2071
|
includeInstructions: true,
|
|
2072
|
+
artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
|
|
2070
2073
|
});
|
|
2071
2074
|
ctx.ui.notify(
|
|
2072
2075
|
parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
|
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
|
+
});
|
package/tests/sdd-status.test.ts
CHANGED
|
@@ -5,9 +5,12 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import {
|
|
8
|
+
isNonAuthoritativeStatus,
|
|
8
9
|
listActiveOpenSpecChanges,
|
|
9
10
|
parseSddStatusCommandArgs,
|
|
11
|
+
renderNativeSddPhasePrompt,
|
|
10
12
|
renderPhaseInstructions,
|
|
13
|
+
renderSddDispatcherMarkdown,
|
|
11
14
|
renderSddStatusMarkdown,
|
|
12
15
|
resolveSddStatus,
|
|
13
16
|
} from "../lib/sdd-status.ts";
|
|
@@ -279,6 +282,76 @@ test("renderSddStatusMarkdown includes structured JSON", async () => {
|
|
|
279
282
|
assert.match(markdown, /"schemaName": "gentle-pi.sdd-status"/);
|
|
280
283
|
});
|
|
281
284
|
|
|
285
|
+
test("resolveSddStatus with artifactStore engram returns non-authoritative status without disk scan", async () => {
|
|
286
|
+
const cwd = await workspace();
|
|
287
|
+
// No openspec directory — simulates an engram-only session
|
|
288
|
+
|
|
289
|
+
const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "my-change" });
|
|
290
|
+
|
|
291
|
+
assert.equal(status.artifactStore, "engram");
|
|
292
|
+
assert.equal(status.changeName, "my-change");
|
|
293
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
294
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
295
|
+
assert.equal(status.dependencies.apply, "not_applicable");
|
|
296
|
+
assert.equal(status.applyState, "not_applicable");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("resolveSddStatus with artifactStore none returns non-authoritative status without disk scan", async () => {
|
|
300
|
+
const cwd = await workspace();
|
|
301
|
+
// No openspec directory
|
|
302
|
+
|
|
303
|
+
const status = resolveSddStatus({ cwd, artifactStore: "none", changeName: "my-change" });
|
|
304
|
+
|
|
305
|
+
assert.equal(status.artifactStore, "none");
|
|
306
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
307
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("resolveSddStatus with artifactStore both uses disk scan and reflects store", async () => {
|
|
311
|
+
const cwd = await workspace();
|
|
312
|
+
seedChange(cwd);
|
|
313
|
+
|
|
314
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
|
|
315
|
+
|
|
316
|
+
assert.equal(status.artifactStore, "both");
|
|
317
|
+
assert.equal(status.changeName, "add-auth");
|
|
318
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("resolveSddStatus with undefined store and existing openspec dir defaults to openspec and blocks", async () => {
|
|
322
|
+
const cwd = await workspace();
|
|
323
|
+
// Create openspec/ directory to signal an openspec workspace
|
|
324
|
+
mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
|
|
325
|
+
|
|
326
|
+
const status = resolveSddStatus({ cwd });
|
|
327
|
+
|
|
328
|
+
// openspec workspace with no active changes → blocked (back-compat)
|
|
329
|
+
assert.equal(status.artifactStore, "openspec");
|
|
330
|
+
assert.match(status.blockedReasons[0] ?? "", /No active SDD changes/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("resolveSddStatus with undefined store and NO openspec dir returns non-authoritative status", async () => {
|
|
334
|
+
const cwd = await workspace();
|
|
335
|
+
// No openspec directory at all — unknown store, no disk evidence
|
|
336
|
+
|
|
337
|
+
const status = resolveSddStatus({ cwd });
|
|
338
|
+
|
|
339
|
+
// Safety net: should not emit the openspec false-block
|
|
340
|
+
assert.equal(status.artifactStore, "none");
|
|
341
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
342
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
343
|
+
assert.equal(status.applyState, "not_applicable");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("resolveSddStatus non-authoritative status has neutral planningHome (no misleading openspec path)", async () => {
|
|
347
|
+
const cwd = await workspace();
|
|
348
|
+
|
|
349
|
+
const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "fix-auth" });
|
|
350
|
+
|
|
351
|
+
assert.equal(status.planningHome.changesDir, "");
|
|
352
|
+
assert.equal(status.planningHome.root, status.actionContext.workspaceRoot);
|
|
353
|
+
});
|
|
354
|
+
|
|
282
355
|
test("parseSddStatusCommandArgs extracts change and json flag", () => {
|
|
283
356
|
assert.deepEqual(parseSddStatusCommandArgs("add-auth --json"), {
|
|
284
357
|
changeName: "add-auth",
|
|
@@ -289,3 +362,250 @@ test("parseSddStatusCommandArgs extracts change and json flag", () => {
|
|
|
289
362
|
json: true,
|
|
290
363
|
});
|
|
291
364
|
});
|
|
365
|
+
|
|
366
|
+
test("resolveSddStatus with artifactStore both and NO openspec dir returns non-authoritative status", async () => {
|
|
367
|
+
const cwd = await workspace();
|
|
368
|
+
// No openspec directory — both store without disk backing is non-authoritative
|
|
369
|
+
|
|
370
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "my-change" });
|
|
371
|
+
|
|
372
|
+
assert.equal(status.artifactStore, "both");
|
|
373
|
+
assert.equal(status.changeName, "my-change");
|
|
374
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
375
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
376
|
+
assert.equal(status.applyState, "not_applicable");
|
|
377
|
+
assert.equal(status.dependencies.apply, "not_applicable");
|
|
378
|
+
assert.equal(status.dependencies.archive, "not_applicable");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("resolveSddStatus with artifactStore both and existing openspec dir runs authoritative disk scan", async () => {
|
|
382
|
+
const cwd = await workspace();
|
|
383
|
+
seedChange(cwd);
|
|
384
|
+
|
|
385
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
|
|
386
|
+
|
|
387
|
+
assert.equal(status.artifactStore, "both");
|
|
388
|
+
assert.equal(status.changeName, "add-auth");
|
|
389
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
390
|
+
assert.equal(status.artifacts.proposal, "done");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Renamed: previously "returns true only when nextRecommended is resolve-via-engram" — now
|
|
394
|
+
// explicitly asserts BOTH the typed isNonAuthoritative field and the sentinel together.
|
|
395
|
+
test("isNonAuthoritativeStatus reads typed isNonAuthoritative field and matches resolve-via-engram sentinel", async () => {
|
|
396
|
+
const cwd = await workspace();
|
|
397
|
+
|
|
398
|
+
const engram = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "x" });
|
|
399
|
+
assert.equal(engram.isNonAuthoritative, true);
|
|
400
|
+
assert.equal(isNonAuthoritativeStatus(engram), true);
|
|
401
|
+
assert.equal(engram.nextRecommended, "resolve-via-engram");
|
|
402
|
+
|
|
403
|
+
const none = resolveSddStatus({ cwd, artifactStore: "none", changeName: "x" });
|
|
404
|
+
assert.equal(none.isNonAuthoritative, true);
|
|
405
|
+
assert.equal(isNonAuthoritativeStatus(none), true);
|
|
406
|
+
assert.equal(none.nextRecommended, "resolve-via-engram");
|
|
407
|
+
|
|
408
|
+
const bothWithoutOpenspec = resolveSddStatus({ cwd, artifactStore: "both", changeName: "x" });
|
|
409
|
+
assert.equal(bothWithoutOpenspec.isNonAuthoritative, true);
|
|
410
|
+
assert.equal(isNonAuthoritativeStatus(bothWithoutOpenspec), true);
|
|
411
|
+
assert.equal(bothWithoutOpenspec.nextRecommended, "resolve-via-engram");
|
|
412
|
+
|
|
413
|
+
seedChange(cwd);
|
|
414
|
+
const bothWithOpenspec = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
|
|
415
|
+
assert.equal(bothWithOpenspec.isNonAuthoritative, false);
|
|
416
|
+
assert.equal(isNonAuthoritativeStatus(bothWithOpenspec), false);
|
|
417
|
+
assert.notEqual(bothWithOpenspec.nextRecommended, "resolve-via-engram");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Fix 4 item 4 — isNonAuthoritative boolean is set correctly on the typed field
|
|
421
|
+
test("isNonAuthoritative boolean field is set correctly across all store/disk combinations", async () => {
|
|
422
|
+
const cwd = await workspace();
|
|
423
|
+
|
|
424
|
+
// engram → non-authoritative
|
|
425
|
+
const engram = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "x" });
|
|
426
|
+
assert.equal(engram.isNonAuthoritative, true);
|
|
427
|
+
|
|
428
|
+
// none → non-authoritative
|
|
429
|
+
const none = resolveSddStatus({ cwd, artifactStore: "none", changeName: "x" });
|
|
430
|
+
assert.equal(none.isNonAuthoritative, true);
|
|
431
|
+
|
|
432
|
+
// both without openspec/ → non-authoritative
|
|
433
|
+
const bothWithout = resolveSddStatus({ cwd, artifactStore: "both", changeName: "x" });
|
|
434
|
+
assert.equal(bothWithout.isNonAuthoritative, true);
|
|
435
|
+
|
|
436
|
+
// both WITH openspec/ and seeded change → authoritative
|
|
437
|
+
seedChange(cwd);
|
|
438
|
+
const bothWith = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
|
|
439
|
+
assert.equal(bothWith.isNonAuthoritative, false);
|
|
440
|
+
|
|
441
|
+
// openspec (default disk scan, seeded) → authoritative
|
|
442
|
+
const openspec = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "add-auth" });
|
|
443
|
+
assert.equal(openspec.isNonAuthoritative, false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Fix 4 item 1 — both + openspec/ dir present + change NOT on disk → non-authoritative
|
|
447
|
+
test("resolveSddStatus with artifactStore both, openspec dir present but change not on disk returns non-authoritative", async () => {
|
|
448
|
+
const cwd = await workspace();
|
|
449
|
+
// Create an openspec/changes dir with a different change — not the requested one
|
|
450
|
+
mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
|
|
451
|
+
|
|
452
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "missing-change" });
|
|
453
|
+
|
|
454
|
+
assert.equal(status.isNonAuthoritative, true);
|
|
455
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
456
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
457
|
+
assert.equal(status.applyState, "not_applicable");
|
|
458
|
+
assert.equal(status.artifactStore, "both");
|
|
459
|
+
// Must NOT be treated as blocked
|
|
460
|
+
assert.notEqual(status.applyState, "blocked");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Fix 4 item 2 — strengthen existing both-with-openspec-and-seeded-change test
|
|
464
|
+
test("resolveSddStatus with artifactStore both, openspec dir present and change on disk is authoritative", async () => {
|
|
465
|
+
const cwd = await workspace();
|
|
466
|
+
seedChange(cwd);
|
|
467
|
+
|
|
468
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "add-auth" });
|
|
469
|
+
|
|
470
|
+
assert.equal(status.artifactStore, "both");
|
|
471
|
+
assert.equal(status.changeName, "add-auth");
|
|
472
|
+
// Must be authoritative
|
|
473
|
+
assert.equal(isNonAuthoritativeStatus(status), false);
|
|
474
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
475
|
+
// Must not be not_applicable — real disk scan ran
|
|
476
|
+
assert.notEqual(status.applyState, "not_applicable");
|
|
477
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
478
|
+
assert.equal(status.artifacts.proposal, "done");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Fix 4 item 3 — pure openspec store + change not found STILL blocks (guard against over-broadening Fix 2)
|
|
482
|
+
test("resolveSddStatus with artifactStore openspec and change not found still blocks", async () => {
|
|
483
|
+
const cwd = await workspace();
|
|
484
|
+
// Create openspec dir with a different change — simulate openspec store with no matching change
|
|
485
|
+
mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
|
|
486
|
+
|
|
487
|
+
const status = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "nonexistent" });
|
|
488
|
+
|
|
489
|
+
// Must block, not return non-authoritative
|
|
490
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
491
|
+
assert.match(status.blockedReasons.join("\n"), /Active change not found/);
|
|
492
|
+
assert.equal(status.applyState, "blocked");
|
|
493
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("renderSddDispatcherMarkdown for both-without-openspec does NOT render Ready", async () => {
|
|
497
|
+
const cwd = await workspace();
|
|
498
|
+
// No openspec directory — both store is non-authoritative
|
|
499
|
+
|
|
500
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
|
|
501
|
+
const markdown = renderSddDispatcherMarkdown(status);
|
|
502
|
+
|
|
503
|
+
assert.doesNotMatch(markdown, /### Ready/);
|
|
504
|
+
assert.match(markdown, /resolve via Engram/i);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("renderNativeSddPhasePrompt for both-without-openspec emits non-authoritative line", async () => {
|
|
508
|
+
const cwd = await workspace();
|
|
509
|
+
|
|
510
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
|
|
511
|
+
const prompt = renderNativeSddPhasePrompt(status, "apply");
|
|
512
|
+
|
|
513
|
+
assert.match(prompt, /non-authoritative/);
|
|
514
|
+
assert.doesNotMatch(prompt, /deterministically/);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("renderPhaseInstructions for not_applicable applyState emits neutral line", async () => {
|
|
518
|
+
const cwd = await workspace();
|
|
519
|
+
|
|
520
|
+
const status = resolveSddStatus({ cwd, artifactStore: "engram", changeName: "fix-x" });
|
|
521
|
+
const instructions = renderPhaseInstructions(status);
|
|
522
|
+
|
|
523
|
+
assert.match(instructions.apply.join("\n"), /Readiness is resolved from Engram/);
|
|
524
|
+
assert.match(instructions.archive.join("\n"), /Readiness is resolved from Engram/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Fix 4 item 1 — both + openspec/ + ZERO changes + no changeName → non-authoritative
|
|
528
|
+
test("resolveSddStatus both + openspec/ dir + zero active changes + no changeName returns non-authoritative", async () => {
|
|
529
|
+
const cwd = await workspace();
|
|
530
|
+
// openspec/ dir exists but holds no active changes (only the changes/ subdir)
|
|
531
|
+
mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
|
|
532
|
+
|
|
533
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both" });
|
|
534
|
+
|
|
535
|
+
assert.equal(status.isNonAuthoritative, true);
|
|
536
|
+
assert.equal(status.nextRecommended, "resolve-via-engram");
|
|
537
|
+
assert.deepEqual(status.blockedReasons, []);
|
|
538
|
+
assert.equal(status.artifactStore, "both");
|
|
539
|
+
assert.equal(status.applyState, "not_applicable");
|
|
540
|
+
assert.equal(status.dependencies.apply, "not_applicable");
|
|
541
|
+
assert.equal(status.dependencies.archive, "not_applicable");
|
|
542
|
+
// Must NOT be treated as blocked
|
|
543
|
+
assert.notEqual(status.applyState, "blocked");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Fix 4 item 2 — both + openspec/ + MULTIPLE changes + no changeName → authoritative select-change
|
|
547
|
+
test("resolveSddStatus both + openspec/ dir + multiple active changes + no changeName stays authoritative", async () => {
|
|
548
|
+
const cwd = await workspace();
|
|
549
|
+
mkdirSync(join(cwd, "openspec", "changes", "alpha"), { recursive: true });
|
|
550
|
+
mkdirSync(join(cwd, "openspec", "changes", "beta"), { recursive: true });
|
|
551
|
+
|
|
552
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both" });
|
|
553
|
+
|
|
554
|
+
// Authoritative ambiguous-selection behavior must be preserved
|
|
555
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
556
|
+
assert.match(status.blockedReasons.join("\n"), /ambiguous/);
|
|
557
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Fix 4 item 3 — both + openspec/ + ONE resolvable change → authoritative
|
|
561
|
+
test("resolveSddStatus both + openspec/ dir + exactly one active change is authoritative", async () => {
|
|
562
|
+
const cwd = await workspace();
|
|
563
|
+
seedChange(cwd);
|
|
564
|
+
|
|
565
|
+
// No changeName supplied — should auto-select the single change
|
|
566
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both" });
|
|
567
|
+
|
|
568
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
569
|
+
assert.equal(status.changeName, "add-auth");
|
|
570
|
+
assert.equal(status.artifactStore, "both");
|
|
571
|
+
assert.notEqual(status.applyState, "not_applicable");
|
|
572
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
573
|
+
assert.equal(status.artifacts.proposal, "done");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Fix 4 item 4 — pure openspec + zero/missing change STILL blocks (guard against over-broadening)
|
|
577
|
+
test("resolveSddStatus openspec + zero active changes still blocks", async () => {
|
|
578
|
+
const cwd = await workspace();
|
|
579
|
+
mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
|
|
580
|
+
|
|
581
|
+
const status = resolveSddStatus({ cwd, artifactStore: "openspec" });
|
|
582
|
+
|
|
583
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
584
|
+
assert.match(status.blockedReasons.join("\n"), /No active SDD changes/);
|
|
585
|
+
assert.equal(status.applyState, "blocked");
|
|
586
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("resolveSddStatus openspec + named change missing still blocks", async () => {
|
|
590
|
+
const cwd = await workspace();
|
|
591
|
+
mkdirSync(join(cwd, "openspec", "changes", "other-change"), { recursive: true });
|
|
592
|
+
|
|
593
|
+
const status = resolveSddStatus({ cwd, artifactStore: "openspec", changeName: "nonexistent" });
|
|
594
|
+
|
|
595
|
+
assert.equal(status.isNonAuthoritative, false);
|
|
596
|
+
assert.match(status.blockedReasons.join("\n"), /Active change not found/);
|
|
597
|
+
assert.equal(status.applyState, "blocked");
|
|
598
|
+
assert.notEqual(status.nextRecommended, "resolve-via-engram");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Fix 4 render test — non-authoritative both status → dispatcher shows "both" not "Engram or none"
|
|
602
|
+
test("renderSddDispatcherMarkdown for non-authoritative both status shows artifact store 'both'", async () => {
|
|
603
|
+
const cwd = await workspace();
|
|
604
|
+
|
|
605
|
+
const status = resolveSddStatus({ cwd, artifactStore: "both", changeName: "fix-x" });
|
|
606
|
+
const markdown = renderSddDispatcherMarkdown(status);
|
|
607
|
+
|
|
608
|
+
assert.match(markdown, /artifact store: both/);
|
|
609
|
+
assert.doesNotMatch(markdown, /Engram or none/);
|
|
610
|
+
assert.match(markdown, /resolve via Engram/i);
|
|
611
|
+
});
|