gentle-pi 2.7.0 → 3.0.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/README.md +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -136,6 +136,7 @@ function runGateGit(cwd: string, args: readonly string[]): string {
|
|
|
136
136
|
encoding: "utf8",
|
|
137
137
|
stdio: ["ignore", "pipe", "pipe"],
|
|
138
138
|
env: reviewGitEnvironment(),
|
|
139
|
+
windowsHide: true,
|
|
139
140
|
});
|
|
140
141
|
if (result.error || result.status !== 0) throw publicationError(`Git publication identity could not be resolved: ${args.join(" ")}`);
|
|
141
142
|
return result.stdout.trim();
|
|
@@ -150,6 +151,7 @@ function listConfiguredRemotes(cwd: string): string[] {
|
|
|
150
151
|
encoding: "utf8",
|
|
151
152
|
stdio: ["ignore", "pipe", "pipe"],
|
|
152
153
|
env: reviewGitEnvironment(),
|
|
154
|
+
windowsHide: true,
|
|
153
155
|
});
|
|
154
156
|
if (result.error || result.status !== 0) throw publicationError("Configured Git remotes could not be listed");
|
|
155
157
|
return result.stdout.split(/\r?\n/).filter(Boolean);
|
|
@@ -160,6 +162,7 @@ function configuredRemoteValues(cwd: string, key: string): string[] {
|
|
|
160
162
|
encoding: "utf8",
|
|
161
163
|
stdio: ["ignore", "pipe", "pipe"],
|
|
162
164
|
env: publicationProbeGitEnvironment(),
|
|
165
|
+
windowsHide: true,
|
|
163
166
|
});
|
|
164
167
|
if (result.error || (result.status !== 0 && result.status !== 1)) {
|
|
165
168
|
throw publicationError(`Configured Git remote value "${key}" could not be resolved`);
|
|
@@ -178,6 +181,7 @@ export function resolveConfiguredPushDestinationV1(cwd: string, remote: string):
|
|
|
178
181
|
encoding: "utf8",
|
|
179
182
|
stdio: ["ignore", "pipe", "pipe"],
|
|
180
183
|
env: publicationProbeGitEnvironment(),
|
|
184
|
+
windowsHide: true,
|
|
181
185
|
});
|
|
182
186
|
if (result.error || result.status !== 0) throw publicationError(`Configured remote "${remote}" push destination could not be resolved`);
|
|
183
187
|
const urls = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
@@ -205,6 +209,7 @@ export function resolvePushRemoteRefV1(
|
|
|
205
209
|
encoding: "utf8",
|
|
206
210
|
stdio: ["ignore", "pipe", "pipe"],
|
|
207
211
|
env: publicationProbeGitEnvironment(),
|
|
212
|
+
windowsHide: true,
|
|
208
213
|
});
|
|
209
214
|
if (result.error || result.status !== 0) throw publicationError(`${label} could not be resolved`);
|
|
210
215
|
if (result.stdout.length === 0) return { destination, object_id: null };
|
|
@@ -232,6 +237,7 @@ export function resolvePushDestinationRefV1(
|
|
|
232
237
|
cwd,
|
|
233
238
|
stdio: "ignore",
|
|
234
239
|
env: reviewGitEnvironment(),
|
|
240
|
+
windowsHide: true,
|
|
235
241
|
});
|
|
236
242
|
if (formatCheck.error || formatCheck.status !== 0) throw publicationError(`${label} is malformed`);
|
|
237
243
|
const destination = resolveConfiguredPushDestinationV1(cwd, remote);
|
|
@@ -240,6 +246,7 @@ export function resolvePushDestinationRefV1(
|
|
|
240
246
|
encoding: "utf8",
|
|
241
247
|
stdio: ["ignore", "pipe", "pipe"],
|
|
242
248
|
env: publicationProbeGitEnvironment(),
|
|
249
|
+
windowsHide: true,
|
|
243
250
|
});
|
|
244
251
|
if (result.error || result.status !== 0) throw publicationError(`${label} could not be resolved`);
|
|
245
252
|
const rows = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
@@ -271,6 +278,7 @@ export function pushRemoteAdvertisesObjectV1(
|
|
|
271
278
|
encoding: "utf8",
|
|
272
279
|
stdio: ["ignore", "pipe", "pipe"],
|
|
273
280
|
env: publicationProbeGitEnvironment(),
|
|
281
|
+
windowsHide: true,
|
|
274
282
|
});
|
|
275
283
|
if (result.error || result.status !== 0) throw publicationError("Push parent advertisement could not be resolved");
|
|
276
284
|
const rows = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
@@ -357,7 +365,7 @@ function defaultGhCommandRunner(
|
|
|
357
365
|
args: readonly string[],
|
|
358
366
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
|
359
367
|
): { status: number | null; stdout: string; error?: Error } {
|
|
360
|
-
const result = spawnSync("gh", args, { ...options, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
368
|
+
const result = spawnSync("gh", args, { ...options, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
361
369
|
return { status: result.status, stdout: result.stdout ?? "", error: result.error };
|
|
362
370
|
}
|
|
363
371
|
|
|
@@ -423,6 +431,7 @@ function resolveConfiguredRemoteUrl(cwd: string, remote: string): string {
|
|
|
423
431
|
encoding: "utf8",
|
|
424
432
|
stdio: ["ignore", "pipe", "pipe"],
|
|
425
433
|
env: reviewGitEnvironment(),
|
|
434
|
+
windowsHide: true,
|
|
426
435
|
});
|
|
427
436
|
if (result.error || result.status !== 0) throw publicationError(`Configured remote "${remote}" URL could not be resolved`);
|
|
428
437
|
const url = result.stdout.trim();
|
|
@@ -437,6 +446,7 @@ function resolveRemoteGateRef(cwd: string, remote: string, ref: string, label: s
|
|
|
437
446
|
encoding: "utf8",
|
|
438
447
|
stdio: ["ignore", "pipe", "pipe"],
|
|
439
448
|
env: reviewGitEnvironment(),
|
|
449
|
+
windowsHide: true,
|
|
440
450
|
});
|
|
441
451
|
if (result.error || result.status !== 0) throw publicationError(`${label} could not be resolved`);
|
|
442
452
|
const matches = result.stdout.split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
package/lib/review-repository.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function publicationProbeGitEnvironment(): NodeJS.ProcessEnv {
|
|
|
75
75
|
function gitLines(cwd: string, args: string[]): string[] {
|
|
76
76
|
let output: string;
|
|
77
77
|
try {
|
|
78
|
-
output = execFileSync("git", ["-C", resolve(cwd), ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, env: reviewGitEnvironment() });
|
|
78
|
+
output = execFileSync("git", ["-C", resolve(cwd), ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, env: reviewGitEnvironment(), windowsHide: true });
|
|
79
79
|
} catch (error) {
|
|
80
80
|
if (error instanceof ReviewRepositoryError) throw error;
|
|
81
81
|
throw new ReviewRepositoryError("Unable to resolve Git repository authority");
|
package/lib/review-snapshot.ts
CHANGED
|
@@ -935,12 +935,12 @@ export function reviewStoreRootForRepository(cwd: string): string {
|
|
|
935
935
|
const repositoryRoot = execFileSync(
|
|
936
936
|
"git",
|
|
937
937
|
["rev-parse", "--show-toplevel"],
|
|
938
|
-
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
938
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
|
939
939
|
).trim();
|
|
940
940
|
const gitPath = execFileSync(
|
|
941
941
|
"git",
|
|
942
942
|
["rev-parse", "--git-path", "gentle-ai/reviews"],
|
|
943
|
-
{ cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
943
|
+
{ cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
|
944
944
|
).trim();
|
|
945
945
|
return isAbsolute(gitPath) ? gitPath : resolve(repositoryRoot, gitPath);
|
|
946
946
|
}
|
|
@@ -950,6 +950,7 @@ function repositoryRootForGate(cwd: string): string {
|
|
|
950
950
|
cwd,
|
|
951
951
|
encoding: "utf8",
|
|
952
952
|
stdio: ["ignore", "pipe", "pipe"],
|
|
953
|
+
windowsHide: true,
|
|
953
954
|
}).trim();
|
|
954
955
|
}
|
|
955
956
|
|
|
@@ -1528,6 +1529,7 @@ function runGateGit(cwd: string, args: readonly string[]): string {
|
|
|
1528
1529
|
cwd,
|
|
1529
1530
|
encoding: "utf8",
|
|
1530
1531
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1532
|
+
windowsHide: true,
|
|
1531
1533
|
}).trim();
|
|
1532
1534
|
}
|
|
1533
1535
|
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -99,7 +99,6 @@ export const SHIPPED_SDD_AGENT_NAMES = Object.freeze([
|
|
|
99
99
|
"sdd-status",
|
|
100
100
|
"sdd-apply",
|
|
101
101
|
"sdd-verify",
|
|
102
|
-
"sdd-sync",
|
|
103
102
|
"sdd-archive",
|
|
104
103
|
"sdd-remediate",
|
|
105
104
|
]);
|
|
@@ -725,6 +724,8 @@ function copyDirectoryFiles(
|
|
|
725
724
|
// history); user-modified copies are left in place and only lose managed
|
|
726
725
|
// ownership.
|
|
727
726
|
const RETIRED_MANAGED_ASSETS = Object.freeze([
|
|
727
|
+
// Canonical spec composition now belongs to archive (gentle-pi#1051).
|
|
728
|
+
"agents/sdd-sync.md",
|
|
728
729
|
"agents/review-refuter.md",
|
|
729
730
|
"agents/review-validator.md",
|
|
730
731
|
]);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, resolve, dirname, basename } from "node:path";
|
|
2
|
-
import { realpathSync, lstatSync
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync, lstatSync } from "node:fs";
|
|
4
3
|
import type { AgentDefinition } from "./agents-config.ts";
|
|
5
4
|
|
|
6
5
|
// Exact registered Pi names, not provider display namespaces. MCP's generic
|
|
@@ -34,14 +33,14 @@ export function resolveResearchCapabilities(pi: Inventory, restriction?: readonl
|
|
|
34
33
|
const capability = (required: string[], guidance: string): Capability => {
|
|
35
34
|
const missing = required.filter(name => !tools.includes(name as typeof RESEARCH_TOOLS[number]));
|
|
36
35
|
return {
|
|
37
|
-
status:
|
|
36
|
+
status: required.some(name => tools.includes(name as typeof RESEARCH_TOOLS[number])) ? "available" : "blocked",
|
|
38
37
|
tools: required.filter(name => tools.includes(name as typeof RESEARCH_TOOLS[number])),
|
|
39
38
|
reason: `${missing.length === 0 ? "" : `Missing active, approved, child-reachable tools: ${missing.join(", ")}. `}${guidance}`,
|
|
40
39
|
};
|
|
41
40
|
};
|
|
42
41
|
return {
|
|
43
42
|
documentation: capability(["fetch_content"], "Fetch official documentation URLs; validate publisher and version before citing."),
|
|
44
|
-
"open-web": capability(["web_search", "source_check", "fetch_content", "get_search_content"], "
|
|
43
|
+
"open-web": capability(["web_search", "source_check", "fetch_content", "get_search_content"], "Use the available authorized tools for the requested questions; search snippets alone are not validated evidence."),
|
|
45
44
|
};
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -50,8 +49,8 @@ export function renderResearchCapabilities(capabilities: ResearchCapabilities):
|
|
|
50
49
|
"## SDD Research Capabilities",
|
|
51
50
|
"Package-approved mapping intersected with active runtime tools and explicit agent restrictions:",
|
|
52
51
|
...Object.entries(capabilities).map(([kind, value]) => `- ${kind}: ${value.status}; tools=${JSON.stringify(value.tools)}. ${value.reason}`),
|
|
53
|
-
"Availability is not evidence
|
|
54
|
-
"
|
|
52
|
+
"Availability is not evidence. Use actual authorized tools, cite retrieved sources with publisher/version/date, and distinguish supported findings from unanswered questions.",
|
|
53
|
+
"Unavailable tools limit the answers, not proposal readiness. Preserve explicit source restrictions, report useful partial findings and unavailable sources honestly, and never claim online access from inventory alone.",
|
|
55
54
|
"Generic MCP and dynamic namespace gateways are not approved evidence routes. Never infer remote method access from gateway names, tool descriptions, bash, persistence tools, or remembered facts.",
|
|
56
55
|
].join("\n");
|
|
57
56
|
}
|
|
@@ -66,54 +65,25 @@ export function researchAgent(agent: AgentDefinition, pi: Inventory, selection?:
|
|
|
66
65
|
for (const [kind, capability] of Object.entries(capabilities)) {
|
|
67
66
|
const value = requested[kind];
|
|
68
67
|
const grant = value && typeof value === "object" ? value as Partial<ResearchGrant> : {};
|
|
69
|
-
const
|
|
68
|
+
const supported: readonly string[] = kind === "documentation" ? ["fetch_content"] : RESEARCH_TOOLS;
|
|
70
69
|
const exact = Object.keys(requested).every(key => Object.hasOwn(capabilities, key)) &&
|
|
71
|
-
Array.isArray(grant.tools) && grant.tools.length ===
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
typeof
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const path of paths) extensionPaths.add(path!);
|
|
82
|
-
}
|
|
70
|
+
Array.isArray(grant.tools) && grant.tools.length > 0 && new Set(grant.tools).size === grant.tools.length &&
|
|
71
|
+
grant.tools.every(name => supported.includes(name)) && grant.extensions &&
|
|
72
|
+
Object.keys(grant.extensions).length === grant.tools.length;
|
|
73
|
+
capability.tools = exact ? grant.tools!.filter(name => {
|
|
74
|
+
const path = registered.find(tool => tool.name === name)?.sourceInfo?.path;
|
|
75
|
+
return capability.tools.includes(name) && typeof path === "string" && isAbsolute(path) && path === grant.extensions![name];
|
|
76
|
+
}) : [];
|
|
77
|
+
capability.status = capability.tools.length ? "available" : "blocked";
|
|
78
|
+
capability.reason = `Only individually selected tools with matching active extension provenance are usable. ${capability.reason}`;
|
|
79
|
+
for (const name of capability.tools) extensionPaths.add(grant.extensions![name]);
|
|
83
80
|
}
|
|
84
81
|
const available = new Set(Object.values(capabilities).filter(value => value.status === "available").flatMap(value => value.tools));
|
|
85
|
-
const
|
|
86
|
-
const tools = agent.tools.filter(name => local.has(name) || available.has(name));
|
|
82
|
+
const tools = agent.tools.filter(name => available.has(name));
|
|
87
83
|
return { agent: { ...agent, tools, instructions: `${agent.instructions}\n\n${renderResearchCapabilities(capabilities)}` }, capabilities, extensionPaths: [...extensionPaths] };
|
|
88
84
|
}
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
const ARTIFACT_STORES = ["openspec", "engram", "both", "none"] as const;
|
|
92
|
-
interface EngramLocator {
|
|
93
|
-
id: number;
|
|
94
|
-
project: string;
|
|
95
|
-
topic_key: string;
|
|
96
|
-
revision_count: number;
|
|
97
|
-
}
|
|
98
|
-
export interface ResearchWriteIdentity {
|
|
99
|
-
revision: number;
|
|
100
|
-
digest: string;
|
|
101
|
-
}
|
|
102
|
-
interface ResearchLocator extends ResearchWriteIdentity {
|
|
103
|
-
artifact: string;
|
|
104
|
-
path?: string;
|
|
105
|
-
engram?: EngramLocator;
|
|
106
|
-
}
|
|
107
|
-
export interface ResearchArtifactIntent {
|
|
108
|
-
store: typeof ARTIFACT_STORES[number];
|
|
109
|
-
worktree: string;
|
|
110
|
-
changeName: string;
|
|
111
|
-
retainedIntent: string;
|
|
112
|
-
locators: ResearchLocator[];
|
|
113
|
-
}
|
|
114
|
-
const record = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
115
|
-
const positive = (value: unknown) => Number.isSafeInteger(value) && Number(value) > 0;
|
|
116
|
-
// Missing leaves are allowed for diagnostic persistence; existing symlinks are not.
|
|
86
|
+
// Resolve existing ancestors for real remediation permission checks, including missing leaves.
|
|
117
87
|
export function canonicalArtifactPath(path: string): string {
|
|
118
88
|
try { lstatSync(path); }
|
|
119
89
|
catch (error) {
|
|
@@ -122,107 +92,3 @@ export function canonicalArtifactPath(path: string): string {
|
|
|
122
92
|
}
|
|
123
93
|
return realpathSync(path);
|
|
124
94
|
}
|
|
125
|
-
export function parseResearchArtifactIntent(value: unknown, cwd: string, previous?: ResearchArtifactIntent): ResearchArtifactIntent {
|
|
126
|
-
const input = record(value);
|
|
127
|
-
if (!ARTIFACT_STORES.includes(input.store as ResearchArtifactIntent["store"])
|
|
128
|
-
|| input.worktree !== realpathSync(cwd) || input.worktree !== cwd) throw new Error("Research artifact worktree/store scope mismatch.");
|
|
129
|
-
if (typeof input.changeName !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(input.changeName)
|
|
130
|
-
|| typeof input.retainedIntent !== "string" || !input.retainedIntent.trim() || !Array.isArray(input.locators)) throw new Error("Missing bounded research scope/retained intent.");
|
|
131
|
-
const scope = structuredClone(input) as unknown as ResearchArtifactIntent;
|
|
132
|
-
const local = scope.store === "openspec" || scope.store === "both";
|
|
133
|
-
const memory = scope.store === "engram" || scope.store === "both";
|
|
134
|
-
if (scope.locators.length > 3 || (scope.store === "none" ? scope.locators.length !== 0 : scope.locators.length === 0)) throw new Error("Invalid research locator scope.");
|
|
135
|
-
const names = new Set<string>();
|
|
136
|
-
for (const locator of scope.locators) {
|
|
137
|
-
const item = record(locator), engram = record(item.engram);
|
|
138
|
-
if (!["research", "preproposal", "explore"].includes(String(item.artifact))
|
|
139
|
-
|| names.has(String(item.artifact))
|
|
140
|
-
|| !positive(item.revision) || typeof item.digest !== "string" || !/^[a-f0-9]{64}$/.test(item.digest)) throw new Error("Invalid research identity scope.");
|
|
141
|
-
names.add(locator.artifact);
|
|
142
|
-
const path = resolve(cwd, "openspec/changes", scope.changeName, `${locator.artifact}.md`);
|
|
143
|
-
if (local ? item.path !== path || canonicalArtifactPath(path) !== path : item.path !== undefined) throw new Error("OpenSpec locator outside exact scope.");
|
|
144
|
-
if (memory ? !positive(engram.id) || !positive(engram.revision_count) || typeof engram.project !== "string" || !engram.project.trim() || engram.topic_key !== `sdd/${scope.changeName}/${locator.artifact}` : item.engram !== undefined) throw new Error("Engram locator outside exact scope.");
|
|
145
|
-
}
|
|
146
|
-
if (previous) {
|
|
147
|
-
if (scope.store !== previous.store || scope.worktree !== previous.worktree
|
|
148
|
-
|| scope.changeName !== previous.changeName || scope.retainedIntent !== previous.retainedIntent || scope.locators.length !== previous.locators.length) throw new Error("Research continuation cannot replace retained scope.");
|
|
149
|
-
for (const next of scope.locators) {
|
|
150
|
-
const old = previous.locators.find(item => item.artifact === next.artifact);
|
|
151
|
-
if (!old || old.path !== next.path || old.engram?.id !== next.engram?.id
|
|
152
|
-
|| old.engram?.project !== next.engram?.project || old.engram?.topic_key !== next.engram?.topic_key) throw new Error("Research continuation cannot broaden scope.");
|
|
153
|
-
if (next.revision < old.revision || (next.engram?.revision_count ?? 0) < (old.engram?.revision_count ?? 0)
|
|
154
|
-
|| next.revision === old.revision && next.digest !== old.digest) throw new Error("Research continuation has stale/divergent scope.");
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return scope;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Negative intersection only: callers must still enforce host permissions/inventory.
|
|
161
|
-
export function researchArtifactCall(scope: ResearchArtifactIntent, cwd: string, tool: string, input: Record<string, unknown>): number {
|
|
162
|
-
if (realpathSync(cwd) !== scope.worktree) throw new Error("Research artifact worktree changed.");
|
|
163
|
-
const index = scope.locators.findIndex(locator => {
|
|
164
|
-
if (locator.artifact === "explore" && ["write", "edit", "mem_save"].includes(tool)) return false;
|
|
165
|
-
if (["read", "write", "edit", "grep"].includes(tool)) {
|
|
166
|
-
if (!locator.path || typeof input.path !== "string") return false;
|
|
167
|
-
const path = resolve(cwd, input.path);
|
|
168
|
-
return path === locator.path && canonicalArtifactPath(path) === path;
|
|
169
|
-
}
|
|
170
|
-
if (!locator.engram) return false;
|
|
171
|
-
if (tool === "mem_get_observation") return input.id === locator.engram.id && Object.keys(input).every(key => key === "id");
|
|
172
|
-
if (tool === "mem_search") return input.project === locator.engram.project && input.query === locator.engram.topic_key && input.all_projects !== true;
|
|
173
|
-
if (tool === "mem_save") return input.project === locator.engram.project && input.topic_key === locator.engram.topic_key;
|
|
174
|
-
return false;
|
|
175
|
-
});
|
|
176
|
-
if (index < 0) throw new Error("Tool arguments outside retained research artifact scope.");
|
|
177
|
-
return index;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Consume actual child tool results, never transported success assertions.
|
|
181
|
-
export function researchArtifactReadback(locator: ResearchLocator, tool: string, returned: unknown, afterWrite = false): boolean {
|
|
182
|
-
let bytes: unknown = returned;
|
|
183
|
-
if (tool === "mem_get_observation") {
|
|
184
|
-
const value = record(returned), expected = locator.engram;
|
|
185
|
-
if (!expected || value.id !== expected.id || value.project !== expected.project || value.topic_key !== expected.topic_key
|
|
186
|
-
|| (afterWrite ? !positive(value.revision_count) || Number(value.revision_count) <= expected.revision_count : value.revision_count !== expected.revision_count)) return false;
|
|
187
|
-
bytes = value.content;
|
|
188
|
-
} else if (tool !== "read") return false;
|
|
189
|
-
if (typeof bytes !== "string" || createHash("sha256").update(bytes).digest("hex") !== locator.digest) return false;
|
|
190
|
-
try { return record(JSON.parse(bytes)).revision === locator.revision; } catch { return false; }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export const RESEARCH_PERSISTENCE_ENTRY = "gentle-sdd-research-persistence";
|
|
194
|
-
export function parseResearchPersistence(value: unknown, scope: ResearchArtifactIntent, cwd: string) {
|
|
195
|
-
const saved = record(value), previous = parseResearchArtifactIntent(saved.scope, cwd);
|
|
196
|
-
parseResearchArtifactIntent(scope, cwd, previous);
|
|
197
|
-
if (saved.version !== 1 || !saved.accepted || typeof saved.accepted !== "object" || Array.isArray(saved.accepted) || !saved.writes || typeof saved.writes !== "object" || Array.isArray(saved.writes)) throw new Error("Invalid research persistence snapshot");
|
|
198
|
-
const accepted = record(saved.accepted) as Record<string, ResearchLocator>, writes = record(saved.writes) as Record<string, ResearchWriteIdentity>;
|
|
199
|
-
const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
|
|
200
|
-
for (const key of new Set([...Object.keys(accepted), ...Object.keys(writes)])) {
|
|
201
|
-
const [index, tool] = key.split(":"), old = previous.locators[Number(index)];
|
|
202
|
-
if (!/^[0-2]:(read|mem_get_observation)$/.test(key) || !old || !tools.includes(tool)) throw new Error("Research persistence key outside scope");
|
|
203
|
-
if (Object.hasOwn(accepted, key)) parseResearchArtifactIntent({ ...previous, locators: [accepted[key]] }, cwd, { ...previous, locators: [old] });
|
|
204
|
-
const desired = writes[key];
|
|
205
|
-
if (Object.hasOwn(writes, key) && (!desired || !positive(desired.revision) || desired.revision <= (accepted[key] ?? old).revision || typeof desired.digest !== "string" || !/^[a-f0-9]{64}$/.test(desired.digest))) throw new Error("Invalid durable desired identity");
|
|
206
|
-
}
|
|
207
|
-
for (const [index, locator] of scope.locators.entries()) {
|
|
208
|
-
const identities = [previous.locators[index], ...tools.map(tool => ({ ...(accepted[`${index}:${tool}`] ?? previous.locators[index]), ...writes[`${index}:${tool}`] }))];
|
|
209
|
-
const advanced = identities.slice(1).filter(item => item.revision > previous.locators[index].revision);
|
|
210
|
-
if (scope.store === "both" && advanced.length === 2 && (advanced[0].revision !== advanced[1].revision || advanced[0].digest !== advanced[1].digest)) throw new Error("Divergent durable hybrid desired identity");
|
|
211
|
-
if (!identities.some(item => item.revision === locator.revision && item.digest === locator.digest && item.engram?.revision_count === locator.engram?.revision_count)) throw new Error("Transported research identity differs from retained operation");
|
|
212
|
-
}
|
|
213
|
-
return structuredClone({ accepted, writes });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Pi may defer custom entries in an in-memory/unflushed session. Verify the
|
|
217
|
-
// actual bounded tail, not appendEntry's void return or an in-memory receipt.
|
|
218
|
-
export function assertResearchCheckpoint(file: string, expected: unknown): void {
|
|
219
|
-
const fd = openSync(file, "r");
|
|
220
|
-
try {
|
|
221
|
-
const size = fstatSync(fd).size, length = Math.min(size, 65_536), tail = Buffer.alloc(length);
|
|
222
|
-
if (readSync(fd, tail, 0, length, size - length) !== length) throw new Error("Incomplete research checkpoint read");
|
|
223
|
-
const text = tail.toString("utf8");
|
|
224
|
-
if (!text.endsWith("\n")) throw new Error("Research checkpoint append incomplete");
|
|
225
|
-
const last = JSON.parse(text.trimEnd().split("\n").at(-1)!);
|
|
226
|
-
if (last.type !== "custom" || last.customType !== RESEARCH_PERSISTENCE_ENTRY || JSON.stringify(last.data) !== JSON.stringify(expected)) throw new Error("Research checkpoint not durably retained");
|
|
227
|
-
} finally { closeSync(fd); }
|
|
228
|
-
}
|