gentle-pi 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -11,13 +11,61 @@ export type { SddArtifactStore };
11
11
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
12
12
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
13
13
  const MANAGED_ASSETS_MANIFEST = "managed-assets.json";
14
+ const MANAGED_ASSETS_LOCK = "managed-assets.lock";
14
15
  const MANAGED_ASSETS_SCHEMA_VERSION = 1;
16
+ const MANAGED_ASSETS_LOCK_TIMEOUT_MS = 5_000;
17
+ const MANAGED_ASSETS_LOCK_RETRY_MS = 25;
15
18
  const LEGACY_MANAGED_ASSET_MANIFESTS = Object.freeze([
16
19
  { path: join(ASSETS_DIR, "migrations", "managed-assets-v0.10.7.json"), version: "0.10.7" },
17
20
  { path: join(ASSETS_DIR, "migrations", "managed-assets-v0.13.json"), version: "0.13.0" },
18
21
  { path: join(ASSETS_DIR, "migrations", "managed-assets-v0.14.json"), version: "0.14.0" },
22
+ { path: join(ASSETS_DIR, "migrations", "managed-assets-v2.5.0.json"), version: "2.5.0" },
19
23
  ]);
20
24
 
25
+ const ASSET_OWNER_BY_KEY = Object.freeze({
26
+ "agents/gentle-ai-explore.md": "delegation",
27
+ "agents/gentle-ai-verify.md": "delegation",
28
+ "agents/gentle-ai-worker.md": "delegation",
29
+ "agents/jd-fix-agent.md": "review",
30
+ "agents/jd-judge-a.md": "review",
31
+ "agents/jd-judge-b.md": "review",
32
+ "agents/review-readability.md": "review",
33
+ "agents/review-reliability.md": "review",
34
+ "agents/review-resilience.md": "review",
35
+ "agents/review-risk.md": "review",
36
+ "agents/review-refuter.md": "review",
37
+ "agents/review-validator.md": "review",
38
+ "chains/4r-review.chain.md": "review",
39
+ "agents/sdd-apply.md": "sdd",
40
+ "agents/sdd-archive.md": "sdd",
41
+ "agents/sdd-design.md": "sdd",
42
+ "agents/sdd-explore.md": "sdd",
43
+ "agents/sdd-init.md": "sdd",
44
+ "agents/sdd-onboard.md": "sdd",
45
+ "agents/sdd-proposal.md": "sdd",
46
+ "agents/sdd-research.md": "sdd",
47
+ "agents/sdd-remediate.md": "sdd",
48
+ "agents/sdd-spec.md": "sdd",
49
+ "agents/sdd-status.md": "sdd",
50
+ "agents/sdd-sync.md": "sdd",
51
+ "agents/sdd-tasks.md": "sdd",
52
+ "agents/sdd-verify.md": "sdd",
53
+ "chains/sdd-full.chain.md": "sdd",
54
+ "chains/sdd-plan.chain.md": "sdd",
55
+ "chains/sdd-verify.chain.md": "sdd",
56
+ "gentle-ai/support/sdd-status-contract.md": "sdd",
57
+ "gentle-ai/support/strict-tdd.md": "sdd",
58
+ "gentle-ai/support/strict-tdd-verify.md": "sdd",
59
+ } as const);
60
+
61
+ export type PackageAssetOwner = (typeof ASSET_OWNER_BY_KEY)[keyof typeof ASSET_OWNER_BY_KEY];
62
+
63
+ export function getPackageAssetOwner(ownershipKey: string): PackageAssetOwner | undefined {
64
+ return Object.hasOwn(ASSET_OWNER_BY_KEY, ownershipKey)
65
+ ? ASSET_OWNER_BY_KEY[ownershipKey as keyof typeof ASSET_OWNER_BY_KEY]
66
+ : undefined;
67
+ }
68
+
21
69
  function gentlePiAgentHome(): string {
22
70
  return resolveGentlePiAgentHome();
23
71
  }
@@ -37,6 +85,24 @@ export type SddChainedPrStrategy =
37
85
  | "force-chained";
38
86
  export type SddPreflightField = "executionMode" | "artifactStore" | "chainedPrStrategy" | "reviewBudgetLines";
39
87
  export const SDD_PREFLIGHT_FIELDS = ["executionMode", "artifactStore", "chainedPrStrategy", "reviewBudgetLines"] as const;
88
+ // The parent dispatch and the process-spawn boundary share this exact shipped
89
+ // inventory so a newly packaged SDD actor cannot bypass preflight transport.
90
+ export const SHIPPED_SDD_AGENT_NAMES = Object.freeze([
91
+ "sdd-init",
92
+ "sdd-onboard",
93
+ "sdd-explore",
94
+ "sdd-research",
95
+ "sdd-proposal",
96
+ "sdd-spec",
97
+ "sdd-design",
98
+ "sdd-tasks",
99
+ "sdd-status",
100
+ "sdd-apply",
101
+ "sdd-verify",
102
+ "sdd-sync",
103
+ "sdd-archive",
104
+ "sdd-remediate",
105
+ ]);
40
106
 
41
107
  export interface SddPreflightPreferences {
42
108
  executionMode: SddExecutionMode;
@@ -86,6 +152,20 @@ interface LegacyManagedAssetsManifest extends ManagedAssetsManifest {
86
152
  packageVersion: string;
87
153
  }
88
154
 
155
+ interface ManagedAssetsLockOwner {
156
+ schemaVersion: 1;
157
+ token: string;
158
+ pid: number;
159
+ createdAtMs: number;
160
+ }
161
+
162
+ /** @internal The hold option exists only to make process-lock regression tests deterministic. */
163
+ interface PackageAssetInstallLockOptions {
164
+ timeoutMs?: number;
165
+ retryMs?: number;
166
+ holdLockMs?: number;
167
+ }
168
+
89
169
  export const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = Object.freeze({
90
170
  executionMode: "auto",
91
171
  artifactStore: "openspec",
@@ -198,6 +278,89 @@ function managedAssetHash(content: string): string {
198
278
  return createHash("sha256").update(content).digest("hex");
199
279
  }
200
280
 
281
+ function readManagedAssetsLockOwner(lockPath: string): ManagedAssetsLockOwner | undefined {
282
+ try {
283
+ if (!lstatSync(lockPath).isFile()) return undefined;
284
+ const parsed: unknown = JSON.parse(readFileSync(lockPath, "utf8"));
285
+ if (!isRecord(parsed) || parsed.schemaVersion !== 1 || typeof parsed.token !== "string" || parsed.token.length === 0 || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.createdAtMs !== "number" || !Number.isFinite(parsed.createdAtMs)) return undefined;
286
+ return parsed as ManagedAssetsLockOwner;
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ }
291
+
292
+ function waitForManagedAssetsLock(milliseconds: number): void {
293
+ if (milliseconds <= 0) return;
294
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
295
+ }
296
+
297
+ function normalizedLockDuration(value: number | undefined, fallback: number): number {
298
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
299
+ ? Math.floor(value)
300
+ : fallback;
301
+ }
302
+
303
+ function acquireManagedAssetsLock(
304
+ agentHome: string,
305
+ options: PackageAssetInstallLockOptions = {},
306
+ ): { path: string; owner: ManagedAssetsLockOwner } {
307
+ const lockParent = join(agentHome, "gentle-ai");
308
+ const lockPath = join(lockParent, MANAGED_ASSETS_LOCK);
309
+ const timeoutMs = normalizedLockDuration(options.timeoutMs, MANAGED_ASSETS_LOCK_TIMEOUT_MS);
310
+ const retryMs = Math.max(1, normalizedLockDuration(options.retryMs, MANAGED_ASSETS_LOCK_RETRY_MS));
311
+ const deadline = Date.now() + timeoutMs;
312
+ mkdirSync(lockParent, { recursive: true });
313
+ for (;;) {
314
+ const owner: ManagedAssetsLockOwner = {
315
+ schemaVersion: 1,
316
+ token: randomUUID(),
317
+ pid: process.pid,
318
+ createdAtMs: Date.now(),
319
+ };
320
+ try {
321
+ writeFileSync(lockPath, JSON.stringify(owner), { encoding: "utf8", flag: "wx" });
322
+ return { path: lockPath, owner };
323
+ } catch (error) {
324
+ if (!isRecord(error) || error.code !== "EEXIST") throw error;
325
+ try {
326
+ if (!lstatSync(lockPath).isFile()) {
327
+ throw new Error(`Managed-assets lock path is unsafe and must be a regular file: ${lockPath}`);
328
+ }
329
+ } catch (inspectionError) {
330
+ if (isRecord(inspectionError) && inspectionError.code === "ENOENT") continue;
331
+ throw inspectionError;
332
+ }
333
+ if (Date.now() >= deadline) {
334
+ throw new Error(`Timed out acquiring managed-assets lock file ${lockPath}. Verify no installer is active before removing this exact lock file.`);
335
+ }
336
+ waitForManagedAssetsLock(retryMs);
337
+ }
338
+ }
339
+ }
340
+
341
+ function releaseManagedAssetsLock(lock: { path: string; owner: ManagedAssetsLockOwner }): void {
342
+ if (readManagedAssetsLockOwner(lock.path)?.token !== lock.owner.token) return;
343
+ try {
344
+ unlinkSync(lock.path);
345
+ } catch {
346
+ // An unreadable or replaced lock remains for an operator to inspect.
347
+ }
348
+ }
349
+
350
+ function withManagedAssetsLock<T>(
351
+ agentHome: string,
352
+ action: () => T,
353
+ options: PackageAssetInstallLockOptions | undefined,
354
+ ): T {
355
+ const lock = acquireManagedAssetsLock(agentHome, options);
356
+ try {
357
+ waitForManagedAssetsLock(normalizedLockDuration(options?.holdLockMs, 0));
358
+ return action();
359
+ } finally {
360
+ releaseManagedAssetsLock(lock);
361
+ }
362
+ }
363
+
201
364
  function readLegacyManagedAssets(
202
365
  path: string,
203
366
  version: string,
@@ -289,10 +452,36 @@ function migrateLegacyAssetContent(
289
452
  return updateAgentFrontmatterRouting(packagedContent, routingLines);
290
453
  }
291
454
 
455
+ function replaceManagedAssetFileAtomically(path: string, content: string): void {
456
+ const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
457
+ let mode: number | undefined;
458
+ try {
459
+ const stat = lstatSync(path);
460
+ if (stat.isFile()) mode = stat.mode & 0o777;
461
+ } catch (error) {
462
+ if (!isRecord(error) || error.code !== "ENOENT") throw error;
463
+ }
464
+ try {
465
+ writeFileSync(temporaryPath, content, {
466
+ encoding: "utf8",
467
+ flag: "wx",
468
+ ...(mode === undefined ? {} : { mode }),
469
+ });
470
+ renameSync(temporaryPath, path);
471
+ } finally {
472
+ try {
473
+ unlinkSync(temporaryPath);
474
+ } catch {
475
+ // A renamed or otherwise inaccessible temporary file needs no further action.
476
+ }
477
+ }
478
+ }
479
+
292
480
  export function updatePackageManagedSddAgentOwnership(
293
481
  installedPath: string,
294
482
  previousContent: string,
295
483
  nextContent: string,
484
+ lockOptions?: PackageAssetInstallLockOptions,
296
485
  ): boolean {
297
486
  const agentHome = gentlePiAgentHome();
298
487
  const relativePath = relative(join(agentHome, "agents"), installedPath);
@@ -305,19 +494,51 @@ export function updatePackageManagedSddAgentOwnership(
305
494
  return false;
306
495
  }
307
496
  const ownershipKey = `agents/${relativePath.split(sep).join("/")}`;
308
- const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST);
309
- const manifest = readManagedAssetsManifest(manifestPath);
310
- if (manifest.assets[ownershipKey] !== managedAssetHash(previousContent)) {
311
- return false;
312
- }
313
- try {
314
- if (readFileSync(installedPath, "utf8") !== nextContent) return false;
315
- manifest.assets[ownershipKey] = managedAssetHash(nextContent);
316
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
497
+ return withManagedAssetsLock(agentHome, () => {
498
+ const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST);
499
+ const manifest = readManagedAssetsManifest(manifestPath);
500
+ if (manifest.assets[ownershipKey] !== managedAssetHash(previousContent)) {
501
+ return false;
502
+ }
503
+ const installedContent = readFileSync(installedPath, "utf8");
504
+ // The next-content branch preserves the prior internal caller contract:
505
+ // callers that wrote the file before this function still receive a managed
506
+ // manifest update. New callers take the previous-content branch below so
507
+ // the file and manifest update share this lock.
508
+ if (installedContent !== nextContent && installedContent !== previousContent) {
509
+ return false;
510
+ }
511
+ try {
512
+ if (installedContent === previousContent) {
513
+ replaceManagedAssetFileAtomically(installedPath, nextContent);
514
+ }
515
+ manifest.assets[ownershipKey] = managedAssetHash(nextContent);
516
+ replaceManagedAssetFileAtomically(
517
+ manifestPath,
518
+ JSON.stringify(manifest, null, 2),
519
+ );
520
+ } catch (error) {
521
+ try {
522
+ replaceManagedAssetFileAtomically(installedPath, previousContent);
523
+ } catch (rollbackError) {
524
+ throw new Error(
525
+ `Managed routing update failed and could not restore ${installedPath}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
526
+ { cause: error },
527
+ );
528
+ }
529
+ throw error;
530
+ }
317
531
  return true;
318
- } catch {
319
- return false;
320
- }
532
+ }, lockOptions);
533
+ }
534
+
535
+ export function hasPackageAssetOwnerInstallation(owner: PackageAssetOwner): boolean {
536
+ const agentHome = gentlePiAgentHome();
537
+ const manifest = readManagedAssetsManifest(join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST));
538
+ return Object.keys(manifest.assets).some((key) => getPackageAssetOwner(key) === owner) ||
539
+ Object.entries(ASSET_OWNER_BY_KEY).some(([key, candidate]) =>
540
+ candidate === owner && existsSync(join(agentHome, key)),
541
+ );
321
542
  }
322
543
 
323
544
  export function isPackageManagedSddAsset(
@@ -411,8 +632,12 @@ function copyDirectoryFiles(
411
632
  force: boolean,
412
633
  manifest: ManagedAssetsManifest,
413
634
  legacyAssetHashes: (() => Readonly<Record<string, readonly string[]>>) | undefined,
635
+ selected?: ReadonlySet<string>,
414
636
  ): { copied: number; skipped: number } {
415
637
  if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 };
638
+ if (selected && ![...selected].some(key => key.startsWith(`${ownershipPrefix}/`))) {
639
+ return { copied: 0, skipped: 0 };
640
+ }
416
641
  mkdirSync(targetDir, { recursive: true });
417
642
  let copied = 0;
418
643
  let skipped = 0;
@@ -428,12 +653,13 @@ function copyDirectoryFiles(
428
653
  force,
429
654
  manifest,
430
655
  legacyAssetHashes,
656
+ selected,
431
657
  );
432
658
  copied += child.copied;
433
659
  skipped += child.skipped;
434
660
  continue;
435
661
  }
436
- if (!entry.isFile()) continue;
662
+ if (!entry.isFile() || (selected && !selected.has(ownershipKey))) continue;
437
663
  const source = readFileSync(sourcePath, "utf8");
438
664
  let nextSource = source;
439
665
  if (existsSync(targetPath)) {
@@ -476,6 +702,9 @@ function copyDirectoryFiles(
476
702
  delete manifest.assets[ownershipKey];
477
703
  skipped += 1;
478
704
  continue;
705
+ } else if (ownershipKey === "agents/sdd-research.md" && installedContent !== undefined) {
706
+ // Keep routing adopted by the legacy migration on subsequent refreshes.
707
+ nextSource = migrateLegacyAssetContent(ownershipKey, installedContent, source);
479
708
  }
480
709
  }
481
710
  writeFileSync(targetPath, nextSource);
@@ -503,9 +732,11 @@ const RETIRED_MANAGED_ASSETS = Object.freeze([
503
732
  function removeRetiredManagedAssets(
504
733
  agentHome: string,
505
734
  manifest: ManagedAssetsManifest,
735
+ selected?: ReadonlySet<string>,
506
736
  ): void {
507
737
  let legacyHashes: Record<string, readonly string[]> | undefined;
508
738
  for (const ownershipKey of RETIRED_MANAGED_ASSETS) {
739
+ if (selected && !selected.has(ownershipKey)) continue;
509
740
  const installedPath = join(agentHome, ...ownershipKey.split("/"));
510
741
  if (!existsSync(installedPath)) {
511
742
  delete manifest.assets[ownershipKey];
@@ -536,52 +767,80 @@ function removeRetiredManagedAssets(
536
767
  }
537
768
  }
538
769
 
770
+ // Legacy all-owner entry point retained for compatibility.
771
+ // Owner-specific commands and SDD preflight use installPackageAssets directly.
539
772
  export function installSddAssets(
773
+ cwd: string,
774
+ force: boolean,
775
+ ): { agents: number; chains: number; support: number; skipped: number } {
776
+ return installPackageAssets(cwd, force);
777
+ }
778
+
779
+ export function installPackageAssets(
540
780
  _cwd: string,
541
781
  force: boolean,
782
+ owners?: readonly PackageAssetOwner[],
783
+ lockOptions?: PackageAssetInstallLockOptions,
542
784
  ): { agents: number; chains: number; support: number; skipped: number } {
543
785
  const agentHome = gentlePiAgentHome();
544
- const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST);
545
- let legacyAssetHashes: (() => Readonly<Record<string, readonly string[]>>) | undefined;
546
- if (force) {
547
- let cachedLegacyAssetHashes: Record<string, readonly string[]> | undefined;
548
- legacyAssetHashes = () =>
549
- (cachedLegacyAssetHashes ??= readLegacyManagedAssetHashes());
550
- }
551
- const manifest = readManagedAssetsManifest(manifestPath);
552
- removeRetiredManagedAssets(agentHome, manifest);
553
- const agents = copyDirectoryFiles(
554
- join(ASSETS_DIR, "agents"),
555
- join(agentHome, "agents"),
556
- "agents",
557
- force,
558
- manifest,
559
- legacyAssetHashes,
560
- );
561
- const chains = copyDirectoryFiles(
562
- join(ASSETS_DIR, "chains"),
563
- join(agentHome, "chains"),
564
- "chains",
565
- force,
566
- manifest,
567
- legacyAssetHashes,
568
- );
569
- const support = copyDirectoryFiles(
570
- join(ASSETS_DIR, "support"),
571
- join(agentHome, "gentle-ai", "support"),
572
- "gentle-ai/support",
573
- force,
574
- manifest,
575
- legacyAssetHashes,
576
- );
577
- mkdirSync(dirname(manifestPath), { recursive: true });
578
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
579
- return {
580
- agents: agents.copied,
581
- chains: chains.copied,
582
- support: support.copied,
583
- skipped: agents.skipped + chains.skipped + support.skipped,
584
- };
786
+ return withManagedAssetsLock(agentHome, () => {
787
+ const selected = owners === undefined ? undefined : new Set(
788
+ Object.entries(ASSET_OWNER_BY_KEY).filter(([, owner]) => owners.includes(owner)).map(([key]) => key),
789
+ );
790
+ const manifestPath = join(agentHome, "gentle-ai", MANAGED_ASSETS_MANIFEST);
791
+ let legacyAssetHashes: (() => Readonly<Record<string, readonly string[]>>) | undefined;
792
+ if (force) {
793
+ let cachedLegacyAssetHashes: Record<string, readonly string[]> | undefined;
794
+ legacyAssetHashes = () =>
795
+ (cachedLegacyAssetHashes ??= readLegacyManagedAssetHashes());
796
+ }
797
+ const manifest = readManagedAssetsManifest(manifestPath);
798
+ removeRetiredManagedAssets(agentHome, manifest, selected);
799
+ const agents = copyDirectoryFiles(
800
+ join(ASSETS_DIR, "agents"),
801
+ join(agentHome, "agents"),
802
+ "agents",
803
+ force,
804
+ manifest,
805
+ legacyAssetHashes,
806
+ selected,
807
+ );
808
+ const chains = copyDirectoryFiles(
809
+ join(ASSETS_DIR, "chains"),
810
+ join(agentHome, "chains"),
811
+ "chains",
812
+ force,
813
+ manifest,
814
+ legacyAssetHashes,
815
+ selected,
816
+ );
817
+ const support = copyDirectoryFiles(
818
+ join(ASSETS_DIR, "support"),
819
+ join(agentHome, "gentle-ai", "support"),
820
+ "gentle-ai/support",
821
+ force,
822
+ manifest,
823
+ legacyAssetHashes,
824
+ selected,
825
+ );
826
+ mkdirSync(dirname(manifestPath), { recursive: true });
827
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
828
+ return {
829
+ agents: agents.copied,
830
+ chains: chains.copied,
831
+ support: support.copied,
832
+ skipped: agents.skipped + chains.skipped + support.skipped,
833
+ };
834
+ }, lockOptions);
835
+ }
836
+
837
+ function hasAffirmativeSddIntent(text: string): boolean {
838
+ // Natural-language routing must not depend on a closed list of complete
839
+ // phrases. An SDD mention becomes an invocation only with an imperative,
840
+ // request, or first-person intent marker; a neutral statement such as
841
+ // "I use SDD sometimes" remains ordinary conversation.
842
+ if (!/\bsdd\b/i.test(text)) return false;
843
+ return /(?:\b(?:please|por\s+favor)\b|\b(?:want|need|would\s+like|let'?s|quiero|queremos|necesito|quisiera|me\s+gustar[ií]a|vamos|vayamos|hagamos|usemos)\b|^(?:use|run|start|build|create|implement|handle|make|usa|usá|corre|corré|arranca|arrancá|inicia|iniciá|empeza|empezá|hacelo|hazlo|hacerlo)\b)/i.test(text);
585
844
  }
586
845
 
587
846
  export function isSddPreflightTrigger(text: string): boolean {
@@ -589,19 +848,12 @@ export function isSddPreflightTrigger(text: string): boolean {
589
848
  if (/^\/(?:gentle-)?sdd(?:[-:][^\s]*)?(?:\s|$)/i.test(trimmed)) return true;
590
849
  if (/[??]\s*$/.test(trimmed)) return false;
591
850
  if (
592
- /\b(?:don't|do\s+not|not\s+use|never\s+use|without\s+using|sin\s+usar|no\s+(?:quiero|queremos|vamos\s+a)?\s*usar)\s+sdd\b/i.test(
593
- trimmed,
594
- )
851
+ /(?:\b(?:don't|do\s+not|never)\b|\bnot\s+(?:want|need|plan(?:ning)?|intend|use|using)\b)[^.!?\n]{0,80}\bsdd\b/i.test(trimmed) ||
852
+ /\b(?:sin\s+usar|no\s+(?:quiero|queremos|necesito|necesitamos|quisiera|quisiéramos|vamos\s+a|pienso|planeo|usar))\b[^.!?\n]{0,80}\bsdd\b/i.test(trimmed)
595
853
  ) {
596
854
  return false;
597
855
  }
598
- return [
599
- /^(?:please\s+)?(?:use|run|start)\s+(?:the\s+|an?\s+)?sdd(?:\s+(?:flow|process|workflow|plan))?\b/i,
600
- /^(?:please\s+)?(?:do|handle|implement)\b.+\b(?:with|using)\s+(?:the\s+|an?\s+)?sdd\b/i,
601
- /^(?:por\s+favor[\s,]+)?(?:vamos|vayamos)\s+con\s+(?:el\s+)?sdd\b/i,
602
- /^(?:por\s+favor[\s,]+)?(?:usa|usá|usemos|corre|corré|arranca|arrancá|inicia|iniciá|empeza|empezá)\s+(?:el\s+)?sdd\b/i,
603
- /^(?:por\s+favor[\s,]+)?(?:hacelo|hazlo|hacerlo)\s+(?:con|usando)\s+(?:el\s+)?sdd\b/i,
604
- ].some((pattern) => pattern.test(trimmed));
856
+ return hasAffirmativeSddIntent(trimmed);
605
857
  }
606
858
 
607
859
  export function sddPreflightSessionKey(ctx: ExtensionContext): string {
@@ -646,13 +898,23 @@ export async function collectSddPreflightPreferences(
646
898
  engramAvailable: boolean,
647
899
  options: SddPreflightResolutionOptions = {},
648
900
  ): Promise<SddPreflightPreferences> {
649
- const persistedPrompted = options.persisted?.prompted === true;
901
+ // Disk preferences suggest values; they never carry current-session consent.
650
902
  const allowExceptionOk = options.acceptSizeException === true;
651
903
  const persisted = normalizedSelections(options.persisted, engramAvailable, allowExceptionOk);
652
904
  const resolved: Partial<Record<SddPreflightField, unknown>> = { ...persisted };
653
- let prompted = persistedPrompted;
905
+ let prompted = false;
654
906
  let sizeExceptionAccepted = allowExceptionOk && persisted.chainedPrStrategy === "exception-ok";
655
907
  const promptFields = new Set(options.promptFields ?? []);
908
+ // RPC is headless even though Pi exposes functional dialog methods there.
909
+ if (ctx.hasUI && ctx.mode !== "rpc" && promptFields.size === 0) {
910
+ const suggestions = { ...DEFAULT_SDD_PREFLIGHT, ...persisted };
911
+ ctx.ui.notify(`SDD session suggestions: mode=${suggestions.executionMode}; artifacts=${suggestions.artifactStore}; delivery=${suggestions.chainedPrStrategy}; budget=${suggestions.reviewBudgetLines}. Saved preferences are not session consent.`, "info");
912
+ if (typeof ctx.ui.select !== "function") throw new Error("SDD preflight confirmation UI unavailable; no session consent recorded.");
913
+ const answer = await ctx.ui.select("Confirm SDD session preflight", ["Confirm", "Change choices"]);
914
+ if (answer === "Confirm") prompted = true;
915
+ else if (answer === "Change choices") for (const field of SDD_PREFLIGHT_FIELDS) promptFields.add(field);
916
+ else throw new Error("SDD preflight cancelled; no session consent recorded.");
917
+ }
656
918
 
657
919
  const usePromptedValue = (
658
920
  field: SddPreflightField,
@@ -671,15 +933,23 @@ export async function collectSddPreflightPreferences(
671
933
  read: () => Promise<unknown>,
672
934
  enabled = true,
673
935
  ): Promise<void> => {
674
- if (ctx.hasUI && enabled && promptFields.has(field)) {
675
- usePromptedValue(field, await read());
936
+ if (ctx.hasUI && ctx.mode !== "rpc" && enabled && promptFields.has(field)) {
937
+ const value = await read();
938
+ if (normalizedSelections({ [field]: value }, engramAvailable, allowExceptionOk)[field] === undefined) {
939
+ throw new Error("SDD preflight cancelled or invalid; no session consent recorded.");
940
+ }
941
+ usePromptedValue(field, value);
676
942
  }
677
943
  };
678
944
  const artifactOptions = engramAvailable ? ["openspec", "engram", "hybrid"] : ["openspec"];
679
- await promptField("executionMode", () => ctx.ui.select("SDD execution mode", ["interactive", "auto"]));
680
- await promptField("artifactStore", () => ctx.ui.select("SDD artifact store", artifactOptions), artifactOptions.length > 1);
681
- await promptField("chainedPrStrategy", () => ctx.ui.select("SDD delivery strategy", ["ask-on-risk", "auto-chain", "single-pr"]));
682
- await promptField("reviewBudgetLines", () => ctx.ui.input("SDD review budget lines", String(DEFAULT_SDD_PREFLIGHT.reviewBudgetLines)));
945
+ const suggestedFirst = (field: SddPreflightField, values: string[]): string[] => {
946
+ const suggested = String(resolved[field] ?? DEFAULT_SDD_PREFLIGHT[field]);
947
+ return values.includes(suggested) ? [suggested, ...values.filter(value => value !== suggested)] : values;
948
+ };
949
+ await promptField("executionMode", () => ctx.ui.select("SDD execution mode", suggestedFirst("executionMode", ["interactive", "auto"])));
950
+ await promptField("artifactStore", () => ctx.ui.select("SDD artifact store", suggestedFirst("artifactStore", artifactOptions)), artifactOptions.length > 1);
951
+ await promptField("chainedPrStrategy", () => ctx.ui.select("SDD delivery strategy", suggestedFirst("chainedPrStrategy", ["ask-on-risk", "auto-chain", "single-pr"])));
952
+ await promptField("reviewBudgetLines", () => ctx.ui.input("SDD review budget lines", String(resolved.reviewBudgetLines ?? DEFAULT_SDD_PREFLIGHT.reviewBudgetLines)));
683
953
 
684
954
  const resolvedValue = <T>(field: SddPreflightField, fallback: T): T =>
685
955
  (resolved[field] as T | undefined) ?? fallback;
@@ -694,6 +964,16 @@ export async function collectSddPreflightPreferences(
694
964
  };
695
965
  }
696
966
 
967
+ export function isParentConfirmedSddPreflightContext(context: unknown): context is string {
968
+ if (typeof context !== "string") return false;
969
+ return /^## SDD Session Preflight\n(?:These SDD preferences are explicit current-session choices\. Reuse them unless the user explicitly changes them\.|These SDD preferences are canonical defaults or persisted choices\. Treat them as authoritative; do not revisit dependent decisions unless a genuine human-control gate is reached\.)\n- Execution mode: (?:interactive|auto)\n- Artifact store: (?:openspec|engram|hybrid|none)(?: \(Engram unavailable in this session\))?\n- Delivery strategy: (?:ask-on-risk|auto-chain|single-pr|exception-ok)\n- Delivery strategy domain: `ask-on-risk` \| `auto-chain` \| `single-pr` \| `exception-ok`\n- Review budget: [1-9]\d* changed lines \(400 is the canonical threshold unless explicitly changed\)\n- Chain strategy: deferred until chaining is selected\./.test(context);
970
+ }
971
+
972
+ export function extractParentConfirmedSddPreflightContext(context: unknown): string | undefined {
973
+ if (!isParentConfirmedSddPreflightContext(context)) return undefined;
974
+ return context.split("\n\n", 1)[0];
975
+ }
976
+
697
977
  export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string {
698
978
  const deliveryStrategy = normalizeSddChainedPrStrategy(
699
979
  prefs.chainedPrStrategy,
@@ -733,6 +1013,12 @@ export async function ensureSddPreflight(
733
1013
  callbacks: SddPreflightCallbacks,
734
1014
  resolutionOptions: SddPreflightResolutionOptions = {},
735
1015
  ): Promise<SddPreflightPreferences> {
1016
+ // `collectSddPreflightPreferences` remains a pure suggestion resolver for
1017
+ // callers that need to render options. Persisting or promoting those options
1018
+ // is parent-only: an RPC child must consume the transported rendered block.
1019
+ if (ctx.mode === "rpc") {
1020
+ throw new Error("SDD preflight must be resolved by the parent; an RPC child cannot originate or persist defaults.");
1021
+ }
736
1022
  const sessionKey = sddPreflightSessionKey(ctx);
737
1023
  const existing = sddPreflightBySession.get(sessionKey);
738
1024
  if (existing && !(resolutionOptions.promptFields?.length ?? 0)) return existing;
@@ -747,7 +1033,7 @@ export async function ensureSddPreflight(
747
1033
  });
748
1034
  const result =
749
1035
  (await callbacks.installAssets?.(ctx.cwd)) ??
750
- installSddAssets(ctx.cwd, false);
1036
+ installPackageAssets(ctx.cwd, false, ["sdd"]);
751
1037
  const modelResult = (await callbacks.applyModelConfig?.(ctx.cwd)) ?? {
752
1038
  updated: 0,
753
1039
  skipped: 0,
@@ -788,11 +1074,6 @@ export function getSddPreflightPreferences(
788
1074
  const sessionKey = sddPreflightSessionKey(ctx);
789
1075
  const cached = sddPreflightBySession.get(sessionKey);
790
1076
  if (cached) return cached;
791
- // Cache miss: check the durable disk store (survives restarts and non-SDD agent starts)
792
- const persisted = readSddPreflightFromDisk(ctx.cwd);
793
- if (persisted) {
794
- sddPreflightBySession.set(sessionKey, persisted);
795
- return persisted;
796
- }
1077
+ // Only ensureSddPreflight may promote disk suggestions to resolved session choices.
797
1078
  return undefined;
798
1079
  }