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.
Files changed (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. 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) => {
@@ -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");
@@ -171,6 +171,7 @@ function runGit(
171
171
  encoding: "utf8",
172
172
  stdio: ["ignore", "pipe", "pipe"],
173
173
  env,
174
+ windowsHide: true,
174
175
  }).trim();
175
176
  }
176
177
 
@@ -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
 
@@ -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, openSync, readSync, fstatSync, closeSync } from "node:fs";
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: missing.length === 0 ? "available" : "blocked",
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"], "All four tools are required. Search, check sources and retrieve original content; inventory and search snippets alone are not evidence."),
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 or proposal admission. Run selected supported classes, record tool calls, source URLs, retrieval time, publisher/version, excerpts and claim-to-source IDs. Child-local inventory must confirm availability before evidence collection.",
54
- "Missing required tools block only the affected class. Any selected unavailable or partial class keeps proposal_ready=false. Preserve explicit source restrictions; never recommend skipping selected research because of a blanket denial.",
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 required = kind === "documentation" ? ["fetch_content"] : ["web_search", "source_check", "fetch_content", "get_search_content"];
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 === required.length &&
72
- required.every(name => grant.tools!.includes(name)) &&
73
- grant.extensions && Object.keys(grant.extensions).length === required.length;
74
- const paths = required.map(name => registered.find(tool => tool.name === name)?.sourceInfo?.path);
75
- capability.tools = exact ? required.filter((name, i) => capability.tools.includes(name) &&
76
- typeof paths[i] === "string" && isAbsolute(paths[i]!) && paths[i] === grant.extensions![name]) : [];
77
- if (capability.tools.length !== required.length) {
78
- capability.status = "blocked";
79
- capability.reason = `Selected exact grants and matching active extension provenance required. ${capability.reason}`;
80
- } else {
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 local = new Set(["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"]);
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
- export const RESEARCH_ARTIFACT_ENV = "GENTLE_PI_RESEARCH_ARTIFACT";
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
- }