gentle-pi 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -0,0 +1,266 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ OPAQUE_PI_REVIEWER_ARGV,
9
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
10
+ OpaquePiReviewerTransportError,
11
+ resolvePiLaunch,
12
+ runOpaquePiReviewer,
13
+ } from "../lib/opaque-pi-reviewer-adapter.ts";
14
+
15
+ const FAKE_PI = `#!/usr/bin/env node
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+ const argv = process.argv.slice(2);
19
+ const chunks = [];
20
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
21
+ process.stdin.on("end", () => {
22
+ const stdin = Buffer.concat(chunks);
23
+ if (process.env.OPAQUE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.OPAQUE_PI_STDIN_CAPTURE, stdin);
24
+ const mode = process.env.OPAQUE_PI_MODE || "ok";
25
+ const log = {
26
+ argv,
27
+ cwd: process.cwd(),
28
+ entries_before: fs.readdirSync(process.cwd()),
29
+ entries_after: fs.readdirSync(process.cwd()),
30
+ };
31
+ if (process.env.OPAQUE_PI_LOG) fs.appendFileSync(process.env.OPAQUE_PI_LOG, JSON.stringify(log) + "\\n");
32
+ if (mode === "break-cleanup" || process.env.OPAQUE_PI_BREAK_CLEANUP === "true") {
33
+ fs.chmodSync(path.dirname(process.cwd()), 0o500);
34
+ }
35
+ if (mode === "empty") process.exit(0);
36
+ if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
37
+ if (mode === "nonzero") { process.stderr.write("opaque pi failed\\n"); process.exit(7); }
38
+ process.stdout.write(Buffer.from(process.env.OPAQUE_PI_OUTPUT_B64 || "", "base64"));
39
+ });
40
+ `;
41
+
42
+ const PROMPT_BYTES = Buffer.concat([
43
+ Buffer.from("opaque prompt\r\n\u0000", "utf8"),
44
+ Buffer.from([0x01, 0xff, 0xfe, 0x00]),
45
+ ]);
46
+ const OUTPUT_BYTES = Buffer.concat([
47
+ Buffer.from("opaque output\r\n\u0000", "utf8"),
48
+ Buffer.from([0x07, 0xff, 0xfe, 0x00]),
49
+ ]);
50
+
51
+ interface OpaqueHarness {
52
+ directory: string;
53
+ pi: string;
54
+ logPath: string;
55
+ stdinCapturePath: string;
56
+ environment: NodeJS.ProcessEnv;
57
+ }
58
+
59
+ interface OpaquePiLog {
60
+ argv: string[];
61
+ cwd: string;
62
+ entries_before: string[];
63
+ entries_after: string[];
64
+ }
65
+
66
+ function harness(t: test.TestContext, overrides: Record<string, string> = {}): OpaqueHarness {
67
+ const directory = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-"));
68
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
69
+ const pi = join(directory, "pi");
70
+ writeFileSync(pi, FAKE_PI);
71
+ chmodSync(pi, 0o755);
72
+ const logPath = join(directory, "pi.log");
73
+ const stdinCapturePath = join(directory, "stdin.bin");
74
+ return {
75
+ directory,
76
+ pi,
77
+ logPath,
78
+ stdinCapturePath,
79
+ environment: {
80
+ ...process.env,
81
+ OPAQUE_PI_LOG: logPath,
82
+ OPAQUE_PI_STDIN_CAPTURE: stdinCapturePath,
83
+ OPAQUE_PI_OUTPUT_B64: OUTPUT_BYTES.toString("base64"),
84
+ ...overrides,
85
+ },
86
+ };
87
+ }
88
+
89
+ function readLog(path: string): OpaquePiLog[] {
90
+ if (!existsSync(path)) return [];
91
+ return readFileSync(path, "utf8")
92
+ .split("\n")
93
+ .filter((line) => line.length > 0)
94
+ .map((line) => JSON.parse(line) as OpaquePiLog);
95
+ }
96
+
97
+ async function rejectsWithTransportError(
98
+ promise: Promise<unknown>,
99
+ kind: (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE],
100
+ ): Promise<OpaquePiReviewerTransportError> {
101
+ let caught: OpaquePiReviewerTransportError | undefined;
102
+ await assert.rejects(promise, (error: unknown) => {
103
+ assert.ok(error instanceof OpaquePiReviewerTransportError, `expected OpaquePiReviewerTransportError, received ${String(error)}`);
104
+ caught = error;
105
+ return error.kind === kind;
106
+ });
107
+ return caught!;
108
+ }
109
+
110
+ test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim through the fixed Pi subprocess", async (t) => {
111
+ const fixture = harness(t);
112
+ const result = await runOpaquePiReviewer(PROMPT_BYTES, {
113
+ piExecutable: fixture.pi,
114
+ environment: fixture.environment,
115
+ timeoutMs: 10_000,
116
+ });
117
+
118
+ assert.equal(result.promptByteLength, PROMPT_BYTES.length);
119
+ assert.equal(result.stdoutByteLength, OUTPUT_BYTES.length);
120
+ assert.deepEqual(result.stdout, OUTPUT_BYTES);
121
+ assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
122
+
123
+ const calls = readLog(fixture.logPath);
124
+ assert.equal(calls.length, 1);
125
+ assert.deepEqual(calls[0]!.argv, [...OPAQUE_PI_REVIEWER_ARGV]);
126
+ assert.deepEqual(calls[0]!.entries_before, []);
127
+ assert.deepEqual(calls[0]!.entries_after, []);
128
+ assert.notEqual(calls[0]!.cwd, process.cwd());
129
+ assert.equal(existsSync(calls[0]!.cwd), false, "the empty scratch directory is removed after success");
130
+ });
131
+
132
+ test("the opaque adapter returns typed transport errors for launch, nonzero, empty, timeout, and cancellation", async (t) => {
133
+ const fixture = harness(t);
134
+ await rejectsWithTransportError(
135
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: join(fixture.directory, "missing-pi"), environment: fixture.environment, timeoutMs: 10_000 }),
136
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED,
137
+ );
138
+
139
+ const nonzero = await rejectsWithTransportError(
140
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "nonzero" }, timeoutMs: 10_000 }),
141
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
142
+ );
143
+ assert.equal(nonzero.exitCode, 7);
144
+ assert.deepEqual(nonzero.stderr, Buffer.from("opaque pi failed\n"));
145
+
146
+ await rejectsWithTransportError(
147
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "empty" }, timeoutMs: 10_000 }),
148
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
149
+ );
150
+
151
+ const timedOut = await rejectsWithTransportError(
152
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang" }, timeoutMs: 300 }),
153
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
154
+ );
155
+ assert.equal(timedOut.timedOut, true);
156
+
157
+ const controller = new AbortController();
158
+ const cancellation = runOpaquePiReviewer(PROMPT_BYTES, {
159
+ piExecutable: fixture.pi,
160
+ environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang" },
161
+ timeoutMs: 10_000,
162
+ signal: controller.signal,
163
+ });
164
+ setTimeout(() => controller.abort(), 100).unref();
165
+ const cancelled = await rejectsWithTransportError(cancellation, OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED);
166
+ assert.equal(cancelled.cancelled, true);
167
+ });
168
+
169
+ test("the opaque adapter leaves no prompt or result file in scratch and reports cleanup failure as transport-only", async (t) => {
170
+ const fixture = harness(t);
171
+ const scratchParent = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-cleanup-"));
172
+ const originalTmpdir = process.env.TMPDIR;
173
+ process.env.TMPDIR = scratchParent;
174
+ try {
175
+ const error = await rejectsWithTransportError(
176
+ runOpaquePiReviewer(PROMPT_BYTES, {
177
+ piExecutable: fixture.pi,
178
+ environment: { ...fixture.environment, OPAQUE_PI_MODE: "break-cleanup" },
179
+ timeoutMs: 10_000,
180
+ }),
181
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CLEANUP_FAILED,
182
+ );
183
+ assert.match(error.message, /scratch directory/i);
184
+ } finally {
185
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
186
+ else process.env.TMPDIR = originalTmpdir;
187
+ chmodSync(scratchParent, 0o700);
188
+ rmSync(scratchParent, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test("the opaque adapter preserves primary nonzero and timeout errors when scratch cleanup also fails", async (t) => {
193
+ const fixture = harness(t);
194
+ const scratchParent = mkdtempSync(join(tmpdir(), "gentle-pi-opaque-reviewer-primary-failure-"));
195
+ const originalTmpdir = process.env.TMPDIR;
196
+ process.env.TMPDIR = scratchParent;
197
+ try {
198
+ const nonzero = await rejectsWithTransportError(
199
+ runOpaquePiReviewer(PROMPT_BYTES, {
200
+ piExecutable: fixture.pi,
201
+ environment: { ...fixture.environment, OPAQUE_PI_MODE: "nonzero", OPAQUE_PI_BREAK_CLEANUP: "true" },
202
+ timeoutMs: 10_000,
203
+ }),
204
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
205
+ );
206
+ assert.equal(nonzero.exitCode, 7);
207
+ assert.deepEqual(nonzero.stderr, Buffer.from("opaque pi failed\n"));
208
+ chmodSync(scratchParent, 0o700);
209
+
210
+ const timedOut = await rejectsWithTransportError(
211
+ runOpaquePiReviewer(PROMPT_BYTES, {
212
+ piExecutable: fixture.pi,
213
+ environment: { ...fixture.environment, OPAQUE_PI_MODE: "hang", OPAQUE_PI_BREAK_CLEANUP: "true" },
214
+ timeoutMs: 300,
215
+ }),
216
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
217
+ );
218
+ assert.equal(timedOut.timedOut, true);
219
+ } finally {
220
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
221
+ else process.env.TMPDIR = originalTmpdir;
222
+ chmodSync(scratchParent, 0o700);
223
+ rmSync(scratchParent, { recursive: true, force: true });
224
+ }
225
+ });
226
+
227
+ test("the opaque adapter has no review lifecycle imports or identifiers", () => {
228
+ const adapterPath = fileURLToPath(new URL("../lib/opaque-pi-reviewer-adapter.ts", import.meta.url));
229
+ const source = readFileSync(adapterPath, "utf8");
230
+ assert.doesNotMatch(source, /review-integration|gentle-ai|materialize|submit/i);
231
+ for (const identifier of ["lineage", "target", "revision", "receipt", "lens", "order", "subject", "schema", "capture", "submission", "status", "model", "provider", "profile"]) {
232
+ assert.doesNotMatch(source, new RegExp(`\\b${identifier}\\b`, "i"), `adapter must not contain lifecycle identifier ${identifier}`);
233
+ }
234
+ });
235
+
236
+ // #468 / #519: on Windows a bare `pi` resolves to pi.cmd, pi.ps1, or a POSIX
237
+ // shim, none of which Node can spawn with shell:false (EINVAL or ENOENT). The
238
+ // relay already runs inside Pi, so the host's own JavaScript entry is spawned
239
+ // through process.execPath instead. Other platforms keep today's exact shape.
240
+ test("the Pi launch shape spawns the host entry through process.execPath on win32 and stays byte-identical elsewhere", () => {
241
+ const host = {
242
+ execPath: "C:\\Program Files\\nodejs\\node.exe",
243
+ entry: "C:\\Users\\dev\\AppData\\Local\\pnpm\\global\\5\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js",
244
+ };
245
+ const windows = resolvePiLaunch(undefined, "win32", host);
246
+ assert.deepEqual(windows, { file: host.execPath, arguments: [host.entry, ...OPAQUE_PI_REVIEWER_ARGV] });
247
+ assert.notEqual(windows.file, "pi");
248
+ assert.equal(windows.arguments.includes("pi"), false);
249
+ for (const platform of ["linux", "darwin", "freebsd"] as const) {
250
+ assert.deepEqual(resolvePiLaunch(undefined, platform, host), { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] }, platform);
251
+ assert.deepEqual(resolvePiLaunch("/opt/pi/bin/pi", platform, host), { file: "/opt/pi/bin/pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] }, platform);
252
+ }
253
+ assert.deepEqual(resolvePiLaunch("C:\\tools\\pi.exe", "win32", host), { file: "C:\\tools\\pi.exe", arguments: [...OPAQUE_PI_REVIEWER_ARGV] });
254
+ for (const entry of [undefined, "", "cli.js", "dist/bundle/cli.js"]) {
255
+ assert.throws(() => resolvePiLaunch(undefined, "win32", { execPath: host.execPath, entry }), /host entry/, `entry ${JSON.stringify(entry)} must fail closed instead of spawning a bare pi`);
256
+ }
257
+ });
258
+
259
+ test("the default Pi launch resolves from the running host process", () => {
260
+ const launch = resolvePiLaunch(undefined);
261
+ if (process.platform === "win32") {
262
+ assert.deepEqual(launch, { file: process.execPath, arguments: [process.argv[1]!, ...OPAQUE_PI_REVIEWER_ARGV] });
263
+ } else {
264
+ assert.deepEqual(launch, { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] });
265
+ }
266
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test, { after } from "node:test";
@@ -9,8 +9,8 @@ import test, { after } from "node:test";
9
9
  // orchestrator-lazy-diet migration tests
10
10
  //
11
11
  // Locks the split of the always-on `assets/orchestrator.md` into a thin core
12
- // plus three path-substituted lazy reference files (see design.md "Core
13
- // budget rebuilt from measured drafts" and "Appendix: drafted core texts").
12
+ // plus lazy reference files (see design.md "Core budget rebuilt from measured
13
+ // drafts" and "Appendix: drafted core texts").
14
14
  //
15
15
  // `getOrchestratorPrompt`'s rendered return value is memoized in a
16
16
  // module-level cache (first-read-wins for the process lifetime — see design.md
@@ -19,17 +19,19 @@ import test, { after } from "node:test";
19
19
  // ambient environment variables, so production runtime asset resolution stays
20
20
  // deterministic. The representative production assets directory below is
21
21
  // populated by COPYING the real repo assets (dynamically, at test-run time)
22
- // into a short-path tmpdir this isolates byte-budget measurement from the
23
- // real repo's absolute path length while keeping content representative of
24
- // production. Tests that need to inspect the real repo files directly (the
25
- // disposition-mapped union sweep, the core-alone token assertions) read
26
- // `assets/*.md` directly via `readFileSync`, sidestepping the cache entirely.
22
+ // into short and deliberately long tmpdir paths. This isolates byte-budget
23
+ // measurement from the real repo's absolute path length while keeping content
24
+ // representative of production. Tests that need to inspect the real repo files
25
+ // directly (the disposition-mapped union sweep, the core-alone token
26
+ // assertions) read `assets/*.md` directly via `readFileSync`, sidestepping the
27
+ // cache entirely.
27
28
  // ---------------------------------------------------------------------------
28
29
 
29
30
  const REPO_ROOT = join(import.meta.dirname, "..");
30
31
  const REAL_ASSETS_DIR = join(REPO_ROOT, "assets");
31
32
  const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "orchestrator.pre-diet.md");
32
- const BUDGET_BYTES = 10240;
33
+ const BUDGET_BYTES = 8192;
34
+ const MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS = 93;
33
35
 
34
36
  const LAZY_ASSET_NAMES = [
35
37
  "orchestrator.md",
@@ -38,42 +40,56 @@ const LAZY_ASSET_NAMES = [
38
40
  "orchestrator-memory.md",
39
41
  "orchestrator-skills.md",
40
42
  ] as const;
43
+ const LAZY_REFERENCE_FILE_NAMES = LAZY_ASSET_NAMES.slice(1);
41
44
 
42
- const representativeProductionAssetsDir = mkdtempSync(join(tmpdir(), "gp-b-"));
43
- for (const name of LAZY_ASSET_NAMES) {
44
- const src = join(REAL_ASSETS_DIR, name);
45
- writeFileSync(
46
- join(representativeProductionAssetsDir, name),
47
- existsSync(src) ? readFileSync(src) : `stub placeholder for ${name} (not authored yet)\n`,
48
- );
45
+ function copyRequiredLazyAssets(destination: string): void {
46
+ for (const name of LAZY_ASSET_NAMES) {
47
+ const source = join(REAL_ASSETS_DIR, name);
48
+ assert.ok(existsSync(source), `missing packaged lazy asset: ${name}`);
49
+ copyFileSync(source, join(destination, name));
50
+ }
49
51
  }
50
- const { __testing } = await import("../extensions/gentle-ai.ts");
51
52
 
52
- const MIN_REALISTIC_INSTALL_ASSETS_PATH_CHARS = 59;
53
+ const representativeProductionAssetsDir = mkdtempSync(join(tmpdir(), "gp-b-"));
54
+ copyRequiredLazyAssets(representativeProductionAssetsDir);
55
+ const { __testing } = await import("../extensions/gentle-ai.ts");
53
56
 
54
- // Realistic-length assets path: mirrors an actual installed path such as
55
- // `~/.pi/agent/npm/node_modules/gentle-pi/assets`, so the budget assertion is
56
- // not laundered through an artificially short mkdtemp path. The helper is also
57
- // exercised in a fresh child process (`tests/fixtures/measure-orchestrator-prompt.mjs`)
58
- // to keep production cache behavior separate from fixture measurements.
59
- const realisticBaseDir = mkdtempSync(join(tmpdir(), "gp-realistic-"));
60
- const realisticDir = join(realisticBaseDir, ".pi", "agent", "npm", "node_modules", "gentle-pi", "assets");
61
- mkdirSync(realisticDir, { recursive: true });
57
+ // A controlled long assets root proves the parent prompt remains within the
58
+ // canonical budget independently of the checkout or installed-package path.
59
+ // The child-process measurement keeps production cache behavior separate from
60
+ // fixture measurements.
61
+ // The root is built to one exact length rather than "tmpdir plus a long
62
+ // suffix". The prompt declares the absolute root once, so it grows one byte
63
+ // per root character, and a 48-character macOS tmpdir pushed the previous
64
+ // construction to 164 characters where a 4-character Linux /tmp gave 121.
65
+ // A fixed length makes every platform measure the same budget claim.
66
+ const CONTROLLED_LONG_ASSETS_ROOT_CHARS = 128;
67
+ const controlledLongBaseDir = mkdtempSync(join(tmpdir(), "gp-long-"));
68
+ const controlledLongSegmentChars = CONTROLLED_LONG_ASSETS_ROOT_CHARS - join(controlledLongBaseDir, "x", "assets").length + 1;
62
69
  assert.ok(
63
- realisticDir.length >= MIN_REALISTIC_INSTALL_ASSETS_PATH_CHARS,
64
- `realistic scratch assets path is only ${realisticDir.length} chars, need >= ${MIN_REALISTIC_INSTALL_ASSETS_PATH_CHARS} to mirror a real install path`,
70
+ controlledLongSegmentChars >= 1,
71
+ `tmpdir ${tmpdir()} is too long to build a ${CONTROLLED_LONG_ASSETS_ROOT_CHARS}-char controlled assets root`,
65
72
  );
66
- for (const name of LAZY_ASSET_NAMES) {
67
- const src = join(REAL_ASSETS_DIR, name);
68
- writeFileSync(
69
- join(realisticDir, name),
70
- existsSync(src) ? readFileSync(src) : `stub placeholder for ${name} (not authored yet)\n`,
71
- );
72
- }
73
+ const controlledLongAssetsDir = join(
74
+ controlledLongBaseDir,
75
+ "path-independent-prompt-budget-".repeat(Math.ceil(controlledLongSegmentChars / 31)).slice(0, controlledLongSegmentChars),
76
+ "assets",
77
+ );
78
+ mkdirSync(controlledLongAssetsDir, { recursive: true });
79
+ assert.equal(
80
+ controlledLongAssetsDir.length,
81
+ CONTROLLED_LONG_ASSETS_ROOT_CHARS,
82
+ `controlled long assets root is ${controlledLongAssetsDir.length} chars, want exactly ${CONTROLLED_LONG_ASSETS_ROOT_CHARS}`,
83
+ );
84
+ assert.ok(
85
+ controlledLongAssetsDir.length >= MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS,
86
+ `controlled long assets root is only ${controlledLongAssetsDir.length} chars, need >= ${MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS}`,
87
+ );
88
+ copyRequiredLazyAssets(controlledLongAssetsDir);
73
89
 
74
90
  after(() => {
75
91
  rmSync(representativeProductionAssetsDir, { recursive: true, force: true });
76
- rmSync(realisticBaseDir, { recursive: true, force: true });
92
+ rmSync(controlledLongBaseDir, { recursive: true, force: true });
77
93
  });
78
94
 
79
95
  function readRealAsset(name: string): string {
@@ -98,7 +114,7 @@ function measureOrchestratorPromptBytes(assetsDir: string): number {
98
114
  // 2.2 — Byte budget (Spec: Always-On Injection Byte Budget)
99
115
  // ---------------------------------------------------------------------------
100
116
 
101
- test("getOrchestratorPrompt return value stays within the 10,240 B budget (short-path stub sanity check)", () => {
117
+ test("getOrchestratorPrompt return value stays within the canonical 8,192 B budget at a short assets root", () => {
102
118
  const rendered = __testing.renderOrchestratorPrompt(representativeProductionAssetsDir);
103
119
  const bytes = Buffer.byteLength(rendered, "utf8");
104
120
  assert.ok(
@@ -107,33 +123,46 @@ test("getOrchestratorPrompt return value stays within the 10,240 B budget (short
107
123
  );
108
124
  });
109
125
 
110
- test(`getOrchestratorPrompt return value stays within the 10,240 B budget at a realistic (>= ${MIN_REALISTIC_INSTALL_ASSETS_PATH_CHARS} char) install path length`, () => {
111
- const bytes = measureOrchestratorPromptBytes(realisticDir);
126
+ test(`getOrchestratorPrompt keeps a controlled long (>= ${MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS} char) assets root within the canonical budget`, () => {
127
+ const rendered = __testing.renderOrchestratorPrompt(controlledLongAssetsDir);
128
+ const bytes = measureOrchestratorPromptBytes(controlledLongAssetsDir);
129
+ assert.equal(bytes, Buffer.byteLength(rendered, "utf8"), "child-process and direct render byte counts must match");
112
130
  assert.ok(
113
131
  bytes <= BUDGET_BYTES,
114
- `getOrchestratorPrompt() returned ${bytes} B at a realistic ${realisticDir.length}-char ASSETS_DIR path, exceeds the ${BUDGET_BYTES} B budget`,
132
+ `getOrchestratorPrompt() returned ${bytes} B at controlled ${controlledLongAssetsDir.length}-char assets root, exceeds the ${BUDGET_BYTES} B budget`,
133
+ );
134
+ assert.equal(
135
+ rendered.split(controlledLongAssetsDir).length - 1,
136
+ 1,
137
+ "the absolute assets root must be declared exactly once",
138
+ );
139
+ assert.ok(
140
+ rendered.includes(`Package assets root: \`${controlledLongAssetsDir}\`. Lazy asset paths below are relative to this root.`),
141
+ "the parent prompt must declare how to resolve relative lazy asset paths",
115
142
  );
143
+ for (const name of LAZY_REFERENCE_FILE_NAMES) {
144
+ assert.ok(rendered.includes(`\`${name}\``), `lazy asset filename is missing: ${name}`);
145
+ }
146
+ assert.doesNotMatch(rendered, /\{\{/, "unresolved {{...}} placeholder leaked into the rendered prompt");
116
147
  });
117
148
 
118
149
  // ---------------------------------------------------------------------------
119
150
  // 2.3 — Disposition-mapped union sweep (Spec: No Normative Content Loss +
120
151
  // Pointer reachability)
121
152
  //
122
- // Every normative line of the frozen pre-diet fixture is assigned to exactly
123
- // one documented disposition: CORE_VERBATIM (byte-identical in the new
124
- // core), LAZY_VERBATIM (byte-identical in one specific lazy file), or OBSOLETE
125
- // (intentionally absent from every live model-facing asset). Section headings
126
- // that are reused unchanged as the new core's summary heading are
127
- // CORE_VERBATIM; section bodies that are condensed away in core are
128
- // LAZY_VERBATIM against their one target lazy file — never a blanket union
129
- // across all three.
153
+ // Every normative line of the frozen pre-diet fixture is assigned to a
154
+ // documented disposition: CORE_VERBATIM (byte-identical in the core),
155
+ // LAZY_VERBATIM (byte-identical in one specific lazy file), OBSOLETE
156
+ // (intentionally absent), or REPLACED (superseded by the focused #3417 asset
157
+ // policy ratchets below). REPLACED preserves the historical fixture without
158
+ // treating a retired prompt mirror as a current normative source.
130
159
  // ---------------------------------------------------------------------------
131
160
 
132
161
  type Target = "core" | "delegation" | "memory" | "skills";
133
162
 
134
163
  interface DispositionRange {
135
164
  lines: [number, number];
136
- target: Target | "obsolete";
165
+ target: Target | "obsolete" | "replaced";
137
166
  label: string;
138
167
  }
139
168
 
@@ -158,13 +187,21 @@ const DISPOSITION_MAP: DispositionRange[] = [
158
187
  { lines: [25, 29], target: "delegation", label: "Language Boundary LB5 (exceptions)" },
159
188
  { lines: [31, 40], target: "core", label: "Mental Model" },
160
189
  { lines: [42, 42], target: "core", label: "Work Routing Ladder heading" },
161
- { lines: [44, 97], target: "delegation", label: "Work Routing Ladder body + Pi Subagent Model Routing" },
190
+ {
191
+ lines: [44, 97],
192
+ target: "replaced",
193
+ label: "Pre-RDD routing detail replaced by focused direct-delegation guidance (#3417)",
194
+ },
162
195
  {
163
196
  lines: [98, 107],
164
197
  target: "obsolete",
165
198
  label: "Size/risk-selected SDD tier replaced by explicit-request/accepted-proposal selection (#312)",
166
199
  },
167
- { lines: [108, 108], target: "delegation", label: "SDD explicit-request trigger" },
200
+ {
201
+ lines: [108, 108],
202
+ target: "replaced",
203
+ label: "Earlier SDD trigger wording replaced by the focused SDD boundary (#3417)",
204
+ },
168
205
  {
169
206
  lines: [109, 110],
170
207
  target: "obsolete",
@@ -179,8 +216,8 @@ const DISPOSITION_MAP: DispositionRange[] = [
179
216
  },
180
217
  {
181
218
  lines: [128, 132],
182
- target: "delegation",
183
- label: "Mandatory Triggers heading + Pi trigger preamble + 4-file binding",
219
+ target: "replaced",
220
+ label: "Pre-RDD trigger wording replaced by focused direct-delegation guidance (#3417)",
184
221
  },
185
222
  {
186
223
  lines: [133, 133],
@@ -189,8 +226,8 @@ const DISPOSITION_MAP: DispositionRange[] = [
189
226
  },
190
227
  {
191
228
  lines: [134, 167],
192
- target: "delegation",
193
- label: "Mandatory Triggers remainder + Cost/Context Balance + Canonical Workflows",
229
+ target: "replaced",
230
+ label: "Pre-RDD trigger and workflow wording replaced by focused delegation guidance (#3417)",
194
231
  },
195
232
  {
196
233
  lines: [169, 181],
@@ -199,8 +236,16 @@ const DISPOSITION_MAP: DispositionRange[] = [
199
236
  },
200
237
  { lines: [183, 191], target: "core", label: "SDD Workflow pointer" },
201
238
  { lines: [193, 193], target: "core", label: "Memory Contract heading" },
202
- { lines: [195, 195], target: "core", label: "Memory Contract intro" },
203
- { lines: [197, 201], target: "core", label: "Memory Contract Non-SDD delegation" },
239
+ {
240
+ lines: [195, 195],
241
+ target: "replaced",
242
+ label: "Verbose memory introduction replaced by compact parent/subagent ownership (#3417)",
243
+ },
244
+ {
245
+ lines: [197, 201],
246
+ target: "replaced",
247
+ label: "Verbose non-SDD memory forwarding replaced by compact ownership (#3417)",
248
+ },
204
249
  { lines: [203, 230], target: "memory", label: "Memory Contract SDD phases table + artifact keys + lifecycle rule" },
205
250
  { lines: [232, 232], target: "core", label: "Skill Registry Protocol heading" },
206
251
  { lines: [234, 253], target: "skills", label: "Skill Registry Protocol detail" },
@@ -219,6 +264,11 @@ function isNormativeLine(line: string): boolean {
219
264
  }
220
265
 
221
266
  const fixtureLines = readFileSync(FIXTURE_PATH, "utf8").split("\n");
267
+ // Fixture lines 187 and 191 predate the root-relative lazy-asset contract and
268
+ // canonical-authority resolution. Keep their coverage by asserting the
269
+ // intentionally updated production wording instead of weakening the range.
270
+ const CURRENT_SDD_WORKFLOW_PATH = "`sdd-orchestrator-workflow.md`";
271
+ const CURRENT_HARD_PREFLIGHT_INVARIANT = "Hard preflight invariant: `openspec/config.yaml`, existing SDD changes, installed `.pi`/global SDD assets, or a todo named \"preflight\" are not session preflight. Do not mark SDD preflight complete, start `sdd-init`, launch SDD subagents/chains, or move to explore/proposal/spec/design/tasks until this session has an injected `## SDD Session Preflight` block or a canonical-authority resolution. Defaults and capability constraints may resolve fields without confirmation prompts; preserve unresolved-choice and safety gates.";
222
272
  const SUPERSEDED_LIFECYCLE_REVIEW_LINES = new Set([
223
273
  70,
224
274
  // 74/77: the loose mode-choice background lines were replaced by the
@@ -240,6 +290,7 @@ const SUPERSEDED_LIFECYCLE_REVIEW_LINES = new Set([
240
290
  ]);
241
291
 
242
292
  for (const range of DISPOSITION_MAP) {
293
+ if (range.target === "replaced") continue;
243
294
  test(
244
295
  `disposition-mapped union: ${range.label} (fixture:${range.lines[0]}-${range.lines[1]}) -> ${range.target}`,
245
296
  () => {
@@ -251,6 +302,12 @@ for (const range of DISPOSITION_MAP) {
251
302
  const raw = fixtureLines[ln - 1];
252
303
  if (raw === undefined || !isNormativeLine(raw)) continue;
253
304
  const trimmed = raw.trim();
305
+ const expected =
306
+ ln === 187
307
+ ? CURRENT_SDD_WORKFLOW_PATH
308
+ : ln === 191
309
+ ? CURRENT_HARD_PREFLIGHT_INVARIANT
310
+ : trimmed;
254
311
  if (SUPERSEDED_LIFECYCLE_REVIEW_LINES.has(ln)) {
255
312
  assert.ok(
256
313
  !targetContent.includes(trimmed),
@@ -266,8 +323,8 @@ for (const range of DISPOSITION_MAP) {
266
323
  continue;
267
324
  }
268
325
  assert.ok(
269
- targetContent.includes(trimmed),
270
- `normative line lost: fixture:${ln} "${trimmed}" not found verbatim in ${TARGET_FILE[range.target]} (disposition: ${range.target}, section: ${range.label})`,
326
+ targetContent.includes(expected),
327
+ `normative line lost: fixture:${ln} "${expected}" not found verbatim in ${TARGET_FILE[range.target]} (disposition: ${range.target}, section: ${range.label})`,
271
328
  );
272
329
  }
273
330
  },
@@ -279,32 +336,28 @@ for (const range of DISPOSITION_MAP) {
279
336
  // string alone, no lazy union.
280
337
  // ---------------------------------------------------------------------------
281
338
 
282
- test("core-alone: load-bearing delegation tokens present without lazy union", () => {
339
+ test("core-alone: load-bearing direct-delegation tokens remain without lazy union", () => {
283
340
  const core = readRealAsset("orchestrator.md");
284
341
  assert.match(core, /4-file rule/);
285
342
  assert.match(core, /Multi-file write rule/);
286
- assert.match(core, /Lifecycle gate rule/);
287
343
  assert.match(core, /Incident rule/);
344
+ assert.match(core, /Verification rule/);
288
345
  assert.match(core, /Long-session rule/);
289
- assert.match(core, /Review actor rule/);
290
346
  });
291
347
 
292
- test("core-alone: receipt-only lifecycle and independent safety are present without lazy union", () => {
348
+ test("core-alone: dynamic Gentle AI ownership replaces package lifecycle instructions", () => {
293
349
  const core = readRealAsset("orchestrator.md");
294
- assert.match(core, /start -> finalize -> validate/i);
295
- assert.match(core, /Compact gates use zero actors/i);
296
- assert.match(core, /Release from protected `main` may bypass receipt validation only when/i);
297
- assert.match(core, /Major and post-incident releases require explicit extraordinary review/i);
298
- assert.match(core, /Dangerous-command safety remains independent and authoritative/i);
299
- assert.match(core, /SDD completion adds no review or Judgment Day pass/i);
350
+ assert.match(core, /dynamically supplies runtime-specific RDD instructions via generated Pi APPEND_SYSTEM composition/);
351
+ assert.match(core, /if absent or unsupported, this package does not invent or fall back/);
352
+ assert.doesNotMatch(core, /start -> finalize -> validate/i);
353
+ assert.doesNotMatch(core, /receipt validation/i);
300
354
  });
301
355
 
302
- test("lazy bounded-review contract lists all four review lens names", () => {
303
- const content = `${readRealAsset("orchestrator.md")}\n${readRealAsset("orchestrator-delegation.md")}`;
304
- assert.match(content, /review-risk/);
305
- assert.match(content, /review-reliability/);
306
- assert.match(content, /review-resilience/);
307
- assert.match(content, /review-readability/);
356
+ test("lazy delegation detail has no native RDD controller markers", () => {
357
+ const delegation = readRealAsset("orchestrator-delegation.md");
358
+ for (const marker of ["next_transition", "review.capture-result", "reconcile-terminal-mirrors"]) {
359
+ assert.ok(!delegation.includes(marker), `stale RDD marker retained: ${marker}`);
360
+ }
308
361
  });
309
362
 
310
363
  test("live orchestrator assets remove the stale strong-gate retry contract", () => {
@@ -345,20 +398,11 @@ test("relocated lazy bodies are not double-delivered in the always-on core", ()
345
398
  );
346
399
  });
347
400
 
348
- test("relocated lazy files are reachable via in-core pointer paths", () => {
401
+ test("relocated lazy files are reachable via root-relative in-core filenames", () => {
349
402
  const rendered = __testing.getOrchestratorPrompt();
350
- assert.ok(
351
- rendered.includes(join(REAL_ASSETS_DIR, "orchestrator-delegation.md")),
352
- "core is missing a reachable pointer to orchestrator-delegation.md",
353
- );
354
- assert.ok(
355
- rendered.includes(join(REAL_ASSETS_DIR, "orchestrator-memory.md")),
356
- "core is missing a reachable pointer to orchestrator-memory.md",
357
- );
358
- assert.ok(
359
- rendered.includes(join(REAL_ASSETS_DIR, "orchestrator-skills.md")),
360
- "core is missing a reachable pointer to orchestrator-skills.md",
361
- );
403
+ for (const name of LAZY_REFERENCE_FILE_NAMES) {
404
+ assert.ok(rendered.includes(`\`${name}\``), `core is missing a reachable pointer to ${name}`);
405
+ }
362
406
  });
363
407
 
364
408
  // ---------------------------------------------------------------------------