gentle-pi 4.2.0 → 4.3.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 (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { createRequire, syncBuiltinESMExports } from "node:module";
@@ -25,11 +25,13 @@ import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TAS
25
25
  import { NativePointerScope } from "../lib/native-pointer-region.ts";
26
26
  import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
27
27
  import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
28
+ import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
28
29
  import { stripAnsi } from "../lib/terminal-theme.ts";
29
30
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
30
- import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
31
+ import { AgentRunner, REQUESTED_TOOLS_ENV, type TaskRequest } from "../lib/agents-runner.ts";
31
32
  import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
32
33
  import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
34
+ import { taskHeader } from "../lib/agents-view.ts";
33
35
  import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
34
36
  import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
35
37
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
@@ -2266,7 +2268,10 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2266
2268
  assert.equal(captured[index]?.command, "/fixture/pi");
2267
2269
  assert.deepEqual(captured[index]?.args, args);
2268
2270
  assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
2269
- assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
2271
+ const scratch = captured[index]?.options.env.TMPDIR;
2272
+ assert.match(scratch ?? "", /[\\/]ga[A-Za-z0-9]{6}$/, "each child receives its own TMPDIR");
2273
+ const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
2274
+ assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
2270
2275
  assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
2271
2276
  assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
2272
2277
  assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
@@ -3378,6 +3383,167 @@ test("follow mode re-resolves the defaults on every launch without a notice; Ent
3378
3383
  assert.deepEqual(session.driftNotices(), []);
3379
3384
  });
3380
3385
 
3386
+ // gentle-shell#2180: the launch seam records which profile layer supplied the
3387
+ // model, next to the model itself, so every task card shows the winning layer
3388
+ // and stale disk edits below a frozen session become visible.
3389
+ interface LaunchCapture {
3390
+ request: TaskRequest;
3391
+ record: TaskRecord;
3392
+ }
3393
+
3394
+ function captureLaunches(t: TestContext): LaunchCapture[] {
3395
+ const captures: LaunchCapture[] = [];
3396
+ const original = AgentRunner.prototype.run;
3397
+ t.mock.method(AgentRunner.prototype, "run", function (this: AgentRunner, request: TaskRequest) {
3398
+ const record = original.call(this, request);
3399
+ captures.push({ request, record });
3400
+ return record;
3401
+ });
3402
+ return captures;
3403
+ }
3404
+
3405
+ // A non-Git variant of launchSession: the resolver returns nothing, so the
3406
+ // declaration is the only pin layer (as non-Git writer admission reads it).
3407
+ async function launchNonGitSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
3408
+ const harness = deps();
3409
+ harness.deps.resolveWorktree = () => undefined;
3410
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
3411
+ const { pi, tools, fire } = fakePi();
3412
+ gentleAgents(pi, {}, harness.deps);
3413
+ const { ctx } = fakeContext();
3414
+ // Outside Git the declaration is read at the session's own cwd, so the
3415
+ // fixture binds a dedicated session directory instead of leaking the
3416
+ // declaration into the shared fixture cwd other tests launch from.
3417
+ const sessionCwd = join(base.root, "session");
3418
+ mkdirSync(join(sessionCwd, ".pi", "gentle-ai"), { recursive: true });
3419
+ copyFileSync(base.declarationPath, join(sessionCwd, ".pi", "gentle-ai", "profile.json"));
3420
+ ctx.sessionManager.getCwd = () => sessionCwd;
3421
+ await fire("session_start", ctx);
3422
+ return {
3423
+ launch: async () => {
3424
+ await tools.get("subagent_run")!.execute("source", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
3425
+ await tick();
3426
+ },
3427
+ shutdown: async () => {
3428
+ await fire("session_shutdown", ctx);
3429
+ await tick();
3430
+ },
3431
+ };
3432
+ }
3433
+
3434
+ test("a bound session launch records the binding as the model source on the task record", async t => {
3435
+ t.after(() => resetSessionProfileBindingsForTesting());
3436
+ const captures = captureLaunches(t);
3437
+ const base = pinFixture("source-binding");
3438
+ base.writeStore({ pinned: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3439
+ base.writePin("pinned");
3440
+ bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
3441
+ const session = await launchSession(base);
3442
+ t.after(session.shutdown);
3443
+ await session.launch();
3444
+ assert.equal(captures[0]!.request.modelSource, "binding", "the explicit Enter binding wins per the chain precedence");
3445
+ assert.equal(captures[0]!.record.modelSource, "binding", "the record copies the request's layer token");
3446
+ });
3447
+
3448
+ test("a frozen global-active launch records frozen:global as the model source", async t => {
3449
+ const captures = captureLaunches(t);
3450
+ const base = pinFixture("source-freeze-global");
3451
+ base.writeStore(DRIFT_PROFILES, "frontier");
3452
+ const session = await launchSession(base);
3453
+ t.after(session.shutdown);
3454
+ await session.launch();
3455
+ assert.equal(captures[0]!.request.modelSource, "frozen:global");
3456
+ });
3457
+
3458
+ test("a frozen local-pin launch records frozen:local as the model source", async t => {
3459
+ const captures = captureLaunches(t);
3460
+ const base = pinFixture("source-freeze-local");
3461
+ base.writeStore(DRIFT_PROFILES);
3462
+ base.writePin("frontier");
3463
+ const session = await launchSession(base);
3464
+ t.after(session.shutdown);
3465
+ await session.launch();
3466
+ assert.equal(captures[0]!.request.modelSource, "frozen:local");
3467
+ });
3468
+
3469
+ test("a session frozen on a non-Git declaration records frozen:repo as the model source", async t => {
3470
+ const captures = captureLaunches(t);
3471
+ const base = pinFixture("source-freeze-declaration");
3472
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3473
+ base.writeDeclaration("declared");
3474
+ const session = await launchNonGitSession(base);
3475
+ t.after(session.shutdown);
3476
+ await session.launch();
3477
+ assert.equal(captures[0]!.request.modelSource, "frozen:repo");
3478
+ });
3479
+
3480
+ test("an agent outside every profile records its frontmatter as the model source", async t => {
3481
+ const captures = captureLaunches(t);
3482
+ const base = pinFixture("source-definition");
3483
+ // The pinned profile replaces routing wholesale and does not name explore,
3484
+ // so explore's model comes from its own frontmatter.
3485
+ base.writeStore({ helper: { model: "openai/beta" } });
3486
+ base.writePin("helper");
3487
+ const session = await launchSession(base);
3488
+ t.after(session.shutdown);
3489
+ await session.launch();
3490
+ assert.equal(captures[0]!.request.modelSource, "definition");
3491
+ });
3492
+
3493
+ test("a follow launch through the globally active profile records profile as the model source", async t => {
3494
+ const captures = captureLaunches(t);
3495
+ const base = pinFixture("source-follow-global");
3496
+ base.writeStore(DRIFT_PROFILES, "frontier");
3497
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3498
+ t.after(session.shutdown);
3499
+ await session.launch();
3500
+ assert.equal(captures[0]!.request.modelSource, "profile");
3501
+ });
3502
+
3503
+ test("a follow launch through the local pin records pin:local as the model source", async t => {
3504
+ const captures = captureLaunches(t);
3505
+ const base = pinFixture("source-follow-pin");
3506
+ base.writeStore(DRIFT_PROFILES);
3507
+ base.writePin("frontier");
3508
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3509
+ t.after(session.shutdown);
3510
+ await session.launch();
3511
+ assert.equal(captures[0]!.request.modelSource, "pin:local");
3512
+ });
3513
+
3514
+ test("a follow launch through a committed repository declaration records pin:repo as the model source", async t => {
3515
+ const captures = captureLaunches(t);
3516
+ const base = pinFixture("source-follow-declaration");
3517
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3518
+ base.writeDeclaration("declared");
3519
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3520
+ t.after(session.shutdown);
3521
+ await session.launch();
3522
+ assert.equal(captures[0]!.request.modelSource, "pin:repo");
3523
+ });
3524
+
3525
+ test("a follow launch through a non-Git repository declaration records declaration as the model source", async t => {
3526
+ const captures = captureLaunches(t);
3527
+ const base = pinFixture("source-follow-nongit");
3528
+ base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
3529
+ base.writeDeclaration("declared");
3530
+ const session = await launchNonGitSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3531
+ t.after(session.shutdown);
3532
+ await session.launch();
3533
+ assert.equal(captures[0]!.request.modelSource, "declaration");
3534
+ });
3535
+
3536
+ test("the agents task card shows the winning layer next to the model, and renders unchanged without one (#2180)", () => {
3537
+ const record = {
3538
+ id: "task", agent: "explore", mode: "task", prompt: "Map the repo", label: "Map", cwd: "/repo", parentSessionId: "s1",
3539
+ status: TASK_STATUS.RUNNING, createdAt: 60_000, startedAt: 60_000, endedAt: null, model: "nan/glm5.3",
3540
+ thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", lastActivityAt: 60_000,
3541
+ turns: 0, toolCalls: 0, tokens: 0, cost: 0,
3542
+ } as TaskRecord;
3543
+ assert.equal(taskHeader({ ...record, modelSource: "binding" }, 61_000), "explore · running · nan/glm5.3 · binding · 1s");
3544
+ assert.equal(taskHeader(record, 61_000), "explore · running · nan/glm5.3 · 1s", "absent modelSource renders exactly as today");
3545
+ });
3546
+
3381
3547
  test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
3382
3548
  assert.equal(agentsEnabled({}), true);
3383
3549
  assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
@@ -4562,6 +4728,39 @@ test("registered orchestrator_list joins peer metadata without child launches or
4562
4728
  } finally { publisher.dispose(); }
4563
4729
  });
4564
4730
 
4731
+ test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => {
4732
+ const h = fakePi();
4733
+ const runtime = deps();
4734
+ const profile = realpathSync(mkdtempSync(join(root, "discovery-legacy-")));
4735
+ const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
4736
+ // A peer running pre-publication code heartbeats its schema-1 header only.
4737
+ const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] });
4738
+ try {
4739
+ let ready = false;
4740
+ const registry = { list: async () => [], listActivations: async () => [peer] };
4741
+ runtime.deps.agentHome = profile;
4742
+ runtime.deps.sessionTransport = {
4743
+ createRegistry: async () => registry,
4744
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4745
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4746
+ };
4747
+ gentleAgents(h.pi, {}, runtime.deps);
4748
+ const { ctx } = fakeContext();
4749
+ await h.fire("session_start", ctx);
4750
+ await eventually(() => ready, "discovery transport ready");
4751
+ const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4752
+ assert.equal(result.content[0].text, "Advertised sessions (reachability is unknown):\n"
4753
+ + "- peer · gentle-shell · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)"
4754
+ + " · repository: not published · metadata: legacy\n recorded catalog: unknown");
4755
+ // Consultation stays fail-closed: a legacy label is display-only.
4756
+ const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx);
4757
+ assert.equal(JSON.parse(consult.content[0].text).status, "unavailable");
4758
+ assert.equal(runtime.spawned.length, 0);
4759
+ assert.equal(h.sent.length, 0);
4760
+ await h.fire("session_shutdown", ctx);
4761
+ } finally { publisher.dispose(); }
4762
+ });
4763
+
4565
4764
  test("registered reasoning and revocation use live SDK host and published source only", async () => {
4566
4765
  const h = fakePi(), runtime = deps();
4567
4766
  const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
@@ -6500,3 +6699,83 @@ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, th
6500
6699
  jobs.cleanup();
6501
6700
  }
6502
6701
  });
6702
+
6703
+ // Harness roadmap T6: todo progress reaches peers without a model turn spent on
6704
+ // orchestrator_session_id, and never replaces the owner's curated fields.
6705
+ test("todo progress publishes merged into the owner's curated state, only for this session", async () => {
6706
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6707
+ const profile = realpathSync(mkdtempSync(join(root, "todo-progress-")));
6708
+ runtime.deps.agentHome = profile;
6709
+ const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress.sock", createdAt: 1 };
6710
+ const registry = { list: async () => [], listActivations: async () => [peer] };
6711
+ runtime.deps.sessionTransport = {
6712
+ createRegistry: async () => registry,
6713
+ createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
6714
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
6715
+ };
6716
+ Object.assign(h.pi, { setSessionName() {} });
6717
+ Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6718
+ gentleAgents(h.pi, {}, runtime.deps);
6719
+ await h.fire("session_start", ctx);
6720
+ const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6721
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 2, current: "Write parser" });
6722
+ // PR #2290 review: a todo title is private work text; peers see counts only.
6723
+ assert.deepEqual(published(), { progress: "0/2 done" }, "progress publishes before any curated state, without the todo title");
6724
+ assert.doesNotMatch(JSON.stringify(readDiscovery(profile, listPresence(profile).entries[0])), /Write parser/, "the todo title never reaches peers");
6725
+ await h.tools.get("orchestrator_session_id")!.execute("own", { subject: "Ledger", state: { objective: "Ship the ledger", decisions: "inline" } }, undefined, undefined, ctx);
6726
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 2, current: null });
6727
+ assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline", progress: "1/2 done" });
6728
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.aliases?.currentAlias, "Ledger", "the subject survives");
6729
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "other", done: 2, total: 2, current: null });
6730
+ assert.equal(published()?.progress, "1/2 done", "another session's todo is ignored");
6731
+ // R1 review advisory: a cleared list published "0/0 done" as if it were progress.
6732
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 0, current: null });
6733
+ assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline" }, "a cleared list removes the progress line and keeps curated fields");
6734
+ // R1 review advisory: a withdrawn state must stay withdrawn until the owner publishes again.
6735
+ await h.tools.get("orchestrator_session_id")!.execute("withdraw", { state: null }, undefined, undefined, ctx);
6736
+ assert.equal(published(), null);
6737
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Docs" });
6738
+ assert.equal(published(), null, "automatic progress does not republish a withdrawn state");
6739
+ await h.tools.get("orchestrator_session_id")!.execute("own", { state: { objective: "Ship v2" } }, undefined, undefined, ctx);
6740
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
6741
+ assert.deepEqual(published(), { objective: "Ship v2", progress: "2/3 done" }, "progress resumes once the owner publishes again");
6742
+ });
6743
+
6744
+ // PR #2290 review: an owner publication without `progress` replaced the automatic
6745
+ // progress line, so peers lost it until the next todo write.
6746
+ test("an owner publication without progress keeps the automatic progress; explicit progress and null still win", async () => {
6747
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6748
+ const profile = realpathSync(mkdtempSync(join(root, "todo-progress-merge-")));
6749
+ runtime.deps.agentHome = profile;
6750
+ const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress-merge.sock", createdAt: 1 };
6751
+ const registry = { list: async () => [], listActivations: async () => [peer] };
6752
+ runtime.deps.sessionTransport = {
6753
+ createRegistry: async () => registry,
6754
+ createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
6755
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
6756
+ };
6757
+ Object.assign(h.pi, { setSessionName() {} });
6758
+ Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6759
+ gentleAgents(h.pi, {}, runtime.deps);
6760
+ await h.fire("session_start", ctx);
6761
+ const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6762
+ const own = (state: unknown) => h.tools.get("orchestrator_session_id")!.execute("own", { state }, undefined, undefined, ctx);
6763
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Parser" });
6764
+ await own({ objective: "Ship the ledger" });
6765
+ assert.deepEqual(published(), { objective: "Ship the ledger", progress: "1/3 done" }, "omitted progress keeps the automatic line");
6766
+ await own({ objective: "Ship the ledger", progress: "Reviewing the parser" });
6767
+ assert.deepEqual(published(), { objective: "Ship the ledger", progress: "Reviewing the parser" }, "explicit owner progress wins");
6768
+ await own({ objective: "Ship the ledger" });
6769
+ assert.deepEqual(published(), { objective: "Ship the ledger" }, "owner progress is replaced like any curated field");
6770
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
6771
+ await own(null);
6772
+ assert.equal(published(), null, "null withdraws everything");
6773
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 3, total: 3, current: null });
6774
+ assert.equal(published(), null, "the withdrawal holds against automatic progress");
6775
+ await own({ objective: "Ship v2" });
6776
+ assert.deepEqual(published(), { objective: "Ship v2" }, "a withdrawn progress line is not merged back");
6777
+ h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 4, current: null });
6778
+ const full = "x".repeat(2048);
6779
+ await own({ objective: full });
6780
+ assert.deepEqual(published(), { objective: full }, "a merge over the state budget publishes the owner's state as given");
6781
+ });
@@ -98,7 +98,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
98
98
  method: "go-sumdb-source-build",
99
99
  package: "github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai",
100
100
  module: "github.com/gentleman-programming/gentle-ai/v4",
101
- tag: "v4.1.1",
101
+ tag: "v4.2.0",
102
102
  architecture: process.arch === "x64" ? "x64" : "arm64",
103
103
  binarySha256: createHash("sha256").update(binary).digest("hex"),
104
104
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
@@ -164,6 +165,66 @@ test("child session_start re-arms YOLO and the review sidebar for its own sessio
164
165
  assert.ok(sidebarSessions().every((id) => id === "session-child-next"), JSON.stringify(sidebarSessions()));
165
166
  });
166
167
 
168
+ // Harness roadmap T5: the delivered-test lint runs in the shared tool_call
169
+ // handler, so it holds for the parent's inline writes and for every child.
170
+ for (const child of [false, true]) {
171
+ test(`${child ? "child" : "parent"} tool_call blocks a test that reads a literal commit, and allows the same write elsewhere`, async (t) => {
172
+ const { cwd } = isolate(t);
173
+ const h = harness(child, cwd);
174
+ await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
175
+ const content = 'execSync("git show 3f9c2e1:src/ledger.ts");\n';
176
+ const call = (path: string) => h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "call-1", input: { path, content } }, h.ctx) as Promise<{ block?: boolean; reason?: string } | undefined>;
177
+ const blocked = await call("tests/ledger.test.ts");
178
+ assert.equal(blocked?.block, true);
179
+ assert.match(blocked?.reason ?? "", /git history/);
180
+ assert.equal((await call("scripts/check.ts"))?.block, undefined, "a non-test file is not linted");
181
+ });
182
+ }
183
+
184
+ // Harness roadmap T4: only the parent owns delivery, so only its commits get
185
+ // the slice line; the bash output and structured content are kept.
186
+ for (const child of [false, true]) {
187
+ test(`${child ? "child" : "parent"} bash tool_result ${child ? "never adds" : "appends"} the commit slice line`, async (t) => {
188
+ const { cwd } = isolate(t);
189
+ const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8" });
190
+ git("init", "-q", "-b", "main");
191
+ writeFileSync(join(cwd, "README.md"), "base\n");
192
+ git("add", "README.md");
193
+ git("commit", "-q", "-m", "base");
194
+ git("switch", "-q", "-c", "feat/x");
195
+ writeFileSync(join(cwd, "a.ts"), "one\ntwo\n");
196
+ git("add", "a.ts");
197
+ git("commit", "-q", "-m", "feat: a");
198
+ const h = harness(child, cwd);
199
+ await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
200
+ const event = { toolName: "bash", toolCallId: "call-1", isError: false, input: { command: "git commit -m 'feat: a'" }, content: [{ type: "text", text: "[feat/x 1234567] feat: a" }], structuredContent: { exitCode: 0 } };
201
+ const result = await h.handlers.get("tool_result")!(event, h.ctx) as { content?: Array<{ text?: string }>; structuredContent?: unknown } | undefined;
202
+ if (child) return assert.equal(result, undefined);
203
+ assert.equal(result?.content?.[0]?.text, "[feat/x 1234567] feat: a", "the command output is kept first");
204
+ assert.match(result?.content?.[1]?.text ?? "", /Commit slice: 2 authored changed lines in 1 commit on feat\/x since main/);
205
+ assert.deepEqual(result?.structuredContent, { exitCode: 0 });
206
+ const failed = await h.handlers.get("tool_result")!({ ...event, isError: true }, h.ctx);
207
+ assert.equal(failed, undefined, "a failed commit reports nothing");
208
+ });
209
+ }
210
+
211
+ // Native review tools hung when called from a codemode script (a nested call with
212
+ // parentToolCallId), while direct calls returned at once. The runtime refuses the
213
+ // nested form so a review never stalls the session.
214
+ test("review tools called from inside another tool are refused; direct calls and other nested tools are not", async (t) => {
215
+ const { cwd } = isolate(t);
216
+ const h = harness(false, cwd);
217
+ await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
218
+ const call = (toolName: string, parentToolCallId?: string) => h.handlers.get("tool_call")!({ toolName, toolCallId: "call-1", input: { operation: "status" }, ...(parentToolCallId ? { parentToolCallId } : {}) }, h.ctx) as Promise<{ block?: boolean; reason?: string } | undefined>;
219
+ for (const tool of ["gentle_review", "gentle_review_capture", "gentle_review_capture_group", "gentle_review_scope"]) {
220
+ const nested = await call(tool, "codemode-1");
221
+ assert.equal(nested?.block, true, tool);
222
+ assert.match(nested?.reason ?? "", /call it directly, never from codemode or another tool/, tool);
223
+ }
224
+ assert.equal((await call("gentle_review_scope"))?.block, undefined, "a direct review call is not refused by this guard");
225
+ assert.equal((await call("read", "codemode-1"))?.block, undefined, "other nested tools still run");
226
+ });
227
+
167
228
  async function writeToolResult(child: boolean, t: TestContext) {
168
229
  const { cwd } = isolate(t);
169
230
  mkdirSync(join(cwd, "src"), { recursive: true });
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { createServer, type Server } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test, { afterEach, beforeEach } from "node:test";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
+ import { setGentleAiDevBinaryEnvironmentForTesting } from "../lib/gentle-ai-binary.ts";
10
+
11
+ // /gentle:doctor surfaces the Herdr sidebar setup only inside Herdr. It is
12
+ // read-only guidance: Herdr config is never written.
13
+
14
+ const ISOLATED = ["GENTLE_PI_CONFIG_HOME", "GENTLE_PI_AGENT_HOME", "GENTLE_PI_AGENTS_CHILD", "HERDR_ENV", "HERDR_PANE_ID", "HERDR_SOCKET_PATH"];
15
+ let saved: Record<string, string | undefined> = {};
16
+ beforeEach(async () => {
17
+ saved = Object.fromEntries(ISOLATED.map((name) => [name, process.env[name]]));
18
+ for (const name of ISOLATED) delete process.env[name];
19
+ process.env.GENTLE_PI_CONFIG_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-doctor-config-"));
20
+ process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-doctor-agent-"));
21
+ setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home: await mkdtemp(join(tmpdir(), "gentle-pi-doctor-home-")) });
22
+ });
23
+ afterEach(() => {
24
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
25
+ for (const name of ISOLATED) {
26
+ if (saved[name] === undefined) delete process.env[name];
27
+ else process.env[name] = saved[name];
28
+ }
29
+ });
30
+
31
+ async function runDoctor(): Promise<Array<{ message: string; severity: string }>> {
32
+ const commands = new Map<string, { handler: (args: string, ctx: ExtensionContext) => Promise<void> }>();
33
+ const pi = {
34
+ on() {},
35
+ registerCommand(name: string, registration: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) {
36
+ commands.set(name, registration);
37
+ },
38
+ registerTool() {},
39
+ } as unknown as ExtensionAPI;
40
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
41
+ const notifications: Array<{ message: string; severity: string }> = [];
42
+ const ctx = {
43
+ cwd: await mkdtemp(join(tmpdir(), "gentle-pi-doctor-cwd-")),
44
+ mode: "tui",
45
+ hasUI: true,
46
+ ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
47
+ } as unknown as ExtensionContext;
48
+ await commands.get("gentle:doctor")!.handler("", ctx);
49
+ return notifications;
50
+ }
51
+
52
+ async function listen(path: string): Promise<Server> {
53
+ const server = createServer();
54
+ await new Promise<void>((resolve) => server.listen(path, resolve));
55
+ return server;
56
+ }
57
+
58
+ test("outside Herdr, /gentle:doctor stays silent about Herdr", async () => {
59
+ const notifications = await runDoctor();
60
+ assert.equal(notifications.length, 1);
61
+ assert.match(notifications[0]!.message, /^el Gentleman doctor\n/);
62
+ assert.doesNotMatch(notifications[0]!.message, /herdr|\$summary/i);
63
+ });
64
+
65
+ test("inside Herdr, /gentle:doctor appends the read-only sidebar setup hint", async () => {
66
+ const baseline = await runDoctor();
67
+ const socket = join(await mkdtemp(join(tmpdir(), "gd-")), "h.sock");
68
+ const server = await listen(socket);
69
+ try {
70
+ Object.assign(process.env, { HERDR_ENV: "1", HERDR_PANE_ID: "pane-1", HERDR_SOCKET_PATH: socket });
71
+ const notifications = await runDoctor();
72
+ assert.equal(notifications.length, 1);
73
+ assert.equal(notifications[0]!.severity, baseline[0]!.severity, "the hint does not change doctor severity");
74
+ assert.ok(notifications[0]!.message.endsWith([
75
+ "Herdr",
76
+ "pass: Herdr task-summary publishing active",
77
+ "info: Herdr's default sidebar does not show task titles; add these two rows to the Agents sidebar rows in your Herdr config: ['$summary'], ['$summary2']",
78
+ "info: Sidebar rows belong to the config of the Herdr client that displays the session, including when Pi runs over SSH",
79
+ "info: After editing, reload or restart Herdr; Gentle Shell never changes Herdr config",
80
+ "info: Details: https://github.com/Gentleman-Programming/gentle-shell/blob/main/docs/readme-reference.md#herdr-active-work-summary",
81
+ ].join("\n")), notifications[0]!.message);
82
+ assert.ok(notifications[0]!.message.startsWith(baseline[0]!.message), "existing doctor lines are unchanged");
83
+ } finally {
84
+ await new Promise((resolve) => server.close(resolve));
85
+ }
86
+ });
87
+
88
+ test("inside Herdr without a live socket, /gentle:doctor reports publishing inactive", async () => {
89
+ Object.assign(process.env, { HERDR_ENV: "1", HERDR_PANE_ID: "pane-1", HERDR_SOCKET_PATH: join(tmpdir(), "missing-herdr.sock") });
90
+ const notifications = await runDoctor();
91
+ assert.equal(notifications.length, 1);
92
+ assert.match(notifications[0]!.message, /\nwarn: Herdr task-summary publishing inactive: HERDR_SOCKET_PATH is not a Unix socket\n/);
93
+ assert.match(notifications[0]!.message, /\['\$summary'\], \['\$summary2'\]/);
94
+ });