gentle-pi 2.2.0 → 2.4.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 (160) hide show
  1. package/README.md +76 -79
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-explore.md +1 -0
  8. package/assets/agents/sdd-proposal.md +2 -0
  9. package/assets/agents/sdd-research.md +54 -0
  10. package/assets/agents/sdd-status.md +4 -4
  11. package/assets/agents/sdd-tasks.md +2 -3
  12. package/assets/agents/sdd-verify.md +21 -1
  13. package/assets/chains/sdd-full.chain.md +1 -1
  14. package/assets/chains/sdd-verify.chain.md +1 -1
  15. package/assets/orchestrator-delegation.md +56 -249
  16. package/assets/orchestrator-memory.md +2 -0
  17. package/assets/orchestrator.md +20 -40
  18. package/assets/sdd-orchestrator-workflow.md +30 -37
  19. package/assets/support/sdd-status-contract.md +7 -7
  20. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  21. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  22. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  23. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  24. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  25. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  26. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  27. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  28. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  29. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  30. package/docs/native-authority-architecture.md +9 -11
  31. package/docs/review-integration.md +27 -373
  32. package/extensions/ask-user-choice.ts +151 -0
  33. package/extensions/gentle-ai.ts +2349 -3785
  34. package/extensions/quiet-tools.ts +515 -32
  35. package/extensions/sdd-init.ts +4 -8
  36. package/lib/agent-home.ts +8 -0
  37. package/lib/gentle-ai-renderer.ts +70 -0
  38. package/lib/model-routing-authority.ts +133 -0
  39. package/lib/native-review-cli.ts +387 -940
  40. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  41. package/lib/provider-contract-bundle.ts +88 -6
  42. package/lib/review-candidate-view.ts +341 -132
  43. package/lib/review-host-relay.ts +324 -96
  44. package/lib/review-integration-v2.ts +868 -307
  45. package/lib/review-last-event-controller.ts +35 -0
  46. package/lib/sdd-preflight.ts +179 -68
  47. package/lib/sdd-status.ts +66 -111
  48. package/lib/terminal-theme.ts +1 -1
  49. package/package.json +83 -82
  50. package/runtime/gentle-ai-binary.mjs +1 -1
  51. package/runtime/native-review-cli.mjs +337 -890
  52. package/runtime/review-integration-v2.mjs +816 -255
  53. package/runtime/review-relay-contract.mjs +1 -1
  54. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  55. package/scripts/gentle-ai-installer.mjs +75 -22
  56. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  57. package/scripts/test-packed-runner.mjs +4 -7
  58. package/scripts/verify-package-files.mjs +23 -23
  59. package/skills/_shared/review-ledger-contract.md +14 -14
  60. package/skills/chained-pr/SKILL.md +3 -0
  61. package/skills/cognitive-doc-design/SKILL.md +1 -1
  62. package/skills/comment-writer/SKILL.md +1 -1
  63. package/skills/gentle-ai/SKILL.md +7 -74
  64. package/skills/issue-creation/SKILL.md +53 -93
  65. package/skills/judgment-day/SKILL.md +5 -7
  66. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  67. package/skills/release/SKILL.md +3 -3
  68. package/skills/skill-registry/SKILL.md +1 -1
  69. package/skills/work-unit-commits/SKILL.md +3 -1
  70. package/tests/artifact-language.test.ts +24 -11
  71. package/tests/ask-user-choice.test.ts +264 -0
  72. package/tests/codegraph-tools.test.ts +3 -3
  73. package/tests/crosslane/cross-lane.mjs +15 -1168
  74. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  75. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  76. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  77. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  78. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  79. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  80. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  81. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  82. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  83. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  84. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  85. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  86. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  87. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  89. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  92. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  93. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  94. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  95. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  96. package/tests/gentle-ai-binary.test.ts +83 -13
  97. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  98. package/tests/gentle-ai-installer.test.ts +137 -47
  99. package/tests/gentle-ai.test.ts +821 -131
  100. package/tests/gentle-theme.test.ts +133 -0
  101. package/tests/issue-creation-skill.test.ts +103 -0
  102. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  103. package/tests/model-routing-authority.test.ts +257 -0
  104. package/tests/native-review-capability-contract.test.ts +45 -1
  105. package/tests/native-review-cli.test.ts +586 -962
  106. package/tests/native-review-consent.test.ts +150 -66
  107. package/tests/native-review-parity-runtime.test.ts +80 -359
  108. package/tests/native-review-parity.test.ts +674 -925
  109. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  110. package/tests/orchestrator-budget.test.ts +132 -88
  111. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  112. package/tests/package-manifest.test.ts +152 -36
  113. package/tests/provider-contract-bundle.test.ts +76 -0
  114. package/tests/provider-contract-mirror.test.ts +19 -0
  115. package/tests/provider-defect-handoff.test.ts +15 -118
  116. package/tests/quiet-tool-rendering.test.ts +1055 -28
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-authority-recovery-docs.test.ts +1 -2
  119. package/tests/review-candidate-view.test.ts +409 -3
  120. package/tests/review-compact-contract.test.ts +4 -30
  121. package/tests/review-contract-prompt.test.ts +139 -0
  122. package/tests/review-controller-lock-status.test.ts +2 -2
  123. package/tests/review-controller-native-recovery.test.ts +363 -1450
  124. package/tests/review-controller-native-routing.test.ts +1251 -5642
  125. package/tests/review-controller-retired-ops.test.ts +1 -1
  126. package/tests/review-controller-workspace-root.test.ts +222 -71
  127. package/tests/review-controller.test.ts +26 -816
  128. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  129. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  130. package/tests/review-gate.test.ts +0 -45
  131. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  132. package/tests/review-host-relay-routing.test.ts +328 -91
  133. package/tests/review-host-relay.test.ts +436 -14
  134. package/tests/review-integration-v2-forward.test.ts +623 -553
  135. package/tests/review-integration-v2.test.ts +239 -165
  136. package/tests/review-last-event-closure.test.ts +408 -0
  137. package/tests/review-ledger-contract.test.ts +97 -35
  138. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  139. package/tests/review-relay-transport-agent.test.ts +119 -46
  140. package/tests/review-snapshot.test.ts +3 -2
  141. package/tests/runtime-harness.mjs +362 -196
  142. package/tests/sdd-agent-tools.test.ts +36 -0
  143. package/tests/sdd-preflight.test.ts +81 -15
  144. package/tests/sdd-status.test.ts +109 -110
  145. package/tests/skill-collision-prefixes.test.ts +5 -8
  146. package/tests/writer-edit-surface-scope.test.ts +366 -0
  147. package/themes/Gentleman-Cute.json +94 -0
  148. package/themes/Gentleman-Sexy.json +92 -0
  149. package/lib/git-commit-transaction.ts +0 -861
  150. package/runtime/git-commit-transaction.mjs +0 -862
  151. package/scripts/run-git-commit-transaction.mjs +0 -35
  152. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  153. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  154. package/tests/git-commit-transaction.test.ts +0 -530
  155. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  156. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  157. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  158. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  159. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  160. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -15,13 +15,13 @@ This skill guides public collaboration. It does not grant issue approval, label,
15
15
 
16
16
  ## Hard Rules
17
17
 
18
- - Check the user-owned RDD kill switch first. When disabled, do not start receipt reviews or fabricate approval; follow ordinary policy and report `disabled/unmanaged`.
18
+ - Review and Judgment Day evidence is review-only. Pi never mints delivery authority: ordinary commit, push, PR, and release always follow repository policy, regardless of RDD mode.
19
19
  - Require an approved issue (`status:approved`) and clean current `main` reproduction before implementation. Audit existing PRs for supersession or conflict; stop or narrow stale claims.
20
20
  - Group by causal authority invariant. Use one issue and one PR or explicit chain per independent invariant and rollback boundary. Split independent causes; never merge a superseded or conflicting authority line.
21
21
  - Inventory every operator flow claimed by the issue or PR, including entry, mode, environment, expectation, and negative controls. Require one truthful black-box bench journey per CLI or lifecycle flow, or actual runtime E2E proof when the core bench cannot represent it. Synthetic proxy coverage never proves another runtime.
22
22
  - Use CodeGraph-first impact mapping, a dedicated worktree, and behavior-first tests. Run source-mutating normalization before candidate freeze.
23
23
  - Forecast authored changes before edits. The hard limit is 400 additions plus deletions; above it, STOP for a chain or explicit maintainer-approved exception.
24
- - Only when RDD is enabled, bind receipts, lineage, correction, recovery, and delivery gates to the exact candidate. Keep bounded review defects in one correction transaction.
24
+ - When RDD is enabled, bind review receipts, lineage, correction, and recovery evidence to the exact candidate. Keep bounded review defects in one correction transaction; never treat that evidence as delivery authority.
25
25
  - Require independent read-only candidate validation before publication. Validation cannot edit source or authority; findings require a new candidate.
26
26
  - Keep communication humane and evidence-based. Repository labels and workflow metadata are maintainer-owned, never evidence of contributor blame.
27
27
 
@@ -29,7 +29,7 @@ This skill guides public collaboration. It does not grant issue approval, label,
29
29
 
30
30
  | Condition | Action |
31
31
  | --- | --- |
32
- | RDD disabled | Ordinary policy; `disabled/unmanaged`; no receipt or approval claim. |
32
+ | Any RDD mode | Review evidence remains review-only; ordinary commit, push, PR, and release follow repository policy with no Pi delivery authority. |
33
33
  | Issue gate or reproduction fails | Wait, stop, or narrow with evidence. |
34
34
  | Invariant or rollback is independent | Separate issue and authoritative PR line. |
35
35
  | Core bench fits / does not fit | Bench journey / actual runtime E2E; never proxy. |
@@ -17,8 +17,8 @@ Use this skill when preparing, publishing, or verifying a `gentle-pi` release.
17
17
  - npm publishing MUST go through the GitHub Actions workflow `.github/workflows/publish.yml` so provenance, environment protection, and registry credentials are controlled by GitHub.
18
18
  - Dispatch the trusted workflow definition from protected default `main`, never from a release tag. Its only caller input is the exact annotated version tag.
19
19
  - Use a clean worktree for release commits. Do not package unrelated local files or scratch artifacts.
20
- - Validate the approved receipt against the exact immutable release target with zero review actors before publication.
21
- - Release from protected `main` may bypass receipt validation only when the tag targets the current immutable `origin/main` SHA, required CI for that exact SHA is successful, the remote head is rechecked before tag push, and no fresh risk evidence exists; otherwise fail closed through native receipt validation. Never infer the tag target from local `HEAD`. Major and post-incident releases require explicit extraordinary review even when fast-path checks pass.
20
+ - Review outcomes are informational. Release delivery follows ordinary repository policy and must not be blocked, authorized, or rewritten by RDD.
21
+ - Never infer the release tag target from local `HEAD`; use the freshly fetched `origin/main` commit and the repository's normal release safeguards.
22
22
  - Never skip package verification. The publish workflow runs verification again, but local validation should still pass before tagging.
23
23
 
24
24
  ## Release Procedure
@@ -114,7 +114,7 @@ Use this skill when preparing, publishing, or verifying a `gentle-pi` release.
114
114
 
115
115
  ## Failure Handling
116
116
 
117
- - A publication failure never reopens the closed review lineage. Diagnose and retry publication separately without resetting review counters.
117
+ - A publication failure is handled through ordinary repository policy. It does not reopen or alter a review lineage.
118
118
  - Never attempt or retry `npm publish` locally. Re-dispatch from trusted `main` only when the same tag still targets the current remote `main` and the failure was publication-only.
119
119
  - If remote `main` advances, do not move or recreate the existing tag. Prepare a new release commit/version and create a new annotated version tag.
120
120
  - If the workflow fails, inspect logs with:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: skill-registry
2
+ name: gentle-ai-skill-registry
3
3
  description: "Trigger: update skills, skill registry, actualizar skills, after skill changes. Index available skills by trigger and path."
4
4
  license: MIT
5
5
  metadata:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: work-unit-commits
2
+ name: gentle-ai-work-unit-commits
3
3
  description: "Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code."
4
4
  license: Apache-2.0
5
5
  metadata:
@@ -30,6 +30,7 @@ Use it for:
30
30
  | Tell a story | A reviewer should understand why each commit exists from its diff and message. |
31
31
  | Future PR-ready | Each commit should be a candidate chained PR when the change grows. |
32
32
  | SDD workload guard | If SDD tasks forecast a >400-line change, group commits into chained PR slices before implementation. |
33
+ | Budget is not code-golf | Never shrink a diff by deleting comments, blank lines, docs, or tests, or by compressing code, to fit the review budget (400 by default, or the session `review_budget_lines`). Slice by work unit or report the overage. |
33
34
 
34
35
  ## Work Unit Checklist
35
36
 
@@ -66,6 +67,7 @@ When `sdd-tasks` produces a Review Workload Forecast:
66
67
  - Low risk: keep work-unit commits inside one PR.
67
68
  - Medium risk: commit by work unit and monitor changed lines before PR creation.
68
69
  - High risk: follow SDD `delivery_strategy` — ask on `ask-on-risk`, auto-slice on `auto-chain`, require `size:exception` on over-budget `single-pr`, or record accepted `size:exception` on `exception-ok`.
70
+ - Splitting is bounded: after one honest slicing pass, if no cohesive work-unit split fits the budget, stop and report the smallest honest count with a `size:exception` recommendation. Do not iterate shrinking the code to reach the number.
69
71
 
70
72
  Each SDD work unit should map cleanly to a commit or PR with:
71
73
 
@@ -67,14 +67,15 @@ test("rendered SDD preflight prompt is English artifact copy", () => {
67
67
  const prefs: SddPreflightPreferences = {
68
68
  executionMode: "interactive",
69
69
  artifactStore: "openspec",
70
- chainedPrStrategy: "ask-always",
70
+ chainedPrStrategy: "ask-on-risk",
71
71
  reviewBudgetLines: 400,
72
72
  engramAvailable: false,
73
73
  prompted: true,
74
74
  };
75
75
  const prompt = renderSddPreflightPrompt(prefs);
76
76
 
77
- assert.match(prompt, /The user already chose these SDD preferences/);
77
+ assert.match(prompt, /These SDD preferences are explicit current-session choices/);
78
+ assert.match(prompt, /Delivery strategy: ask-on-risk/);
78
79
  assert.match(prompt, /Review budget: 400 changed lines/);
79
80
  assert.match(prompt, /complete only the current SDD phase/i);
80
81
  assert.match(prompt, /Do not start the next SDD phase/i);
@@ -82,9 +83,15 @@ test("rendered SDD preflight prompt is English artifact copy", () => {
82
83
  assert.match(prompt, /offer the user a proposal question round/i);
83
84
  assert.match(prompt, /business rules, implications, impact, edge cases/i);
84
85
  assert.match(prompt, /second question round/i);
86
+ assert.match(prompt, /explicit acceptance of `size:exception`/);
87
+ assert.match(prompt, /human-controlled consent, authorization, security, destructive\/publishing/);
85
88
  for (const pattern of SPANISH_PREFLIGHT_COPY) {
86
89
  assert.doesNotMatch(prompt, pattern);
87
90
  }
91
+
92
+ const headless = renderSddPreflightPrompt({ ...prefs, executionMode: "auto", prompted: false });
93
+ assert.match(headless, /canonical defaults or persisted choices/);
94
+ assert.match(headless, /ambiguous-scope/);
88
95
  });
89
96
 
90
97
  test("orchestrator Memory Contract carries the Engram memory lifecycle rule", async () => {
@@ -159,7 +166,12 @@ test("orchestrator lazy-loads detailed SDD workflow", async () => {
159
166
  const workflow = await readFile(join(ROOT, "assets/sdd-orchestrator-workflow.md"), "utf8");
160
167
 
161
168
  assert.match(orchestrator, /## SDD Workflow \(lazy-loaded\)/);
162
- assert.match(orchestrator, /\{\{GENTLE_PI_SDD_WORKFLOW_PATH\}\}/);
169
+ assert.match(orchestrator, /Package assets root: `\{\{GENTLE_PI_ASSETS_ROOT\}\}`\. Lazy asset paths below are relative to this root\./);
170
+ assert.match(orchestrator, /`sdd-orchestrator-workflow\.md`/);
171
+ assert.doesNotMatch(orchestrator, /\{\{GENTLE_PI_SDD_WORKFLOW_PATH\}\}/);
172
+ assert.match(orchestrator, /injected `## SDD Session Preflight` block or a canonical-authority resolution/);
173
+ assert.match(orchestrator, /Defaults and capability constraints may resolve fields without confirmation prompts/);
174
+ assert.doesNotMatch(orchestrator, /or an explicit user answer covering the preflight choices/);
163
175
  assert.doesNotMatch(orchestrator, /## Native SDD Dispatcher/);
164
176
  assert.match(workflow, /## Native SDD Dispatcher/);
165
177
  assert.match(workflow, /## SDD Status Contract/);
@@ -188,7 +200,7 @@ test("persistent harness prompt assets do not hardcode Spanish SDD artifact copy
188
200
  assert.deepEqual(failures, []);
189
201
  });
190
202
 
191
- test("SDD assets define ownership markers and yield post-apply lifecycle to the parent", async () => {
203
+ test("SDD assets route completed implementation directly through verify, sync, and archive", async () => {
192
204
  const [tasks, apply, status, contract, chain] = await Promise.all([
193
205
  readFile(join(ROOT, "assets/agents/sdd-tasks.md"), "utf8"),
194
206
  readFile(join(ROOT, "assets/agents/sdd-apply.md"), "utf8"),
@@ -196,15 +208,16 @@ test("SDD assets define ownership markers and yield post-apply lifecycle to the
196
208
  readFile(join(ROOT, "assets/support/sdd-status-contract.md"), "utf8"),
197
209
  readFile(join(ROOT, "assets/chains/sdd-full.chain.md"), "utf8"),
198
210
  ]);
211
+ const assets = [tasks, apply, status, contract, chain].join("\n");
199
212
 
200
213
  assert.match(tasks, /<!-- sdd-owner: implementation -->/);
201
- assert.match(tasks, /<!-- sdd-owner: parent -->/);
202
- assert.match(apply, /only implementation-owned/);
203
- assert.match(apply, /MUST NOT start bounded-review/);
204
- assert.match(status, /malformed/i);
205
- assert.match(contract, /deferredParentActions/);
206
- assert.match(contract, /parent-lifecycle/);
207
- assert.match(chain, /parent.lifecycle/i);
214
+ assert.match(apply, /next_recommended: "sdd-verify"/);
215
+ assert.match(status, /verify.*ready/i);
216
+ assert.match(contract, /apply.*verify.*sync.*archive/is);
217
+ assert.match(chain, /apply.*verification/is);
218
+ assert.doesNotMatch(assets, /<!-- sdd-owner: parent -->/);
219
+ assert.doesNotMatch(assets, /parent-lifecycle/);
220
+ assert.doesNotMatch(assets, /approved receipt|bounded review/i);
208
221
  assert.doesNotMatch(chain, /## sdd-review/);
209
222
  });
210
223
 
@@ -0,0 +1,264 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import askUserChoice from "../extensions/ask-user-choice.ts";
4
+
5
+ interface ChoiceResult {
6
+ content: Array<{ type: string; text: string }>;
7
+ details: Record<string, unknown>;
8
+ }
9
+
10
+ interface ChoiceOptionSchema {
11
+ additionalProperties?: boolean;
12
+ properties?: Record<string, unknown>;
13
+ }
14
+
15
+ interface ChoiceParameters {
16
+ additionalProperties?: boolean;
17
+ properties?: {
18
+ question?: unknown;
19
+ options?: {
20
+ minItems?: number;
21
+ maxItems?: number;
22
+ items?: ChoiceOptionSchema;
23
+ };
24
+ };
25
+ }
26
+
27
+ interface ChoiceTool {
28
+ name: string;
29
+ parameters: ChoiceParameters;
30
+ execute: (...args: unknown[]) => Promise<ChoiceResult>;
31
+ }
32
+
33
+ interface ChoiceLifecycleEvent {
34
+ channel: string;
35
+ data: { active: boolean };
36
+ }
37
+
38
+ type BeforeAgentStart = (event: unknown, ctx: { mode: string }) => void | Promise<void>;
39
+
40
+ function registerChoiceTool(
41
+ initialTools: string[] = [],
42
+ onLifecycleEvent?: (event: ChoiceLifecycleEvent) => void,
43
+ ) {
44
+ let activeTools = initialTools;
45
+ let runtimeActionsAllowed = false;
46
+ let getActiveToolsCalls = 0;
47
+ let setActiveToolsCalls = 0;
48
+ let tool: ChoiceTool | undefined;
49
+ const hooks: BeforeAgentStart[] = [];
50
+ const registeredToolNames: string[] = [];
51
+ const emittedEvents: ChoiceLifecycleEvent[] = [];
52
+ const pi = {
53
+ getActiveTools: () => {
54
+ getActiveToolsCalls++;
55
+ if (!runtimeActionsAllowed) {
56
+ throw new Error("runtime actions are unavailable while the extension is loading");
57
+ }
58
+ return activeTools;
59
+ },
60
+ setActiveTools: (names: string[]) => {
61
+ setActiveToolsCalls++;
62
+ if (!runtimeActionsAllowed) {
63
+ throw new Error("runtime actions are unavailable while the extension is loading");
64
+ }
65
+ activeTools = names;
66
+ },
67
+ registerTool: (candidate: unknown) => {
68
+ tool = candidate as ChoiceTool;
69
+ registeredToolNames.push(tool.name);
70
+ },
71
+ on: (event: string, handler: BeforeAgentStart) => {
72
+ if (event === "before_agent_start") hooks.push(handler);
73
+ },
74
+ events: {
75
+ emit(channel: string, data: { active: boolean }) {
76
+ const event = { channel, data };
77
+ emittedEvents.push(event);
78
+ onLifecycleEvent?.(event);
79
+ },
80
+ },
81
+ };
82
+ askUserChoice(pi as never);
83
+ assert.ok(tool, "ask_user_choice must register");
84
+ return {
85
+ hooks,
86
+ tool,
87
+ registeredToolNames,
88
+ activeTools: () => [...activeTools],
89
+ emittedEvents: () => [...emittedEvents],
90
+ runtimeActionCalls: () => ({ getActiveTools: getActiveToolsCalls, setActiveTools: setActiveToolsCalls }),
91
+ allowRuntimeActions: () => {
92
+ runtimeActionsAllowed = true;
93
+ },
94
+ };
95
+ }
96
+
97
+ const options = [
98
+ { label: "Authorize observed hash", description: "Accept the baseline hash observed in this runtime.", value: "authorize_observed_hash" },
99
+ { label: "Preserve requested hash", description: "Keep the hash from the original request.", value: "preserve_requested_hash" },
100
+ ];
101
+
102
+ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
103
+ return {
104
+ mode: "tui",
105
+ ui: {
106
+ custom: async (factory: (tui: { requestRender(): void }, theme: { fg(_color: string, text: string): string; bold(text: string): string }, keybindings: unknown, done: (value: unknown) => void) => { render(width: number): string[]; handleInput(data: string): void }) => {
107
+ let result: unknown;
108
+ const component = factory(
109
+ { requestRender() {} },
110
+ { fg: (_color, text) => text, bold: (text) => text },
111
+ {},
112
+ (value) => {
113
+ result = value;
114
+ },
115
+ );
116
+ rendered.value = component.render(100).join("\n");
117
+ for (const input of inputs) component.handleInput(input);
118
+ return result;
119
+ },
120
+ },
121
+ };
122
+ }
123
+
124
+ test("ask_user_choice registers without runtime actions or overriding the open questionnaire", () => {
125
+ const registration = registerChoiceTool(["read", "ask_user_question"]);
126
+
127
+ assert.deepEqual(registration.registeredToolNames, ["ask_user_choice"]);
128
+ assert.deepEqual(registration.runtimeActionCalls(), { getActiveTools: 0, setActiveTools: 0 });
129
+ assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
130
+ });
131
+
132
+ test("ask_user_choice exposes a strict closed single-select schema", () => {
133
+ const { tool } = registerChoiceTool();
134
+ const optionsSchema = tool.parameters.properties?.options;
135
+ const optionSchema = optionsSchema?.items;
136
+
137
+ assert.equal(tool.name, "ask_user_choice");
138
+ assert.equal(tool.parameters.additionalProperties, false);
139
+ assert.deepEqual(Object.keys(tool.parameters.properties ?? {}).sort(), ["options", "question"]);
140
+ assert.equal(optionsSchema?.minItems, 2);
141
+ assert.equal(optionsSchema?.maxItems, 4);
142
+ assert.equal(optionSchema?.additionalProperties, false);
143
+ assert.deepEqual(Object.keys(optionSchema?.properties ?? {}).sort(), ["description", "label", "value"]);
144
+ });
145
+
146
+ test("ask_user_choice handles a closed Kilo hash decision with an opaque envelope value", async () => {
147
+ const { tool } = registerChoiceTool(["read"]);
148
+ const rendered = { value: "" };
149
+ const result = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, tuiContext(["\x1b[B", "\r"], rendered));
150
+ assert.match(rendered.value, /Proceed\?|Authorize observed hash|Accept the baseline hash|Preserve requested hash|Keep the hash/);
151
+ assert.doesNotMatch(rendered.value, /Type something|authorize_observed_hash|preserve_requested_hash/);
152
+ assert.deepEqual(result.details.selection, { value: "preserve_requested_hash", label: "Preserve requested hash", index: 2 });
153
+ assert.equal(result.content[0]?.text, "User selected: 2. Preserve requested hash (value: preserve_requested_hash)");
154
+ });
155
+
156
+ test("ask_user_choice cancels without a value and remains unavailable outside the TUI", async () => {
157
+ const { tool } = registerChoiceTool();
158
+ const rendered = { value: "" };
159
+ const cancelled = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, tuiContext(["\x1b"], rendered));
160
+ assert.equal(cancelled.details.selection, undefined);
161
+ assert.equal(cancelled.details.cancelled, true);
162
+ await assert.rejects(
163
+ () => tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, { mode: "print" }),
164
+ /unavailable outside the interactive TUI/,
165
+ );
166
+ });
167
+
168
+ test("ask_user_choice emits a private balanced lifecycle around selection and cancellation", async () => {
169
+ const sequence: string[] = [];
170
+ const selectedRegistration = registerChoiceTool([], ({ data }) => {
171
+ sequence.push(data.active ? "active" : "inactive");
172
+ });
173
+ const selected = await selectedRegistration.tool.execute(
174
+ "call",
175
+ { question: "private choice question", options },
176
+ new AbortController().signal,
177
+ undefined,
178
+ {
179
+ mode: "tui",
180
+ ui: {
181
+ custom: async () => {
182
+ sequence.push("custom");
183
+ return { value: "preserve_requested_hash", label: "Preserve requested hash", index: 2 };
184
+ },
185
+ },
186
+ },
187
+ );
188
+ assert.equal(selected.details.selection?.value, "preserve_requested_hash");
189
+ assert.deepEqual(sequence, ["active", "custom", "inactive"]);
190
+ assert.deepEqual(selectedRegistration.emittedEvents(), [
191
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },
192
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: false } },
193
+ ]);
194
+ assert.doesNotMatch(
195
+ JSON.stringify(selectedRegistration.emittedEvents()),
196
+ /private choice question|Authorize observed hash|Accept the baseline hash|preserve_requested_hash/,
197
+ );
198
+
199
+ const cancelledRegistration = registerChoiceTool();
200
+ const cancelled = await cancelledRegistration.tool.execute(
201
+ "call",
202
+ { question: "Proceed?", options },
203
+ new AbortController().signal,
204
+ undefined,
205
+ { mode: "tui", ui: { custom: async () => undefined } },
206
+ );
207
+ assert.equal(cancelled.details.cancelled, true);
208
+ assert.deepEqual(cancelledRegistration.emittedEvents(), [
209
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },
210
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: false } },
211
+ ]);
212
+ });
213
+
214
+ test("ask_user_choice settles its lifecycle after a custom UI error and emits nothing outside the TUI", async () => {
215
+ const failedRegistration = registerChoiceTool();
216
+ const customError = new Error("custom UI failed");
217
+ await assert.rejects(
218
+ failedRegistration.tool.execute(
219
+ "call",
220
+ { question: "Proceed?", options },
221
+ new AbortController().signal,
222
+ undefined,
223
+ { mode: "tui", ui: { custom: async () => { throw customError; } } },
224
+ ),
225
+ (error) => error === customError,
226
+ );
227
+ assert.deepEqual(failedRegistration.emittedEvents(), [
228
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },
229
+ { channel: "gentle-pi:ask-user-choice:blocked", data: { active: false } },
230
+ ]);
231
+
232
+ const nonTuiRegistration = registerChoiceTool();
233
+ let customCalled = false;
234
+ await assert.rejects(
235
+ nonTuiRegistration.tool.execute(
236
+ "call",
237
+ { question: "Proceed?", options },
238
+ new AbortController().signal,
239
+ undefined,
240
+ {
241
+ mode: "print",
242
+ ui: {
243
+ custom: async () => {
244
+ customCalled = true;
245
+ return undefined;
246
+ },
247
+ },
248
+ },
249
+ ),
250
+ /unavailable outside the interactive TUI/,
251
+ );
252
+ assert.equal(customCalled, false);
253
+ assert.deepEqual(nonTuiRegistration.emittedEvents(), []);
254
+ });
255
+
256
+ test("ask_user_choice is offered only for interactive TUI turns and preserves the open questionnaire", async () => {
257
+ const registration = registerChoiceTool(["read", "ask_user_question"]);
258
+ registration.allowRuntimeActions();
259
+
260
+ for (const hook of registration.hooks) await hook({}, { mode: "tui" });
261
+ assert.deepEqual(registration.activeTools(), ["read", "ask_user_question", "ask_user_choice"]);
262
+ for (const hook of registration.hooks) await hook({}, { mode: "print" });
263
+ assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
264
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -11,14 +11,14 @@ import codeGraphTools, {
11
11
  } from "../extensions/codegraph-tools.ts";
12
12
 
13
13
  function workspace(t: test.TestContext): string {
14
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-"));
14
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-")));
15
15
  execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
16
16
  t.after(() => rmSync(cwd, { recursive: true, force: true }));
17
17
  return cwd;
18
18
  }
19
19
 
20
20
  test("CodeGraph tool rejects non-project, nested-project, HOME, and temporary workspaces before init", async (t) => {
21
- const nonProject = mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-non-project-"));
21
+ const nonProject = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-non-project-")));
22
22
  t.after(() => rmSync(nonProject, { recursive: true, force: true }));
23
23
  const root = workspace(t);
24
24
  const nested = join(root, "nested");