gentle-pi 4.1.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.
- package/README.md +352 -128
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +188 -35
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +40 -8
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +20 -12
- package/docs/install-wizard.md +17 -10
- package/docs/permission-modes.md +51 -6
- package/docs/readme-reference.md +155 -31
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +21 -7
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +146 -28
- package/extensions/gentle-ai.ts +380 -68
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +53 -43
- package/extensions/gentle-todo.ts +62 -18
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +215 -28
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +62 -8
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +146 -12
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +40 -16
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +39 -8
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/vim-editor-adapter.ts +22 -4
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +5 -6
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +61 -7
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +144 -10
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/pi-update.mjs +223 -0
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +208 -16
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +35 -7
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +443 -8
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +180 -17
- package/tests/gentle-shell-bundled.test.ts +235 -38
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +97 -13
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +113 -14
- package/tests/gentle-todo.test.ts +258 -27
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +13 -5
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +28 -24
- package/tests/pi-update-launcher.test.ts +106 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +131 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +37 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +24 -1
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { type TestContext } from "node:test";
|
|
7
|
+
import { commitSliceReport, commitSliceToolResult, commitTargetDirectory, isGitCommitCommand } from "../lib/commit-slice-signal.ts";
|
|
8
|
+
|
|
9
|
+
// Harness roadmap T4 (R1): the ~400-line slice rule lived in the prompt and the
|
|
10
|
+
// model had to estimate line counts. After each parent commit the runtime now
|
|
11
|
+
// reports the authored changed lines on the branch, so slicing reads a number.
|
|
12
|
+
|
|
13
|
+
function repo(t: TestContext): { dir: string; git: (...args: string[]) => string; commitFile: (path: string, lines: number) => void } {
|
|
14
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-")));
|
|
15
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
16
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: dir, encoding: "utf8" });
|
|
17
|
+
const commitFile = (path: string, lines: number) => {
|
|
18
|
+
mkdirSync(join(dir, path, ".."), { recursive: true });
|
|
19
|
+
writeFileSync(join(dir, path), Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n") + "\n");
|
|
20
|
+
git("add", "--", path);
|
|
21
|
+
git("commit", "-q", "-m", `add ${path}`);
|
|
22
|
+
};
|
|
23
|
+
git("init", "-q", "-b", "main");
|
|
24
|
+
commitFile("README.md", 3);
|
|
25
|
+
return { dir, git, commitFile };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("isGitCommitCommand recognizes commits inside shell commands, nothing else", () => {
|
|
29
|
+
for (const command of ["git commit -m 'feat: x'", "git add -A && git commit -qm x", "cd pkg && git -C . commit --amend --no-edit", "git -c user.name=a commit -m x"]) {
|
|
30
|
+
assert.equal(isGitCommitCommand(command), true, command);
|
|
31
|
+
}
|
|
32
|
+
for (const command of ["git status", "git log --grep commit", "echo git commit", "npm test", "git commit-tree abc"]) {
|
|
33
|
+
assert.equal(isGitCommitCommand(command), false, command);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// R1 review advisory: any `echo ... git` segment hid a real commit elsewhere in
|
|
38
|
+
// the command, while text that only mentioned `git commit` counted as one.
|
|
39
|
+
test("isGitCommitCommand counts a git invocation that starts a shell segment, not text that mentions one", () => {
|
|
40
|
+
for (const command of ["echo 'staging git files' && git commit -m x", "echo git status; git commit -m x", "GIT_AUTHOR_DATE=now git commit -m x", "(cd pkg && git commit -m x)", "git add -A\ngit commit -m x", "git commit -m 'feat: git commit counting'"]) {
|
|
41
|
+
assert.equal(isGitCommitCommand(command), true, command);
|
|
42
|
+
}
|
|
43
|
+
for (const command of ["printf 'run git commit now'", "gh pr create --body \"then git commit -m x\"", "grep -rn 'git commit' docs", "echo \"git commit -m x\""]) {
|
|
44
|
+
assert.equal(isGitCommitCommand(command), false, command);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("commitTargetDirectory follows git -C and a leading cd", () => {
|
|
49
|
+
assert.equal(commitTargetDirectory("git commit -m x", "/work/repo"), "/work/repo");
|
|
50
|
+
assert.equal(commitTargetDirectory("git -C ../other commit -m x", "/work/repo"), "/work/other");
|
|
51
|
+
assert.equal(commitTargetDirectory("cd /tmp/x && git commit -m x", "/work/repo"), "/tmp/x");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// PR #2290 review: only a leading `cd <dir> &&` moved the count, so a commit after
|
|
55
|
+
// `cd <dir>;` or a later `cd` counted the session cwd instead.
|
|
56
|
+
test("commitTargetDirectory follows a cd that precedes the commit through ;, && or ||", () => {
|
|
57
|
+
assert.equal(commitTargetDirectory("cd /tmp/x; git commit -m x", "/work/repo"), "/tmp/x");
|
|
58
|
+
assert.equal(commitTargetDirectory("git add -A && cd pkg && git commit -m x", "/work/repo"), "/work/repo/pkg");
|
|
59
|
+
assert.equal(commitTargetDirectory("cd /tmp/x || exit 1; git commit -m x", "/work/repo"), "/tmp/x");
|
|
60
|
+
assert.equal(commitTargetDirectory("cd a && cd 'b c'\ngit -C d commit -m x", "/work/repo"), "/work/repo/a/b c/d");
|
|
61
|
+
assert.equal(commitTargetDirectory("git commit -m x && cd /tmp/x", "/work/repo"), "/work/repo", "a cd after the commit does not move it");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a feature branch reports authored changed lines since the default branch, lock files excluded", async (t) => {
|
|
65
|
+
const { dir, git, commitFile } = repo(t);
|
|
66
|
+
git("switch", "-q", "-c", "feat/slice");
|
|
67
|
+
commitFile("src/a.ts", 120);
|
|
68
|
+
commitFile("pnpm-lock.yaml", 900);
|
|
69
|
+
commitFile("tests/a.test.ts", 30);
|
|
70
|
+
const report = await commitSliceReport(dir);
|
|
71
|
+
assert.match(report ?? "", /^Commit slice: 150 authored changed lines in 3 commits on feat\/slice since main/);
|
|
72
|
+
assert.match(report ?? "", /250 lines left before the ~400-line slice/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// PR #2290 review: `--no-renames` counted a moved file as all of its lines deleted
|
|
76
|
+
// and added again; rename detection counts only what the move changed.
|
|
77
|
+
test("a move-only commit counts no authored lines", async (t) => {
|
|
78
|
+
const { dir, git, commitFile } = repo(t);
|
|
79
|
+
commitFile("src/old.ts", 120);
|
|
80
|
+
git("switch", "-q", "-c", "feat/move");
|
|
81
|
+
git("mv", "src/old.ts", "src/new.ts");
|
|
82
|
+
git("commit", "-q", "-m", "move src/old.ts");
|
|
83
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 0 authored changed lines in 1 commit on feat\/move/);
|
|
84
|
+
git("mv", "src/new.ts", "src/newer.ts");
|
|
85
|
+
appendFileSync(join(dir, "src/newer.ts"), "line 120\n");
|
|
86
|
+
git("commit", "-qam", "move and extend src/new.ts");
|
|
87
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 1 authored changed lines in 2 commits on feat\/move/, "a move that edits counts only the edit");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// PR #2290 review: Git quotes an unusual path in plain numstat output, so the
|
|
91
|
+
// trailing quote hid a lock file from the generated-file filter.
|
|
92
|
+
test("a lock file whose path Git quotes is still excluded", async (t) => {
|
|
93
|
+
const { dir, git, commitFile } = repo(t);
|
|
94
|
+
git("switch", "-q", "-c", "feat/quoted");
|
|
95
|
+
commitFile("packages/caf\u00e9/pnpm-lock.yaml", 900);
|
|
96
|
+
commitFile("src/a.ts", 10);
|
|
97
|
+
assert.match(git("-c", "core.quotePath=true", "diff", "--numstat", "main", "HEAD"), /"packages\/caf\\303\\251\/pnpm-lock\.yaml"/, "the fixture path needs quoting");
|
|
98
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 10 authored changed lines in 2 commits on feat\/quoted/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("past ~400 lines the report says to apply the delivery strategy before the next commit", async (t) => {
|
|
102
|
+
const { dir, git, commitFile } = repo(t);
|
|
103
|
+
git("switch", "-q", "-c", "feat/big");
|
|
104
|
+
commitFile("src/big.ts", 450);
|
|
105
|
+
assert.match(await commitSliceReport(dir) ?? "", /450 authored changed lines in 1 commit on feat\/big since main\b.*Past the ~400-line slice: apply the feature's delivery strategy before the next commit/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// R1 review advisory: the report ran git synchronously in the parent's
|
|
109
|
+
// tool_result handler, so the interface froze for as long as git took.
|
|
110
|
+
test("the parent keeps running while git computes the report", async (t) => {
|
|
111
|
+
const { dir, git, commitFile } = repo(t);
|
|
112
|
+
git("switch", "-q", "-c", "feat/async");
|
|
113
|
+
commitFile("src/a.ts", 10);
|
|
114
|
+
let ran = false;
|
|
115
|
+
setImmediate(() => { ran = true; });
|
|
116
|
+
const report = await commitSliceReport(dir);
|
|
117
|
+
assert.equal(ran, true, "the event loop ran while git answered");
|
|
118
|
+
assert.match(report ?? "", /^Commit slice: 10 authored changed lines in 1 commit on feat\/async/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// R1 review advisory: a failed or timed-out `git diff` read as an empty diff
|
|
122
|
+
// and reported "0 authored changed lines"; no number beats a wrong one.
|
|
123
|
+
test("a failed diff reports nothing instead of zero lines", async () => {
|
|
124
|
+
const answers: Record<string, string | undefined> = {
|
|
125
|
+
"symbolic-ref -q --short HEAD": "feat/slice",
|
|
126
|
+
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
|
|
127
|
+
"merge-base HEAD origin/main": "base",
|
|
128
|
+
"rev-list --count base..HEAD": "2",
|
|
129
|
+
"diff --numstat -z -M base HEAD": "12\t3\tsrc/a.ts\0",
|
|
130
|
+
};
|
|
131
|
+
const git = async (args: string[]) => answers[args.join(" ")];
|
|
132
|
+
assert.match(await commitSliceReport("/repo", git) ?? "", /^Commit slice: 15 authored changed lines in 2 commits/);
|
|
133
|
+
delete answers["diff --numstat -z -M base HEAD"];
|
|
134
|
+
assert.equal(await commitSliceReport("/repo", git), undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// R1 review advisory: a background job's result arrives when the job starts,
|
|
138
|
+
// before its commit exists, so its slice line would describe the old HEAD.
|
|
139
|
+
test("only a foreground bash commit carries the slice line", async () => {
|
|
140
|
+
const git = async (args: string[]) => ({
|
|
141
|
+
"symbolic-ref -q --short HEAD": "feat/slice",
|
|
142
|
+
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
|
|
143
|
+
"merge-base HEAD origin/main": "base",
|
|
144
|
+
"rev-list --count base..HEAD": "1",
|
|
145
|
+
"diff --numstat -z -M base HEAD": "5\t0\tsrc/a.ts\0",
|
|
146
|
+
} as Record<string, string>)[args.join(" ")];
|
|
147
|
+
const event = (toolName: string) => ({ toolName, input: { command: "git commit -m x" }, isError: false, content: [{ type: "text" as const, text: "ok" }], structuredContent: undefined });
|
|
148
|
+
assert.match(JSON.stringify((await commitSliceToolResult(event("bash"), "/repo", git))?.content), /Commit slice: 5 authored changed lines/);
|
|
149
|
+
assert.equal(await commitSliceToolResult(event("bash_background"), "/repo", git), undefined);
|
|
150
|
+
assert.equal(await commitSliceToolResult(event("monitor"), "/repo", git), undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("commits on the default branch, outside Git, or with nothing ahead report nothing", async (t) => {
|
|
154
|
+
const { dir, git, commitFile } = repo(t);
|
|
155
|
+
commitFile("src/direct.ts", 10);
|
|
156
|
+
assert.equal(await commitSliceReport(dir), undefined, "the default branch has no slice");
|
|
157
|
+
git("switch", "-q", "-c", "feat/empty");
|
|
158
|
+
assert.equal(await commitSliceReport(dir), undefined, "nothing ahead of the default branch");
|
|
159
|
+
const plain = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-plain-")));
|
|
160
|
+
t.after(() => rmSync(plain, { recursive: true, force: true }));
|
|
161
|
+
assert.equal(await commitSliceReport(plain), undefined, "outside Git");
|
|
162
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { type TestContext } from "node:test";
|
|
7
|
+
import type { ContextUsage, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
|
+
import {
|
|
10
|
+
CONTEXT_BACKSTOP_FRACTION_ENV,
|
|
11
|
+
CONTEXT_BACKSTOP_TOKENS_ENV,
|
|
12
|
+
contextBackstopReached,
|
|
13
|
+
contextBackstopThreshold,
|
|
14
|
+
} from "../lib/context-backstop.ts";
|
|
15
|
+
|
|
16
|
+
// Harness roadmap R1: the parent-context backstop was a fixed "~150k tokens" in prompt text, so the model had to
|
|
17
|
+
// estimate its own context. The runtime now measures it against min(absolute cap, fraction x context window) and
|
|
18
|
+
// tells the primary session once per crossing.
|
|
19
|
+
|
|
20
|
+
test("the threshold is the smaller of the absolute cap and 15% of the context window", () => {
|
|
21
|
+
assert.equal(contextBackstopThreshold(1_000_000, {}), 150_000, "a large window keeps the absolute cap");
|
|
22
|
+
assert.equal(contextBackstopThreshold(400_000, {}), 60_000);
|
|
23
|
+
// Bench gate (T12): a 272k window reaches it near 41k, where delegating the wide task paid off.
|
|
24
|
+
assert.equal(contextBackstopThreshold(272_000, {}), 40_800);
|
|
25
|
+
assert.equal(contextBackstopThreshold(200_000, {}), 30_000, "a small window reaches it at 15% of its size");
|
|
26
|
+
assert.equal(contextBackstopThreshold(128_000, {}), 19_200);
|
|
27
|
+
for (const unknown of [undefined, null, 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
28
|
+
assert.equal(contextBackstopThreshold(unknown, {}), 150_000, `window ${unknown} falls back to the cap`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("the cap and the fraction are overridable by environment; invalid values keep the defaults", () => {
|
|
33
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "100000" }), 100_000);
|
|
34
|
+
assert.equal(contextBackstopThreshold(4_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "300000" }), 300_000, "a raised cap applies where the fraction allows it");
|
|
35
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: "0.1" }), 100_000);
|
|
36
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "80000", [CONTEXT_BACKSTOP_FRACTION_ENV]: "1" }), 80_000);
|
|
37
|
+
for (const value of ["", "abc", "0", "-5", "1.5e999"]) {
|
|
38
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: value }), 150_000, `tokens ${JSON.stringify(value)}`);
|
|
39
|
+
}
|
|
40
|
+
for (const value of ["", "abc", "0", "-0.5", "1.5"]) {
|
|
41
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: value }), 30_000, `fraction ${JSON.stringify(value)}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// R1 review advisory: a tiny window or fraction floored the threshold to 0, so an
|
|
46
|
+
// empty context counted as reaching the backstop (and admitted every writer).
|
|
47
|
+
test("the threshold is never below one token, so an empty context never reaches it", () => {
|
|
48
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: "0.000001" }), 1);
|
|
49
|
+
assert.equal(contextBackstopThreshold(1, {}), 1);
|
|
50
|
+
assert.equal(contextBackstopReached({ tokens: 0, contextWindow: 1 }, {}), false);
|
|
51
|
+
assert.equal(contextBackstopReached({ tokens: 1, contextWindow: 1 }, {}), true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("unknown tokens never count as reaching the backstop", () => {
|
|
55
|
+
assert.equal(contextBackstopReached({ tokens: 150_000, contextWindow: 1_000_000 }, {}), true);
|
|
56
|
+
assert.equal(contextBackstopReached({ tokens: 149_999, contextWindow: 1_000_000 }, {}), false);
|
|
57
|
+
assert.equal(contextBackstopReached({ tokens: 19_200, contextWindow: 128_000 }, {}), true);
|
|
58
|
+
assert.equal(contextBackstopReached({ tokens: 19_199, contextWindow: 128_000 }, {}), false);
|
|
59
|
+
assert.equal(contextBackstopReached({ tokens: null, contextWindow: 128_000 }, {}), false, "null right after compaction");
|
|
60
|
+
assert.equal(contextBackstopReached(undefined, {}), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
64
|
+
type Patch = { content?: Array<{ type: string; text?: string }>; structuredContent?: unknown; isError?: boolean } | undefined;
|
|
65
|
+
|
|
66
|
+
function session(t: TestContext, child: boolean) {
|
|
67
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "context-backstop-")));
|
|
68
|
+
const configHome = mkdtempSync(join(tmpdir(), "context-backstop-config-"));
|
|
69
|
+
const previous = process.env.GENTLE_PI_CONFIG_HOME;
|
|
70
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
71
|
+
t.after(() => {
|
|
72
|
+
if (previous === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
73
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previous;
|
|
74
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
75
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
const handlers = new Map<string, Handler>();
|
|
78
|
+
const pi = { on: (name: string, handler: Handler) => { handlers.set(name, handler); }, events: { on() {}, emit() {} }, registerCommand() {}, registerTool() {} } as unknown as ExtensionAPI;
|
|
79
|
+
createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: child ? "1" : "0", GENTLE_AI_TELEMETRY: "0" } })(pi);
|
|
80
|
+
let usage: ContextUsage | undefined = { tokens: 10_000, contextWindow: 1_000_000, percent: 1 };
|
|
81
|
+
const sessionManager = { getSessionId: () => "session-backstop", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] };
|
|
82
|
+
const ctx = { cwd, hasUI: false, ui: { notify() {} }, sessionManager, getContextUsage: () => usage } as unknown as ExtensionContext;
|
|
83
|
+
const setTokens = (tokens: number | null, contextWindow = 1_000_000) => {
|
|
84
|
+
usage = { tokens, contextWindow, percent: tokens === null ? null : (tokens / contextWindow) * 100 };
|
|
85
|
+
};
|
|
86
|
+
const read = (overrides: Record<string, unknown> = {}) => handlers.get("tool_result")!({
|
|
87
|
+
toolName: "read", toolCallId: "call-read", isError: false, input: { path: "a.ts" },
|
|
88
|
+
content: [{ type: "text", text: "file body" }], structuredContent: { lines: 1 }, ...overrides,
|
|
89
|
+
}, ctx) as Promise<Patch>;
|
|
90
|
+
return { cwd, ctx, handlers, setTokens, read };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("the primary session gets one notice when its measured context first reaches the backstop", async (t) => {
|
|
94
|
+
const s = session(t, false);
|
|
95
|
+
assert.equal(await s.read(), undefined, "below the backstop nothing is appended");
|
|
96
|
+
s.setTokens(160_000);
|
|
97
|
+
const crossed = await s.read();
|
|
98
|
+
assert.equal(crossed?.content?.[0]?.text, "file body", "the tool output is kept first");
|
|
99
|
+
const notice = crossed?.content?.[1]?.text ?? "";
|
|
100
|
+
assert.match(notice, /Context backstop/);
|
|
101
|
+
assert.match(notice, /160,000 tokens/, "measured tokens");
|
|
102
|
+
assert.match(notice, /16% of the 1,000,000-token context window/, "percent of the window");
|
|
103
|
+
assert.match(notice, /150,000-token backstop/, "the threshold");
|
|
104
|
+
assert.match(notice, /delegate the next bounded unit that needs several steps \(its reads, edits, and checks\) with a short handoff/);
|
|
105
|
+
assert.match(notice, /single small edits and state checks stay inline/);
|
|
106
|
+
assert.deepEqual(crossed?.structuredContent, { lines: 1 }, "structured content is kept");
|
|
107
|
+
assert.equal(crossed && "isError" in crossed, false, "the error flag is never altered");
|
|
108
|
+
assert.equal(await s.read(), undefined, "once per crossing");
|
|
109
|
+
s.setTokens(null);
|
|
110
|
+
assert.equal(await s.read(), undefined, "unknown tokens neither notify nor re-arm");
|
|
111
|
+
s.setTokens(170_000);
|
|
112
|
+
assert.equal(await s.read(), undefined, "still past the same crossing");
|
|
113
|
+
s.setTokens(40_000);
|
|
114
|
+
assert.equal(await s.read(), undefined, "dropping below the backstop re-arms it");
|
|
115
|
+
s.setTokens(19_200, 128_000);
|
|
116
|
+
const again = await s.read({ isError: true, structuredContent: undefined });
|
|
117
|
+
assert.match(again?.content?.[1]?.text ?? "", /19,200 tokens \(15% of the 128,000-token context window\), at or past the 19,200-token backstop/, "a small window reaches it at 15% of its size");
|
|
118
|
+
assert.equal(again && "structuredContent" in again, false, "absent structured content stays absent");
|
|
119
|
+
assert.equal(again && "isError" in again, false, "an error result keeps its flag");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("child sessions never get the notice", async (t) => {
|
|
123
|
+
const s = session(t, true);
|
|
124
|
+
s.setTokens(390_000);
|
|
125
|
+
assert.equal(await s.read(), undefined);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a parent commit result carries both the slice line and the backstop notice", async (t) => {
|
|
129
|
+
const s = session(t, false);
|
|
130
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: s.cwd, encoding: "utf8" });
|
|
131
|
+
git("init", "-q", "-b", "main");
|
|
132
|
+
writeFileSync(join(s.cwd, "README.md"), "base\n");
|
|
133
|
+
git("add", "README.md");
|
|
134
|
+
git("commit", "-q", "-m", "base");
|
|
135
|
+
git("switch", "-q", "-c", "feat/x");
|
|
136
|
+
writeFileSync(join(s.cwd, "a.ts"), "one\n");
|
|
137
|
+
git("add", "a.ts");
|
|
138
|
+
git("commit", "-q", "-m", "feat: a");
|
|
139
|
+
s.setTokens(200_000);
|
|
140
|
+
const result = await s.handlers.get("tool_result")!({
|
|
141
|
+
toolName: "bash", toolCallId: "call-1", isError: false, input: { command: "git commit -m 'feat: a'" },
|
|
142
|
+
content: [{ type: "text", text: "[feat/x 1234567] feat: a" }], structuredContent: { exitCode: 0 },
|
|
143
|
+
}, s.ctx) as Patch;
|
|
144
|
+
assert.equal(result?.content?.[0]?.text, "[feat/x 1234567] feat: a");
|
|
145
|
+
assert.match(result?.content?.[1]?.text ?? "", /Commit slice: 1 authored changed lines/);
|
|
146
|
+
assert.match(result?.content?.[2]?.text ?? "", /Context backstop/);
|
|
147
|
+
assert.deepEqual(result?.structuredContent, { exitCode: 0 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a parent write result carries the notice after its own bookkeeping", async (t) => {
|
|
151
|
+
const s = session(t, false);
|
|
152
|
+
s.setTokens(150_000);
|
|
153
|
+
const result = await s.handlers.get("tool_result")!({
|
|
154
|
+
toolName: "write", toolCallId: "call-w", isError: false, input: { path: "src/a.ts" },
|
|
155
|
+
content: [{ type: "text", text: "Wrote src/a.ts" }],
|
|
156
|
+
}, s.ctx) as Patch;
|
|
157
|
+
assert.equal(result?.content?.[0]?.text, "Wrote src/a.ts");
|
|
158
|
+
assert.match(result?.content?.[1]?.text ?? "", /Context backstop/);
|
|
159
|
+
});
|
|
@@ -30,11 +30,19 @@ function lineStarting(text: string, prefix: string): string {
|
|
|
30
30
|
const coreWriter = lineStarting(core, "5. **Writer rule**");
|
|
31
31
|
const delegationWriter = lineStarting(delegation, "5. **Writer trigger (Writer rule):**");
|
|
32
32
|
|
|
33
|
+
// Writer-rule fix (benchmark finding): a low-effort orchestrator read "2+
|
|
34
|
+
// independent units, disjoint files" as tests vs README and split one behavior
|
|
35
|
+
// across writers. The core rule now defines a unit as independent behavior,
|
|
36
|
+
// keeps tests, docs, and code of one behavior together, and sets a size floor.
|
|
33
37
|
test("AC1: the core writer rule fires on named reasons, never on size or file count", () => {
|
|
34
38
|
for (const clause of [
|
|
35
39
|
"never by file count or a large task alone",
|
|
36
40
|
"`orchestrator-writer.md`",
|
|
37
|
-
"2+ independent units
|
|
41
|
+
"2+ independent units of behavior (separate features or fixes that do not depend on each other), each heavier than a subagent start",
|
|
42
|
+
"a unit qualifies only when it needs several minutes of work: several files or roughly 100+ changed lines",
|
|
43
|
+
"a small change to one module or one command is done inline even when several such changes are independent",
|
|
44
|
+
"Tests, docs, and code of the same behavior are one unit, never separate writers",
|
|
45
|
+
"a delivery that depends on an earlier one is not independent",
|
|
38
46
|
"Context backstop",
|
|
39
47
|
]) {
|
|
40
48
|
assert.ok(coreWriter.includes(clause), `core Writer rule is missing: ${clause}`);
|
|
@@ -65,13 +73,19 @@ test("AC1: the lazy writer trigger names its reasons and keeps the logbook on th
|
|
|
65
73
|
assert.ok(skill.includes("a writer reason (parallel units or context)"), "the skill must name the writer reasons");
|
|
66
74
|
});
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
76
|
+
// S9 canon change (gentle-ai 87a3b2eb): the lazy trigger mirrors the canon's
|
|
77
|
+
// behavior unit and size threshold instead of "clearly heavier than starting a
|
|
78
|
+
// subagent; medium tasks included".
|
|
79
|
+
test("AC2: parallelism needs 2+ independent behavior units, disjoint surfaces, each worth several minutes", () => {
|
|
80
|
+
for (const clause of [
|
|
81
|
+
"(a) parallelism — 2+ independent units of behavior with disjoint edit surfaces, each needing several minutes of work (several files or ~100+ changed lines; a subagent spends 1-2 minutes reading before it writes), launched together",
|
|
82
|
+
"A unit is one independent piece of behavior with its code, tests, and docs together: never split one behavior into test, docs, and code writers",
|
|
83
|
+
"a delivery that depends on another is not independent",
|
|
84
|
+
"several small independent changes run inline, one after another",
|
|
85
|
+
]) {
|
|
86
|
+
assert.ok(delegationWriter.includes(clause), `lazy Writer trigger is missing: ${clause}`);
|
|
87
|
+
}
|
|
88
|
+
assert.ok(!delegationWriter.includes("medium tasks included"), "lazy Writer trigger keeps the retired medium-task clause");
|
|
75
89
|
assert.ok(writer.includes("parallel units"), "the writer module must name the parallelism reason");
|
|
76
90
|
});
|
|
77
91
|
|
|
@@ -96,16 +110,20 @@ test("T24/L49: configured per-agent model routing still applies to writers launc
|
|
|
96
110
|
);
|
|
97
111
|
});
|
|
98
112
|
|
|
113
|
+
// Verification by named risk changed the core rule 3 text: only a named
|
|
114
|
+
// high-risk item, passed as assess `escalate`, adds the independent verifier.
|
|
115
|
+
// odd/tasks/verifier-admission.md S6: the rule names the runtime admission of the verifier launch.
|
|
99
116
|
test("AC4: verification stays risk-gated and the writer reasons never include risk", () => {
|
|
100
117
|
assert.ok(
|
|
101
118
|
core.includes(
|
|
102
|
-
"3. **Verification rule** — high
|
|
119
|
+
"3. **Verification rule** — only a named high-risk item (assess `escalate`) → independent `gentle-ai-verify`; else inline; runtime admission, gates/overengineering: `orchestrator-verification.md`.",
|
|
103
120
|
),
|
|
104
121
|
"core Verification rule changed",
|
|
105
122
|
);
|
|
106
123
|
assert.ok(
|
|
107
124
|
delegation.includes(
|
|
108
|
-
|
|
125
|
+
// S9 canon change (gentle-ai 6b9a71ef): only a named high-risk item adds the verifier.
|
|
126
|
+
"3. **Verification rule** (gentle-pi#661/#662, RDD-aware): only a change whose Risk line names a high-risk item (Task Size, assess `escalate`) gets an independent `gentle-ai-verify` run after the change's own checks, as rarely as possible; otherwise whoever made the change runs its focused test and suite inline, small tasks included.",
|
|
109
127
|
),
|
|
110
128
|
"lazy Verification rule changed",
|
|
111
129
|
);
|
|
@@ -177,38 +195,41 @@ test("T12/S6: one independent verify per delegated unit after its self-review, n
|
|
|
177
195
|
"once, after its final self-review",
|
|
178
196
|
"never per worker cycle or retry",
|
|
179
197
|
"its Risk line",
|
|
180
|
-
"
|
|
198
|
+
"high-risk list in Task Size",
|
|
181
199
|
]) {
|
|
182
200
|
assert.ok(item.includes(clause), `independent verify timing is missing: ${clause}`);
|
|
183
201
|
}
|
|
184
202
|
assert.ok(!item.includes("(1) "), "the timing rule restates the high-risk list");
|
|
203
|
+
// Verification by named risk retired the small-model bias.
|
|
204
|
+
assert.ok(!item.includes("small-model bias"), "the per-unit verify must not add a verifier for the writer's model");
|
|
185
205
|
});
|
|
186
206
|
|
|
187
|
-
// T15 (S6, L38): after3 B x4 paid two same-model verifies with 0 defects
|
|
188
|
-
//
|
|
189
|
-
|
|
207
|
+
// T15 (S6, L38): after3 B x4 paid two same-model verifies with 0 defects.
|
|
208
|
+
// Verification by named risk (user decision: the verifier follows the change's
|
|
209
|
+
// risk, never the model that wrote it) dropped the smaller-model exception.
|
|
210
|
+
test("T15/S6: inline code over several deliveries gets one verify at the feature's end, whatever model wrote it", () => {
|
|
190
211
|
const item = reviewItem("2. **Independent verify per unit**");
|
|
191
212
|
for (const clause of [
|
|
192
|
-
"
|
|
213
|
+
"Inline code over several deliveries of one feature",
|
|
193
214
|
"one verify at the feature's end",
|
|
194
|
-
"smaller-model writer code stays per unit",
|
|
195
215
|
"only when that unit is high risk",
|
|
196
|
-
"small-model bias per the tier table",
|
|
197
216
|
]) {
|
|
198
217
|
assert.ok(item.includes(clause), `independent verify exception is missing: ${clause}`);
|
|
199
218
|
}
|
|
219
|
+
assert.ok(!item.includes("smaller-model"), "the writer's model never keeps a per-unit verify");
|
|
200
220
|
});
|
|
201
221
|
|
|
202
222
|
test("T11/S4: the parent hands verify the whole spec, authorizes its probes and resolves every unmet item", () => {
|
|
203
223
|
const item = reviewItem("4. **Verify handoff**");
|
|
204
224
|
for (const clause of [
|
|
205
225
|
"the whole feature document",
|
|
206
|
-
"
|
|
226
|
+
"the assigned unit and its linked `S#` plus affected interactions",
|
|
227
|
+
"final feature verification covers every `S#`",
|
|
207
228
|
"the baseline commit",
|
|
208
229
|
"probes on isolated state",
|
|
209
230
|
"typecheck",
|
|
210
231
|
"`gentle-ai-verify` holds the checklist",
|
|
211
|
-
"every unmet item before closing",
|
|
232
|
+
"every in-scope unmet item before closing the unit",
|
|
212
233
|
"the writer commits its probes as regression tests",
|
|
213
234
|
]) {
|
|
214
235
|
assert.ok(item.includes(clause), `verify handoff item is missing: ${clause}`);
|
|
@@ -216,12 +237,16 @@ test("T11/S4: the parent hands verify the whole spec, authorizes its probes and
|
|
|
216
237
|
});
|
|
217
238
|
|
|
218
239
|
// verify-always-rdd-high S1 deliberately rewrote this section: verification follows the
|
|
219
|
-
// risk tier in every RDD state.
|
|
240
|
+
// risk tier in every RDD state. Verification by named risk deliberately rewrote
|
|
241
|
+
// the tier table again (escalate-only verifier, no small-model bias), and
|
|
242
|
+
// verifier admission (odd/tasks/verifier-admission.md S6) names the runtime
|
|
243
|
+
// check of the verifier launch; the PR #2290 review (odd/tasks/pr-2290-review.md S4)
|
|
244
|
+
// names every line that admits it. The pin still guards against accidental drift.
|
|
220
245
|
test("AC4/S4: the normative verification rule text is unchanged by the review protocol", () => {
|
|
221
246
|
const normative = sectionFrom(verification, "## Verification rule (normative)");
|
|
222
247
|
assert.equal(
|
|
223
248
|
createHash("sha256").update(normative).digest("hex"),
|
|
224
|
-
"
|
|
249
|
+
"b159829cad24a31e23d6309b3bfe0e4cc71eec2316775ba07314fecd1fb6a63f",
|
|
225
250
|
"the normative Verification rule section changed",
|
|
226
251
|
);
|
|
227
252
|
assert.ok(verification.includes("or a delegated writer returns"), "the module must still load when a delegated writer returns");
|
|
@@ -298,7 +323,8 @@ test("T10: escalate labels name the core high-risk items", () => {
|
|
|
298
323
|
test("T9/AC2: the parallelism reason launches every unit together in background and waits for all", () => {
|
|
299
324
|
const parallel = lineStarting(writer, "- **Parallelism**");
|
|
300
325
|
for (const clause of [
|
|
301
|
-
|
|
326
|
+
// Writer-rule fix: a unit is one independent piece of behavior.
|
|
327
|
+
"every independent unit in the same turn",
|
|
302
328
|
'`subagent_run` with `mode: "background"`',
|
|
303
329
|
"wait for all completions",
|
|
304
330
|
"serial launches void the reason",
|
|
@@ -317,11 +343,52 @@ test("AC6: parallel writers need disjoint Allowed edit surfaces (runtime-enforce
|
|
|
317
343
|
const harness = read("extensions/gentle-ai.ts");
|
|
318
344
|
const docs = read("docs/readme-reference.md");
|
|
319
345
|
assert.ok(core.includes(`- P${rule}.`), "core Safety must carry the relaxed writer rule");
|
|
320
|
-
|
|
321
|
-
|
|
346
|
+
// S11 T3: deduplicated. The harness principle repeated core Safety in the same
|
|
347
|
+
// parent prompt; the rendered prompt now carries the rule once, from Safety.
|
|
348
|
+
assert.ok(!harness.includes(rule), "harness principles restate the core Safety writer rule");
|
|
349
|
+
for (const persona of ["gentleman", "neutral"] as const) {
|
|
350
|
+
assert.equal(__testing.buildGentlePrompt(persona).split(rule).length - 1, 1, `[${persona}] the parent prompt must carry the writer rule once`);
|
|
351
|
+
}
|
|
352
|
+
for (const [path, text] of Object.entries({ core, delegation, skill, docs })) {
|
|
322
353
|
assert.ok(text.includes(rule), `${path} is missing the relaxed writer rule`);
|
|
354
|
+
}
|
|
355
|
+
for (const [path, text] of Object.entries({ core, delegation, harness, skill, docs })) {
|
|
323
356
|
for (const retired of ["single-threaded", "Never run parallel writers in one worktree", "do not run parallel writers unless isolated worktrees", "preserves a single writer thread", "one writer per task"]) {
|
|
324
357
|
assert.ok(!text.includes(retired), `${path} keeps the single-writer wording: ${retired}`);
|
|
325
358
|
}
|
|
326
359
|
}
|
|
327
360
|
});
|
|
361
|
+
|
|
362
|
+
// Writer-rule fix (benchmark finding): "Mandatory Delegation Triggers ...
|
|
363
|
+
// and is then mandatory" read as a default to delegate. The core heading now
|
|
364
|
+
// says a trigger holds only when its condition truly holds, inline when in doubt.
|
|
365
|
+
test("the core delegation triggers hold only when their condition truly holds, none by default", () => {
|
|
366
|
+
const intro = lineStarting(core, "Delegation Triggers — ");
|
|
367
|
+
for (const clause of [
|
|
368
|
+
"each mechanism turns on only when its own trigger condition truly holds, and only then is it mandatory",
|
|
369
|
+
"no trigger holds by default",
|
|
370
|
+
"when in doubt work inline",
|
|
371
|
+
]) {
|
|
372
|
+
assert.ok(intro.includes(clause), `core delegation triggers are missing: ${clause}`);
|
|
373
|
+
}
|
|
374
|
+
assert.ok(!core.includes("Mandatory Delegation Triggers"), "the core must not read the triggers as mandatory by default");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("the writer module sets the size threshold and keeps one behavior in one unit", () => {
|
|
378
|
+
const size = lineStarting(writer, "- **Size threshold**");
|
|
379
|
+
for (const clause of [
|
|
380
|
+
"a subagent costs one to two minutes of reading and setup before it writes anything",
|
|
381
|
+
"delegate a unit only when it needs several minutes of work (several files or roughly 100+ changed lines)",
|
|
382
|
+
"Several small independent changes are faster inline, one after another",
|
|
383
|
+
]) {
|
|
384
|
+
assert.ok(size.includes(clause), `writer size threshold is missing: ${clause}`);
|
|
385
|
+
}
|
|
386
|
+
const parallel = lineStarting(writer, "- **Parallelism**");
|
|
387
|
+
for (const clause of [
|
|
388
|
+
"a unit is one independent piece of behavior with its own code, tests, and docs",
|
|
389
|
+
"never split one behavior into a test writer, a docs writer, and inline code",
|
|
390
|
+
"never delegate a unit that depends on another unit still in progress",
|
|
391
|
+
]) {
|
|
392
|
+
assert.ok(parallel.includes(clause), `writer parallelism unit rule is missing: ${clause}`);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { deliveredTestViolation, evaluateDeliveredTestTool, isTestFilePath } from "../lib/delivered-test-lint.ts";
|
|
4
|
+
|
|
5
|
+
// Harness roadmap T5 (R1): blind review found delivered tests that ran
|
|
6
|
+
// `git show <baseline commit>` and failed in another clone, and orchestrators
|
|
7
|
+
// that pointed TMPDIR into the repository. The prompt rule did not hold, so the
|
|
8
|
+
// runtime blocks writing such a test instead of asking the model not to.
|
|
9
|
+
|
|
10
|
+
test("isTestFilePath recognizes common test layouts and nothing else", () => {
|
|
11
|
+
for (const path of ["tests/ledger.test.ts", "src/__tests__/a.ts", "test/cli.spec.js", "pkg/store_test.go", "tests/test_cli.py", "/abs/repo/test/x.mjs"]) {
|
|
12
|
+
assert.equal(isTestFilePath(path), true, path);
|
|
13
|
+
}
|
|
14
|
+
for (const path of ["src/ledger.ts", "README.md", "src/testing-utils.ts", "docs/tests.md"]) {
|
|
15
|
+
assert.equal(isTestFilePath(path), false, path);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a test that reads a literal commit from git history is a violation", () => {
|
|
20
|
+
for (const content of [
|
|
21
|
+
'const old = execSync("git show 3f9c2e1:src/ledger.ts").toString();',
|
|
22
|
+
"execFileSync('git', ['show', 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0:src/cli.ts']);",
|
|
23
|
+
"git checkout 9e8d7c6 -- src/cli.ts",
|
|
24
|
+
"git diff 4c5d6e7f HEAD -- src",
|
|
25
|
+
"git archive 77aa12b | tar -x -C \"$scratch\"",
|
|
26
|
+
]) {
|
|
27
|
+
assert.match(deliveredTestViolation(content) ?? "", /git history/, content);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a test that points TMPDIR inside the working directory is a violation", () => {
|
|
32
|
+
for (const content of [
|
|
33
|
+
"TMPDIR=$PWD/.test-tmp node --test",
|
|
34
|
+
'const env = { ...process.env, TMPDIR: join(process.cwd(), ".tmp") };',
|
|
35
|
+
"process.env.TMPDIR = path.join(__dirname, 'tmp');",
|
|
36
|
+
"export TMPDIR=\"$(pwd)/tmp\"",
|
|
37
|
+
'os.environ["TMPDIR"] = os.getcwd()',
|
|
38
|
+
]) {
|
|
39
|
+
assert.match(deliveredTestViolation(content) ?? "", /TMPDIR/, content);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// PR #2290 review: a relative TMPDIR resolves against the working directory just
|
|
44
|
+
// like `$PWD/...`, so test scratch entered the diff unflagged.
|
|
45
|
+
test("a test that sets TMPDIR to a relative path is a violation", () => {
|
|
46
|
+
for (const content of [
|
|
47
|
+
"TMPDIR=./.tmp node --test",
|
|
48
|
+
"TMPDIR=.tmp node --test",
|
|
49
|
+
"export TMPDIR=tmp/x",
|
|
50
|
+
'const env = { ...process.env, TMPDIR: "./.tmp" };',
|
|
51
|
+
"process.env.TMPDIR = '.tmp';",
|
|
52
|
+
'process.env.TMPDIR = "tmp/x";',
|
|
53
|
+
'os.environ["TMPDIR"] = "../scratch"',
|
|
54
|
+
]) {
|
|
55
|
+
assert.match(deliveredTestViolation(content) ?? "", /TMPDIR/, content);
|
|
56
|
+
}
|
|
57
|
+
for (const content of [
|
|
58
|
+
'TMPDIR="$TMPDIR/ledger" node --test',
|
|
59
|
+
"TMPDIR=${TMPDIR:-/tmp}/ledger node --test",
|
|
60
|
+
"export TMPDIR=/var/tmp/ledger",
|
|
61
|
+
'const env = { TMPDIR: "/tmp/ledger" };',
|
|
62
|
+
"const env = { TMPDIR: os.tmpdir() };",
|
|
63
|
+
"const env = { TMPDIR: path.join(tmpdir(), 'ledger') };",
|
|
64
|
+
"TMPDIR=~/scratch node --test",
|
|
65
|
+
'process.env.TMPDIR = "C:\\\\Temp";',
|
|
66
|
+
"const env = { TMPDIR: `${tmpdir()}/ledger` };",
|
|
67
|
+
'const task = runner.run(request({ env: { TMPDIR: "reltmp" } }));',
|
|
68
|
+
]) {
|
|
69
|
+
assert.equal(deliveredTestViolation(content), undefined, content);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("ordinary tests, git use in throwaway repos, and outside scratch stay allowed", () => {
|
|
74
|
+
for (const content of [
|
|
75
|
+
'const dir = mkdtempSync(join(tmpdir(), "ledger-"));',
|
|
76
|
+
'execFileSync("git", ["init", "-q"], { cwd: dir }); execFileSync("git", ["stash"], { cwd: dir });',
|
|
77
|
+
'git show HEAD:src/a.ts',
|
|
78
|
+
"const env = { TMPDIR: scratch, PATH: [dirname(process.execPath)].join(':') };",
|
|
79
|
+
"git log -1 --format='%h' 53d62fbf3",
|
|
80
|
+
'assert.equal(hash("deadbeef"), "cafebabe");',
|
|
81
|
+
"assert.equal(env.TMPDIR === process.cwd(), false);",
|
|
82
|
+
]) {
|
|
83
|
+
assert.equal(deliveredTestViolation(content), undefined, content);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("evaluateDeliveredTestTool blocks write and edit of a test file, never a source file", () => {
|
|
88
|
+
const bad = 'execSync("git show 3f9c2e1:src/ledger.ts")';
|
|
89
|
+
const write = evaluateDeliveredTestTool("write", { path: "tests/ledger.test.ts", content: bad });
|
|
90
|
+
assert.equal(write?.block, true);
|
|
91
|
+
assert.match(write?.reason ?? "", /tests\/ledger\.test\.ts/);
|
|
92
|
+
assert.match(write?.reason ?? "", /mktemp/);
|
|
93
|
+
assert.equal(evaluateDeliveredTestTool("edit", { path: "tests/ledger.test.ts", edits: [{ oldText: "a", newText: bad }] })?.block, true);
|
|
94
|
+
assert.equal(evaluateDeliveredTestTool("edit", { path: "tests/ledger.test.ts", oldText: "a", newText: bad })?.block, true);
|
|
95
|
+
assert.equal(evaluateDeliveredTestTool("write", { path: "scripts/diff-check.sh", content: bad }), undefined);
|
|
96
|
+
assert.equal(evaluateDeliveredTestTool("read", { path: "tests/ledger.test.ts" }), undefined);
|
|
97
|
+
assert.equal(evaluateDeliveredTestTool("write", { path: "tests/ledger.test.ts", content: "assert.ok(true);" }), undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("absolute paths are judged relative to the session cwd, so a project under a test/ directory is not a test", () => {
|
|
101
|
+
const bad = 'execSync("git show 3f9c2e1:src/ledger.ts")';
|
|
102
|
+
assert.equal(evaluateDeliveredTestTool("write", { path: "/home/u/test/app/src/release.ts", content: bad }, "/home/u/test/app"), undefined);
|
|
103
|
+
assert.equal(evaluateDeliveredTestTool("write", { path: "/home/u/test/app/tests/a.test.ts", content: bad }, "/home/u/test/app")?.block, true);
|
|
104
|
+
assert.equal(evaluateDeliveredTestTool("write", { path: "/elsewhere/tests/a.ts", content: bad }, "/home/u/test/app")?.block, true, "outside the cwd the path is judged whole");
|
|
105
|
+
});
|