@deftai/directive-core 0.93.0 → 0.95.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 (118) hide show
  1. package/dist/authz/classify.js +485 -0
  2. package/dist/cache/fetch.d.ts +29 -0
  3. package/dist/cache/fetch.js +131 -4
  4. package/dist/cache/operations.d.ts +8 -0
  5. package/dist/cache/operations.js +67 -4
  6. package/dist/check/cached-orchestrator.js +11 -0
  7. package/dist/check/consumer-gate-integrity.d.ts +68 -0
  8. package/dist/check/consumer-gate-integrity.js +265 -0
  9. package/dist/check/index.d.ts +1 -0
  10. package/dist/check/index.js +1 -0
  11. package/dist/check/orchestrator.js +10 -0
  12. package/dist/content-contracts/skills/greptile-detector.d.ts +9 -0
  13. package/dist/content-contracts/skills/greptile-detector.js +9 -2
  14. package/dist/doctor/main.js +98 -9
  15. package/dist/doctor/types.d.ts +2 -2
  16. package/dist/finish-loop/pr-finish-loop.js +1 -0
  17. package/dist/freshness/bind.d.ts +67 -0
  18. package/dist/freshness/bind.js +201 -0
  19. package/dist/freshness/cli.d.ts +30 -0
  20. package/dist/freshness/cli.js +180 -0
  21. package/dist/freshness/compare.d.ts +14 -0
  22. package/dist/freshness/compare.js +134 -0
  23. package/dist/freshness/generation.d.ts +44 -0
  24. package/dist/freshness/generation.js +172 -0
  25. package/dist/freshness/index.d.ts +13 -0
  26. package/dist/freshness/index.js +13 -0
  27. package/dist/freshness/report.d.ts +43 -0
  28. package/dist/freshness/report.js +139 -0
  29. package/dist/freshness/types.d.ts +70 -0
  30. package/dist/freshness/types.js +30 -0
  31. package/dist/handoff-evidence/index.d.ts +8 -0
  32. package/dist/handoff-evidence/index.js +7 -0
  33. package/dist/handoff-evidence/validate.d.ts +105 -0
  34. package/dist/handoff-evidence/validate.js +423 -0
  35. package/dist/hooks/dispatcher.d.ts +27 -2
  36. package/dist/hooks/dispatcher.js +134 -14
  37. package/dist/hooks/readonly.d.ts +14 -0
  38. package/dist/hooks/readonly.js +126 -0
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +2 -0
  41. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  42. package/dist/init-deposit/agent-hooks.js +5 -2
  43. package/dist/init-deposit/init-deposit.d.ts +7 -1
  44. package/dist/init-deposit/init-deposit.js +24 -4
  45. package/dist/init-deposit/refresh.d.ts +9 -1
  46. package/dist/init-deposit/refresh.js +48 -5
  47. package/dist/intake/issue-ingest.d.ts +20 -0
  48. package/dist/intake/issue-ingest.js +58 -0
  49. package/dist/policy/index.d.ts +4 -0
  50. package/dist/policy/index.js +50 -1
  51. package/dist/policy/min-greptile-confidence.d.ts +57 -0
  52. package/dist/policy/min-greptile-confidence.js +123 -0
  53. package/dist/pr-merge-readiness/compute.d.ts +11 -1
  54. package/dist/pr-merge-readiness/compute.js +17 -4
  55. package/dist/pr-merge-readiness/evaluate.d.ts +8 -1
  56. package/dist/pr-merge-readiness/evaluate.js +7 -4
  57. package/dist/pr-merge-readiness/mergeability.d.ts +7 -1
  58. package/dist/pr-merge-readiness/mergeability.js +14 -2
  59. package/dist/pr-monitor/main.d.ts +2 -0
  60. package/dist/pr-monitor/main.js +31 -1
  61. package/dist/pr-monitor/monitor.js +5 -1
  62. package/dist/pr-monitor/readiness.js +3 -1
  63. package/dist/pr-monitor/types.d.ts +8 -0
  64. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  65. package/dist/pr-wait-mergeable/cascade.js +28 -1
  66. package/dist/pr-wait-mergeable/main.d.ts +2 -0
  67. package/dist/pr-wait-mergeable/main.js +16 -0
  68. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  69. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  70. package/dist/pr-wait-mergeable/wrappers.js +3 -0
  71. package/dist/pr-watch/constants.d.ts +4 -1
  72. package/dist/pr-watch/constants.js +5 -2
  73. package/dist/pr-watch/main.js +1 -0
  74. package/dist/pr-watch/probe.d.ts +1 -1
  75. package/dist/pr-watch/probe.js +4 -1
  76. package/dist/pr-watch/types.d.ts +7 -1
  77. package/dist/pr-watch/watch.js +3 -2
  78. package/dist/release-e2e/greenfield-python-free-smoke.js +7 -3
  79. package/dist/review-monitor/index.d.ts +1 -0
  80. package/dist/review-monitor/index.js +1 -0
  81. package/dist/review-monitor/l4-owner.d.ts +46 -0
  82. package/dist/review-monitor/l4-owner.js +262 -0
  83. package/dist/session/ritual-entrypoint.d.ts +6 -2
  84. package/dist/session/ritual-entrypoint.js +15 -2
  85. package/dist/session/session-ready.js +31 -14
  86. package/dist/session/session-start.d.ts +2 -1
  87. package/dist/session/session-start.js +66 -3
  88. package/dist/session/verify-session-ritual.d.ts +4 -2
  89. package/dist/session/verify-session-ritual.js +11 -11
  90. package/dist/slash/product-set.js +4 -1
  91. package/dist/swarm/verify-review-clean.d.ts +4 -3
  92. package/dist/swarm/verify-review-clean.js +28 -73
  93. package/dist/tool-events/classify.js +1 -0
  94. package/dist/triage/classify/index.d.ts +9 -0
  95. package/dist/triage/classify/index.js +34 -2
  96. package/dist/triage/classify/label-mirror.d.ts +143 -0
  97. package/dist/triage/classify/label-mirror.js +684 -0
  98. package/dist/triage/help/registry-data.d.ts +7 -7
  99. package/dist/triage/help/registry-data.js +27 -7
  100. package/dist/triage/queue/show.d.ts +1 -1
  101. package/dist/triage/queue/show.js +4 -2
  102. package/dist/triage/welcome/writers.js +16 -7
  103. package/dist/umbrella-current-shape/index.d.ts +45 -0
  104. package/dist/umbrella-current-shape/index.js +162 -8
  105. package/dist/vbrief-reconcile/index.d.ts +2 -2
  106. package/dist/vbrief-reconcile/index.js +1 -1
  107. package/dist/vbrief-reconcile/types.d.ts +14 -0
  108. package/dist/vbrief-reconcile/umbrellas.d.ts +31 -2
  109. package/dist/vbrief-reconcile/umbrellas.js +231 -19
  110. package/dist/vbrief-validate/project-definition.js +1 -1
  111. package/dist/verify-env/agent-hook-readiness.d.ts +42 -0
  112. package/dist/verify-env/agent-hook-readiness.js +150 -0
  113. package/dist/verify-env/agent-hooks-live-probe.d.ts +16 -4
  114. package/dist/verify-env/agent-hooks-live-probe.js +120 -107
  115. package/dist/verify-env/agent-hooks.js +11 -3
  116. package/dist/verify-env/index.d.ts +1 -0
  117. package/dist/verify-env/index.js +1 -0
  118. package/package.json +7 -3
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Deterministic handoff-evidence validator (#3120).
3
+ *
4
+ * status pass is forbidden when any remote artifact is claimed unless
5
+ * proof_status is bound and same-turn probe snippets cover each claim.
6
+ * Invented-done (false remote artifacts under pass) ranks stricter than
7
+ * empty-done (pass with no completion substance).
8
+ */
9
+ const PROOF_STATUSES = new Set(["bound", "unbound", "n/a-no-remote-claim"]);
10
+ const AXIS_STATES = new Set(["done", "in_progress", "not_started", "blocked", "n/a"]);
11
+ const STATUS_VALUES = new Set(["pass", "fail", "blocked", "partial"]);
12
+ function isNonEmptyString(value) {
13
+ return typeof value === "string" && value.trim().length > 0;
14
+ }
15
+ function isPresentClaim(value) {
16
+ if (value === null || value === undefined)
17
+ return false;
18
+ if (typeof value === "string")
19
+ return value.trim().length > 0;
20
+ if (typeof value === "number")
21
+ return Number.isFinite(value);
22
+ return true;
23
+ }
24
+ /** Commands that look like same-turn forge/git/gate tool invocations. */
25
+ const TOOLISH_CMD_RE = /^(?:gh\b|git\b|task\b|deft\b|directive\b|pnpm\b|npm\b|npx\b|curl\b|python\b|node\b)/i;
26
+ function probePresent(probe) {
27
+ if (!probe)
28
+ return false;
29
+ if (!isNonEmptyString(probe.command) || !isNonEmptyString(probe.snippet))
30
+ return false;
31
+ // Reject freeform narration commands ("I checked" / "x") — require tool-like invocation.
32
+ if (!TOOLISH_CMD_RE.test(probe.command.trim()))
33
+ return false;
34
+ // Trivial snippet that is only the claim itself is not a probe payload.
35
+ if (probe.snippet.trim().length < 8)
36
+ return false;
37
+ return true;
38
+ }
39
+ /** Escape untrusted claim text before interpolating into RegExp constructors. */
40
+ function escapeRe(value) {
41
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+ /**
44
+ * Token-boundary match: needle must appear not as a substring of a longer
45
+ * alphanumeric identifier (PR 12 ⊄ 3120; score 5 ⊄ elapsed=15s).
46
+ */
47
+ function hasToken(snippet, needle) {
48
+ if (!needle)
49
+ return false;
50
+ try {
51
+ return new RegExp(`(^|[^A-Za-z0-9_])${escapeRe(needle)}([^A-Za-z0-9_]|$)`, "i").test(snippet);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /**
58
+ * True when the probe's raw snippet actually mentions the claimed remote value
59
+ * with token-level binding (not incidental substring collisions).
60
+ * Never throws on malformed claims — returns false instead.
61
+ *
62
+ * Scope: textual shape binding only. Live probe execution/provenance is agent
63
+ * process responsibility (preamble probe-then-fill); this library does not
64
+ * re-run forge/git commands.
65
+ */
66
+ export function probeBindsClaim(claim, evidence, probe) {
67
+ try {
68
+ if (!probePresent(probe) || !probe)
69
+ return false;
70
+ const snippet = probe.snippet;
71
+ switch (claim) {
72
+ case "pr_url": {
73
+ const url = String(evidence.pr_url ?? "").trim();
74
+ if (!url)
75
+ return false;
76
+ // Exact claimed URL present in probe output.
77
+ if (snippet.toLowerCase().includes(url.toLowerCase()))
78
+ return true;
79
+ // GitHub HTML or API form must preserve owner/repo + number identity.
80
+ const html = url.match(/github\.com\/([^/]+)\/([^/]+)\/pulls?\/(\d+)/i);
81
+ if (html) {
82
+ const owner = html[1] ?? "";
83
+ const repo = html[2] ?? "";
84
+ const num = html[3] ?? "";
85
+ const o = escapeRe(owner);
86
+ const r = escapeRe(repo);
87
+ const n = escapeRe(num);
88
+ return (new RegExp(`${o}\\/${r}\\/pulls?\\/${n}(?:[^0-9]|$)`, "i").test(snippet) ||
89
+ new RegExp(`repos\\/${o}\\/${r}\\/pulls\\/${n}(?:[^0-9]|$)`, "i").test(snippet));
90
+ }
91
+ // Non-GitHub URL: require the full URL string; no number-only fallback.
92
+ return false;
93
+ }
94
+ case "pr_number": {
95
+ const num = String(evidence.pr_number ?? "").trim();
96
+ if (!num || !/^\d+$/.test(num))
97
+ return false;
98
+ const n = escapeRe(num);
99
+ return (new RegExp(`(?:number["'\\s:=]+|#|pulls?\\/)${n}(?:[^0-9]|$)`, "i").test(snippet) ||
100
+ hasToken(snippet, num));
101
+ }
102
+ case "commit_sha":
103
+ case "head_sha": {
104
+ const sha = String(claim === "commit_sha" ? (evidence.commit_sha ?? "") : (evidence.head_sha ?? ""))
105
+ .trim()
106
+ .toLowerCase();
107
+ if (sha.length < 7 || !/^[0-9a-f]+$/i.test(sha))
108
+ return false;
109
+ const prefix = sha.slice(0, 7);
110
+ return (hasToken(snippet, sha) ||
111
+ new RegExp(`(^|[^0-9a-f])${prefix}(?![0-9a-f])`, "i").test(snippet));
112
+ }
113
+ case "ci_status": {
114
+ const ci = String(evidence.ci_status ?? "")
115
+ .trim()
116
+ .toLowerCase();
117
+ if (!ci)
118
+ return false;
119
+ const greenFamily = [
120
+ "green",
121
+ "pass",
122
+ "passed",
123
+ "success",
124
+ "successful",
125
+ "ok",
126
+ "clean",
127
+ ];
128
+ const redFamily = [
129
+ "fail",
130
+ "failed",
131
+ "failure",
132
+ "failing",
133
+ "error",
134
+ "errored",
135
+ "cancelled",
136
+ "canceled",
137
+ ];
138
+ if (greenFamily.includes(ci)) {
139
+ const hasGreen = /"state"\s*:\s*"(success|successful|ok|clean|pass(?:ed)?)"/i.test(snippet) ||
140
+ greenFamily.some((tok) => hasToken(snippet, tok));
141
+ const hasRed = /"state"\s*:\s*"(failure|failed|error|cancelled|canceled)"/i.test(snippet) ||
142
+ redFamily.some((tok) => hasToken(snippet, tok));
143
+ // Mixed success+failure in one snippet is not a clean green bind.
144
+ return hasGreen && !hasRed;
145
+ }
146
+ if (redFamily.includes(ci)) {
147
+ return (/"state"\s*:\s*"(failure|failed|error|cancelled|canceled)"/i.test(snippet) ||
148
+ hasToken(snippet, ci));
149
+ }
150
+ return hasToken(snippet, ci);
151
+ }
152
+ case "review_score": {
153
+ const score = String(evidence.review_score ?? "").trim();
154
+ if (!score || !/^\d+(\.\d+)?$/.test(score))
155
+ return false;
156
+ const s = escapeRe(score);
157
+ // Labeled fields only — bare hasToken would bind p0=5 to review_score 5.
158
+ return new RegExp(`(?:confidence(?:\\s*score)?|score|rating)\\s*[:=]?\\s*${s}(?:\\s*\\/\\s*5)?(?:[^0-9]|$)`, "i").test(snippet);
159
+ }
160
+ default:
161
+ return false;
162
+ }
163
+ }
164
+ catch {
165
+ // Malformed claims must fail closed as unbound, never throw.
166
+ return false;
167
+ }
168
+ }
169
+ /** Detect which remote claim keys are present on the evidence object. */
170
+ export function detectRemoteClaims(evidence) {
171
+ const claims = [];
172
+ if (isPresentClaim(evidence.pr_url))
173
+ claims.push("pr_url");
174
+ if (isPresentClaim(evidence.pr_number))
175
+ claims.push("pr_number");
176
+ if (isPresentClaim(evidence.commit_sha))
177
+ claims.push("commit_sha");
178
+ if (isPresentClaim(evidence.head_sha))
179
+ claims.push("head_sha");
180
+ if (isPresentClaim(evidence.ci_status)) {
181
+ const ci = String(evidence.ci_status).trim();
182
+ // Neutral tokens are not remote claims; green-family and other explicit
183
+ // forge outcomes (failing, cancelled, …) are claims that need probes.
184
+ if (!/^(unknown|pending|n\/a|none|skipped)$/i.test(ci)) {
185
+ claims.push("ci_status");
186
+ }
187
+ }
188
+ if (isPresentClaim(evidence.review_score))
189
+ claims.push("review_score");
190
+ return claims;
191
+ }
192
+ /** Map a remote claim key to the probe slot that must bind it. */
193
+ function probeKeyForClaim(claim) {
194
+ if (claim === "pr_url" || claim === "pr_number")
195
+ return "pr";
196
+ if (claim === "commit_sha" || claim === "head_sha")
197
+ return "sha";
198
+ if (claim === "ci_status")
199
+ return "ci";
200
+ return "review";
201
+ }
202
+ function unboundClaimKeys(evidence, claims) {
203
+ const unbound = [];
204
+ for (const claim of claims) {
205
+ const slot = probeKeyForClaim(claim);
206
+ const probe = evidence.probes?.[slot];
207
+ if (!probeBindsClaim(claim, evidence, probe)) {
208
+ unbound.push(claim);
209
+ }
210
+ }
211
+ // Cross-claim consistency: PR + SHA must co-appear so unrelated forge artifacts
212
+ // cannot be stitched into one "bound" pass (Greptile residual on #3120).
213
+ try {
214
+ const claimSet = new Set(claims);
215
+ const hasPr = claimSet.has("pr_url") || claimSet.has("pr_number");
216
+ const shaClaim = claimSet.has("head_sha")
217
+ ? "head_sha"
218
+ : claimSet.has("commit_sha")
219
+ ? "commit_sha"
220
+ : null;
221
+ const rawSha = shaClaim
222
+ ? String(shaClaim === "head_sha" ? (evidence.head_sha ?? "") : (evidence.commit_sha ?? ""))
223
+ .trim()
224
+ .toLowerCase()
225
+ : "";
226
+ // Only hex SHAs participate in co-binding; malformed SHA fails closed as unbound.
227
+ const hexOk = /^[0-9a-f]{7,}$/i.test(rawSha);
228
+ if (shaClaim && !hexOk) {
229
+ if (!unbound.includes(shaClaim))
230
+ unbound.push(shaClaim);
231
+ }
232
+ const prefix = hexOk ? rawSha.slice(0, 7) : "";
233
+ if (hasPr && shaClaim && prefix) {
234
+ const prSnippet = evidence.probes?.pr?.snippet ?? "";
235
+ if (!new RegExp(`(^|[^0-9a-f])${prefix}`, "i").test(prSnippet)) {
236
+ if (!unbound.includes(shaClaim))
237
+ unbound.push(shaClaim);
238
+ if (!unbound.includes("pr_number") && claimSet.has("pr_number")) {
239
+ unbound.push("pr_number");
240
+ }
241
+ if (!unbound.includes("pr_url") && claimSet.has("pr_url")) {
242
+ unbound.push("pr_url");
243
+ }
244
+ }
245
+ }
246
+ if (claimSet.has("ci_status") && shaClaim && prefix) {
247
+ const ciCmd = evidence.probes?.ci?.command ?? "";
248
+ const ciSnippet = evidence.probes?.ci?.snippet ?? "";
249
+ const joined = `${ciCmd}\n${ciSnippet}`;
250
+ if (!new RegExp(`(^|[^0-9a-f])${prefix}`, "i").test(joined)) {
251
+ if (!unbound.includes("ci_status"))
252
+ unbound.push("ci_status");
253
+ }
254
+ }
255
+ }
256
+ catch {
257
+ // Fail closed on any unexpected error during consistency checks.
258
+ for (const claim of claims) {
259
+ if (!unbound.includes(claim))
260
+ unbound.push(claim);
261
+ }
262
+ }
263
+ return unbound;
264
+ }
265
+ function isPassStatus(status) {
266
+ return status.trim().toLowerCase() === "pass";
267
+ }
268
+ /** Collect shape errors for unsupported status / proof_status / axis enums. */
269
+ function enumShapeReasons(evidence) {
270
+ const reasons = [];
271
+ const status = evidence.status.toString().trim().toLowerCase();
272
+ if (!STATUS_VALUES.has(status)) {
273
+ reasons.push(`status must be one of pass|fail|blocked|partial (got ${evidence.status})`);
274
+ }
275
+ if (evidence.proof_status !== undefined && evidence.proof_status !== null) {
276
+ const proof = evidence.proof_status.toString().trim().toLowerCase();
277
+ if (proof.length > 0 && !PROOF_STATUSES.has(proof)) {
278
+ reasons.push(`proof_status must be bound|unbound|n/a-no-remote-claim (got ${evidence.proof_status})`);
279
+ }
280
+ }
281
+ for (const [name, axis] of [
282
+ ["work", evidence.work],
283
+ ["ship", evidence.ship],
284
+ ["gate", evidence.gate],
285
+ ]) {
286
+ const state = axis?.state;
287
+ if (state === undefined || state === null)
288
+ continue;
289
+ const s = state.toString().trim().toLowerCase();
290
+ if (s.length > 0 && !AXIS_STATES.has(s)) {
291
+ reasons.push(`${name}.state must be done|in_progress|not_started|blocked|n/a (got ${state})`);
292
+ }
293
+ }
294
+ return reasons;
295
+ }
296
+ function isEmptyDone(evidence) {
297
+ // Pass claimed but no work/ship/gate substance and no remote claims —
298
+ // the empty completion shape (stricter handling still prefers invented-done
299
+ // when remote claims are present without binding).
300
+ const work = evidence.work?.state?.toString().trim().toLowerCase();
301
+ const ship = evidence.ship?.state?.toString().trim().toLowerCase();
302
+ const gate = evidence.gate?.state?.toString().trim().toLowerCase();
303
+ const axesEmpty = (!work || work === "n/a" || work === "not_started") &&
304
+ (!ship || ship === "n/a" || ship === "not_started") &&
305
+ (!gate || gate === "n/a" || gate === "not_started");
306
+ return axesEmpty;
307
+ }
308
+ /**
309
+ * Validate handoff evidence against the #3120 bound-proof contract.
310
+ *
311
+ * Ranking when invalid under status pass:
312
+ * 1. invented-done — remote claims present without bound proof
313
+ * 2. empty-done — pass with no completion substance and no remote claims
314
+ * 3. unbound-remote-claim — non-pass status but still has unbound remote claims
315
+ * 4. shape-error — malformed proof_status / inconsistent n/a
316
+ */
317
+ export function validateHandoffEvidence(evidence) {
318
+ if (!evidence || typeof evidence !== "object") {
319
+ return {
320
+ ok: false,
321
+ failClass: "shape-error",
322
+ reasons: ["evidence must be a non-null object"],
323
+ hasRemoteClaims: false,
324
+ unboundClaims: [],
325
+ };
326
+ }
327
+ if (!isNonEmptyString(evidence.status)) {
328
+ return {
329
+ ok: false,
330
+ failClass: "shape-error",
331
+ reasons: ["status is required"],
332
+ hasRemoteClaims: false,
333
+ unboundClaims: [],
334
+ };
335
+ }
336
+ const claims = detectRemoteClaims(evidence);
337
+ const hasRemoteClaims = claims.length > 0;
338
+ const unbound = unboundClaimKeys(evidence, claims);
339
+ const proof = (evidence.proof_status ?? "").toString().trim().toLowerCase();
340
+ const reasons = [...enumShapeReasons(evidence)];
341
+ const pass = isPassStatus(evidence.status);
342
+ // proof_status consistency
343
+ if (hasRemoteClaims && proof === "n/a-no-remote-claim") {
344
+ reasons.push("proof_status is n/a-no-remote-claim but remote claims are present (" +
345
+ claims.join(", ") +
346
+ ")");
347
+ }
348
+ if (!hasRemoteClaims && proof === "bound") {
349
+ reasons.push("proof_status is bound but no remote claims are present");
350
+ }
351
+ // Core rule: pass + remote claims requires bound + probes
352
+ if (hasRemoteClaims) {
353
+ if (proof !== "bound") {
354
+ reasons.push(`remote claims present (${claims.join(", ")}) require proof_status=bound` +
355
+ (proof ? ` (got ${proof})` : " (missing)"));
356
+ }
357
+ if (unbound.length > 0) {
358
+ reasons.push(`missing same-turn probes for: ${unbound.join(", ")} ` +
359
+ "(probe-then-fill: copy IDs from probe JSON/text, never recollection)");
360
+ }
361
+ }
362
+ else if (pass && (!proof || proof === "unbound")) {
363
+ // No remote claims under pass: n/a-no-remote-claim is the legal proof_status.
364
+ // unbound without remote claims is a soft shape issue under pass.
365
+ if (proof === "unbound") {
366
+ reasons.push("proof_status=unbound with no remote claims; use n/a-no-remote-claim under pass");
367
+ }
368
+ }
369
+ // Rank fail class
370
+ let failClass = "none";
371
+ let ok = reasons.length === 0;
372
+ if (pass && hasRemoteClaims && (proof !== "bound" || unbound.length > 0)) {
373
+ // Invented-done: complete-looking remote artifacts under pass without binding.
374
+ failClass = "invented-done";
375
+ ok = false;
376
+ if (!reasons.some((r) => r.includes("invented-done"))) {
377
+ reasons.unshift("invented-done: status pass with remote PR/SHA/CI/review claims " +
378
+ "without bound same-turn probes (stricter than empty-done)");
379
+ }
380
+ }
381
+ else if (pass && !hasRemoteClaims && isEmptyDone(evidence)) {
382
+ failClass = "empty-done";
383
+ ok = false;
384
+ reasons.unshift("empty-done: status pass with no work/ship/gate substance and no remote claims");
385
+ }
386
+ else if (!pass && hasRemoteClaims && (proof !== "bound" || unbound.length > 0)) {
387
+ failClass = "unbound-remote-claim";
388
+ ok = false;
389
+ }
390
+ else if (reasons.length > 0) {
391
+ failClass = "shape-error";
392
+ ok = false;
393
+ }
394
+ // Legal partial: work done + ship not_started without PR fields → ok when not pass-with-remote
395
+ if (ok &&
396
+ evidence.status === "partial" &&
397
+ !hasRemoteClaims &&
398
+ (evidence.ship?.state === "not_started" || !evidence.ship?.state)) {
399
+ return {
400
+ ok: true,
401
+ failClass: "none",
402
+ reasons: [],
403
+ hasRemoteClaims: false,
404
+ unboundClaims: [],
405
+ };
406
+ }
407
+ return {
408
+ ok,
409
+ failClass,
410
+ reasons,
411
+ hasRemoteClaims,
412
+ unboundClaims: unbound,
413
+ };
414
+ }
415
+ /**
416
+ * Convenience: true when status may legally be pass for this evidence.
417
+ * Equivalent to validateHandoffEvidence(...).ok under a pass status intent.
418
+ */
419
+ export function canClaimPass(evidence) {
420
+ const candidate = { ...evidence, status: "pass" };
421
+ return validateHandoffEvidence(candidate).ok;
422
+ }
423
+ //# sourceMappingURL=validate.js.map
@@ -5,7 +5,7 @@ import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
5
5
  import { type VerifyResult } from "../session/verify-session-ritual.js";
6
6
  import { type HookPayloadContext } from "./classify/index.js";
7
7
  import { type ActiveScopeInspection } from "./scope.js";
8
- export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
8
+ export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
9
9
  export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
10
10
  export declare const HOOK_HOSTS: readonly ["claude", "grok", "cursor", "codex"];
11
11
  export type HookHost = (typeof HOOK_HOSTS)[number];
@@ -21,7 +21,11 @@ export type HookDecisionCode = "session-start" | "session-start-disabled" | "ses
21
21
  /** Enforcement skipped: root `.deft-directive-disable` test kill-switch (#3039). */
22
22
  | "directive-disabled" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
23
23
  /** Host closed stdin with zero bytes — integration failure, not a policy gate (#2864). */
24
- | "stdin-empty" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope"
24
+ | "stdin-empty" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready"
25
+ /** Allowlisted assist/scratch write without active xBRIEF (#1802). */
26
+ | "write-assist-scratch-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready"
27
+ /** Non-lifecycle assist/docs spawn allowed without active xBRIEF (#3080). */
28
+ | "spawn-ephemeral-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope"
25
29
  /** Shell/MCP classifiable push/merge allowed under runtimeAuthority (#2711). */
26
30
  | "shell-op-ready"
27
31
  /** Shell/MCP tool seen but command/tool not classifiable as push/merge — fail open (#2711). */
@@ -50,6 +54,9 @@ export interface HookDispatchInput {
50
54
  readonly environ?: NodeJS.ProcessEnv;
51
55
  }
52
56
  export interface HookPolicySeams {
57
+ /** Active mutation-boundary verifier. Defaults to gated verification. */
58
+ readonly verifyRitual?: (projectRoot: string) => VerifyResult;
59
+ /** @deprecated Compatibility seam for existing tests and callers. */
53
60
  readonly inspectRitual?: (projectRoot: string) => VerifyResult;
54
61
  readonly inspectScope?: (projectRoot: string) => ActiveScopeInspection;
55
62
  readonly sessionStart?: (projectRoot: string) => {
@@ -102,6 +109,24 @@ export declare function isOutsideProjectRootWrite(projectRoot: string, targetPat
102
109
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
103
110
  */
104
111
  export declare function isProposedLifecycleWrite(projectRoot: string, targetPath: string | null): boolean;
112
+ /**
113
+ * Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
114
+ * Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
115
+ * are never listed here.
116
+ */
117
+ export declare const ASSIST_SCRATCH_ROOT_PREFIXES: readonly [".deft-scratch/", "temp/"];
118
+ /**
119
+ * True when the write target is under an allowlisted disposable scratch root (#1802).
120
+ * Fail closed on null/empty/unparseable targets and on path escape (`..`).
121
+ * Does not authorize tracked product paths even under assist posture.
122
+ */
123
+ export declare function isAllowlistedAssistScratchPath(projectRoot: string, targetPath: string | null): boolean;
124
+ /**
125
+ * Assist/ephemeral classification for scratch-write carve-out (#1802).
126
+ * Requires allowlisted path AND (assist posture markers OR ephemeral spawn markers).
127
+ * Path alone without posture markers fails closed to the mutation gate stack.
128
+ */
129
+ export declare function isAssistScratchWrite(projectRoot: string, targetPath: string | null, payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
105
130
  /** Normalize hook project-root resolution on Windows (doubled drive + MSYS `/c/...`). */
106
131
  export declare function normalizeHookProjectRoot(path: string): string;
107
132
  export declare function projectRootFromHookPayload(payload: unknown, fallback: string): string;
@@ -11,14 +11,14 @@ import { detectBranch } from "../session/git.js";
11
11
  import { emitSessionRitualBlockedProcessCost } from "../session/process-cost.js";
12
12
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
13
13
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
14
- import { formatRitualRecoveryInstruction, inspectSessionRitual, } from "../session/verify-session-ritual.js";
14
+ import { formatRitualRecoveryInstruction, verifySessionRitual, } from "../session/verify-session-ritual.js";
15
15
  import { hookMcpArgsText, hookShellCommand, hookToolName, hookWriteTargetPath, missingToolNameMessage, record, } from "./classify/index.js";
16
- import { isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
16
+ import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
17
17
  import { inspectActiveScope } from "./scope.js";
18
18
  import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
19
19
  // Pure parse/classify helpers are defined in ./classify/ and re-exported from
20
20
  // ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
21
- export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
21
+ export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
22
22
  export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
23
23
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
24
24
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
@@ -99,6 +99,47 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
99
99
  return false;
100
100
  return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
101
101
  }
102
+ /**
103
+ * Canonical + gitignored assist scratch roots for low-ceremony disposable notes (#1802).
104
+ * Path fence only — not free-text "for Obsidian" NLP. Tracked trees (src/, packages/, …)
105
+ * are never listed here.
106
+ */
107
+ export const ASSIST_SCRATCH_ROOT_PREFIXES = [".deft-scratch/", "temp/"];
108
+ /**
109
+ * True when the write target is under an allowlisted disposable scratch root (#1802).
110
+ * Fail closed on null/empty/unparseable targets and on path escape (`..`).
111
+ * Does not authorize tracked product paths even under assist posture.
112
+ */
113
+ export function isAllowlistedAssistScratchPath(projectRoot, targetPath) {
114
+ if (targetPath === null || targetPath.trim().length === 0)
115
+ return false;
116
+ const posix = toProjectRelativePosix(projectRoot, targetPath);
117
+ // resolve()+relative() collapses mid-path `..`; only outside-root `..` remains.
118
+ if (posix === ".." || posix.startsWith("../") || isAbsolute(posix))
119
+ return false;
120
+ if (isLexicalOutsideProjectRoot(posix))
121
+ return false;
122
+ for (const prefix of ASSIST_SCRATCH_ROOT_PREFIXES) {
123
+ if (posix === prefix.slice(0, -1) || posix.startsWith(prefix))
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ /**
129
+ * Assist/ephemeral classification for scratch-write carve-out (#1802).
130
+ * Requires allowlisted path AND (assist posture markers OR ephemeral spawn markers).
131
+ * Path alone without posture markers fails closed to the mutation gate stack.
132
+ */
133
+ export function isAssistScratchWrite(projectRoot, targetPath, payload, environ = process.env) {
134
+ if (!isAllowlistedAssistScratchPath(projectRoot, targetPath))
135
+ return false;
136
+ // Structural classification only — compose with #3080 ephemeral markers.
137
+ if (isAssistPosture(payload, environ))
138
+ return true;
139
+ if (isEphemeralSpawn(payload))
140
+ return true;
141
+ return false;
142
+ }
102
143
  function isWindowsDriveOnlyRoot(value) {
103
144
  return /^[A-Za-z]:[/\\]?$/.test(value.trim());
104
145
  }
@@ -448,10 +489,52 @@ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
448
489
  }
449
490
  function inspectMutationGates(input, toolName, seams, options) {
450
491
  const projectRoot = resolve(input.projectRoot);
492
+ const environ = input.environ ?? process.env;
493
+ // Assist/scratch low-ceremony writes (#1802): allowlisted gitignored roots under
494
+ // assist/ephemeral classification skip ritual + active-scope (no fake scope:activate).
495
+ // Tracked product paths never match the path fence. Read-only still denied upstream.
496
+ if (!isSpawnTool(toolName)) {
497
+ const scratchTarget = hookWriteTargetPath(input.payload);
498
+ if (isAssistScratchWrite(projectRoot, scratchTarget, input.payload, environ)) {
499
+ const relPath = scratchTarget !== null ? toProjectRelativePosix(projectRoot, scratchTarget) : null;
500
+ const authzDeny = authzForMutation(input, toolName, seams, {
501
+ isDirectWrite: true,
502
+ relPath,
503
+ scopePath: null,
504
+ });
505
+ if (authzDeny !== null)
506
+ return authzDeny;
507
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
508
+ if (runtimeDeny !== null)
509
+ return runtimeDeny;
510
+ return {
511
+ verdict: "allow",
512
+ code: "write-assist-scratch-ready",
513
+ event: input.event,
514
+ host: input.host,
515
+ toolName,
516
+ projectRoot,
517
+ message: `Directive write gate allowed ${toolName} under allowlisted assist scratch ` +
518
+ "root (disposable notes; active scope and story-start not required). " +
519
+ "Tracked product paths still require mutation gates — do not smuggle source " +
520
+ "under .deft-scratch/ or temp/.",
521
+ scopePath: null,
522
+ };
523
+ }
524
+ }
451
525
  let ritual;
452
526
  try {
453
- ritual = (seams.inspectRitual ??
454
- ((root) => inspectSessionRitual(root, { tier: "gated", posture: "mutation" })))(projectRoot);
527
+ // Mutation dispatch is a live gated boundary: active verification reruns
528
+ // non-cacheable agent-hook readiness instead of trusting persisted success.
529
+ // Its allow fixture is non-write and its deny fixture is read-only, so the
530
+ // installed-shim probe cannot recurse into this mutation path.
531
+ ritual = (seams.verifyRitual ??
532
+ seams.inspectRitual ??
533
+ ((root) => verifySessionRitual(root, {
534
+ tier: "gated",
535
+ posture: "mutation",
536
+ bypass: false,
537
+ })))(projectRoot);
455
538
  }
456
539
  catch (cause) {
457
540
  // #2994: best-effort local process-cost; never changes deny verdict.
@@ -520,16 +603,38 @@ function inspectMutationGates(input, toolName, seams, options) {
520
603
  // Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
521
604
  const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
522
605
  if (!outsideRoot || isSpawnTool(toolName)) {
523
- const proposedPathHint = options.proposedLifecycleExempt &&
606
+ let proposedPathHint;
607
+ if (isSpawnTool(toolName)) {
608
+ // Multi-path recovery for implement-class spawns (#3080 AC4).
609
+ proposedPathHint =
610
+ " Recovery: (1) Product implementation — run `deft scope:activate -- <path>` " +
611
+ "for the approved xBRIEF, then re-run the pre-start_agent gate stack. " +
612
+ "(2) Read-only research — spawn with `subagent_type`/`worker_role` explore. " +
613
+ "(3) Ephemeral docs/analysis — spawn with `worker_role: ephemeral` " +
614
+ "(aliases: docs, assist; see commands.md), or continue in the parent without " +
615
+ "a lifecycle story. Do not invent a fake scope only to satisfy this gate.";
616
+ }
617
+ else if (options.proposedLifecycleExempt &&
524
618
  relTarget !== null &&
525
- (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
526
- ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
527
- "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
528
- "planning writes (#2625)."
529
- : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
530
- (options.proposedLifecycleExempt
531
- ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
532
- : "then re-run the pre-start_agent gate stack.");
619
+ (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))) {
620
+ proposedPathHint =
621
+ " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
622
+ "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
623
+ "planning writes (#2625).";
624
+ }
625
+ else {
626
+ proposedPathHint =
627
+ " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
628
+ (options.proposedLifecycleExempt
629
+ ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption), " +
630
+ "or for disposable research notes write under `.deft-scratch/` (or `temp/`) " +
631
+ "with assist/ephemeral posture (`DEFT_SESSION_POSTURE=assist` or " +
632
+ "`worker_role: assist` / ephemeral — see commands.md #1802 / #3080). " +
633
+ "Do not invent a fake scope only to capture Obsidian/scratch notes."
634
+ : "then re-run the pre-start_agent gate stack. " +
635
+ "For disposable research notes only: write under `.deft-scratch/` with " +
636
+ "assist posture (commands.md #1802) — do not fake `scope:activate` for notes.");
637
+ }
533
638
  const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
534
639
  return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
535
640
  }
@@ -736,6 +841,21 @@ export function decideHook(input, seams = {}) {
736
841
  scopePath: null,
737
842
  };
738
843
  }
844
+ // Ephemeral/docs/assist: write-capable non-lifecycle spawn; no active xBRIEF (#3080).
845
+ // Does not authorize push/merge/deploy — those remain on shell/MCP matchers.
846
+ if (isEphemeralSpawn(input.payload)) {
847
+ return {
848
+ verdict: "allow",
849
+ code: "spawn-ephemeral-ready",
850
+ event: input.event,
851
+ host: input.host,
852
+ toolName,
853
+ projectRoot,
854
+ message: `Directive allowed ephemeral ${toolName} spawn without active-xBRIEF ` +
855
+ "implementation gates (non-lifecycle assist/docs posture).",
856
+ scopePath: null,
857
+ };
858
+ }
739
859
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
740
860
  }
741
861
  // Shell/Bash and classifiable MCP: enforce scopes.push / scopes.merge (#2711).