@planu/cli 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/config/environment-schema.json +161 -21
  3. package/dist/config/hook-templates/planu-session-safeguard.sh +14 -4
  4. package/dist/config/release-policy.json +0 -8
  5. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  6. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  7. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  8. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  9. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  10. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  11. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  21. package/dist/engine/session-safeguard/autopush.js +8 -2
  22. package/dist/engine/validation/validation-freshness.d.ts +20 -1
  23. package/dist/engine/validation/validation-freshness.js +181 -21
  24. package/dist/engine/validator/analyzer.d.ts +5 -2
  25. package/dist/engine/validator/analyzer.js +81 -23
  26. package/dist/engine/validator/deep-code-checker.d.ts +10 -0
  27. package/dist/engine/validator/deep-code-checker.js +33 -0
  28. package/dist/engine/validator/test-evidence-grounding.d.ts +56 -0
  29. package/dist/engine/validator/test-evidence-grounding.js +187 -0
  30. package/dist/engine/validator.js +15 -4
  31. package/dist/tools/reconcile-session-safeguard-hook.js +14 -3
  32. package/dist/tools/validate.js +6 -2
  33. package/dist/types/analysis.d.ts +2 -0
  34. package/dist/types/validation-receipt.d.ts +7 -0
  35. package/package.json +20 -20
  36. package/planu-native.json +1 -1
  37. package/planu-plugin.json +1 -1
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.2.0",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.1",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.2.0",
10
+ "version": "5.3.1",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "828e4ca1e0aac93c5a3ae1c4f8a306cc7eb306e765ea459cb323359075fe19ec"
14
+ "content": "121e0cde6094d4812a1003f5e8d271c73180f87253d242c0ef2a3ce020114fb2"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.2.0",
1109
+ "ref": "pkg:cargo/planu-core@5.3.1",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.2.0",
4
- "engineVersion": "5.2.0",
5
- "buildId": "1-5.2.0-x86_64-unknown-linux-musl",
3
+ "cliVersion": "5.3.1",
4
+ "engineVersion": "5.3.1",
5
+ "buildId": "1-5.3.1-x86_64-unknown-linux-musl",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "linux",
17
17
  "arch": "x64",
18
18
  "libc": "musl",
19
- "artifactSha256": "ecca8121c6a5d0163641a2f612736adb2dfa19d2d576f189b827c802426d1c6d",
19
+ "artifactSha256": "8bca20fd7b4458f07f9993babe7ae9417bbce0f8dadac3cd6e723bfe5162e405",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.linux-x64-musl.node.sbom.json",
23
- "sha256": "fafb2001aba46875a02bf13be9ed6931dc79d2a3f47659914c4a8c8637f6cf49"
23
+ "sha256": "d6638d7152102d2ba32851fd2df269e4e2a2a2ddc5fc788f4602c0dd22c0ae42"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "121065e63e9fd2c067367e286ecba619ecc3f18a",
27
+ "sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "yy7pcnf8jJzWrYp8gYHwG+Gbncuzx8PKYDAmsqdeXPSFCDuwDlkK8os3Arr0fJZp+uVWH4oFbYY4xuhf4/VsAw=="
36
+ "value": "vZ9UA5RzHSuz+/P18RorLLyH92erubxrJwq4nW766XwepyzlM0m8ol3QYfyrvYvHy4oP9ptee40YvsmexCQcBg=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.2.0",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.1",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.2.0",
10
+ "version": "5.3.1",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "ecca8121c6a5d0163641a2f612736adb2dfa19d2d576f189b827c802426d1c6d"
14
+ "content": "8bca20fd7b4458f07f9993babe7ae9417bbce0f8dadac3cd6e723bfe5162e405"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.2.0",
1109
+ "ref": "pkg:cargo/planu-core@5.3.1",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.2.0",
4
- "engineVersion": "5.2.0",
5
- "buildId": "1-5.2.0-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.1",
4
+ "engineVersion": "5.3.1",
5
+ "buildId": "1-5.3.1-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "1e69721c524c6f6a970d4644c5483529bc6ec17f8174f4f418a5b9191a3a99ef",
19
+ "artifactSha256": "dbc7bc2092e9280e9cbd9c18d472acc57b171fb51b34ac403d36858e460a5558",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "bb526f914a2f60f431c01c953fe8cd0f02068f7faa5feb7d53289b4beb76e311"
23
+ "sha256": "ac0dfa26e7466da91c35ffff0b44eddfe0e871fb4ba4e2527d74b7e6e4ad2166"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "121065e63e9fd2c067367e286ecba619ecc3f18a",
27
+ "sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "C/mDKI2Cq6Z/aMPgGQx5kESd4Ow363fsf/qzdtv/rLuAGMKeMy8ukvLyV7yYMB7BGrlNaGU4o1l5pD349v0UDw=="
36
+ "value": "dXDAyNUV0maapcjOspyRQByaq39viQeMYq7iypA8UC9cUqcvOa33hojUp056RIkG+9dtu4wTFGO1GzF2oPDNCQ=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.2.0",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.1",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.2.0",
10
+ "version": "5.3.1",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "1e69721c524c6f6a970d4644c5483529bc6ec17f8174f4f418a5b9191a3a99ef"
14
+ "content": "dbc7bc2092e9280e9cbd9c18d472acc57b171fb51b34ac403d36858e460a5558"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.2.0",
1109
+ "ref": "pkg:cargo/planu-core@5.3.1",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.2.0",
4
- "engineVersion": "5.2.0",
5
- "buildId": "1-5.2.0-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.1",
4
+ "engineVersion": "5.3.1",
5
+ "buildId": "1-5.3.1-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "bb7d31e5232b18f0ec9e03196cffdc57e48ac6993b6df064ff85b60c37804b93",
19
+ "artifactSha256": "5d65e9be51ed1f1a391064fbf8892d3ac570c774854b36487d430ee7e7b0b5c8",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "5c12145fab572d9432e20fda2e9d16ef0615db05c7c1b8c75448791f5442172b"
23
+ "sha256": "6563cbe0310464b49325b14f1e7e17a94e476496dd9bd1f522dafabac5da5d18"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "121065e63e9fd2c067367e286ecba619ecc3f18a",
27
+ "sourceCommit": "cff22e20c8f95a4081485b852254fa5fbe1532ef",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "M7Szjm7IuOAy5X01ppGd4i2s7JZ3elesGf0vQcdpvAzIohLkXn5htr3w4F8kmAKy4GhB5YK/P7sjwXpc8SSTAg=="
36
+ "value": "z2Hd9RD31bHF9HmmWsymR4XVqxLPiNSrdCvtWBUXEEBlKdk8rEde8ZPd3BEJCeEsdX0akECDyN7LenOYuFirAQ=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.2.0",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.1",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.2.0",
10
+ "version": "5.3.1",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "bb7d31e5232b18f0ec9e03196cffdc57e48ac6993b6df064ff85b60c37804b93"
14
+ "content": "5d65e9be51ed1f1a391064fbf8892d3ac570c774854b36487d430ee7e7b0b5c8"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.2.0",
1109
+ "ref": "pkg:cargo/planu-core@5.3.1",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -2,7 +2,9 @@
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  const execFileAsync = promisify(execFile);
5
- const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop']);
5
+ // SPEC-1379: unified with DEFAULT_PROTECTED_BRANCHES (git-helpers.ts) plus the
6
+ // `release` mirror branch; the shell hook template mirrors this same set.
7
+ const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'release', 'production']);
6
8
  /** Resolve the current branch name for the given projectPath. */
7
9
  async function getCurrentBranch(projectPath) {
8
10
  try {
@@ -88,7 +90,11 @@ export async function safeAutopush(projectPath, allowedPatterns = ['feat/*', 'fi
88
90
  };
89
91
  }
90
92
  try {
91
- await execFileAsync('git', ['push', '--ff-only', 'origin', branch], { cwd: projectPath });
93
+ // No --force (git push has no --ff-only flag): the receiving remote
94
+ // rejects any non-fast-forward push, which is the ff-only guarantee.
95
+ await execFileAsync('git', ['push', '--end-of-options', 'origin', branch], {
96
+ cwd: projectPath,
97
+ });
92
98
  return { pushed: true, commitsPushed: commitCount, ref: `origin/${branch}` };
93
99
  }
94
100
  catch (err) {
@@ -1,4 +1,4 @@
1
- import type { ValidationFreshnessLease, ValidationReceiptBindings } from '../../types/validation-receipt.js';
1
+ import type { ValidationFreshnessLease, ValidationReceiptBindings, ValidationWatchEventClassification } from '../../types/validation-receipt.js';
2
2
  export type { ValidationFreshnessLease } from '../../types/validation-receipt.js';
3
3
  /**
4
4
  * Invalidate the active validation lease for a spec.
@@ -10,6 +10,25 @@ export declare function invalidateValidationFreshness(input: {
10
10
  projectId: string;
11
11
  specId: string;
12
12
  }): boolean;
13
+ /**
14
+ * Classify one raw watcher event as a direct relevant/ignored child path, a
15
+ * synthetic root event (macOS emits these for the watched directory itself),
16
+ * or an ambiguous filename-less event that needs authoritative verification
17
+ * before it can be trusted to mean real byte drift.
18
+ */
19
+ export declare function classifyValidationWatchEventForTests(input: {
20
+ filename: string | null;
21
+ watchedRoot: string;
22
+ relevant: (filename: string) => boolean;
23
+ }): ValidationWatchEventClassification;
24
+ /** Inject one classified watcher event without relying on host-specific fs.watch timing. */
25
+ export declare function injectValidationWatchEventForTests(input: {
26
+ lease: Pick<ValidationFreshnessLease, 'projectId' | 'specId' | 'epoch'>;
27
+ scope: 'project' | 'handoff';
28
+ filename: string | null;
29
+ }): ValidationWatchEventClassification | undefined;
30
+ /** Pause or observe authoritative verification turns without changing watcher topology. */
31
+ export declare function setValidationFreshnessVerificationHookForTests(hook: (() => Promise<void>) | undefined): void;
13
32
  /** Start both watchers before snapshotting and invalidate any older receipt immediately. */
14
33
  export declare function beginValidationFreshnessBarrier(input: {
15
34
  projectId: string;
@@ -67,6 +67,7 @@ const FINGERPRINT_CHUNK_BYTES = 64 * 1024;
67
67
  const EVIDENCE_FILE = /^(?:discovery|task-plan|traceability-matrix|contract-validation-(?:api|graphql|event|ui|mcp))\.json$/u;
68
68
  const active = new Map();
69
69
  const beginQueues = new Map();
70
+ let verificationHookForTests;
70
71
  function key(projectId, specId) {
71
72
  return `${projectId}:${specId}`;
72
73
  }
@@ -149,18 +150,57 @@ export function invalidateValidationFreshness(input) {
149
150
  runInvalidationTurn(input.projectId, input.specId, state);
150
151
  return true;
151
152
  }
152
- function startWatch(path, relevant, onRelevantChange, onError) {
153
+ /**
154
+ * Invalidate only if `expected` is still the exact live state for this
155
+ * identity. `invalidateValidationFreshness` mutates whatever is currently
156
+ * active for a projectId/specId regardless of epoch — callers holding a
157
+ * lease captured before an `await` must use this guard so a stale lease can
158
+ * never invalidate the epoch that replaced it.
159
+ */
160
+ function invalidateExactEpoch(lease, expected) {
161
+ if (active.get(key(lease.projectId, lease.specId)) !== expected) {
162
+ return false;
163
+ }
164
+ return invalidateValidationFreshness(lease);
165
+ }
166
+ /**
167
+ * Classify one raw watcher event as a direct relevant/ignored child path, a
168
+ * synthetic root event (macOS emits these for the watched directory itself),
169
+ * or an ambiguous filename-less event that needs authoritative verification
170
+ * before it can be trusted to mean real byte drift.
171
+ */
172
+ export function classifyValidationWatchEventForTests(input) {
173
+ if (!input.filename) {
174
+ return 'ambiguous';
175
+ }
176
+ const normalized = input.filename.replaceAll('\\', '/');
177
+ if (normalized === input.watchedRoot ||
178
+ normalized === `${input.watchedRoot}/` ||
179
+ normalized === '.' ||
180
+ normalized === './') {
181
+ return 'synthetic';
182
+ }
183
+ const rootPrefix = `${input.watchedRoot}/`;
184
+ const relative = normalized.startsWith(rootPrefix)
185
+ ? normalized.slice(rootPrefix.length)
186
+ : normalized;
187
+ return input.relevant(relative) ? 'direct-relevant' : 'ignored';
188
+ }
189
+ function startWatch(path, relevant, onRelevantChange, onAmbiguousChange, onError) {
153
190
  try {
154
191
  const watchedRoot = basename(path);
155
- const watcher = watch(path, { recursive: true }, (event, filename) => {
156
- // macOS emits a synthetic `change` for the watched directory itself in
157
- // addition to the actionable relative child event.
158
- if (event === 'change' && filename === watchedRoot) {
159
- return;
160
- }
161
- if (!filename || relevant(filename.replaceAll('\\', '/'))) {
192
+ const watcher = watch(path, { recursive: true }, (_event, filename) => {
193
+ const classification = classifyValidationWatchEventForTests({
194
+ filename,
195
+ watchedRoot,
196
+ relevant,
197
+ });
198
+ if (classification === 'direct-relevant') {
162
199
  onRelevantChange();
163
200
  }
201
+ else if (classification === 'synthetic' || classification === 'ambiguous') {
202
+ onAmbiguousChange();
203
+ }
164
204
  });
165
205
  watcher.on('error', onError);
166
206
  watcher.unref();
@@ -172,6 +212,111 @@ function startWatch(path, relevant, onRelevantChange, onError) {
172
212
  return undefined;
173
213
  }
174
214
  }
215
+ /** Inject one classified watcher event without relying on host-specific fs.watch timing. */
216
+ export function injectValidationWatchEventForTests(input) {
217
+ const state = active.get(key(input.lease.projectId, input.lease.specId));
218
+ if (state?.epoch !== input.lease.epoch || !state.accepting) {
219
+ return undefined;
220
+ }
221
+ const projectScope = input.scope === 'project';
222
+ const classification = classifyValidationWatchEventForTests({
223
+ filename: input.filename,
224
+ watchedRoot: basename(projectScope ? state.projectPath : state.handoffPath),
225
+ relevant: projectScope ? isValidationSourcePath : (filename) => EVIDENCE_FILE.test(filename),
226
+ });
227
+ if (classification === 'direct-relevant') {
228
+ invalidateValidationFreshness(input.lease);
229
+ }
230
+ else if (classification === 'synthetic' || classification === 'ambiguous') {
231
+ noteAmbiguousEvent(state);
232
+ }
233
+ return classification;
234
+ }
235
+ /** Pause or observe authoritative verification turns without changing watcher topology. */
236
+ export function setValidationFreshnessVerificationHookForTests(hook) {
237
+ verificationHookForTests = hook;
238
+ }
239
+ function noteAmbiguousEvent(state) {
240
+ // Every synthetic or filename-less event counts, even while a cycle is
241
+ // already in flight: the in-flight cycle's target snapshot was captured
242
+ // before this event arrived, so skipping it here would let a mutation
243
+ // signaled only by this event slip past with no follow-up verification
244
+ // turn. The drain loop still bounds the total work to one leading and one
245
+ // trailing turn regardless of how many events land during a cycle.
246
+ state.ambiguousEvents += 1;
247
+ }
248
+ /**
249
+ * Run one authoritative verification turn confirming the exact epoch, source
250
+ * digest, and evidence fingerprint are unchanged as of `targetEvent`.
251
+ */
252
+ async function runAmbiguousVerificationTurn(lease, state, bindings, targetEvent) {
253
+ const generationBefore = state.generation;
254
+ try {
255
+ await verificationHookForTests?.();
256
+ const fingerprintBefore = await evidenceFingerprint(state.handoffPath);
257
+ const sourceDigest = await gitSourceTreeDigest(state.projectPath);
258
+ const fingerprintAfter = await evidenceFingerprint(state.handoffPath);
259
+ const confirmed = active.get(key(lease.projectId, lease.specId));
260
+ if (confirmed !== state ||
261
+ confirmed.epoch !== lease.epoch ||
262
+ !confirmed.healthy ||
263
+ confirmed.generation !== generationBefore ||
264
+ sourceDigest !== bindings.sourceDigest ||
265
+ fingerprintBefore !== state.evidenceFingerprint ||
266
+ fingerprintAfter !== fingerprintBefore) {
267
+ invalidateExactEpoch(lease, state);
268
+ return false;
269
+ }
270
+ state.verifiedAmbiguousEvents = Math.max(state.verifiedAmbiguousEvents, targetEvent);
271
+ return true;
272
+ }
273
+ catch (error) {
274
+ reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
275
+ invalidateExactEpoch(lease, state);
276
+ return false;
277
+ }
278
+ }
279
+ /**
280
+ * Run the bounded leading-plus-trailing verification cycle for every
281
+ * ambiguous event noted so far. Every concurrent caller shares the SAME
282
+ * cycle promise for its full duration — joining mid-cycle can never start an
283
+ * independent third turn, which keeps AC4's "at most one leading and one
284
+ * trailing turn" bound even when callers interleave with new events.
285
+ */
286
+ function runAmbiguousDrainCycle(lease, state, bindings) {
287
+ const cycle = (async () => {
288
+ for (let turnIndex = 0; turnIndex < 2; turnIndex += 1) {
289
+ if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
290
+ return true;
291
+ }
292
+ if (!bindings) {
293
+ invalidateExactEpoch(lease, state);
294
+ return false;
295
+ }
296
+ const targetEvent = state.ambiguousEvents;
297
+ if (!(await runAmbiguousVerificationTurn(lease, state, bindings, targetEvent))) {
298
+ return false;
299
+ }
300
+ }
301
+ if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
302
+ return true;
303
+ }
304
+ invalidateExactEpoch(lease, state);
305
+ return false;
306
+ })().finally(() => {
307
+ if (state.ambiguousDrainCycle === cycle) {
308
+ state.ambiguousDrainCycle = undefined;
309
+ }
310
+ });
311
+ return cycle;
312
+ }
313
+ async function drainAmbiguousEvents(lease, state, bindings) {
314
+ if (state.ambiguousEvents === state.verifiedAmbiguousEvents) {
315
+ return true;
316
+ }
317
+ state.ambiguousDrainCycle ??= runAmbiguousDrainCycle(lease, state, bindings);
318
+ return state.ambiguousDrainCycle;
319
+ }
175
320
  async function evidenceFingerprint(path) {
176
321
  const names = (await readdir(path, { withFileTypes: true }))
177
322
  .filter((entry) => EVIDENCE_FILE.test(entry.name))
@@ -263,6 +408,8 @@ export async function beginValidationFreshnessBarrier(input) {
263
408
  epoch: randomUUID(),
264
409
  expiresAt: Date.now() + policy.freshnessLeaseTtlMs,
265
410
  generation: 0,
411
+ ambiguousEvents: 0,
412
+ verifiedAmbiguousEvents: 0,
266
413
  healthy: true,
267
414
  accepting: true,
268
415
  projectPath: input.projectPath,
@@ -279,14 +426,19 @@ export async function beginValidationFreshnessBarrier(input) {
279
426
  invalidateValidationFreshness(input);
280
427
  }
281
428
  };
429
+ const ambiguous = () => {
430
+ if (active.get(identity)?.epoch === watchedEpoch && state.accepting) {
431
+ noteAmbiguousEvent(state);
432
+ }
433
+ };
282
434
  const failed = () => {
283
435
  if (active.get(identity)?.epoch === watchedEpoch && state.accepting) {
284
436
  state.healthy = false;
285
437
  changed();
286
438
  }
287
439
  };
288
- const projectWatcher = startWatch(input.projectPath, isValidationSourcePath, changed, failed);
289
- const handoffWatcher = startWatch(input.handoffPath, (filename) => EVIDENCE_FILE.test(filename), changed, failed);
440
+ const projectWatcher = startWatch(input.projectPath, isValidationSourcePath, changed, ambiguous, failed);
441
+ const handoffWatcher = startWatch(input.handoffPath, (filename) => EVIDENCE_FILE.test(filename), changed, ambiguous, failed);
290
442
  if (!projectWatcher || !handoffWatcher) {
291
443
  projectWatcher?.close();
292
444
  handoffWatcher?.close();
@@ -346,10 +498,11 @@ export async function closeValidationFreshnessLease(lease) {
346
498
  return;
347
499
  }
348
500
  state.closing ??= (async () => {
349
- state.accepting = false;
350
501
  state.watchers.splice(0).forEach((watcher) => {
351
502
  watcher.close();
352
503
  });
504
+ await drainAmbiguousEvents(lease, state, state.bindings);
505
+ state.accepting = false;
353
506
  if (state.ttlTimer) {
354
507
  clearTimeout(state.ttlTimer);
355
508
  }
@@ -386,27 +539,30 @@ export async function bindValidationFreshnessLease(lease, bindings) {
386
539
  Date.now() >= state.expiresAt) {
387
540
  return false;
388
541
  }
542
+ if (!(await drainAmbiguousEvents(lease, state, bindings))) {
543
+ return false;
544
+ }
389
545
  let fingerprintBefore;
390
546
  try {
391
547
  fingerprintBefore = await evidenceFingerprint(state.handoffPath);
392
548
  if ((await gitSourceTreeDigest(state.projectPath)) !== bindings.sourceDigest) {
393
- invalidateValidationFreshness(lease);
549
+ invalidateExactEpoch(lease, state);
394
550
  return false;
395
551
  }
396
552
  }
397
553
  catch (error) {
398
554
  reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
399
- invalidateValidationFreshness(lease);
555
+ invalidateExactEpoch(lease, state);
400
556
  return false;
401
557
  }
402
558
  if (fingerprintBefore !== state.evidenceFingerprint) {
403
- invalidateValidationFreshness(lease);
559
+ invalidateExactEpoch(lease, state);
404
560
  return false;
405
561
  }
406
562
  const confirmed = active.get(key(lease.projectId, lease.specId));
407
563
  if (!confirmed?.healthy ||
408
564
  confirmed.epoch !== state.epoch ||
409
- confirmed.generation !== state.generation) {
565
+ confirmed.generation !== lease.generation) {
410
566
  return false;
411
567
  }
412
568
  confirmed.bindings = structuredClone(bindings);
@@ -443,29 +599,32 @@ export async function assertValidationFreshnessBarrier(lease, bindings) {
443
599
  candidate.generation !== lease.generation) {
444
600
  return false;
445
601
  }
602
+ if (!(await drainAmbiguousEvents(lease, candidate, candidate.bindings))) {
603
+ return false;
604
+ }
446
605
  let fingerprintBefore;
447
606
  try {
448
607
  fingerprintBefore = await evidenceFingerprint(candidate.handoffPath);
449
608
  }
450
609
  catch (error) {
451
610
  reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
452
- invalidateValidationFreshness(lease);
611
+ invalidateExactEpoch(lease, candidate);
453
612
  return false;
454
613
  }
455
614
  if (fingerprintBefore !== candidate.evidenceFingerprint) {
456
- invalidateValidationFreshness(lease);
615
+ invalidateExactEpoch(lease, candidate);
457
616
  return false;
458
617
  }
459
618
  try {
460
619
  if (candidate.bindings &&
461
620
  (await gitSourceTreeDigest(candidate.projectPath)) !== candidate.bindings.sourceDigest) {
462
- invalidateValidationFreshness(lease);
621
+ invalidateExactEpoch(lease, candidate);
463
622
  return false;
464
623
  }
465
624
  }
466
625
  catch (error) {
467
626
  reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
468
- invalidateValidationFreshness(lease);
627
+ invalidateExactEpoch(lease, candidate);
469
628
  return false;
470
629
  }
471
630
  // A generation change invalidates the lease synchronously. Do not wait for
@@ -481,7 +640,7 @@ export async function assertValidationFreshnessBarrier(lease, bindings) {
481
640
  fingerprintAfter = undefined;
482
641
  }
483
642
  if (!state || fingerprintAfter !== fingerprintBefore) {
484
- invalidateValidationFreshness(lease);
643
+ invalidateExactEpoch(lease, candidate);
485
644
  return false;
486
645
  }
487
646
  if (state.generation !== lease.generation) {
@@ -518,7 +677,7 @@ export async function rebaselineValidationFreshnessLeaseAfterRecompute(input) {
518
677
  }
519
678
  catch (error) {
520
679
  reportClassifiedDegradation('VALIDATION_FRESHNESS_CHECK_FAILED', error);
521
- invalidateValidationFreshness(input.lease);
680
+ invalidateExactEpoch(input.lease, before);
522
681
  return undefined;
523
682
  }
524
683
  const after = await readStableState(input.lease);
@@ -549,6 +708,7 @@ export async function resetValidationFreshnessForTests() {
549
708
  });
550
709
  await Promise.all(leases.map((lease) => closeValidationFreshnessLease(lease)));
551
710
  beginQueues.clear();
711
+ verificationHookForTests = undefined;
552
712
  }
553
713
  /** Count-only process diagnostic; intentionally absent from the package entrypoint. */
554
714
  export function getValidationFreshnessDiagnosticsForTests() {
@@ -12,8 +12,11 @@ export declare function scanCodeForSpec(spec: Spec, projectPath: string): Promis
12
12
  * buildFileContentsCache from criterion-matcher) to avoid a redundant
13
13
  * glob+readFile pass when checking multiple criteria in the same call.
14
14
  */
15
- export declare function checkCriterionEvidence(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>): Promise<CriterionEvidenceResult>;
16
- export declare function checkCriterion(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>): Promise<boolean>;
15
+ export declare function checkCriterionEvidence(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>, declaredTestPaths?: string[], probeCache?: Map<string, {
16
+ passed: boolean;
17
+ evidence: string;
18
+ }>): Promise<CriterionEvidenceResult>;
19
+ export declare function checkCriterion(criterion: string, projectPath: string, codeState: CodeState, verifyBlock?: VerifyBlock, sharedFileContents?: Map<string, string>, declaredTestPaths?: string[]): Promise<boolean>;
17
20
  /**
18
21
  * Classify the severity of a missing criterion based on its content.
19
22
  * Keywords and severity levels are loaded from src/config/drift-severity.json (SPEC-209).