gentle-pi 3.7.0 → 4.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.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -364,6 +364,164 @@ test("candidate owner publication never removes a replaced marker", (t) => {
|
|
|
364
364
|
assert.equal(readdirSync(parent).some((name) => name.endsWith(".reaper-lock")), false);
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
+
test("public POSIX candidate-views parent reports bounded privacy guidance without changing permissions or adding a worktree", { skip: process.platform === "win32" }, (t) => {
|
|
368
|
+
const cwd = repository(t);
|
|
369
|
+
const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
370
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
371
|
+
chmodSync(parent, 0o777);
|
|
372
|
+
let adds = 0;
|
|
373
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
374
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
375
|
+
return execFileSync(file, args, options);
|
|
376
|
+
});
|
|
377
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
|
|
378
|
+
assert.ok(error instanceof CandidateViewError);
|
|
379
|
+
assert.equal(error.reason, "candidate-owner-preparation-failed");
|
|
380
|
+
assert.deepEqual(error.diagnostics, {
|
|
381
|
+
code: "candidate-owner-parent-privacy",
|
|
382
|
+
message: "candidate-views parent must be owned by the current user and inaccessible to group and others; inspect its ownership and permissions, then correct them out of band before retrying START",
|
|
383
|
+
});
|
|
384
|
+
assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
|
|
385
|
+
return true;
|
|
386
|
+
});
|
|
387
|
+
assert.equal(lstatSync(parent).mode & 0o777, 0o777);
|
|
388
|
+
assert.equal(adds, 0);
|
|
389
|
+
chmodSync(parent, 0o700);
|
|
390
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
391
|
+
view.cleanup();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("POSIX chmod-ineffective parent reports filesystem capability guidance before worktree creation", { skip: process.platform === "win32" }, (t) => {
|
|
395
|
+
const cwd = repository(t);
|
|
396
|
+
const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
397
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
398
|
+
chmodSync(parent, 0o777);
|
|
399
|
+
const originalLstat = fs.lstatSync;
|
|
400
|
+
t.mock.method(fs, "lstatSync", (path: Parameters<typeof fs.lstatSync>[0], ...args: unknown[]) => {
|
|
401
|
+
const stat = (originalLstat as (...arguments_: unknown[]) => ReturnType<typeof fs.lstatSync>)(path, ...args);
|
|
402
|
+
return typeof path === "string" && path.includes(".gentle-ai-chmod-probe-")
|
|
403
|
+
? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { mode: (Number(stat.mode) & ~0o777) | 0o777, uid: process.getuid?.() })
|
|
404
|
+
: stat;
|
|
405
|
+
});
|
|
406
|
+
syncBuiltinESMExports();
|
|
407
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
408
|
+
let adds = 0;
|
|
409
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
410
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
411
|
+
return execFileSync(file, args, options);
|
|
412
|
+
});
|
|
413
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
|
|
414
|
+
assert.ok(error instanceof CandidateViewError);
|
|
415
|
+
assert.equal(error.reason, "candidate-owner-preparation-failed");
|
|
416
|
+
assert.deepEqual(error.diagnostics, {
|
|
417
|
+
code: "candidate-owner-parent-chmod-ineffective",
|
|
418
|
+
message: "candidate-views parent filesystem does not honor POSIX permission changes; move this repository's Git common directory to a POSIX-metadata filesystem or enable metadata support before retrying START",
|
|
419
|
+
});
|
|
420
|
+
assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
|
|
421
|
+
return true;
|
|
422
|
+
});
|
|
423
|
+
assert.equal(adds, 0);
|
|
424
|
+
assert.equal(readdirSync(parent).some((name) => name.includes(".gentle-ai-chmod-probe-")), false);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("inconclusive private-mode probe keeps bounded privacy guidance instead of claiming ineffective chmod", { skip: process.platform === "win32" }, (t) => {
|
|
428
|
+
const cwd = repository(t);
|
|
429
|
+
const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
430
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
431
|
+
chmodSync(parent, 0o777);
|
|
432
|
+
const originalMkdir = fs.mkdirSync;
|
|
433
|
+
t.mock.method(fs, "mkdirSync", (path: Parameters<typeof fs.mkdirSync>[0], ...args: unknown[]) => {
|
|
434
|
+
if (typeof path === "string" && path.includes(".gentle-ai-chmod-probe-")) throw Object.assign(new Error("probe creation refused"), { code: "EACCES" });
|
|
435
|
+
return (originalMkdir as (...arguments_: unknown[]) => ReturnType<typeof fs.mkdirSync>)(path, ...args);
|
|
436
|
+
});
|
|
437
|
+
syncBuiltinESMExports();
|
|
438
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
439
|
+
let adds = 0;
|
|
440
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
441
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
442
|
+
return execFileSync(file, args, options);
|
|
443
|
+
});
|
|
444
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }), (error: unknown) => {
|
|
445
|
+
assert.ok(error instanceof CandidateViewError);
|
|
446
|
+
assert.equal(error.reason, "candidate-owner-preparation-failed");
|
|
447
|
+
assert.deepEqual(error.diagnostics, {
|
|
448
|
+
code: "candidate-owner-parent-privacy",
|
|
449
|
+
message: "candidate-views parent must be owned by the current user and inaccessible to group and others; inspect its ownership and permissions, then correct them out of band before retrying START",
|
|
450
|
+
});
|
|
451
|
+
assert.doesNotMatch(JSON.stringify(error.diagnostics), new RegExp(cwd));
|
|
452
|
+
return true;
|
|
453
|
+
});
|
|
454
|
+
assert.equal(adds, 0);
|
|
455
|
+
assert.equal(readdirSync(parent).some((name) => name.includes(".gentle-ai-chmod-probe-")), false);
|
|
456
|
+
chmodSync(parent, 0o700);
|
|
457
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
458
|
+
view.cleanup();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("POSIX parent privacy classification does not probe a replaced path after validating it", { skip: process.platform === "win32" }, (t) => {
|
|
462
|
+
const cwd = repository(t);
|
|
463
|
+
const commonDir = realpathSync(join(cwd, ".git"));
|
|
464
|
+
const parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
465
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
466
|
+
const originalLstat = fs.lstatSync;
|
|
467
|
+
let parentProbes = 0;
|
|
468
|
+
t.mock.method(fs, "lstatSync", (path: Parameters<typeof fs.lstatSync>[0], ...args: unknown[]) => {
|
|
469
|
+
if (path === parent && ++parentProbes === 2) {
|
|
470
|
+
const moved = `${parent}-moved`;
|
|
471
|
+
renameSync(parent, moved);
|
|
472
|
+
symlinkSync(moved, parent, "dir");
|
|
473
|
+
}
|
|
474
|
+
return (originalLstat as (...args: unknown[]) => ReturnType<typeof fs.lstatSync>)(path, ...args);
|
|
475
|
+
});
|
|
476
|
+
syncBuiltinESMExports();
|
|
477
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
478
|
+
let failure: unknown;
|
|
479
|
+
try { assertCandidateOwnerParent(commonDir); } catch (error) { failure = error; }
|
|
480
|
+
assert.equal(parentProbes, 1, "the privacy decision must share the checked directory probe");
|
|
481
|
+
assert.equal(failure, undefined);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("POSIX non-directory and symlink parents never receive privacy guidance", { skip: process.platform === "win32" }, (t) => {
|
|
485
|
+
const cwd = repository(t);
|
|
486
|
+
const commonDir = realpathSync(join(cwd, ".git"));
|
|
487
|
+
const control = join(commonDir, "gentle-ai");
|
|
488
|
+
const parent = join(control, "candidate-views");
|
|
489
|
+
mkdirSync(control, { recursive: true, mode: 0o700 });
|
|
490
|
+
for (const kind of ["file", "symlink"] as const) {
|
|
491
|
+
if (kind === "file") writeFileSync(parent, "not a directory");
|
|
492
|
+
else symlinkSync(control, parent, "dir");
|
|
493
|
+
assert.throws(() => assertCandidateOwnerParent(commonDir), (error: unknown) => {
|
|
494
|
+
assert.notEqual((error as Error).name, "PosixCandidateOwnerParentPrivacyError");
|
|
495
|
+
return true;
|
|
496
|
+
});
|
|
497
|
+
rmSync(parent);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("owner privacy diagnostic sanitizer rejects injected text", () => {
|
|
502
|
+
for (const code of ["candidate-owner-parent-privacy", "candidate-owner-parent-chmod-ineffective"] as const) {
|
|
503
|
+
const forged = new CandidateViewError("rejected", "candidate-owner-preparation-failed", {
|
|
504
|
+
code,
|
|
505
|
+
message: "private-fixture-path-and-user",
|
|
506
|
+
} as unknown as ConstructorParameters<typeof CandidateViewError>[2]);
|
|
507
|
+
assert.equal(forged.diagnostics, undefined);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("unknown owner failures never expose arbitrary messages in diagnostics", (t) => {
|
|
512
|
+
const cwd = repository(t);
|
|
513
|
+
const secret = "private-fixture-path-and-user";
|
|
514
|
+
t.mock.method(fs, "fsyncSync", () => { throw new Error(secret); });
|
|
515
|
+
syncBuiltinESMExports();
|
|
516
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
517
|
+
assert.throws(() => new CandidateViewRegistry().create({ contributorRoot: cwd }), (error: unknown) => {
|
|
518
|
+
assert.ok(error instanceof CandidateViewError);
|
|
519
|
+
assert.equal(error.diagnostics, undefined);
|
|
520
|
+
assert.doesNotMatch(error.message, /private-fixture-path-and-user/);
|
|
521
|
+
return true;
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
367
525
|
test("candidate owner rejects an absent POSIX getuid before worktree creation", { skip: process.platform === "win32" }, (t) => {
|
|
368
526
|
const originalGetuid = process.getuid;
|
|
369
527
|
Object.defineProperty(process, "getuid", { configurable: true, value: undefined });
|
|
@@ -1619,6 +1777,100 @@ test("candidate view accepts internal relative symlink targets and rejects unsaf
|
|
|
1619
1777
|
}
|
|
1620
1778
|
});
|
|
1621
1779
|
|
|
1780
|
+
test("unchanged committed symlinks materialize as links with core.symlinks disabled or enabled", (t) => {
|
|
1781
|
+
const contributorRoot = repository(t);
|
|
1782
|
+
mkdirSync(join(contributorRoot, "nested"));
|
|
1783
|
+
mkdirSync(join(contributorRoot, "target-dir"));
|
|
1784
|
+
writeFileSync(join(contributorRoot, "target-dir", "file.txt"), "frozen target\n");
|
|
1785
|
+
try {
|
|
1786
|
+
symlinkSync("../target-dir/file.txt", join(contributorRoot, "nested", "link"));
|
|
1787
|
+
symlinkSync("../target-dir", join(contributorRoot, "nested", "directory-link"), "dir");
|
|
1788
|
+
symlinkSync("../missing-file", join(contributorRoot, "nested", "dangling-link"));
|
|
1789
|
+
}
|
|
1790
|
+
catch { t.skip("native symlink creation unavailable"); return; }
|
|
1791
|
+
git(contributorRoot, "add", "nested/link", "nested/directory-link", "nested/dangling-link", "target-dir/file.txt");
|
|
1792
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
|
|
1793
|
+
for (const setting of ["false", "true"]) {
|
|
1794
|
+
git(contributorRoot, "config", "core.symlinks", setting);
|
|
1795
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), `changed ${setting}\n`);
|
|
1796
|
+
const view = createCandidateView({ contributorRoot });
|
|
1797
|
+
try {
|
|
1798
|
+
assert.deepEqual(view.paths, ["tracked.txt"]);
|
|
1799
|
+
assert.equal(lstatSync(join(view.root, "nested", "link")).isSymbolicLink(), true);
|
|
1800
|
+
assert.equal(readFileSync(join(view.root, "nested", "link"), "utf8"), "frozen target\n");
|
|
1801
|
+
assert.equal(readFileSync(join(view.root, "nested", "directory-link", "file.txt"), "utf8"), "frozen target\n");
|
|
1802
|
+
assert.equal(lstatSync(join(view.root, "nested", "dangling-link")).isSymbolicLink(), true);
|
|
1803
|
+
assert.equal(readFileSync(join(view.root, "tracked.txt"), "utf8"), `changed ${setting}\n`);
|
|
1804
|
+
assert.equal(git(contributorRoot, "config", "core.symlinks"), setting);
|
|
1805
|
+
view.verify();
|
|
1806
|
+
} finally { view.cleanup(); }
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
test("frozen symlink bytes survive disabled checkout, while replaced links and regular files fail verification", (t) => {
|
|
1811
|
+
const cwd = repository(t);
|
|
1812
|
+
try { symlinkSync("tracked.txt", join(cwd, "alias")); }
|
|
1813
|
+
catch { t.skip("native symlink creation unavailable"); return; }
|
|
1814
|
+
git(cwd, "add", "alias");
|
|
1815
|
+
git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
|
|
1816
|
+
git(cwd, "config", "core.symlinks", "false");
|
|
1817
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
1818
|
+
const view = createCandidateView({ contributorRoot: cwd });
|
|
1819
|
+
try {
|
|
1820
|
+
assert.deepEqual(readFileSync(join(view.root, "alias"), "utf8"), "changed\n");
|
|
1821
|
+
assert.equal(git(cwd, "cat-file", "blob", git(cwd, "rev-parse", "HEAD:alias")), "tracked.txt");
|
|
1822
|
+
for (const replacement of ["other.txt", "../escape", null]) {
|
|
1823
|
+
chmodSync(view.root, 0o755);
|
|
1824
|
+
rmSync(join(view.root, "alias"));
|
|
1825
|
+
if (replacement === null) writeFileSync(join(view.root, "alias"), "tracked.txt");
|
|
1826
|
+
else symlinkSync(replacement, join(view.root, "alias"));
|
|
1827
|
+
assert.throws(() => view.verify(), CandidateViewError);
|
|
1828
|
+
}
|
|
1829
|
+
} finally { view.cleanup(); }
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test("unsafe frozen symlink blobs are rejected before any link is created with core.symlinks disabled", (t) => {
|
|
1833
|
+
for (const target of ["/absolute", "../escape", ".git/config", "bad\\\\target", "bad//target"]) {
|
|
1834
|
+
const cwd = repository(t);
|
|
1835
|
+
git(cwd, "config", "core.symlinks", "false");
|
|
1836
|
+
const safe = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: "tracked.txt", encoding: "utf8" }).trim();
|
|
1837
|
+
const unsafe = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: target, encoding: "utf8" }).trim();
|
|
1838
|
+
git(cwd, "update-index", "--add", "--cacheinfo", `120000,${safe},a-safe-link`);
|
|
1839
|
+
git(cwd, "update-index", "--add", "--cacheinfo", `120000,${unsafe},z-unsafe-link`);
|
|
1840
|
+
const base = git(cwd, "rev-parse", "HEAD");
|
|
1841
|
+
git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "unsafe frozen link");
|
|
1842
|
+
let creations = 0;
|
|
1843
|
+
const original = fs.symlinkSync;
|
|
1844
|
+
t.mock.method(fs, "symlinkSync", (...args: Parameters<typeof fs.symlinkSync>) => { creations++; return original(...args); });
|
|
1845
|
+
syncBuiltinESMExports();
|
|
1846
|
+
try { assert.throws(() => createCandidateView({ contributorRoot: cwd, baseRef: base, committedOnly: true }), CandidateViewError, target); }
|
|
1847
|
+
finally { t.mock.restoreAll(); syncBuiltinESMExports(); }
|
|
1848
|
+
assert.equal(creations, 0, target);
|
|
1849
|
+
assert.equal(git(cwd, "config", "core.symlinks"), "false");
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test("unavailable native symlink capability fails with bounded reason and cleans owned view", (t) => {
|
|
1854
|
+
const cwd = repository(t);
|
|
1855
|
+
try { symlinkSync("tracked.txt", join(cwd, "alias")); }
|
|
1856
|
+
catch { t.skip("native symlink creation unavailable for fixture"); return; }
|
|
1857
|
+
git(cwd, "add", "alias");
|
|
1858
|
+
git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "link base");
|
|
1859
|
+
git(cwd, "config", "core.symlinks", "false");
|
|
1860
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
1861
|
+
const original = fs.symlinkSync;
|
|
1862
|
+
t.mock.method(fs, "symlinkSync", (...args: Parameters<typeof fs.symlinkSync>) => {
|
|
1863
|
+
if (String(args[1]).includes("candidate-views")) throw Object.assign(new Error("private path and user"), { code: "EPERM" });
|
|
1864
|
+
return original(...args);
|
|
1865
|
+
});
|
|
1866
|
+
syncBuiltinESMExports();
|
|
1867
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
1868
|
+
assert.throws(() => createCandidateView({ contributorRoot: cwd }), (error: unknown) => error instanceof CandidateViewError && error.reason === "symlink-materialization-failed" && !error.message.includes("private path and user"));
|
|
1869
|
+
const parent = join(cwd, ".git", "gentle-ai", "candidate-views");
|
|
1870
|
+
assert.deepEqual(readdirSync(parent), []);
|
|
1871
|
+
assert.equal(git(cwd, "worktree", "list", "--porcelain").includes("candidate-views"), false);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1622
1874
|
test("candidate view detects symlink target-byte tampering after materialization", (t) => {
|
|
1623
1875
|
const contributorRoot = repository(t);
|
|
1624
1876
|
const link = join(contributorRoot, "candidate-link");
|
|
@@ -1843,6 +2095,52 @@ test("native projections recover a committed range base from its frozen tree", (
|
|
|
1843
2095
|
assert.equal(projection.committedOnly, true);
|
|
1844
2096
|
});
|
|
1845
2097
|
|
|
2098
|
+
test("provider-owned tree remains the exact base across reuse, correction and native dispatch restoration", (t) => {
|
|
2099
|
+
const contributorRoot = repository(t);
|
|
2100
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
2101
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "tree-backed candidate\n");
|
|
2102
|
+
git(contributorRoot, "add", "tracked.txt");
|
|
2103
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "candidate");
|
|
2104
|
+
const head = git(contributorRoot, "rev-parse", "HEAD");
|
|
2105
|
+
const candidateTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
2106
|
+
const registry = new CandidateViewRegistry();
|
|
2107
|
+
const first = registry.createOrReuse({ contributorRoot, providerBaseTree: baseTree, committedOnly: true, replayKey: "provider-tree" });
|
|
2108
|
+
try {
|
|
2109
|
+
const reused = registry.createOrReuse({ contributorRoot, providerBaseTree: baseTree, committedOnly: true, replayKey: "provider-tree" });
|
|
2110
|
+
assert.equal(reused.token, first.token);
|
|
2111
|
+
assert.equal(first.baseTree, baseTree);
|
|
2112
|
+
assert.equal(first.baseCommit, head, "HEAD anchors the worktree, not the diff base");
|
|
2113
|
+
registry.retain(first.token, "tree-lineage");
|
|
2114
|
+
const corrected = registry.createCorrected("tree-lineage", contributorRoot, "tree-correction");
|
|
2115
|
+
assert.equal(corrected.baseTree, baseTree);
|
|
2116
|
+
registry.promoteCorrected("tree-lineage", corrected.token, contributorRoot);
|
|
2117
|
+
assert.equal(registry.resolveProjection("tree-lineage", contributorRoot).providerBaseTree, baseTree);
|
|
2118
|
+
const restored = new CandidateViewRegistry();
|
|
2119
|
+
try {
|
|
2120
|
+
restored.restoreCurrentForDispatchFromNative("restored-tree", contributorRoot, { baseTree, currentCandidateTree: candidateTree, paths: ["tracked.txt"], intendedUntracked: [], projection: "staged" }, ["review-reliability"], baseTree);
|
|
2121
|
+
const view = restored.resolveCurrentForLens("review-reliability", contributorRoot);
|
|
2122
|
+
assert.equal(view.baseTree, baseTree);
|
|
2123
|
+
assert.equal(restored.resolveProjection("restored-tree", contributorRoot).providerBaseTree, baseTree);
|
|
2124
|
+
} finally { restored.cleanupAll(); }
|
|
2125
|
+
const finalized = new CandidateViewRegistry();
|
|
2126
|
+
try {
|
|
2127
|
+
const view = finalized.restoreForFinalizeFromNative("finalized-tree", contributorRoot, { baseTree, currentCandidateTree: candidateTree, paths: ["tracked.txt"], intendedUntracked: [], projection: "staged" }, baseTree);
|
|
2128
|
+
assert.equal(view.baseTree, baseTree);
|
|
2129
|
+
assert.equal(finalized.resolveProjection("finalized-tree", contributorRoot).providerBaseTree, baseTree);
|
|
2130
|
+
} finally { finalized.cleanupAll(); }
|
|
2131
|
+
} finally { registry.cleanupAll(); }
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("provider tree materialization and restoration reject wrong objects and mismatched frozen bases", (t) => {
|
|
2135
|
+
const contributorRoot = repository(t);
|
|
2136
|
+
const head = git(contributorRoot, "rev-parse", "HEAD");
|
|
2137
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
2138
|
+
assert.throws(() => new CandidateViewRegistry().create({ contributorRoot, providerBaseTree: head, committedOnly: true }), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
|
|
2139
|
+
assert.throws(() => new CandidateViewRegistry().create({ contributorRoot, baseRef: head, providerBaseTree: baseTree, committedOnly: true }), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
|
|
2140
|
+
const registry = new CandidateViewRegistry();
|
|
2141
|
+
assert.throws(() => registry.restoreProjectionFromNative("wrong-tree", contributorRoot, { baseTree, currentCandidateTree: baseTree, paths: [], intendedUntracked: [], projection: "workspace" }, head), (error: unknown) => error instanceof CandidateViewError && error.reason === "provider-base-tree-invalid");
|
|
2142
|
+
});
|
|
2143
|
+
|
|
1846
2144
|
test("fresh registries restore only one exact authoritative reviewing candidate and reject zero or multiple matches", (t) => {
|
|
1847
2145
|
const contributorRoot = repository(t);
|
|
1848
2146
|
writeFileSync(join(contributorRoot, "tracked.txt"), "reviewing\n");
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import test from "node:test";
|
|
6
|
+
import test, { after, before } from "node:test";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
@@ -16,12 +16,43 @@ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
|
16
16
|
// These tests exercise the real committed mirror (contracts/review-provider-contract-mirror/)
|
|
17
17
|
// rather than a fake one: the mirror IS the package under test.
|
|
18
18
|
|
|
19
|
-
type BeforeAgentStartResult =
|
|
19
|
+
type BeforeAgentStartResult = undefined;
|
|
20
20
|
type BeforeAgentStartHandler = (event: unknown, ctx: ExtensionContext) => Promise<BeforeAgentStartResult>;
|
|
21
|
+
type MutableEvent = { agentName?: string; systemPrompt: string; systemPromptOptions: { appendSystemPrompt: string } };
|
|
21
22
|
|
|
22
23
|
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
23
24
|
const MIRROR_LOCK_PATH = join(REPO_ROOT, "contracts", "review-provider-contract-mirror", "provider-contract.lock.json");
|
|
24
25
|
|
|
26
|
+
let fixtureRoot: string | undefined;
|
|
27
|
+
let fixtureCwd: string;
|
|
28
|
+
const fixtureEnvironment: NodeJS.ProcessEnv = {};
|
|
29
|
+
const previousEnvironment = new Map<string, string | undefined>();
|
|
30
|
+
before(() => {
|
|
31
|
+
fixtureRoot = mkdtempSync(join(tmpdir(), "gentle-pi-review-prompt-"));
|
|
32
|
+
fixtureCwd = join(fixtureRoot, "project");
|
|
33
|
+
const home = join(fixtureRoot, "home");
|
|
34
|
+
mkdirSync(fixtureCwd);
|
|
35
|
+
mkdirSync(home);
|
|
36
|
+
Object.assign(fixtureEnvironment, {
|
|
37
|
+
HOME: home, USERPROFILE: home,
|
|
38
|
+
GENTLE_PI_CONFIG_HOME: join(home, "config"),
|
|
39
|
+
GENTLE_PI_AGENT_HOME: join(home, "agents"),
|
|
40
|
+
PI_CODING_AGENT_DIR: join(home, "pi"),
|
|
41
|
+
XDG_CONFIG_HOME: join(home, "xdg"),
|
|
42
|
+
});
|
|
43
|
+
for (const [key, value] of Object.entries(fixtureEnvironment)) {
|
|
44
|
+
previousEnvironment.set(key, process.env[key]);
|
|
45
|
+
process.env[key] = value;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
after(() => {
|
|
49
|
+
for (const [key, value] of previousEnvironment) {
|
|
50
|
+
if (value === undefined) delete process.env[key];
|
|
51
|
+
else process.env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
25
56
|
function mirroredPiOrchestrationText(): string {
|
|
26
57
|
const lock = JSON.parse(readFileSync(MIRROR_LOCK_PATH, "utf8")) as { contract_semver: string };
|
|
27
58
|
return readFileSync(
|
|
@@ -30,7 +61,7 @@ function mirroredPiOrchestrationText(): string {
|
|
|
30
61
|
).trim();
|
|
31
62
|
}
|
|
32
63
|
|
|
33
|
-
function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: BeforeAgentStartHandler } {
|
|
64
|
+
function harness(nativeReviewCli: NativeReviewCli | null, processEnv?: NodeJS.ProcessEnv): { beforeAgentStart: BeforeAgentStartHandler } {
|
|
34
65
|
const handlers = new Map<string, BeforeAgentStartHandler>();
|
|
35
66
|
const pi = {
|
|
36
67
|
on(name: string, handler: BeforeAgentStartHandler) {
|
|
@@ -40,7 +71,12 @@ function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: B
|
|
|
40
71
|
registerCommand() {},
|
|
41
72
|
registerTool() {},
|
|
42
73
|
} as unknown as ExtensionAPI;
|
|
43
|
-
createGentleAiExtension({
|
|
74
|
+
createGentleAiExtension({
|
|
75
|
+
nativeReviewCli,
|
|
76
|
+
processEnv: { ...fixtureEnvironment, GENTLE_PI_AGENTS_CHILD: "0", ...processEnv, GENTLE_AI_TELEMETRY: "0" },
|
|
77
|
+
resolveTelemetryTriggerBinary: () => join(fixtureCwd, "never-executed"),
|
|
78
|
+
telemetryTriggerSpawn: () => assert.fail("Review prompt fixtures must not spawn telemetry"),
|
|
79
|
+
})(pi);
|
|
44
80
|
const beforeAgentStart = handlers.get("before_agent_start");
|
|
45
81
|
assert.equal(typeof beforeAgentStart, "function");
|
|
46
82
|
return { beforeAgentStart: beforeAgentStart as BeforeAgentStartHandler };
|
|
@@ -48,7 +84,7 @@ function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: B
|
|
|
48
84
|
|
|
49
85
|
function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
|
|
50
86
|
return {
|
|
51
|
-
cwd:
|
|
87
|
+
cwd: fixtureCwd,
|
|
52
88
|
hasUI: true,
|
|
53
89
|
ui: { notify() {} },
|
|
54
90
|
sessionManager: { getSessionId: () => "review-contract-prompt-session" },
|
|
@@ -56,28 +92,35 @@ function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
|
|
|
56
92
|
} as unknown as ExtensionContext;
|
|
57
93
|
}
|
|
58
94
|
|
|
59
|
-
|
|
95
|
+
function primaryEvent(overrides: Partial<MutableEvent> = {}): MutableEvent {
|
|
96
|
+
return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" }, ...overrides };
|
|
97
|
+
}
|
|
60
98
|
|
|
61
|
-
test("before_agent_start injects the mirrored review execution contract for the primary session", async () => {
|
|
99
|
+
test("before_agent_start injects the mirrored review execution contract for the primary session through appendSystemPrompt, never a returned systemPrompt", async () => {
|
|
62
100
|
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
63
|
-
const
|
|
101
|
+
const event = primaryEvent();
|
|
102
|
+
const result = await beforeAgentStart(event, ctx());
|
|
103
|
+
assert.equal(result, undefined, "the handler must not return a replacement systemPrompt");
|
|
104
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
64
105
|
const expected = mirroredPiOrchestrationText();
|
|
65
|
-
assert.match(
|
|
66
|
-
assert.match(
|
|
67
|
-
assert.match(
|
|
68
|
-
assert.
|
|
69
|
-
assert.match(
|
|
70
|
-
assert.doesNotMatch(
|
|
71
|
-
assert.match(
|
|
72
|
-
assert.
|
|
73
|
-
assert.match(
|
|
74
|
-
assert.
|
|
75
|
-
assert.match(
|
|
76
|
-
assert.match(
|
|
77
|
-
assert.match(
|
|
78
|
-
assert.match(
|
|
79
|
-
assert.match(
|
|
80
|
-
|
|
106
|
+
assert.match(appended, /Substantial authorized work: use ODD/);
|
|
107
|
+
assert.match(appended, /For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks/);
|
|
108
|
+
assert.match(appended, /Test presence alone does not establish applicability; no chat or TUI toggle activates it/);
|
|
109
|
+
assert.match(appended, /no meaningful RED, explain why and run proportionate ordinary functional or structural verification/);
|
|
110
|
+
assert.match(appended, /Never invent lifecycle evidence or skip checks/);
|
|
111
|
+
assert.doesNotMatch(appended, /If tests exist, use strict TDD/);
|
|
112
|
+
assert.match(appended, /ODD \(Default Workflow, harness section above\) is mandatory on every request/);
|
|
113
|
+
assert.doesNotMatch(appended, /Prefer SDD\/OpenSpec artifacts/);
|
|
114
|
+
assert.match(appended, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
|
|
115
|
+
assert.ok(appended.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
|
|
116
|
+
assert.match(appended, /call `gentle_review` with {"operation":"inspect"}/);
|
|
117
|
+
assert.match(appended, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);
|
|
118
|
+
assert.match(appended, /Use `gentle_review_capture` for one current returned slot or `gentle_review_capture_group` for the complete current reviewer group/);
|
|
119
|
+
assert.match(appended, /An eligible interactive Pi host may resolve `gentle-ai\.review-integration\.consent\/v3` before the envelope reaches the model/);
|
|
120
|
+
assert.match(appended, /If `gentle_review` returns the envelope unresolved, it is still the original provider-owned two-choice contract/);
|
|
121
|
+
assert.match(appended, /Never add the host action to a decoded or relayed provider envelope/);
|
|
122
|
+
assert.match(appended, /An approved capture awaits acknowledgement; it is not burned\. On `approved`, use bound facade STATUS to obtain or replay the exact provider-issued `acknowledge-approved` continuation, then execute it unchanged\. Only its successful returned envelope burns authority; do not issue STATUS after that burn\./);
|
|
123
|
+
let previousLifecycleIndex = appended.indexOf("## Gentle AI review execution contract");
|
|
81
124
|
for (const marker of [
|
|
82
125
|
'call `gentle_review` with {"operation":"inspect"}',
|
|
83
126
|
"2. **Freeze once.**",
|
|
@@ -85,47 +128,69 @@ test("before_agent_start injects the mirrored review execution contract for the
|
|
|
85
128
|
"Use `gentle_review_capture` for one current returned slot",
|
|
86
129
|
"5. **Acknowledge exactly.**",
|
|
87
130
|
]) {
|
|
88
|
-
const markerIndex =
|
|
131
|
+
const markerIndex = appended.indexOf(marker, previousLifecycleIndex + 1);
|
|
89
132
|
assert.ok(markerIndex > previousLifecycleIndex, `${marker} must follow the previous lifecycle step`);
|
|
90
133
|
previousLifecycleIndex = markerIndex;
|
|
91
134
|
}
|
|
92
|
-
assert.doesNotMatch(
|
|
93
|
-
assert.doesNotMatch(
|
|
135
|
+
assert.doesNotMatch(appended, /authority is already burned/);
|
|
136
|
+
assert.doesNotMatch(appended, /gentle-ai review status\b.*--agent pi/);
|
|
94
137
|
});
|
|
95
138
|
|
|
96
139
|
test("before_agent_start does not inject the review execution contract for a named agent session", async () => {
|
|
97
140
|
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
98
|
-
const
|
|
99
|
-
|
|
141
|
+
const event = primaryEvent({ agentName: "review-readability" });
|
|
142
|
+
await beforeAgentStart(event, ctx());
|
|
143
|
+
assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Gentle AI review execution contract/);
|
|
100
144
|
});
|
|
101
145
|
|
|
102
146
|
test("before_agent_start does not inject the review execution contract for gentle-ai-worker", async () => {
|
|
103
147
|
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
assert.
|
|
107
|
-
assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
|
|
148
|
+
const event = primaryEvent({ agentName: "gentle-ai-worker" });
|
|
149
|
+
await beforeAgentStart(event, ctx());
|
|
150
|
+
assert.equal(event.systemPromptOptions.appendSystemPrompt, "", "a named agent gets nothing appended");
|
|
108
151
|
});
|
|
109
152
|
|
|
110
153
|
test("before_agent_start does not inject the review execution contract for jd-fix-agent", async () => {
|
|
111
154
|
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
assert.
|
|
115
|
-
assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
|
|
155
|
+
const event = primaryEvent({ agentName: "jd-fix-agent" });
|
|
156
|
+
await beforeAgentStart(event, ctx());
|
|
157
|
+
assert.equal(event.systemPromptOptions.appendSystemPrompt, "", "a named agent gets nothing appended");
|
|
116
158
|
});
|
|
117
159
|
|
|
118
|
-
test("before_agent_start does not
|
|
160
|
+
test("before_agent_start does not let legacy prompt text bypass primary ODD and review injection", async () => {
|
|
119
161
|
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
162
|
+
const event = primaryEvent({ systemPrompt: "SDD apply executor body" });
|
|
163
|
+
await beforeAgentStart(event, ctx());
|
|
164
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
165
|
+
assert.match(appended, /Substantial authorized work: use ODD/);
|
|
166
|
+
assert.match(appended, /Gentle AI review execution contract/);
|
|
167
|
+
assert.doesNotMatch(appended, /### 3\. SDD \(optional\)/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("before_agent_start does not inject the review execution contract or gentlePrompt for a child session (GENTLE_PI_AGENTS_CHILD=1)", async () => {
|
|
171
|
+
const { beforeAgentStart } = harness({} as NativeReviewCli, { GENTLE_PI_AGENTS_CHILD: "1" });
|
|
172
|
+
const event = primaryEvent({ systemPromptOptions: { appendSystemPrompt: "Worker-specific instructions" } });
|
|
173
|
+
const result = await beforeAgentStart(event, ctx());
|
|
174
|
+
assert.equal(result, undefined, "the handler must not return a replacement systemPrompt");
|
|
175
|
+
assert.equal(event.systemPromptOptions.appendSystemPrompt, "Worker-specific instructions", "child instructions must remain unchanged, without primary harness or review injection");
|
|
123
176
|
});
|
|
124
177
|
|
|
125
178
|
test("before_agent_start injects nothing when nativeReviewCli is null", async () => {
|
|
126
179
|
const { beforeAgentStart } = harness(null);
|
|
127
|
-
const
|
|
128
|
-
|
|
180
|
+
const event = primaryEvent();
|
|
181
|
+
await beforeAgentStart(event, ctx());
|
|
182
|
+
assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Gentle AI review execution contract/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("before_agent_start is idempotent: running twice on the same systemPromptOptions never duplicates the harness", async () => {
|
|
186
|
+
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
187
|
+
const event = primaryEvent();
|
|
188
|
+
await beforeAgentStart(event, ctx());
|
|
189
|
+
const firstLength = event.systemPromptOptions.appendSystemPrompt.length;
|
|
190
|
+
await beforeAgentStart(event, ctx());
|
|
191
|
+
assert.equal(event.systemPromptOptions.appendSystemPrompt.length, firstLength, "a second run on the same options object must not append again");
|
|
192
|
+
const occurrences = event.systemPromptOptions.appendSystemPrompt.split("Gentle AI review execution contract").length - 1;
|
|
193
|
+
assert.equal(occurrences, 1);
|
|
129
194
|
});
|
|
130
195
|
|
|
131
196
|
// gentle-ai R1/R3: a tampered mirrored orchestration/pi.md must never be spliced into the system prompt.
|
|
@@ -101,7 +101,6 @@ function fakeNative(status: NativeReviewStatusResult, onStart?: (request: Parame
|
|
|
101
101
|
finalize: async () => { throw new Error("finalize must not run"); },
|
|
102
102
|
validate: async () => { throw new Error("validate must not run"); },
|
|
103
103
|
bindSdd: async () => { throw new Error("bindSdd must not run"); },
|
|
104
|
-
sddStatus: async () => ({ ready: false }),
|
|
105
104
|
reviewStatus: async () => { throw new Error("inventory status must not run"); },
|
|
106
105
|
targetStatus: async () => ({
|
|
107
106
|
applicability: blocking ? "corrupted" : "unrelated",
|