gentle-pi 3.1.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/assets/orchestrator-delegation.md +18 -11
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +15 -5
  4. package/docs/readme-reference.md +43 -18
  5. package/docs/review-integration.md +15 -6
  6. package/extensions/gentle-agents.ts +22 -1
  7. package/extensions/gentle-ai.ts +103 -143
  8. package/extensions/gentle-shell.ts +29 -7
  9. package/lib/background-subagents-policy.ts +148 -0
  10. package/lib/model-routing-authority.ts +1 -1
  11. package/lib/native-review-cli.ts +18 -0
  12. package/lib/opaque-pi-reviewer-adapter.ts +130 -10
  13. package/lib/review-candidate-view.ts +27 -6
  14. package/lib/review-host-relay.ts +95 -2
  15. package/lib/shell-bar.ts +63 -9
  16. package/lib/shell-usage.ts +226 -10
  17. package/package.json +2 -1
  18. package/runtime/native-review-cli.mjs +18 -0
  19. package/scripts/gentle-ai-installer.mjs +10 -10
  20. package/scripts/mirror-odd-routing.mjs +242 -0
  21. package/scripts/verify-package-files.mjs +3 -3
  22. package/skills/chained-pr/SKILL.md +2 -1
  23. package/skills/work-unit-commits/SKILL.md +9 -0
  24. package/tests/background-subagents-default-mode.test.ts +105 -0
  25. package/tests/gentle-agents.test.ts +14 -1
  26. package/tests/gentle-ai-binary.test.ts +1 -1
  27. package/tests/gentle-ai-installer.test.ts +47 -47
  28. package/tests/gentle-shell.test.ts +109 -3
  29. package/tests/native-review-capability-contract.test.ts +27 -1
  30. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  31. package/tests/odd-routing-contract.test.ts +101 -2
  32. package/tests/opaque-pi-reviewer-adapter.test.ts +153 -9
  33. package/tests/package-manifest.test.ts +6 -6
  34. package/tests/review-base-ref-hint.test.ts +39 -0
  35. package/tests/review-candidate-view.test.ts +55 -0
  36. package/tests/review-controller-native-routing.test.ts +60 -1
  37. package/tests/review-host-relay.test.ts +83 -14
  38. package/tests/review-relay-transport-agent.test.ts +86 -1
  39. package/tests/shell-bar.test.ts +153 -3
  40. package/tests/shell-usage-view.test.ts +3 -2
  41. package/tests/shell-usage.test.ts +254 -6
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
8
8
  import type { TUI } from "@earendil-works/pi-tui";
9
- import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
9
+ import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
10
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
11
  import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
12
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
@@ -715,9 +715,9 @@ const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { c
715
715
  const USAGE_PAYLOAD = { plan_type: "pro", rate_limit: { primary_window: { used_percent: 40, limit_window_seconds: 604_800, reset_at: 1_788_777_491 } } };
716
716
 
717
717
  function fakeFetch(payload: unknown = USAGE_PAYLOAD, ok = true) {
718
- const calls: Array<{ url: string; headers: Record<string, string> }> = [];
718
+ const calls: Array<{ url: string; headers: Record<string, string>; init: RequestInit }> = [];
719
719
  const fetchFn = (async (url: string | URL, init?: RequestInit) => {
720
- calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
720
+ calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string>, init: init ?? {} });
721
721
  return { ok, json: async () => payload } as Response;
722
722
  }) as typeof fetch;
723
723
  return { fetchFn, calls };
@@ -738,6 +738,94 @@ test("fetchCodexUsage sends the token and account id and parses the payload", as
738
738
  assert.equal(await fetchCodexUsage(undefined, plain.fetchFn, 0), undefined);
739
739
  });
740
740
 
741
+ const NAN_QUOTA_PAYLOAD = {
742
+ periodEnd: "2026-10-01T00:00:00.000Z",
743
+ models: [
744
+ {
745
+ model: "glm5.3",
746
+ cap: 3_000_000_000,
747
+ fullCap: 3_000_000_000,
748
+ tokensUsed: 820_000_000,
749
+ windowHours: 4,
750
+ windowTokens: 400_000_000,
751
+ windowTokensUsed: 120_000_000,
752
+ windowResetsAt: 1_788_620_161,
753
+ },
754
+ ],
755
+ };
756
+
757
+ test("fetchNanUsage sends the key to the fixed quota origin and never follows a redirect", async () => {
758
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
759
+ const usage = await fetchNanUsage("sk-nan-secret", fetchFn, 1_788_600_000_000);
760
+ assert.equal(usage?.provider, "nan");
761
+ assert.equal(usage?.limits[0].name, "glm5.3");
762
+ assert.equal(calls.length, 1);
763
+ assert.equal(calls[0].url, "https://cloud-api.nan.builders/api/usage/quota");
764
+ assert.equal(calls[0].headers.Authorization, "Bearer sk-nan-secret");
765
+ assert.equal(calls[0].init.redirect, "error", "a redirect would forward the bearer to another origin");
766
+ assert.equal(calls[0].init.cache, "no-store");
767
+ assert.equal(JSON.stringify(usage).includes("sk-nan-secret"), false);
768
+ });
769
+
770
+ test("fetchNanUsage degrades to no snapshot without ever throwing", async () => {
771
+ const noKey = fakeFetch(NAN_QUOTA_PAYLOAD);
772
+ assert.equal(await fetchNanUsage(undefined, noKey.fetchFn, 0), undefined);
773
+ assert.equal(await fetchNanUsage("", noKey.fetchFn, 0), undefined);
774
+ assert.equal(noKey.calls.length, 0, "no key, no request");
775
+
776
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch(NAN_QUOTA_PAYLOAD, false).fetchFn, 0), undefined, "a non-OK response is not a snapshot");
777
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch({ models: [] }).fetchFn, 0), undefined, "an empty quota is not a snapshot");
778
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch({ models: [{ model: "glm5.3", cap: 0, tokensUsed: 0 }] }).fetchFn, 0), undefined);
779
+ const refused = (async () => {
780
+ throw new TypeError("redirect mode is not supported");
781
+ }) as unknown as typeof fetch;
782
+ assert.equal(await fetchNanUsage("sk-nan-secret", refused, 0), undefined, "a refused redirect degrades silently");
783
+ });
784
+
785
+ test("gentleShell fetches NaN quota on session start and shows it in the bar", async () => {
786
+ const { pi, handlers } = fakePi();
787
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
788
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
789
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
790
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
791
+ await fire(handlers, "session_start", ctx);
792
+ await new Promise((resolve) => setTimeout(resolve, 0));
793
+ assert.equal(calls.length, 1);
794
+ assert.equal(calls[0].url, "https://cloud-api.nan.builders/api/usage/quota");
795
+ // The fixture's session model holds no NaN allowance of its own, so the
796
+ // account names the meter: a NaN payload of one model is still per-model data.
797
+ assert.match(renderFooter(ui), /\$0\.000 sub ⟡ nan total ▰+▱+ 27%/);
798
+
799
+ await fire(handlers, "agent_end", ctx);
800
+ await new Promise((resolve) => setTimeout(resolve, 0));
801
+ assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
802
+ });
803
+
804
+ test("a failed NaN refresh keeps the last valid snapshot", async () => {
805
+ const { pi, handlers } = fakePi();
806
+ const calls: string[] = [];
807
+ let fail = false;
808
+ const fetchFn = (async (url: string | URL) => {
809
+ calls.push(String(url));
810
+ if (fail) throw new TypeError("network down");
811
+ return { ok: true, json: async () => NAN_QUOTA_PAYLOAD } as Response;
812
+ }) as typeof fetch;
813
+ let now = 1_788_600_000_000;
814
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => now });
815
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
816
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
817
+ await fire(handlers, "session_start", ctx);
818
+ await new Promise((resolve) => setTimeout(resolve, 0));
819
+ assert.match(renderFooter(ui), /nan total ▰+▱+/);
820
+
821
+ fail = true;
822
+ now += 6 * 60_000;
823
+ await fire(handlers, "agent_end", ctx);
824
+ await new Promise((resolve) => setTimeout(resolve, 0));
825
+ assert.equal(calls.length, 2, "the refresh window elapsed, so the retry was attempted");
826
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "a failed refresh cannot erase the last valid snapshot");
827
+ });
828
+
741
829
  test("gentleShell fetches Codex usage on session start and shows it in the bar", async () => {
742
830
  const { pi, handlers } = fakePi();
743
831
  const { fetchFn, calls } = fakeFetch();
@@ -753,6 +841,24 @@ test("gentleShell fetches Codex usage on session start and shows it in the bar",
753
841
  assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
754
842
  });
755
843
 
844
+ test("a provider switch refreshes the new provider inside the same window", async () => {
845
+ const { pi, handlers } = fakePi();
846
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
847
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
848
+ const { ctx } = fakeContext({ token: JWT });
849
+ await fire(handlers, "session_start", ctx);
850
+ await new Promise((resolve) => setTimeout(resolve, 0));
851
+ assert.equal(calls.length, 1);
852
+
853
+ // The 5-minute rule is per provider: the timestamp one provider set cannot
854
+ // leave the next one waiting for a fetch it never made.
855
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
856
+ await fire(handlers, "agent_end", ctx);
857
+ await new Promise((resolve) => setTimeout(resolve, 0));
858
+ assert.equal(calls.length, 2, "a provider switch is a reason to fetch, not to wait");
859
+ assert.equal(calls[1].url, "https://cloud-api.nan.builders/api/usage/quota");
860
+ });
861
+
756
862
  test("gentleShell records SSE rate-limit headers from provider responses", async () => {
757
863
  const { pi, handlers } = fakePi();
758
864
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
@@ -275,11 +275,37 @@ test("3.0.1 repeats 3.0.0 because the negotiated lane Pi consumes is unchanged",
275
275
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.0.0"] as Record<string, boolean>);
276
276
  });
277
277
 
278
+ test("3.1.0 repeats 3.0.1 because the negotiated lane Pi consumes is unchanged", () => {
279
+ // v3.1.0 changed the ODD orchestrator contract only (gentle-ai #4714).
280
+ // Diffing contracts/review-integration/v2 and
281
+ // contracts/review-provider-contract between the v3.0.2 and v3.1.0 tags
282
+ // in the gentle-ai source tree showed zero byte changes (provider contract
283
+ // stays 1.2.0), so this row repeats 3.0.1. riskEvidence and hint remain
284
+ // dark because neither is proven to reach Pi's negotiated START path.
285
+ const contract = NATIVE_CLI_CONTRACTS["3.1.0"] as Record<string, boolean>;
286
+ assert.equal(contract.riskEvidence, false);
287
+ assert.equal(contract.hint, false);
288
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.0.1"] as Record<string, boolean>);
289
+ });
290
+
291
+ test("3.2.1 repeats 3.1.0 because the negotiated lane Pi consumes is unchanged", () => {
292
+ // v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
293
+ // follow-up). Diffing contracts/review-integration/v2 and
294
+ // contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
295
+ // in the gentle-ai source tree showed zero byte changes (provider contract
296
+ // stays 1.2.0), so this row repeats 3.1.0. riskEvidence and hint remain
297
+ // dark because neither is proven to reach Pi's negotiated START path.
298
+ const contract = NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>;
299
+ assert.equal(contract.riskEvidence, false);
300
+ assert.equal(contract.hint, false);
301
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.1.0"] as Record<string, boolean>);
302
+ });
303
+
278
304
  test("no shipped version key was added beyond the pin bump", () => {
279
305
  // Rows are promises to consumers, so a new key only ever appears in a
280
306
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
281
307
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
282
308
  // so they get no row: a row asserts ground truth measured against a binary
283
309
  // Pi actually ran, and the table only has to be ascending, not gapless.
284
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1"]);
310
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1"]);
285
311
  });
@@ -0,0 +1,293 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import {
6
+ ODD_ROUTING_BLOCK_PREFIX,
7
+ ODD_ROUTING_FIXTURE_RELATIVE,
8
+ ODD_ROUTING_SOURCE_PATH,
9
+ ODD_ROUTING_SOURCE_REPO,
10
+ assertCleanGentleAiCheckout,
11
+ parseOddRoutingFixture,
12
+ renderOddRoutingFixture,
13
+ resolveOddRoutingProvenance,
14
+ sha256Hex,
15
+ } from "../scripts/mirror-odd-routing.mjs";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // ODD routing drift ratchet (gentle-pi#1147 follow-up)
19
+ //
20
+ // gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
21
+ // `internal/components/agentguidance/routing.go` (RenderRouting). There is no
22
+ // automated sync, so a canonical change leaves the pi mirror stale with nothing
23
+ // catching it. This ratchet vendored-snapshots the canonical block into
24
+ // `fixtures/odd-routing-canonical.md` (regenerated via `npm run
25
+ // mirror:odd-routing`) and fails when a mandatory-delegation clause is dropped
26
+ // either from the canonical fixture or from a pi mirror surface.
27
+ //
28
+ // Regeneration writes ONLY the fixture; this ratchet never auto-rewrites mirror
29
+ // assets, so drift stays visible as a deliberate diff.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const REPO_ROOT = join(import.meta.dirname, "..");
33
+ const FIXTURE_PATH = join(REPO_ROOT, ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
34
+ const DELEGATION_PATH = join(REPO_ROOT, "assets", "orchestrator-delegation.md");
35
+ const CORE_PATH = join(REPO_ROOT, "assets", "orchestrator.md");
36
+ const EXTENSION_PATH = join(REPO_ROOT, "extensions", "gentle-ai.ts");
37
+
38
+ const readRepo = (absolutePath: string): string => readFileSync(absolutePath, "utf8");
39
+
40
+ // A mandatory-delegation semantic anchor: the clause the canonical block must
41
+ // still carry, plus the wording each pi mirror surface is expected to carry.
42
+ // A canonical change that drops `canonical` is caught here on regeneration
43
+ // because the fixture no longer carries it.
44
+ interface RoutingAnchor {
45
+ label: string;
46
+ canonical: string;
47
+ mirrors: ReadonlyArray<{ surface: string; includes: string }>;
48
+ }
49
+
50
+ const DELEGATION = "assets/orchestrator-delegation.md";
51
+ const CORE = "assets/orchestrator.md";
52
+ const EXTENSION = "extensions/gentle-ai.ts";
53
+
54
+ const ANCHORS: readonly RoutingAnchor[] = [
55
+ {
56
+ label: "mandatory delegation triggers heading",
57
+ canonical: "### Mandatory Delegation Triggers",
58
+ mirrors: [
59
+ { surface: DELEGATION, includes: "#### Mandatory Delegation Triggers" },
60
+ { surface: CORE, includes: "Mandatory Delegation Triggers" },
61
+ ],
62
+ },
63
+ {
64
+ label: "triggers are mandatory, not advisory",
65
+ canonical: "These triggers are mandatory, not advisory.",
66
+ mirrors: [
67
+ { surface: DELEGATION, includes: "These triggers are mandatory, not advisory." },
68
+ { surface: EXTENSION, includes: "These triggers are mandatory, not advisory" },
69
+ ],
70
+ },
71
+ {
72
+ label: "stop and delegate through the runtime's subagent mechanism",
73
+ canonical: "stop and delegate through the runtime's subagent mechanism before continuing",
74
+ mirrors: [
75
+ { surface: DELEGATION, includes: "stop and delegate through the runtime's subagent mechanism before continuing" },
76
+ ],
77
+ },
78
+ {
79
+ label: "executing past a fired trigger is a routing defect",
80
+ canonical: "executing past a fired trigger inline is a routing defect even if the work succeeds",
81
+ mirrors: [
82
+ { surface: DELEGATION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
83
+ { surface: EXTENSION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
84
+ ],
85
+ },
86
+ {
87
+ label: "mapping trigger at 4 or more files",
88
+ canonical: "**Mapping trigger:** when understanding the work requires 4 or more files",
89
+ mirrors: [
90
+ { surface: DELEGATION, includes: "**Mapping trigger (4-file rule):** when understanding the work requires 4 or more files" },
91
+ { surface: CORE, includes: "**4-file rule** — 4+ files to understand" },
92
+ ],
93
+ },
94
+ {
95
+ label: "writer trigger at 2 or more non-trivial files",
96
+ canonical: "**Writer trigger:** when implementation touches 2 or more non-trivial files",
97
+ mirrors: [
98
+ { surface: DELEGATION, includes: "**Writer trigger (Multi-file write rule):** when implementation touches 2 or more non-trivial files" },
99
+ { surface: CORE, includes: "**Multi-file write rule** — 2+ non-trivial files touched" },
100
+ ],
101
+ },
102
+ {
103
+ label: "preparation trigger",
104
+ canonical: "**Preparation trigger:**",
105
+ mirrors: [{ surface: DELEGATION, includes: "**Preparation trigger:**" }],
106
+ },
107
+ {
108
+ label: "long-session backstop",
109
+ canonical: "**Long-session backstop:**",
110
+ mirrors: [
111
+ { surface: DELEGATION, includes: "**Long-session backstop (Long-session rule):**" },
112
+ { surface: CORE, includes: "**Long-session rule** — ~20 tool calls, 5 exploratory reads, or 2 non-mechanical edits without delegation" },
113
+ ],
114
+ },
115
+ {
116
+ label: "route declaration records the chosen route per task",
117
+ canonical: "**Route declaration:**",
118
+ mirrors: [
119
+ { surface: DELEGATION, includes: "record the chosen route per task" },
120
+ ],
121
+ },
122
+ {
123
+ label: "triggers never select SDD",
124
+ canonical: "These triggers never select SDD and never create SDD artifacts",
125
+ mirrors: [
126
+ { surface: DELEGATION, includes: "These triggers never select SDD and never create SDD artifacts" },
127
+ ],
128
+ },
129
+ {
130
+ label: "ODD step 6 honors its mandatory delegation triggers",
131
+ canonical: "honoring its mandatory delegation triggers",
132
+ mirrors: [{ surface: EXTENSION, includes: "honoring its mandatory delegation triggers" }],
133
+ },
134
+ ];
135
+
136
+ // Condensed trigger rows that live only in the always-on core prompt; the
137
+ // canonical RenderRouting block does not carry the incident or verification
138
+ // rows, so they are mirror-only and not fixture-derived anchors.
139
+ const CORE_ONLY_TRIGGERS = [
140
+ "**Incident rule** — diagnose wrong cwd/worktree/git/tooling incidents separately",
141
+ "**Verification rule** — executing/delegating verification commands",
142
+ ] as const;
143
+
144
+ function fixtureBody(): string {
145
+ assert.ok(
146
+ existsSync(FIXTURE_PATH),
147
+ `missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
148
+ );
149
+ return parseOddRoutingFixture(readRepo(FIXTURE_PATH)).body;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // 1 — Fixture provenance readback
154
+ // ---------------------------------------------------------------------------
155
+
156
+ test("the canonical routing fixture exists with managed provenance (source repo, commit, digest)", () => {
157
+ assert.ok(
158
+ existsSync(FIXTURE_PATH),
159
+ `missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
160
+ );
161
+ const { header } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
162
+ assert.equal(header.source_repo, ODD_ROUTING_SOURCE_REPO);
163
+ assert.equal(header.source_path, ODD_ROUTING_SOURCE_PATH);
164
+ assert.match(header.source_commit ?? "", /^[0-9a-f]{40}$/, "fixture must record the gentle-ai source commit");
165
+ assert.match(header.generated_at ?? "", /^\d{4}-\d{2}-\d{2}T/, "fixture must record a generation date");
166
+ assert.match(header.block_sha256 ?? "", /^[0-9a-f]{64}$/, "fixture must record the block body digest");
167
+ });
168
+
169
+ test("the fixture digest matches its block body byte-for-byte", () => {
170
+ const { header, body } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
171
+ assert.equal(header.block_sha256, sha256Hex(body), "the fixture body was hand-edited without regenerating its digest");
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 2 — Canonical fixture carries every mandatory anchor
176
+ // ---------------------------------------------------------------------------
177
+
178
+ test("the canonical fixture carries every mandatory-delegation anchor", () => {
179
+ const body = fixtureBody();
180
+ for (const anchor of ANCHORS) {
181
+ assert.ok(body.includes(anchor.canonical), `canonical fixture dropped anchor: ${anchor.label}`);
182
+ }
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // 3 — Every pi mirror surface carries the mapped anchor
187
+ // ---------------------------------------------------------------------------
188
+
189
+ test("the pi mirror surfaces carry every mapped mandatory-delegation anchor", () => {
190
+ const surfaces: Record<string, string> = {
191
+ [DELEGATION]: readRepo(DELEGATION_PATH),
192
+ [CORE]: readRepo(CORE_PATH),
193
+ [EXTENSION]: readRepo(EXTENSION_PATH),
194
+ };
195
+ for (const anchor of ANCHORS) {
196
+ for (const mirror of anchor.mirrors) {
197
+ const text = surfaces[mirror.surface];
198
+ assert.ok(text !== undefined, `unknown mirror surface ${mirror.surface}`);
199
+ assert.ok(
200
+ text.includes(mirror.includes),
201
+ `${mirror.surface} is missing the mirror of "${anchor.label}": ${JSON.stringify(mirror.includes)}`,
202
+ );
203
+ }
204
+ }
205
+ });
206
+
207
+ test("the always-on core prompt carries the condensed incident and verification trigger rows", () => {
208
+ const core = readRepo(CORE_PATH);
209
+ for (const row of CORE_ONLY_TRIGGERS) {
210
+ assert.ok(core.includes(row), `assets/orchestrator.md is missing condensed trigger row: ${JSON.stringify(row)}`);
211
+ }
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // 4 — Regeneration provenance: idempotent and fail-closed
216
+ // ---------------------------------------------------------------------------
217
+
218
+ // The fixture header claims provenance from the source commit, so regeneration
219
+ // must be byte-reproducible: the same gentle-ai commit must not produce a
220
+ // date-only diff. `generated_at` therefore tracks the source commit's committer
221
+ // date instead of wall-clock time.
222
+ test("fixture rendering is idempotent and derives generated_at from the source commit", () => {
223
+ const sourceCommit = "e7729359fd9d6cb691ed2a88e8f72b1372f7c92e";
224
+ const committerDate = "2026-09-18T13:49:03+02:00";
225
+ const fakeGit = (args: readonly string[]): string => {
226
+ switch (args[0]) {
227
+ case "status":
228
+ return "";
229
+ case "rev-parse":
230
+ return `${sourceCommit}\n`;
231
+ case "show":
232
+ return `${committerDate}\n`;
233
+ default:
234
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
235
+ }
236
+ };
237
+ const block = `${ODD_ROUTING_BLOCK_PREFIX}\n\n- a canonical clause\n`;
238
+ const first = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
239
+ const second = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
240
+ assert.equal(first, second, "rendering the same source commit twice must be byte-identical");
241
+ const { header } = parseOddRoutingFixture(first);
242
+ assert.equal(header.source_commit, sourceCommit);
243
+ assert.equal(
244
+ header.generated_at,
245
+ committerDate,
246
+ "generated_at must track the source commit's committer date, not wall-clock time",
247
+ );
248
+ });
249
+
250
+ // A dirty checkout would render uncommitted content under the committed
251
+ // provenance claim, so the mirror fails closed before rendering and names the
252
+ // tracked paths. Untracked files (the odd/tasks notes) must never block.
253
+ test("the mirror fails closed on a dirty source checkout and names the tracked paths", () => {
254
+ const recordedCalls: string[][] = [];
255
+ const cleanGit = (args: readonly string[]): string => {
256
+ recordedCalls.push([...args]);
257
+ switch (args[0]) {
258
+ case "status":
259
+ return "";
260
+ case "rev-parse":
261
+ return `${"a".repeat(40)}\n`;
262
+ case "show":
263
+ return "2026-09-18T13:49:03+02:00\n";
264
+ default:
265
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
266
+ }
267
+ };
268
+ resolveOddRoutingProvenance("/fake/gentle-ai", cleanGit);
269
+ assert.deepEqual(
270
+ recordedCalls.find((args) => args[0] === "status"),
271
+ ["status", "--porcelain", "--untracked-files=no"],
272
+ "the dirty guard must ignore untracked files",
273
+ );
274
+
275
+ assert.doesNotThrow(() => assertCleanGentleAiCheckout(""));
276
+ const dirtyGit = (args: readonly string[]): string => {
277
+ if (args[0] === "status") {
278
+ return " M internal/components/agentguidance/routing.go\n M scripts/other.mjs\n";
279
+ }
280
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
281
+ };
282
+ assert.throws(
283
+ () => resolveOddRoutingProvenance("/fake/gentle-ai", dirtyGit),
284
+ (error: unknown) => {
285
+ assert.ok(error instanceof Error);
286
+ assert.match(
287
+ error.message,
288
+ /gentle-ai checkout is dirty; provenance would be unverifiable: internal\/components\/agentguidance\/routing\.go, scripts\/other\.mjs\. Commit or stash first\./,
289
+ );
290
+ return true;
291
+ },
292
+ );
293
+ });
@@ -94,8 +94,8 @@ test("assumption challenge and task checks do not activate or duplicate native r
94
94
  "high-consequence unproven premise",
95
95
  "Deterministic failures need fixes",
96
96
  "native RDD refuter",
97
- "functional checks per task, not an RDD cycle per TODO",
98
- "deliverable candidate boundary",
97
+ "functional checks per task",
98
+ "native review runs at that work-unit commit or PR slice boundary",
99
99
  "native candidate risk assessment",
100
100
  "gentle_review` with `{\"operation\":\"assess\"}",
101
101
  "Passive/low",
@@ -107,6 +107,48 @@ test("assumption challenge and task checks do not activate or duplicate native r
107
107
  ]);
108
108
  });
109
109
 
110
+ test("ODD closes each task with a work-unit commit and reviews the commit or PR slice", () => {
111
+ containsAll(wrapper, [
112
+ "Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch",
113
+ "with tests and docs alongside the behavior, using a Conventional Commit message",
114
+ "record the commit identity in the feature document as evidence",
115
+ "Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions",
116
+ "The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch",
117
+ "close each task with a work-unit commit",
118
+ ]);
119
+ containsAll(delegation, [
120
+ 'after each work-unit commit, assess it with that same call and `{"baseRef":"<last reviewed boundary>","committedOnly":true}`',
121
+ "Passive/low: silent structural checks, no reviewer or consent ceremony, and the boundary advances",
122
+ 'start native review on it right away at that base with `gentle_review` `{"operation":"start"}`',
123
+ "Medium: defer to the PR slice",
124
+ "bounded by the delivery budget of about 400 authored changed lines",
125
+ "The first boundary is the branch point, and every reviewed boundary becomes the next base",
126
+ "Record per task the assessed tier and outcome: granted, declined, passive, deferred to slice, or unavailable",
127
+ "Delivery follows work units",
128
+ "forecast authored changed lines (additions plus deletions, generated files excluded) from the task list",
129
+ "keep a running count from work-unit commits",
130
+ "`ask-on-risk` (default), `auto-chain`, `single-pr`, or `exception-ok`",
131
+ "apply the chosen strategy before the next commit",
132
+ "`ask-on-risk` asks once for the chain strategy, `stacked-to-main` or `feature-branch-chain`",
133
+ "`auto-chain` asks only for a missing chain strategy and slices automatically",
134
+ "Cache both choices, and record slice boundaries",
135
+ "Resolve the `work-unit-commits` and `chained-pr` skills by registry name",
136
+ "never hardcode their paths",
137
+ "The delivery budget below reads the accumulated branch, not this per-task heuristic",
138
+ ]);
139
+ const docs = read("docs/readme-reference.md");
140
+ containsAll(docs, [
141
+ "Every task closes with at least one work-unit commit on the feature branch (branch first when on the default branch)",
142
+ "the feature document records the commit identity as evidence",
143
+ "The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch",
144
+ "**Delivery:**",
145
+ "Close task with a work-unit commit",
146
+ "RDD enabled at work-unit commit boundary",
147
+ "Running authored lines over 400",
148
+ "Apply delivery strategy: chained PR slice",
149
+ ]);
150
+ });
151
+
110
152
  test("user documentation shows recovery and candidate-level consent without claiming model proof", () => {
111
153
  const docs = read("docs/readme-reference.md");
112
154
  containsAll(docs, [
@@ -162,6 +204,63 @@ test("ODD forwards configured TDD without equating test presence with enablement
162
204
  assert.doesNotMatch(wrapper, /If tests exist, use strict TDD/);
163
205
  });
164
206
 
207
+ test("mandatory delegation triggers are behavioral in the lazy canonical port and the always-on ODD step", () => {
208
+ for (const clause of [
209
+ "These triggers are mandatory, not advisory.",
210
+ "stop and delegate through the runtime's subagent mechanism before continuing",
211
+ "executing past a fired trigger inline is a routing defect even if the work succeeds",
212
+ "**Mapping trigger",
213
+ "**Writer trigger",
214
+ "**Preparation trigger:**",
215
+ "**Long-session backstop",
216
+ "pause and delegate the next bounded unit of work",
217
+ "**Route declaration:**",
218
+ "record the chosen route per task",
219
+ "so skipped delegation is observable instead of silent",
220
+ "These triggers never select SDD and never create SDD artifacts",
221
+ ]) {
222
+ assert.ok(delegation.includes(clause), `lazy canonical port is missing mandatory delegation clause: ${clause}`);
223
+ }
224
+ assert.ok(
225
+ wrapper.includes("honoring its mandatory delegation triggers"),
226
+ "the always-on ODD step 6 must honor its mandatory delegation triggers",
227
+ );
228
+ assert.ok(
229
+ wrapper.includes("executing past a fired trigger inline is a routing defect"),
230
+ "the always-on ODD protocol must state that skipping a fired trigger is a routing defect",
231
+ );
232
+ });
233
+
234
+ test("core and lazy canonical trigger lists agree in numbering and semantics", () => {
235
+ for (const entry of [
236
+ "1. **4-file rule**",
237
+ "2. **Multi-file write rule**",
238
+ "3. **Incident rule**",
239
+ "4. **Long-session rule**",
240
+ "5. **Verification rule**",
241
+ ]) {
242
+ assert.ok(core.includes(entry), `always-on core trigger list is missing: ${entry}`);
243
+ }
244
+ for (const entry of [
245
+ "1. **Mapping trigger (4-file rule):**",
246
+ "2. **Writer trigger (Multi-file write rule):**",
247
+ "3. **Incident rule:**",
248
+ "4. **Long-session backstop (Long-session rule):**",
249
+ "5. **Verification rule**",
250
+ ]) {
251
+ assert.ok(delegation.includes(entry), `lazy canonical trigger list is missing: ${entry}`);
252
+ }
253
+ for (const stale of [
254
+ "**Bounded read rule**",
255
+ "**Write rule**",
256
+ "**Context rule**",
257
+ "**Per-action rule**",
258
+ "**Optional SDD rule**",
259
+ ]) {
260
+ assert.ok(!delegation.includes(stale), `reconciled canonical list retains stale trigger framing: ${stale}`);
261
+ }
262
+ });
263
+
165
264
  test("ODD protocol is always-on in the rendered system prompt and runs by default", () => {
166
265
  const orderedClauses = [
167
266
  "Default workflow: Organic Driven Development (MANDATORY)",