gentle-pi 2.1.2 → 2.2.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 (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -89,6 +89,40 @@ interface ReviewGateResult {
89
89
  context: ReviewGateContext;
90
90
  }
91
91
 
92
+ // Every test in this file drives the real pinned binary through a full review
93
+ // lifecycle, and gentle-ai v2.4.0 made receipt-driven development opt-in: an
94
+ // install whose global mode was never set now resolves to off, and START is
95
+ // rejected before it reaches any of the behavior these tests exist to check.
96
+ //
97
+ // Until that release these tests had a silent dependency on whatever the
98
+ // operator happened to have configured. That is not a new hazard introduced by
99
+ // the pin — it was always there — but the flip is what made it visible: the
100
+ // suite was green on a machine with review enabled and red on a fresh CI
101
+ // runner, from identical bytes. A test whose result depends on unversioned
102
+ // machine state is not evidence either way.
103
+ //
104
+ // So each test owns a sandbox HOME and opts in the same way a user does,
105
+ // exactly as gentle-ai did for its own lifecycle fixtures in the commit that
106
+ // flipped the default. The process-wide HOME is what the extension-registered
107
+ // controller path needs, because it spawns the CLI with the inherited
108
+ // environment rather than an explicit one; it is restored afterwards.
109
+ async function reviewEnabledHome(t: baseTest.TestContext): Promise<string> {
110
+ const home = await mkdtemp(join(tmpdir(), "gentle-pi-review-home-"));
111
+ const previousHome = process.env.HOME;
112
+ process.env.HOME = home;
113
+ t.after(async () => {
114
+ if (previousHome === undefined) delete process.env.HOME;
115
+ else process.env.HOME = previousHome;
116
+ await rm(home, { recursive: true, force: true });
117
+ });
118
+ const enabled = await run(binary, ["review", "mode", "enable", "--scope", "global", "--json"], packageRoot, false, { ...process.env, HOME: home });
119
+ // Assert the opt-in landed rather than assuming it. A silently ineffective
120
+ // enable would put these tests straight back to depending on ambient state,
121
+ // which is the exact failure this helper exists to remove.
122
+ assert.match(enabled.stdout, /"effective": "on"/, "the sandbox HOME must have receipt-driven development explicitly enabled");
123
+ return home;
124
+ }
125
+
92
126
  async function run(command: string, arguments_: readonly string[], cwd: string, allowFailure = false, environment?: NodeJS.ProcessEnv): Promise<CommandResult> {
93
127
  try {
94
128
  const result = await execFileAsync(command, [...arguments_], { cwd, encoding: "utf8", shell: false, env: environment });
@@ -204,6 +238,7 @@ async function finalizeEmptyReview(repository: string, artifacts: string, starte
204
238
  }
205
239
 
206
240
  test("official pinned package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => {
241
+ await reviewEnabledHome(t);
207
242
  assert.equal(createHash("sha256").update(await readFile(binary)).digest("hex"), OFFICIAL_BINARY_SHA256);
208
243
  assert.deepEqual(await run(binary, ["version"], packageRoot), { exitCode: 0, stdout: `gentle-ai ${GENTLE_AI_VERSION}\n`, stderr: "" });
209
244
 
@@ -285,6 +320,7 @@ test("official pinned package runtime authorizes an unchanged linked-view candid
285
320
  });
286
321
 
287
322
  test("official pinned package runtime keeps frozen candidate lineages and receipts isolated across replay and replacement", async (t) => {
323
+ await reviewEnabledHome(t);
288
324
  const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-lineage-"));
289
325
  const repository = join(workspace, "repository");
290
326
  const artifacts = join(workspace, "artifacts");
@@ -379,6 +415,7 @@ test("official pinned package runtime keeps frozen candidate lineages and receip
379
415
  });
380
416
 
381
417
  test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
418
+ await reviewEnabledHome(t);
382
419
  const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
383
420
  const repository = join(workspace, "repository");
384
421
  t.after(async () => rm(workspace, { recursive: true, force: true }));
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -8,6 +8,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
9
  import {
10
10
  NATIVE_REVIEW_ERROR_CODE,
11
+ REVIEW_CONSENT_NOTICES,
11
12
  NativeReviewCliError,
12
13
  NativeReviewConsentBindingError,
13
14
  NativeReviewConsentRequiredError,
@@ -21,7 +22,7 @@ import {
21
22
  type NativeStartRequest,
22
23
  } from "../lib/native-review-cli.ts";
23
24
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
24
- import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
25
+ import { readReviewConsentLatch, recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
25
26
  import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
26
27
 
27
28
  // Queued-adapter clients never execute a real process; default to a fixed
@@ -261,6 +262,29 @@ test("START tolerates the exact review-consent-skipped stderr line only when the
261
262
  );
262
263
  });
263
264
 
265
+ // gentle-ai v2.4.0 (feat(review)!: make receipt-driven development opt-in)
266
+ // DELETED reviewConsentSkippedDefaultProvenance. It existed to admit reviews
267
+ // were on because nobody chose; under opt-in RDD a default-source clone is
268
+ // refused before the consent ceremony runs, so the pinned binary can never
269
+ // emit it. The allowlist is a statement about what the PINNED binary writes
270
+ // while still succeeding, so a line no pinned binary can produce does not
271
+ // belong in it: keeping it would silently tolerate that exact text from
272
+ // anywhere else in a START's stderr.
273
+ test("the tolerated-stderr allowlist carries no line the pinned binary cannot emit", async () => {
274
+ assert.ok(
275
+ !REVIEW_CONSENT_NOTICES.some((notice) => notice.startsWith("Reviews are on by default")),
276
+ "the default-provenance notice was deleted upstream in v2.4.0 and must not stay tolerated",
277
+ );
278
+
279
+ const start = JSON.parse(START.stdout) as Record<string, unknown>;
280
+ const deleted = "Reviews are on by default; this was never explicitly chosen. Run 'gentle-ai review mode enable' to make reviews an explicit choice, or 'gentle-ai review mode disable' to turn them off.";
281
+ const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: deleted }]);
282
+ await assert.rejects(
283
+ () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
284
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
285
+ );
286
+ });
287
+
264
288
  test("START rejects near-miss, prefixed, or extra-line stderr even when the mode capability is true", async () => {
265
289
  const start = JSON.parse(START.stdout) as Record<string, unknown>;
266
290
  const notice = "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.";
@@ -441,12 +465,16 @@ interface RegisteredEventFixture {
441
465
  (event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
442
466
  }
443
467
 
444
- function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
468
+ function runtime(
469
+ nativeReviewCli: NativeReviewCli | null,
470
+ writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
471
+ clock?: { now?: () => number; scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void } },
472
+ ): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
445
473
  const tools = new Map<string, RegisteredTool>();
446
474
  const commands = new Map<string, RegisteredCommandFixture>();
447
475
  const events = new Map<string, RegisteredEventFixture>();
448
- const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
449
- createGentleAiExtension(dependencies)({
476
+ const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry(), ...clock } as unknown as Parameters<typeof createGentleAiExtension>[0];
477
+ __testing.createGentleAiExtension(dependencies, writeReviewConsentLatch)({
450
478
  on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
451
479
  registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
452
480
  registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
@@ -523,15 +551,28 @@ test("kill-switch: a global off names the native global-scope command, never Pi'
523
551
  assert.ok(!/\/gentle:review-mode enable to turn/.test(String(result.next_action)), "a global off must never be sent to Pi's clone-scope command");
524
552
  });
525
553
 
526
- // Parity with reviewModeScopeForSource: the default source expresses no opinion
527
- // and can never be what keeps reviews off, so it gets no guessed continuation.
528
- test("kill-switch: an off with the default source names no continuation rather than guessing one", async (t) => {
554
+ // Parity with reviewModeScopeForSource as of gentle-ai v2.4.0, which made
555
+ // receipt-driven development opt-in. Before that release an all-sources-unset
556
+ // install resolved to ON with source `default`, so `default` could never be
557
+ // what kept reviews off and naming a continuation would have been a guess.
558
+ // v2.4.0 resolves the same install to OFF with source `default`, which makes
559
+ // this the MOST COMMON refusal there is: every install that never opted in.
560
+ // gentle-ai answers `global` for it -- not because default is a global
561
+ // opinion, but because global is the only scope that can turn reviews on at
562
+ // all. Pi must say the same thing, because the alternative is handing the
563
+ // single most common state a dead end with no way forward.
564
+ test("kill-switch: an off with the default source names the only scope that can turn reviews on", async (t) => {
529
565
  const cwd = repository(t);
530
566
  const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "default" });
531
567
  const { controller } = runtime(native);
532
568
  const result = await execStart(controller, "kill-switch-default", headlessContext(cwd));
533
569
  assert.equal(result.mode_source, "default");
534
- assert.equal(result.next_action, undefined);
570
+ assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the default mode source keeps it off");
571
+ assert.equal(
572
+ result.next_action,
573
+ "Run `gentle-ai review mode enable --scope=global` to turn reviews on; receipt-driven development is opt-in and nothing here has enabled it yet. /gentle:review-mode enable only sets clone scope, which can never turn reviews on.",
574
+ );
575
+ assert.ok(!/\/gentle:review-mode enable to turn/.test(String(result.next_action)), "a default off must never be sent to Pi's clone-scope command");
535
576
  });
536
577
 
537
578
  test("kill-switch: capability-absent (no reviewMode) leaves today's path unchanged", async (t) => {
@@ -610,8 +651,13 @@ test("consent relay returns the identical complete parent-visible envelope with
610
651
  });
611
652
 
612
653
  test("explicit consent follow-up grants or declines exactly once", async (t) => {
613
- const cwd = repository(t);
614
654
  for (const answer of ["granted", "declined"] as const) {
655
+ const repositoryCwd = repository(t);
656
+ const cwd = `${repositoryCwd}-alias`;
657
+ symlinkSync(repositoryCwd, cwd, process.platform === "win32" ? "junction" : "dir");
658
+ t.after(() => rmSync(cwd, { force: true }));
659
+ const canonicalCwd = realpathSync(cwd);
660
+ assert.equal(readReviewConsentLatch(cwd), false);
615
661
  const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
616
662
  const { controller } = runtime(native);
617
663
  const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
@@ -626,17 +672,42 @@ test("explicit consent follow-up grants or declines exactly once", async (t) =>
626
672
  assert.equal(answerRequests[0]?.consent.targetIdentity, startRequests[0]?.targetIdentity);
627
673
  if (answer === "granted") {
628
674
  const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
629
- assert.equal(actorBinding.workspace_root, cwd);
630
- assert.notEqual(actorBinding.candidate_root, cwd);
675
+ assert.equal(actorBinding.workspace_root, canonicalCwd);
676
+ assert.notEqual(actorBinding.candidate_root, canonicalCwd);
677
+ assert.equal(readReviewConsentLatch(cwd), true);
631
678
  } else {
632
679
  assert.equal(result.outcome, "consent-declined-this-candidate");
633
680
  assert.equal(result.lineage_created, false);
634
- assert.equal(result.actor_binding, undefined);
681
+ assert.equal("actor_binding" in result, false);
682
+ assert.equal("result" in result, false);
683
+ assert.equal(readReviewConsentLatch(cwd), false);
635
684
  }
636
685
  await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
637
686
  }
638
687
  });
639
688
 
689
+ test("granted consent preserves the completed native start when local latch persistence fails", async (t) => {
690
+ const cwd = repository(t);
691
+ const { native, answers } = relayedConsentNative(cwd);
692
+ const notices: Array<{ message: string; type?: string }> = [];
693
+ let latchWrites = 0;
694
+ const { controller } = runtime(native, () => {
695
+ latchWrites += 1;
696
+ throw new Error("injected consent latch failure");
697
+ });
698
+ const blocked = await blockedConsent(controller, "consent-latch-failure", headlessContext(cwd, notices));
699
+ const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd, notices));
700
+
701
+ assert.deepEqual(answers, ["granted"]);
702
+ assert.equal(latchWrites, 1);
703
+ assert.deepEqual(result.result, { lineage_id: "native-lineage", state: "reviewing", risk_tier: "high", selected_lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changed_files: 1, original_changed_lines: 1, correction_budget: 1, action: "created", lenses_required: true });
704
+ assert.equal((result.actor_binding as { workspace_root: string }).workspace_root, realpathSync(cwd));
705
+ assert.equal(readReviewConsentLatch(cwd), false);
706
+ assert.equal(notices.length, 1);
707
+ assert.equal(notices[0]?.type, "warning");
708
+ assert.match(notices[0]?.message ?? "", /native review start completed, but Pi could not record the local consent latch/i);
709
+ });
710
+
640
711
  // Issue #247: a local binding mismatch was indistinguishable from a provider
641
712
  // outage, so the reporter diagnosed a missing --cwd that Pi does forward.
642
713
  test("a consent binding mismatch surfaces as an actionable local failure, not an opaque native operation failure", async (t) => {
@@ -657,6 +728,7 @@ test("a consent binding mismatch surfaces as an actionable local failure, not an
657
728
  assert.equal(result.mutation_outcome, "none");
658
729
  assert.equal(result.next_action, "resolve-consent-binding");
659
730
  assert.deepEqual(answers, []);
731
+ assert.equal(readReviewConsentLatch(cwd), false);
660
732
  });
661
733
 
662
734
  test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
@@ -682,6 +754,17 @@ test("consent follow-up rejects invalid token, unknown id, changed cwd, and chan
682
754
  (consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
683
755
  await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
684
756
  assert.deepEqual(answers, []);
757
+ assert.equal(readReviewConsentLatch(cwd), false);
758
+ });
759
+
760
+ test("unavailable consent follow-up leaves the clone latch unset", async (t) => {
761
+ const cwd = repository(t);
762
+ const { native } = relayedConsentNative(cwd);
763
+ delete native.answerConsent;
764
+ const { controller } = runtime(native);
765
+ const blocked = await blockedConsent(controller, "consent-unavailable", headlessContext(cwd));
766
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent follow-up is unavailable/);
767
+ assert.equal(readReviewConsentLatch(cwd), false);
685
768
  });
686
769
 
687
770
  test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
@@ -696,11 +779,12 @@ test("ambiguous consent mutation consumes the one-shot binding and requires stat
696
779
  await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
697
780
  assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
698
781
  await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
782
+ assert.equal(readReviewConsentLatch(cwd), false);
699
783
  });
700
784
 
701
785
  test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
702
786
  const cwd = repository(t);
703
- const { native, answers } = relayedConsentNative(cwd);
787
+ const { native, answers, startRequests } = relayedConsentNative(cwd);
704
788
  const { controller, events } = runtime(native);
705
789
  const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
706
790
  const shutdown = events.get("session_shutdown");
@@ -709,6 +793,18 @@ test("session shutdown clears pending candidate consent bindings and is idempote
709
793
  await shutdown({}, headlessContext(cwd));
710
794
  await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
711
795
  assert.deepEqual(answers, []);
796
+ const afterShutdown = await blockedConsent(controller, "consent-after-shutdown", headlessContext(cwd));
797
+ assert.notEqual(afterShutdown.consent_binding, blocked.consent_binding, "the same candidate gets a fresh binding after shutdown cleanup");
798
+ assert.equal(startRequests.length, 2, "shutdown loss requires a fresh native START instead of local replay");
799
+ });
800
+
801
+ test("extension reload gives the same candidate a fresh consent binding instead of replaying lost local state", async (t) => {
802
+ const cwd = repository(t);
803
+ const { native, startRequests } = relayedConsentNative(cwd);
804
+ const beforeReload = await blockedConsent(runtime(native).controller, "consent-before-reload", headlessContext(cwd));
805
+ const afterReload = await blockedConsent(runtime(native).controller, "consent-after-reload", headlessContext(cwd));
806
+ assert.notEqual(afterReload.consent_binding, beforeReload.consent_binding);
807
+ assert.equal(startRequests.length, 2, "reload loss requires a fresh native START instead of local replay");
712
808
  });
713
809
 
714
810
  test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
@@ -729,6 +825,90 @@ test("successful explicit disable clears pending candidate consent binding even
729
825
  assert.deepEqual(answers, []);
730
826
  });
731
827
 
828
+ // Issue #264: an unused consent binding and the candidate view retained
829
+ // exclusively for that binding must expire as one lifecycle unit before any
830
+ // later START may reuse the view. Cleanup is synchronous with respect to the
831
+ // observable TTL; the queued cleanup macrotask is a safety net, not the
832
+ // authority, so a fresh-candidate retry after expiry must get a new binding
833
+ // rather than tripping candidate-target-projection-drift on a stale view.
834
+ const REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
835
+
836
+ function fakeConsentClock(): { now: () => number; scheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void }; advance: (ms: number) => void; scheduled: Array<() => void> } {
837
+ const start = Date.now();
838
+ let nowMs = start;
839
+ const scheduled: Array<() => void> = [];
840
+ return {
841
+ now: () => nowMs,
842
+ // Never fires the callback: simulates a queued cleanup macrotask that
843
+ // has not yet gotten a turn on the event loop.
844
+ scheduleTimer: (callback) => { scheduled.push(callback); return { unref: () => {} }; },
845
+ advance: (ms) => { nowMs = start + ms; },
846
+ scheduled,
847
+ };
848
+ }
849
+
850
+ test("an expired unused binding prunes synchronously so a fresh-candidate retry gets a new binding, not candidate-target-projection-drift", async (t) => {
851
+ const cwd = repository(t);
852
+ const { native, startRequests } = relayedConsentNative(cwd);
853
+ const clock = fakeConsentClock();
854
+ const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
855
+ // First START: consent-required, binding T1 retained with a queued cleanup timer.
856
+ const first = await blockedConsent(controller, "expired-fresh-1", headlessContext(cwd));
857
+ const firstBinding = first.consent_binding;
858
+ assert.equal(clock.scheduled.length, 1, "the TTL cleanup macrotask is queued for T1");
859
+ // The candidate changes: a later START on a FRESH candidate must not reuse T1.
860
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
861
+ // Advance time to the exact TTL boundary without letting the queued cleanup macrotask fire.
862
+ clock.advance(REVIEW_CONSENT_TTL_MS);
863
+ assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
864
+ const second = await execStart(controller, "expired-fresh-2", headlessContext(cwd));
865
+ // The fix: a fresh consent-required binding, not candidate-target-projection-drift.
866
+ assert.equal(second.status, "blocked");
867
+ assert.equal(second.outcome, "native-review-consent-required", "the stale view must not trip candidate-target-projection-drift");
868
+ assert.equal(typeof second.consent_binding, "string");
869
+ assert.notEqual(second.consent_binding, firstBinding, "a fresh candidate gets a fresh binding, not the stale one");
870
+ assert.equal(startRequests.length, 2, "native start was attempted for both candidates");
871
+ assert.equal(clock.scheduled.length, 2, "the fresh binding queued its own cleanup macrotask");
872
+ });
873
+
874
+ test("an expired same-candidate consent request creates a fresh binding instead of replaying local state", async (t) => {
875
+ const cwd = repository(t);
876
+ const { native, startRequests } = relayedConsentNative(cwd);
877
+ const clock = fakeConsentClock();
878
+ const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
879
+ const first = await blockedConsent(controller, "same-candidate-expired-1", headlessContext(cwd));
880
+ clock.advance(REVIEW_CONSENT_TTL_MS);
881
+ const second = await blockedConsent(controller, "same-candidate-expired-2", headlessContext(cwd));
882
+ assert.notEqual(second.consent_binding, first.consent_binding);
883
+ assert.equal(startRequests.length, 2, "expiry requires a fresh native START instead of local replay");
884
+ assert.equal(clock.scheduled.length, 2, "the fresh binding queues its own cleanup macrotask");
885
+ });
886
+
887
+ test("a non-expired same-candidate consent request reuses the same binding instead of creating a new one", async (t) => {
888
+ const cwd = repository(t);
889
+ const { native } = relayedConsentNative(cwd);
890
+ const clock = fakeConsentClock();
891
+ const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
892
+ const first = await blockedConsent(controller, "dedup-1", headlessContext(cwd));
893
+ // Same candidate, same consent, still within TTL: the existing binding is reused.
894
+ const second = await blockedConsent(controller, "dedup-2", headlessContext(cwd));
895
+ assert.equal(second.consent_binding, first.consent_binding, "a non-expired same-candidate request deduplicates the binding");
896
+ assert.equal(clock.scheduled.length, 1, "no new cleanup macrotask is queued when the binding is reused");
897
+ });
898
+
899
+ test("an expired consent binding is rejected on answer-consent even before the queued cleanup macrotask fires", async (t) => {
900
+ const cwd = repository(t);
901
+ const { native } = relayedConsentNative(cwd);
902
+ const clock = fakeConsentClock();
903
+ const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
904
+ const blocked = await blockedConsent(controller, "expired-answer-1", headlessContext(cwd));
905
+ // Advance to the exact TTL boundary without firing the queued cleanup macrotask.
906
+ clock.advance(REVIEW_CONSENT_TTL_MS);
907
+ assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
908
+ await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
909
+ assert.equal(clock.scheduled.length, 1, "rejection was synchronous from the TTL observation, not from a fired macrotask");
910
+ });
911
+
732
912
  test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
733
913
  const cwd = repository(t);
734
914
  const { native, answers } = relayedConsentNative(cwd);
@@ -738,11 +918,34 @@ test("candidate-scoped decline creates no authority and the next candidate asks
738
918
  const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
739
919
  assert.equal(result.outcome, "consent-declined-this-candidate");
740
920
  assert.equal(result.lineage_created, false);
741
- assert.equal(result.actor_binding, undefined);
921
+ assert.equal("actor_binding" in result, false);
922
+ assert.equal("result" in result, false);
923
+ assert.equal(readReviewConsentLatch(cwd), false);
742
924
  }
743
925
  assert.deepEqual(answers, ["declined", "declined"]);
744
926
  });
745
927
 
928
+ // Upstream gentle-ai.review-integration.consent/v3 consent is per-candidate:
929
+ // the clone-local latch records only that the one-time question was already
930
+ // put to the user, never that any later candidate is pre-approved. A fresh
931
+ // START with the latch already recorded must still relay the complete
932
+ // blocking envelope, and only an explicit ANSWER_CONSENT may resolve it.
933
+ test("a pre-recorded consent latch never suppresses a later candidate's consent envelope or auto-answers it", async (t) => {
934
+ const cwd = repository(t);
935
+ recordReviewConsentLatch(cwd);
936
+ assert.equal(readReviewConsentLatch(cwd), true);
937
+ const { native, answers, startRequests } = relayedConsentNative(cwd);
938
+ const { controller } = runtime(native);
939
+ const blocked = await blockedConsent(controller, "consent-latched-fresh-start", headlessContext(cwd));
940
+ assert.deepEqual(blocked.consent, candidateConsent(cwd).raw, "the full consent envelope is relayed despite the pre-recorded latch");
941
+ assert.deepEqual(answers, [], "the latch must never auto-answer a later candidate's consent");
942
+ assert.equal(blocked.lineage_created, false);
943
+ assert.equal(startRequests.length, 1);
944
+ const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
945
+ assert.deepEqual(answers, ["granted"], "explicit ANSWER_CONSENT is still required to proceed");
946
+ assert.ok(result.result);
947
+ });
948
+
746
949
  test("low-risk zero-lens START remains silent", async (t) => {
747
950
  const cwd = repository(t);
748
951
  const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
@@ -0,0 +1,235 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Native SDD Runtime Attempt Authority contract tests (issue #256 track 3).
8
+ //
9
+ // Locks the Pi-owned lazy-loaded orchestration/status contract that ports the
10
+ // released Gentle AI v2.4.0 compact SDD attempt ledger authority. Pi must NOT
11
+ // implement a local attempt runtime; it must route every runtime-bearing
12
+ // sdd-apply/sdd-verify/remediation launch through the provider compact CLI.
13
+ //
14
+ // Both assets are Markdown contracts consumed by orchestrators; these tests
15
+ // read the real repo files and assert the essential semantics, not merely
16
+ // keyword presence. Negative controls guard against caller-authored
17
+ // counters, OpenSpec/Engram attempt ledgers, automatic reset, and legacy
18
+ // status|begin|finish|reset normal-flow routing.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const ROOT = join(import.meta.dirname, "..");
22
+ const WORKFLOW = "assets/sdd-orchestrator-workflow.md";
23
+ const STATUS_CONTRACT = "assets/support/sdd-status-contract.md";
24
+ const SECTION = "Native Runtime Attempt Authority";
25
+
26
+ function read(path: string): string {
27
+ return readFileSync(join(ROOT, path), "utf8");
28
+ }
29
+
30
+ function readMarkdownSection(source: string, heading: string): string {
31
+ const lines = source.split(/\r?\n/);
32
+ const matches = lines.flatMap((line, index) => {
33
+ const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
34
+ return match?.[2] === heading ? [{ index, level: match[1].length }] : [];
35
+ });
36
+ assert.equal(
37
+ matches.length,
38
+ 1,
39
+ `Markdown must contain exactly one "${heading}" section (found ${matches.length})`,
40
+ );
41
+ const [{ index: start, level }] = matches;
42
+ const relativeEnd = lines.slice(start + 1).findIndex((line) => {
43
+ const match = line.match(/^(#{1,6})\s+/);
44
+ return match !== null && match[1].length <= level;
45
+ });
46
+ const end = relativeEnd === -1 ? lines.length : start + 1 + relativeEnd;
47
+ return lines.slice(start + 1, end).join("\n").trim();
48
+ }
49
+
50
+ // Extracts one command line beginning with `commandPrefix` so payload args bind
51
+ // to the correct command shape; `--cwd`, `--change`, `--request-id` appear in
52
+ // both acquire and settle, so a section-wide match stays green on arg removal.
53
+ function extractCommandLine(section: string, commandPrefix: string): string {
54
+ const escaped = commandPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ const re = new RegExp(`^[ \t]*${escaped}.*$`, "gm");
56
+ const matches = section.match(re) ?? [];
57
+ assert.equal(
58
+ matches.length,
59
+ 1,
60
+ `Native Runtime Attempt Authority section must contain exactly one command line starting with "${commandPrefix}" (found ${matches.length})`,
61
+ );
62
+ const [line] = matches;
63
+ return line;
64
+ }
65
+
66
+ test("extractCommandLine rejects duplicate matching command lines (negative control)", () => {
67
+ for (const prefix of [
68
+ "gentle-ai sdd-attempt acquire ",
69
+ "gentle-ai sdd-attempt settle ",
70
+ ]) {
71
+ const duplicate = [
72
+ `${prefix}--cwd repo --change change --request-id id`,
73
+ `${prefix}--cwd repo2 --change change2 --request-id id2`,
74
+ ].join("\n");
75
+ assert.throws(
76
+ () => extractCommandLine(duplicate, prefix),
77
+ /exactly one command line/,
78
+ `duplicate ${prefix.trim()} lines must be rejected`,
79
+ );
80
+ }
81
+ });
82
+
83
+ test("workflow asset has exactly one Native Runtime Attempt Authority section", () => {
84
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
85
+ assert.ok(section.length > 0, "section must be non-empty");
86
+ });
87
+
88
+ test("status contract asset has exactly one Native Runtime Attempt Authority section", () => {
89
+ const section = readMarkdownSection(read(STATUS_CONTRACT), SECTION);
90
+ assert.ok(section.length > 0, "section must be non-empty");
91
+ });
92
+
93
+ test("workflow names acquire and settle as sole provider authority for OpenSpec and Engram", () => {
94
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
95
+ assert.match(section, /sdd-attempt acquire/);
96
+ assert.match(section, /sdd-attempt settle/);
97
+ assert.match(section, /sole attempt and changed-line budget authority/i);
98
+ assert.match(section, /Git-common-dir/);
99
+ assert.match(section, /OpenSpec and Engram/);
100
+ });
101
+
102
+ test("status contract names acquire and settle and forbids OpenSpec/Engram attempt ledgers", () => {
103
+ const section = readMarkdownSection(read(STATUS_CONTRACT), SECTION);
104
+ assert.match(section, /acquire/);
105
+ assert.match(section, /settle/);
106
+ assert.match(section, /OpenSpec or Engram attempt ledger/i);
107
+ });
108
+
109
+ test("workflow launches only on proceed; blocked and complete stop the launch", () => {
110
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
111
+ assert.match(section, /proceed/);
112
+ assert.match(section, /blocked/);
113
+ assert.match(section, /complete/);
114
+ assert.match(section, /launch only on `proceed`/i);
115
+ assert.match(section, /blocked.*do not launch/i);
116
+ assert.match(section, /complete.*do not launch/i);
117
+ });
118
+
119
+ test("workflow requires distinct request IDs with own-ID-only idempotent replay", () => {
120
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
121
+ assert.match(section, /distinct from acquire/i);
122
+ assert.match(section, /own ID only for idempotent replay/i);
123
+ });
124
+
125
+ test("workflow acquire command line binds every mandatory payload argument", () => {
126
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
127
+ const acquire = extractCommandLine(section, "gentle-ai sdd-attempt acquire ");
128
+ for (const arg of [
129
+ "--cwd <repo>",
130
+ "--change <change>",
131
+ "--request-id <id>",
132
+ "--work-unit <label>",
133
+ "--evidence-goal <goal>",
134
+ "--max-attempts <count>",
135
+ "--max-changed-lines <count>",
136
+ ]) {
137
+ assert.ok(acquire.includes(arg), `acquire command is missing ${arg}; command: ${acquire}`);
138
+ }
139
+ });
140
+
141
+ test("workflow settle command line binds mandatory arguments and routing invariants", () => {
142
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
143
+ const settle = extractCommandLine(section, "gentle-ai sdd-attempt settle ");
144
+ for (const arg of [
145
+ "--cwd <repo>",
146
+ "--change <change>",
147
+ "--token <token>",
148
+ "--request-id <id>",
149
+ "--outcome <failed|interrupted|passed>",
150
+ "--evidence-revision <sha256:...>",
151
+ "--diagnosis <text>",
152
+ "--harness-disposition <reused|invalidated>",
153
+ "--cleanup-evidence <text>",
154
+ "--process-evidence <text>",
155
+ ]) {
156
+ assert.ok(settle.includes(arg), `settle command is missing ${arg}; command: ${settle}`);
157
+ }
158
+ assert.match(section, /never `none`/);
159
+ assert.match(section, /proceed\|blocked\|complete/);
160
+ assert.match(section, /--successor-lineage/);
161
+ assert.match(section, /--remediates-evidence-revision/);
162
+ });
163
+
164
+ test("workflow forbids caller-authored counters and Pi-owned attempt state", () => {
165
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
166
+ assert.match(section, /never persist caller-authored/i);
167
+ assert.match(section, /OpenSpec artifacts, Engram memory/);
168
+ assert.match(section, /counter|token store|state machine|interception/i);
169
+ });
170
+
171
+ test("workflow states reset is never automatic and names compatibility surfaces", () => {
172
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
173
+ assert.match(section, /`status`, `begin`, `finish`, and `reset`/);
174
+ assert.match(section, /diagnostic\/compatibility surfaces/);
175
+ assert.match(section, /not the normal runtime route/i);
176
+ assert.match(section, /reset.*never automatic/i);
177
+ assert.match(section, /explicit maintainer scope decision/);
178
+ });
179
+
180
+ test("workflow gatekeeper rerun is subordinate to a fresh native acquire", () => {
181
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
182
+ assert.match(section, /rerun never bypasses native attempt authority/i);
183
+ assert.match(section, /fresh compact acquire/i);
184
+ // The gatekeeper quality rule is preserved (not deleted).
185
+ const gatekeeper = readMarkdownSection(read(WORKFLOW), "Automatic Mode Gatekeeper");
186
+ assert.match(gatekeeper, /rerun the same SDD phase once with corrective feedback/);
187
+ assert.match(gatekeeper, /Validate the rerun/);
188
+ });
189
+
190
+ test("workflow does not present status|begin|finish|reset as the primary route", () => {
191
+ const section = readMarkdownSection(read(WORKFLOW), SECTION);
192
+ assert.match(section, /not the normal runtime route/i);
193
+ assert.doesNotMatch(
194
+ section,
195
+ /primary route.*sdd-attempt (?:status|begin|finish|reset)/i,
196
+ );
197
+ });
198
+
199
+ test("status contract authority is artifact-store agnostic and excluded from SDD v1 status", () => {
200
+ const section = readMarkdownSection(read(STATUS_CONTRACT), SECTION);
201
+ assert.match(section, /artifact-store agnostic/i);
202
+ assert.match(section, /MUST NOT be embedded in the SDD v1 status/i);
203
+ assert.match(section, /separate from artifact dispatch/i);
204
+ });
205
+
206
+ test("status contract continuation rule: acquire before launch, settle after run, distinct IDs, provider states route, reset never automatic", () => {
207
+ const section = readMarkdownSection(read(STATUS_CONTRACT), SECTION);
208
+ assert.match(section, /before every runtime-bearing/i);
209
+ assert.match(section, /after the external run completes it MUST settle/i);
210
+ assert.match(section, /distinct/i);
211
+ assert.match(section, /proceed\|blocked\|complete/);
212
+ assert.match(section, /launch only on `proceed`/i);
213
+ assert.match(section, /reset.*never automatic/i);
214
+ });
215
+
216
+ test("status contract schema is unchanged (no attempt authority payload added)", () => {
217
+ const schema = readMarkdownSection(read(STATUS_CONTRACT), "Status Schema");
218
+ assert.doesNotMatch(
219
+ schema,
220
+ /attemptToken|attempt_token|attemptCount|attempt_counter|sddAttempt|sdd_attempt|nativeAttempt/i,
221
+ "Status Schema must not embed the runtime attempt authority payload",
222
+ );
223
+ });
224
+
225
+ test("status contract uses a runtime-safe semantic reference, not a repo-source assets/ path", () => {
226
+ const section = readMarkdownSection(read(STATUS_CONTRACT), SECTION);
227
+ assert.doesNotMatch(
228
+ section,
229
+ /See `assets\/sdd-orchestrator-workflow\.md`|see `assets\//i,
230
+ "status contract must not point installed consumers at a repo-source assets/ path; those are package source paths before installation",
231
+ );
232
+ assert.match(section, /lazy-loaded `SDD Orchestrator Workflow`/i);
233
+ assert.match(section, /Native Runtime Attempt Authority/);
234
+ assert.match(section, /Do not look up `assets\/\.\.\.` paths at runtime/i);
235
+ });