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
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
3
  import { PassThrough, Writable } from "node:stream";
4
4
  import test from "node:test";
5
+ import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts";
6
+ import { AgentRunner, type ChildLike, type TaskRequest } from "../lib/agents-runner.ts";
7
+ import { TaskStore } from "../lib/agents-protocol.ts";
5
8
 
6
9
  const REPOSITORY_ID = `sha256:${"a".repeat(64)}`;
7
10
  const OTHER_REPOSITORY_ID = `sha256:${"b".repeat(64)}`;
@@ -26,37 +29,253 @@ function pair() {
26
29
  };
27
30
  }
28
31
 
29
- async function productionChild(requests: number, authorize: () => boolean, options: { maxRequests?: number; closeChannel?: boolean; withoutChannel?: boolean } = {}): Promise<boolean[]> {
32
+ type Fd3StdioMode = "pipe" | "overlapped";
33
+
34
+ type WindowsFd3DiagnosticTrace = {
35
+ stdioMode: Fd3StdioMode;
36
+ childClientAttached: boolean | "unknown";
37
+ childRequestAttemptCount: number;
38
+ childSettlement: "not-attempted" | "settled-true" | "settled-false" | "unknown";
39
+ childResponseObserved: "unknown-without-production-instrumentation";
40
+ childTimeoutObserved: "unknown-without-production-instrumentation";
41
+ parentRawDataChunkCount: number;
42
+ parentAuthorizationCallbackCount: number;
43
+ parentWriteCallback: "unknown-without-production-instrumentation";
44
+ parentWriteErrorCode: "unknown-without-production-instrumentation";
45
+ parentFd3Cleanup: "not-needed" | "deadline-termination-requested" | "deadline-confirmed-close" | "deadline-unconfirmed-after-grace";
46
+ childClose: "pending" | "observed" | "unconfirmed-after-grace";
47
+ fd3Close: "pending" | "observed" | "unconfirmed-after-grace";
48
+ probeResult: "pending" | "completed" | "spawn-failure" | "child-error" | "child-exit-nonzero" | "parse-failure" | "stream-error" | "deadline-unconfirmed";
49
+ capturedOutput: "within-4096-byte-bound" | "truncated-at-4096-bytes";
50
+ childExit: "unknown" | "zero" | "nonzero" | "error";
51
+ };
52
+
53
+ type ProductionChildOptions = {
54
+ maxRequests?: number;
55
+ closeChannel?: boolean;
56
+ withoutChannel?: boolean;
57
+ stdioMode?: Fd3StdioMode;
58
+ childDeadlineMs?: number;
59
+ childCloseGraceMs?: number;
60
+ diagnosticTrace?: WindowsFd3DiagnosticTrace;
61
+ };
62
+
63
+ const productionFd3StdioMode: Fd3StdioMode = process.platform === "win32" ? "overlapped" : "pipe";
64
+
65
+ function windowsFd3DiagnosticTrace(stdioMode: Fd3StdioMode): WindowsFd3DiagnosticTrace {
66
+ return {
67
+ stdioMode,
68
+ childClientAttached: "unknown",
69
+ childRequestAttemptCount: 0,
70
+ childSettlement: "unknown",
71
+ childResponseObserved: "unknown-without-production-instrumentation",
72
+ childTimeoutObserved: "unknown-without-production-instrumentation",
73
+ parentRawDataChunkCount: 0,
74
+ parentAuthorizationCallbackCount: 0,
75
+ parentWriteCallback: "unknown-without-production-instrumentation",
76
+ parentWriteErrorCode: "unknown-without-production-instrumentation",
77
+ parentFd3Cleanup: "not-needed",
78
+ childClose: "pending",
79
+ fd3Close: "pending",
80
+ probeResult: "pending",
81
+ capturedOutput: "within-4096-byte-bound",
82
+ childExit: "unknown",
83
+ };
84
+ }
85
+
86
+ async function productionChild(requests: number, authorize: () => boolean, options: ProductionChildOptions = {}): Promise<boolean[]> {
30
87
  const moduleUrl = new URL("../lib/review-session-standing-permission-ipc.ts", import.meta.url).href;
88
+ const diagnostic = options.diagnosticTrace !== undefined;
31
89
  const source = `
32
90
  import { createChildStandingReviewPermissionClient } from ${JSON.stringify(moduleUrl)};
33
91
  const client = createChildStandingReviewPermissionClient();
34
92
  const answers = [];
35
- for (let index = 0; index < ${requests}; index += 1) answers.push(await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
93
+ const trace = {
94
+ childClientAttached: client !== undefined,
95
+ childRequestAttemptCount: 0,
96
+ childSettlement: "not-attempted",
97
+ childResponseObserved: "unknown-without-production-instrumentation",
98
+ childTimeoutObserved: "unknown-without-production-instrumentation",
99
+ };
100
+ for (let index = 0; index < ${requests}; index += 1) {
101
+ trace.childRequestAttemptCount += 1;
102
+ const answer = await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false;
103
+ answers.push(answer);
104
+ trace.childSettlement = answer ? "settled-true" : "settled-false";
105
+ }
36
106
  client?.close();
37
- process.stdout.write(JSON.stringify(answers));
107
+ process.stdout.write(JSON.stringify(${diagnostic} ? { answers, trace } : answers));
38
108
  `;
39
109
  const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", source], {
40
110
  env: { ...process.env, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" },
41
- stdio: options.withoutChannel ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe", "pipe"],
111
+ stdio: options.withoutChannel ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe", options.stdioMode ?? productionFd3StdioMode],
42
112
  });
43
113
  const pipe = child.stdio[3];
114
+ const trace = options.diagnosticTrace;
115
+ const onParentData = () => { if (trace !== undefined) trace.parentRawDataChunkCount += 1; };
116
+ if (trace !== undefined && pipe !== null && pipe !== undefined) pipe.on("data", onParentData);
117
+ const parentAuthorize = trace === undefined
118
+ ? authorize
119
+ : () => {
120
+ trace.parentAuthorizationCallbackCount += 1;
121
+ return authorize();
122
+ };
44
123
  const broker = pipe === null || pipe === undefined
45
124
  ? undefined
46
- : new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, authorize, options);
125
+ : new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, parentAuthorize, options);
47
126
  if (options.closeChannel && pipe !== null && pipe !== undefined) {
48
127
  broker?.close();
49
128
  pipe.destroy();
50
129
  }
51
130
  let stdout = "";
52
131
  let stderr = "";
53
- child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
54
- child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
55
- return new Promise((resolve, reject) => child.once("exit", (code) => {
56
- broker?.close();
57
- if (code !== 0) reject(new Error(`child exited ${code}: ${stderr}`));
58
- else resolve(JSON.parse(stdout) as boolean[]);
59
- }));
132
+ if (trace === undefined) {
133
+ child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
134
+ child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
135
+ return new Promise((resolve, reject) => child.once("exit", (code) => {
136
+ broker?.close();
137
+ if (code !== 0) reject(new Error(`child exited ${code}: ${stderr}`));
138
+ else resolve(JSON.parse(stdout) as boolean[]);
139
+ }));
140
+ }
141
+
142
+ const diagnosticTrace = trace;
143
+ const outputLimitBytes = 4_096;
144
+ let stdoutBytes = 0;
145
+ let stderrBytes = 0;
146
+ let cleanupStarted = false;
147
+ let settled = false;
148
+ let deadline: NodeJS.Timeout | undefined;
149
+ let closeGrace: NodeJS.Timeout | undefined;
150
+ const appendBounded = (current: string, chunk: Buffer, currentBytes: number): { value: string; bytes: number } => {
151
+ if (currentBytes >= outputLimitBytes) {
152
+ diagnosticTrace.capturedOutput = "truncated-at-4096-bytes";
153
+ return { value: current, bytes: currentBytes };
154
+ }
155
+ const accepted = chunk.subarray(0, outputLimitBytes - currentBytes);
156
+ if (accepted.length !== chunk.length) diagnosticTrace.capturedOutput = "truncated-at-4096-bytes";
157
+ return { value: current + accepted.toString("utf8"), bytes: currentBytes + accepted.length };
158
+ };
159
+ const onStdoutData = (chunk: Buffer) => {
160
+ const captured = appendBounded(stdout, chunk, stdoutBytes);
161
+ stdout = captured.value;
162
+ stdoutBytes = captured.bytes;
163
+ };
164
+ const onStderrData = (chunk: Buffer) => {
165
+ const captured = appendBounded(stderr, chunk, stderrBytes);
166
+ stderr = captured.value;
167
+ stderrBytes = captured.bytes;
168
+ };
169
+ child.stdout.on("data", onStdoutData);
170
+ child.stderr.on("data", onStderrData);
171
+
172
+ return new Promise((resolve) => {
173
+ const removeOperationalListeners = () => {
174
+ child.off("exit", onChildExit);
175
+ child.off("close", onChildClose);
176
+ child.stdout.off("data", onStdoutData);
177
+ child.stderr.off("data", onStderrData);
178
+ pipe?.off("close", onFd3Close);
179
+ if (pipe !== null && pipe !== undefined) pipe.off("data", onParentData);
180
+ };
181
+ const removeInertErrorGuards = () => {
182
+ child.off("error", onChildError);
183
+ child.stdout.off("error", onStreamError);
184
+ child.stderr.off("error", onStreamError);
185
+ pipe?.off("error", onStreamError);
186
+ };
187
+ const finish = (answers: boolean[], retainInertErrorGuards = false) => {
188
+ if (settled) return;
189
+ settled = true;
190
+ if (deadline !== undefined) clearTimeout(deadline);
191
+ if (closeGrace !== undefined) clearTimeout(closeGrace);
192
+ removeOperationalListeners();
193
+ if (retainInertErrorGuards) child.once("close", removeInertErrorGuards);
194
+ else removeInertErrorGuards();
195
+ broker?.close();
196
+ resolve(answers);
197
+ };
198
+ const parseCompletedAnswers = (): boolean[] | undefined => {
199
+ if (diagnosticTrace.capturedOutput !== "within-4096-byte-bound") return undefined;
200
+ try {
201
+ const result = JSON.parse(stdout) as { answers?: unknown; trace?: Partial<WindowsFd3DiagnosticTrace> };
202
+ if (!Array.isArray(result.answers) || !result.answers.every((answer) => typeof answer === "boolean")) return undefined;
203
+ diagnosticTrace.childClientAttached = result.trace?.childClientAttached === true;
204
+ diagnosticTrace.childRequestAttemptCount = typeof result.trace?.childRequestAttemptCount === "number" ? result.trace.childRequestAttemptCount : 0;
205
+ diagnosticTrace.childSettlement = result.trace?.childSettlement === "settled-true" || result.trace?.childSettlement === "settled-false"
206
+ ? result.trace.childSettlement
207
+ : "unknown";
208
+ return result.answers;
209
+ } catch {
210
+ return undefined;
211
+ }
212
+ };
213
+ const finishFromChildClose = () => {
214
+ if (settled) return;
215
+ if (diagnosticTrace.childExit === "nonzero" || diagnosticTrace.childExit === "error") {
216
+ diagnosticTrace.probeResult = diagnosticTrace.childExit === "error" ? "child-error" : "child-exit-nonzero";
217
+ finish([]);
218
+ return;
219
+ }
220
+ const answers = parseCompletedAnswers();
221
+ if (answers === undefined) {
222
+ diagnosticTrace.probeResult = "parse-failure";
223
+ finish([]);
224
+ return;
225
+ }
226
+ if (diagnosticTrace.probeResult === "pending") diagnosticTrace.probeResult = "completed";
227
+ finish(answers);
228
+ };
229
+ const finishIfPhysicalCleanupConfirmed = () => {
230
+ if (!cleanupStarted || diagnosticTrace.childClose !== "observed" || diagnosticTrace.fd3Close !== "observed") return;
231
+ diagnosticTrace.parentFd3Cleanup = "deadline-confirmed-close";
232
+ finishFromChildClose();
233
+ };
234
+ const onChildExit = (code: number | null) => {
235
+ diagnosticTrace.childExit = code === 0 ? "zero" : "nonzero";
236
+ };
237
+ const onChildClose = (code: number | null) => {
238
+ diagnosticTrace.childClose = "observed";
239
+ if (diagnosticTrace.childExit === "unknown") diagnosticTrace.childExit = code === 0 ? "zero" : "nonzero";
240
+ if (cleanupStarted) finishIfPhysicalCleanupConfirmed();
241
+ else finishFromChildClose();
242
+ };
243
+ const onFd3Close = () => {
244
+ diagnosticTrace.fd3Close = "observed";
245
+ finishIfPhysicalCleanupConfirmed();
246
+ };
247
+ const onChildError = () => {
248
+ diagnosticTrace.childExit = "error";
249
+ if (diagnosticTrace.probeResult === "pending") diagnosticTrace.probeResult = "child-error";
250
+ };
251
+ const onStreamError = () => {
252
+ if (diagnosticTrace.probeResult === "pending") diagnosticTrace.probeResult = "stream-error";
253
+ };
254
+ const startDeadlineCleanup = () => {
255
+ if (settled || cleanupStarted) return;
256
+ cleanupStarted = true;
257
+ diagnosticTrace.parentFd3Cleanup = "deadline-termination-requested";
258
+ broker?.close();
259
+ pipe?.destroy();
260
+ try { child.kill(); } catch { /* The owned child may already have exited. */ }
261
+ closeGrace = setTimeout(() => {
262
+ if (diagnosticTrace.childClose === "pending") diagnosticTrace.childClose = "unconfirmed-after-grace";
263
+ if (diagnosticTrace.fd3Close === "pending") diagnosticTrace.fd3Close = "unconfirmed-after-grace";
264
+ if (diagnosticTrace.fd3Close !== "observed") diagnosticTrace.parentFd3Cleanup = "deadline-unconfirmed-after-grace";
265
+ diagnosticTrace.probeResult = "deadline-unconfirmed";
266
+ finish([], true);
267
+ }, options.childCloseGraceMs ?? 250);
268
+ };
269
+
270
+ child.once("exit", onChildExit);
271
+ child.once("close", onChildClose);
272
+ child.on("error", onChildError);
273
+ child.stdout.on("error", onStreamError);
274
+ child.stderr.on("error", onStreamError);
275
+ pipe?.once("close", onFd3Close);
276
+ pipe?.on("error", onStreamError);
277
+ deadline = setTimeout(startDeadlineCleanup, options.childDeadlineMs ?? 6_000);
278
+ });
60
279
  }
61
280
 
62
281
  test("the production child fd3 client gets grant, revocation, closure, denial, and request bounds from its parent broker", async () => {
@@ -101,7 +320,7 @@ test("fresh Jiti moduleCache:false reloads share fd3 structurally and reject sta
101
320
  `;
102
321
  const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", source], {
103
322
  env: { ...process.env, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" },
104
- stdio: ["ignore", "pipe", "pipe", "pipe"],
323
+ stdio: ["ignore", "pipe", "pipe", productionFd3StdioMode],
105
324
  });
106
325
  const pipe = child.stdio[3];
107
326
  assert.ok(pipe);
@@ -231,3 +450,198 @@ test("a child disconnect during a pending parent response cannot crash the broke
231
450
  parentToChild.destroy();
232
451
  }
233
452
  });
453
+
454
+ test("AgentRunner delivers real fd3 authorization rechecks through its permission launch", { timeout: 15_000 }, async () => {
455
+ const PROBE_DEADLINE_MS = 12_000;
456
+ const CLOSE_GRACE_MS = 750;
457
+ const moduleUrl = new URL("../lib/review-session-standing-permission-ipc.ts", import.meta.url).href;
458
+ const source = `
459
+ import { createChildStandingReviewPermissionClient } from ${JSON.stringify(moduleUrl)};
460
+ const client = createChildStandingReviewPermissionClient();
461
+ const answers = [];
462
+ for (let index = 0; index < 2; index += 1) answers.push(await client?.requestAuthorization(${JSON.stringify(REPOSITORY_ID)}) ?? false);
463
+ client?.close();
464
+ process.stdout.write(JSON.stringify({ answers, fixtureArgs: process.argv.slice(1) }));
465
+ `;
466
+ const agent: AgentDefinition = { name: "permission-fixture", description: "test", filePath: "/test.md", scope: "global", instructions: "", model: undefined, thinking: undefined, mode: undefined, tools: [] };
467
+ const store = new TaskStore();
468
+ let authorizationCalls = 0;
469
+ let child: ReturnType<typeof spawn> | undefined;
470
+ let childStdout: ReturnType<typeof spawn>["stdout"] | undefined;
471
+ let taskId: string | undefined;
472
+ let closeObserved = false;
473
+ let terminating = false;
474
+ let launchFailure: Error | undefined;
475
+ let childFailure: Error | undefined;
476
+ let terminationFailure: Error | undefined;
477
+ let deadline: NodeJS.Timeout | undefined;
478
+ let grace: NodeJS.Timeout | undefined;
479
+ let stdout = "";
480
+ let stdoutBytes = 0;
481
+ let stdoutTruncated = false;
482
+ let finishProbe!: (result: { closeObserved: boolean; failure: Error | undefined }) => void;
483
+ const probe = new Promise<{ closeObserved: boolean; failure: Error | undefined }>((resolve) => { finishProbe = resolve; });
484
+ let probeFinished = false;
485
+ let runner: AgentRunner;
486
+ const clearTimers = () => {
487
+ if (deadline !== undefined) clearTimeout(deadline);
488
+ if (grace !== undefined) clearTimeout(grace);
489
+ deadline = undefined;
490
+ grace = undefined;
491
+ };
492
+ const releaseOwnedReferences = () => {
493
+ const unref = (value: unknown) => {
494
+ if (value !== null && typeof value === "object" && "unref" in value && typeof value.unref === "function") value.unref();
495
+ };
496
+ child?.unref();
497
+ unref(child?.channel);
498
+ unref(child?.stdout);
499
+ unref(child?.stderr);
500
+ for (const stream of child?.stdio ?? []) unref(stream);
501
+ };
502
+ const finish = (result: { closeObserved: boolean; failure: Error | undefined }) => {
503
+ if (probeFinished) return;
504
+ probeFinished = true;
505
+ clearTimers();
506
+ finishProbe(result);
507
+ };
508
+ const onStdout = (chunk: Buffer | string) => {
509
+ if (stdoutBytes >= 4_096) {
510
+ stdoutTruncated = true;
511
+ return;
512
+ }
513
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
514
+ const captured = bytes.subarray(0, 4_096 - stdoutBytes);
515
+ if (captured.length !== bytes.length) stdoutTruncated = true;
516
+ stdout += captured.toString("utf8");
517
+ stdoutBytes += captured.length;
518
+ };
519
+ const removeOperationalListeners = () => {
520
+ child?.off("error", onChildError);
521
+ child?.off("close", onChildClose);
522
+ childStdout?.off("data", onStdout);
523
+ };
524
+ const retainLateCloseGuard = () => {
525
+ const suppressLateStreamError = () => {};
526
+ child?.once("close", () => {
527
+ closeObserved = true;
528
+ releaseOwnedReferences();
529
+ });
530
+ child?.once("error", suppressLateStreamError);
531
+ childStdout?.once("error", suppressLateStreamError);
532
+ child?.stderr?.once("error", suppressLateStreamError);
533
+ for (const stream of child?.stdio ?? []) stream?.once("error", suppressLateStreamError);
534
+ };
535
+ const terminate = (failure: Error) => {
536
+ if (terminating || closeObserved) return;
537
+ terminating = true;
538
+ terminationFailure ??= failure;
539
+ clearTimers();
540
+ if (!taskId || !runner.cancel(taskId)) {
541
+ try { child?.kill(); } catch { /* The owned child may already have exited. */ }
542
+ }
543
+ if (closeObserved) return;
544
+ grace = setTimeout(() => {
545
+ if (closeObserved) return;
546
+ removeOperationalListeners();
547
+ releaseOwnedReferences();
548
+ retainLateCloseGuard();
549
+ finish({ closeObserved: false, failure });
550
+ }, CLOSE_GRACE_MS);
551
+ };
552
+ const onChildError = (error: Error) => {
553
+ childFailure ??= error;
554
+ terminate(error);
555
+ };
556
+ const onChildClose = () => {
557
+ closeObserved = true;
558
+ removeOperationalListeners();
559
+ releaseOwnedReferences();
560
+ finish({ closeObserved: true, failure: childFailure ?? terminationFailure });
561
+ };
562
+ runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
563
+ spawn: (command, args, options) => {
564
+ try {
565
+ child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: options.detached, stdio: options.stdio });
566
+ } catch (error) {
567
+ launchFailure = error instanceof Error ? error : new Error(String(error));
568
+ throw error;
569
+ }
570
+ childStdout = child.stdout;
571
+ if (childStdout === null) {
572
+ launchFailure = new Error("AgentRunner child stdout is required");
573
+ queueMicrotask(() => terminate(launchFailure!));
574
+ } else childStdout.on("data", onStdout);
575
+ child.once("error", onChildError);
576
+ child.once("close", onChildClose);
577
+ deadline = setTimeout(() => terminate(new Error(`owned child startup/handshake probe exceeded ${PROBE_DEADLINE_MS}ms`)), PROBE_DEADLINE_MS);
578
+ return child as unknown as ChildLike;
579
+ },
580
+ now: Date.now,
581
+ schedule: (fn, ms) => {
582
+ const timer = setTimeout(fn, ms);
583
+ return () => clearTimeout(timer);
584
+ },
585
+ pi: { command: process.execPath, args: ["--experimental-strip-types", "--input-type=module", "--eval", source, "--"] },
586
+ }, { askUser: async () => ({ cancelled: true }) });
587
+ const request: TaskRequest = {
588
+ agent,
589
+ prompt: "permission fixture",
590
+ label: undefined,
591
+ context: undefined,
592
+ mode: AGENT_MODE.BACKGROUND,
593
+ cwd: process.cwd(),
594
+ parentSessionId: "test",
595
+ model: undefined,
596
+ thinking: undefined,
597
+ sessionDir: "/tmp",
598
+ resumeSessionPath: undefined,
599
+ env: {},
600
+ authorizeParentStandingReviewPermission: () => ++authorizationCalls === 1,
601
+ };
602
+ try {
603
+ const task = runner.run(request);
604
+ taskId = task.id;
605
+ await new Promise((resolve) => setImmediate(resolve));
606
+ assert.ifError(launchFailure);
607
+ assert.ok(child, "AgentRunner must own the real child launch");
608
+ const result = await probe;
609
+ assert.equal(result.closeObserved, true, result.failure?.message ?? "owned child close was unconfirmed after bounded cleanup");
610
+ assert.ifError(result.failure);
611
+ assert.equal(stdoutTruncated, false, "child stdout capture remains within 4096 bytes");
612
+ const output = (() => {
613
+ try { return JSON.parse(stdout) as { answers?: unknown; fixtureArgs?: unknown }; }
614
+ catch (error) { assert.fail(`could not parse bounded child stdout: ${error instanceof Error ? error.message : String(error)}`); }
615
+ })();
616
+ assert.deepEqual(output.answers, [true, false], "the real child receives one grant and one rechecked denial through AgentRunner");
617
+ assert.ok(Array.isArray(output.fixtureArgs) && output.fixtureArgs.includes("--mode") && output.fixtureArgs.includes("rpc"), "the spawn adapter forwards Runner-appended arguments after Node's -- boundary");
618
+ assert.equal(authorizationCalls, 2, "AgentRunner invokes its parent callback for both child requests");
619
+ } finally {
620
+ if (!closeObserved) terminate(new Error("test cleanup requested owned child termination"));
621
+ await probe;
622
+ }
623
+ });
624
+
625
+ if (process.platform === "win32") {
626
+ test("diagnostic: fd3 production child compares pipe and overlapped without inferring a pipe denial", { timeout: 15_000 }, async (t) => {
627
+ const results: Array<{ answers: boolean[] | undefined; trace: WindowsFd3DiagnosticTrace }> = [];
628
+ for (const stdioMode of ["pipe", "overlapped"] as const) {
629
+ const trace = windowsFd3DiagnosticTrace(stdioMode);
630
+ let answers: boolean[] | undefined;
631
+ try {
632
+ answers = await productionChild(1, () => true, { stdioMode, childDeadlineMs: 6_000, childCloseGraceMs: 250, diagnosticTrace: trace });
633
+ } catch {
634
+ trace.probeResult = "spawn-failure";
635
+ }
636
+ t.diagnostic(JSON.stringify(trace));
637
+ results.push({ answers, trace });
638
+ }
639
+ const overlapped = results.find((result) => result.trace.stdioMode === "overlapped");
640
+ assert.deepEqual(overlapped?.answers, [true], "overlapped must carry a production-client grant from the direct parent broker");
641
+ assert.equal(overlapped?.trace.childClientAttached, true, "the production child must attach its fd3 client");
642
+ assert.equal(overlapped?.trace.childRequestAttemptCount, 1, "the production child must attempt one synthetic request");
643
+ assert.ok((overlapped?.trace.parentRawDataChunkCount ?? 0) > 0, "the parent must receive child request data without assuming stream chunk framing");
644
+ assert.equal(overlapped?.trace.parentAuthorizationCallbackCount, 1, "the direct parent broker must authorize one request");
645
+ assert.equal(overlapped?.trace.probeResult, "completed", "the overlapped probe must close and parse cleanly");
646
+ });
647
+ }
@@ -1254,7 +1254,7 @@ async function run() {
1254
1254
  assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1255
1255
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1256
1256
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
1257
- assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
1257
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), false);
1258
1258
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
1259
1259
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1260
1260
  assert.equal(ctx.ui.selections.length, 1, "first interactive SDD trigger confirms session suggestions");
@@ -1286,7 +1286,7 @@ async function run() {
1286
1286
  assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1287
1287
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1288
1288
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
1289
- assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
1289
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), false);
1290
1290
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
1291
1291
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1292
1292
  const globalSddApply = await readFile(
@@ -1793,7 +1793,7 @@ async function run() {
1793
1793
  await writeFile(join(staleAssetsCwd, ".pi", "gentle-ai", "support", "sdd-status-contract.md"), "stale status contract\n");
1794
1794
  const ctx = createCtx(staleAssetsCwd, true);
1795
1795
  await commands.get("gentle:status").handler("", ctx);
1796
- assert.match(ctx.ui.notifications.at(-1).message, /Active SDD agent overrides: 6 file\(s\)/);
1796
+ assert.match(ctx.ui.notifications.at(-1).message, /Active SDD agent overrides: 5 file\(s\)/);
1797
1797
  assert.match(ctx.ui.notifications.at(-1).message, /active non-builtin SDD agents shadow package assets/);
1798
1798
  await commands.get("gentle:doctor").handler("", ctx);
1799
1799
  assert.match(ctx.ui.notifications.at(-1).message, /el Gentleman doctor/);
@@ -1823,7 +1823,7 @@ async function run() {
1823
1823
  assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1824
1824
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1825
1825
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
1826
- assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
1826
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), false);
1827
1827
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
1828
1828
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1829
1829
  assert.equal(ctx.ui.selections.length, 1, "sdd-init confirms session preflight before project initialization");
@@ -67,10 +67,9 @@ const requiredToolsByAgent: Record<string, string[]> = {
67
67
  "sdd-init.md": ["read", "grep", "find", "edit", "write", "bash", "mem_search", "mem_get_observation", "mem_save", "mem_update"],
68
68
  "sdd-onboard.md": ["read", "grep", "find", "edit", "write", "bash", "mem_search", "mem_get_observation", "mem_save", "mem_update"],
69
69
  "sdd-proposal.md": ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"],
70
- "sdd-research.md": ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save", "fetch_content", "web_search", "source_check", "get_search_content"],
70
+ "sdd-research.md": [ "fetch_content", "web_search", "source_check", "get_search_content"],
71
71
  "sdd-spec.md": ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"],
72
72
  "sdd-status.md": ["read", "grep", "find", "bash", "mem_search", "mem_get_observation"],
73
- "sdd-sync.md": ["read", "grep", "find", "edit", "write", "bash", "mem_search", "mem_get_observation", "mem_save", "mem_update"],
74
73
  "sdd-tasks.md": ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"],
75
74
  "sdd-verify.md": ["read", "grep", "find", "edit", "write", "bash", "mem_search", "mem_get_observation", "mem_save"],
76
75
  };
@@ -91,13 +90,14 @@ test("SDD package agents declare role-appropriate tools as YAML arrays", () => {
91
90
 
92
91
  test("artifact-producing SDD agents can persist OpenSpec files while status remains read-only", () => {
93
92
  for (const fileName of Object.keys(requiredToolsByAgent).filter(
94
- (fileName) => fileName !== "sdd-status.md",
93
+ (fileName) => !["sdd-status.md", "sdd-research.md"].includes(fileName),
95
94
  )) {
96
95
  const tools = readTools(join(assetsAgentsDir, fileName));
97
96
  assert.ok(tools.includes("edit"), `${fileName} must include edit`);
98
97
  assert.ok(tools.includes("write"), `${fileName} must include write`);
99
98
  }
100
99
 
100
+ assert.deepEqual(readTools(join(assetsAgentsDir, "sdd-research.md")), requiredToolsByAgent["sdd-research.md"]);
101
101
  const statusTools = readTools(join(assetsAgentsDir, "sdd-status.md"));
102
102
  assert.ok(!statusTools.includes("edit"), "sdd-status.md must remain read-only");
103
103
  assert.ok(!statusTools.includes("write"), "sdd-status.md must remain read-only");
@@ -108,7 +108,7 @@ test("research instructions require executed evidence rather than blanket denial
108
108
  assert.doesNotMatch(source, /documentation=\[\]; open-web=\[\]/);
109
109
  assert.match(source, /Actually call approved tools/);
110
110
  assert.match(source, /claim maps to source IDs/);
111
- assert.match(source, /proposal_ready: false/);
111
+ assert.match(source, /partial or unavailable research is not a failed proposal gate/);
112
112
  assert.ok(!readTools(join(assetsAgentsDir, "sdd-research.md")).includes("bash"));
113
113
  });
114
114
 
@@ -132,39 +132,18 @@ test("generic non-SDD agents declare exact role tool allowlists", () => {
132
132
  }
133
133
  });
134
134
 
135
- test("sdd-verify phase text carries the verify-result envelope and validate-before-persist rule", () => {
136
- // gentle-pi#535 row 5: the phase must produce a natively admissible report
137
- // on its first persistence attempt without hunting the format elsewhere.
138
- const envelopeFields = [
139
- "schema: gentle-ai.verify-result/v1",
140
- "evidence_revision: sha256:",
141
- "verdict:",
142
- "blockers:",
143
- "critical_findings:",
144
- "requirements:",
145
- "scenarios:",
146
- "test_command:",
147
- "test_exit_code:",
148
- "test_output_hash: sha256:",
149
- "build_command:",
150
- "build_exit_code:",
151
- "build_output_hash: sha256:",
152
- ];
153
-
154
- const agentSource = readFileSync(join(assetsAgentsDir, "sdd-verify.md"), "utf8");
155
- assert.match(agentSource, /```yaml\nschema: gentle-ai\.verify-result\/v1\n/);
156
- for (const field of envelopeFields) {
157
- assert.ok(agentSource.includes(field), `sdd-verify.md envelope must carry \`${field}\``);
135
+ test("optional verification retains practical evidence without retired attestation admission", () => {
136
+ const agent = readFileSync(join(assetsAgentsDir, "sdd-verify.md"), "utf8");
137
+ const chain = readFileSync(join(repoRoot, "assets/chains/sdd-verify.chain.md"), "utf8");
138
+ for (const source of [agent, chain]) {
139
+ assert.doesNotMatch(source, /sdd-verify-validate|gentle-ai\.verify-result\/v1/);
140
+ assert.match(source, /commands/);
141
+ assert.match(source, /blockers/);
158
142
  }
159
- assert.match(agentSource, /first non-empty content/);
160
- assert.match(
161
- agentSource,
162
- /gentle-ai sdd-verify-validate --input <path\|-> --requirements <n> --scenarios <n>/,
163
- );
164
-
165
- const chainSource = readFileSync(join(repoRoot, "assets", "chains", "sdd-verify.chain.md"), "utf8");
166
- assert.match(chainSource, /gentle-ai\.verify-result\/v1/);
167
- assert.match(chainSource, /sdd-verify-validate/);
143
+ assert.match(agent, /strict TDD compliance/);
144
+ assert.match(agent, /exit codes/);
145
+ assert.match(agent, /never fabricate PASS/);
146
+ assert.match(chain, /does not make verification mandatory/);
168
147
  });
169
148
 
170
149
  test("the retired Pi adversarial role agents are not packaged", () => {