@deftai/directive-core 0.94.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 (61) hide show
  1. package/dist/authz/classify.js +485 -0
  2. package/dist/doctor/main.js +41 -9
  3. package/dist/doctor/types.d.ts +2 -2
  4. package/dist/freshness/bind.d.ts +67 -0
  5. package/dist/freshness/bind.js +201 -0
  6. package/dist/freshness/cli.d.ts +30 -0
  7. package/dist/freshness/cli.js +180 -0
  8. package/dist/freshness/compare.d.ts +14 -0
  9. package/dist/freshness/compare.js +134 -0
  10. package/dist/freshness/generation.d.ts +44 -0
  11. package/dist/freshness/generation.js +172 -0
  12. package/dist/freshness/index.d.ts +13 -0
  13. package/dist/freshness/index.js +13 -0
  14. package/dist/freshness/report.d.ts +43 -0
  15. package/dist/freshness/report.js +139 -0
  16. package/dist/freshness/types.d.ts +70 -0
  17. package/dist/freshness/types.js +30 -0
  18. package/dist/handoff-evidence/index.d.ts +8 -0
  19. package/dist/handoff-evidence/index.js +7 -0
  20. package/dist/handoff-evidence/validate.d.ts +105 -0
  21. package/dist/handoff-evidence/validate.js +423 -0
  22. package/dist/hooks/dispatcher.d.ts +3 -0
  23. package/dist/hooks/dispatcher.js +12 -3
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  27. package/dist/init-deposit/agent-hooks.js +5 -2
  28. package/dist/init-deposit/init-deposit.d.ts +7 -1
  29. package/dist/init-deposit/init-deposit.js +24 -4
  30. package/dist/init-deposit/refresh.d.ts +9 -1
  31. package/dist/init-deposit/refresh.js +48 -5
  32. package/dist/policy/index.d.ts +3 -0
  33. package/dist/policy/index.js +35 -0
  34. package/dist/release-e2e/greenfield-python-free-smoke.js +7 -3
  35. package/dist/session/ritual-entrypoint.d.ts +6 -2
  36. package/dist/session/ritual-entrypoint.js +15 -2
  37. package/dist/session/session-ready.js +31 -14
  38. package/dist/session/session-start.d.ts +2 -1
  39. package/dist/session/session-start.js +66 -3
  40. package/dist/session/verify-session-ritual.d.ts +4 -2
  41. package/dist/session/verify-session-ritual.js +11 -11
  42. package/dist/slash/product-set.js +4 -1
  43. package/dist/swarm/verify-review-clean.d.ts +4 -3
  44. package/dist/swarm/verify-review-clean.js +28 -73
  45. package/dist/triage/classify/index.d.ts +9 -0
  46. package/dist/triage/classify/index.js +34 -2
  47. package/dist/triage/classify/label-mirror.d.ts +143 -0
  48. package/dist/triage/classify/label-mirror.js +684 -0
  49. package/dist/triage/help/registry-data.d.ts +7 -7
  50. package/dist/triage/help/registry-data.js +27 -7
  51. package/dist/triage/queue/show.d.ts +1 -1
  52. package/dist/triage/queue/show.js +4 -2
  53. package/dist/vbrief-validate/project-definition.js +1 -1
  54. package/dist/verify-env/agent-hook-readiness.d.ts +42 -0
  55. package/dist/verify-env/agent-hook-readiness.js +150 -0
  56. package/dist/verify-env/agent-hooks-live-probe.d.ts +16 -4
  57. package/dist/verify-env/agent-hooks-live-probe.js +120 -107
  58. package/dist/verify-env/agent-hooks.js +11 -3
  59. package/dist/verify-env/index.d.ts +1 -0
  60. package/dist/verify-env/index.js +1 -0
  61. 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
@@ -54,6 +54,9 @@ export interface HookDispatchInput {
54
54
  readonly environ?: NodeJS.ProcessEnv;
55
55
  }
56
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. */
57
60
  readonly inspectRitual?: (projectRoot: string) => VerifyResult;
58
61
  readonly inspectScope?: (projectRoot: string) => ActiveScopeInspection;
59
62
  readonly sessionStart?: (projectRoot: string) => {
@@ -11,7 +11,7 @@ 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
16
  import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
17
17
  import { inspectActiveScope } from "./scope.js";
@@ -524,8 +524,17 @@ function inspectMutationGates(input, toolName, seams, options) {
524
524
  }
525
525
  let ritual;
526
526
  try {
527
- ritual = (seams.inspectRitual ??
528
- ((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);
529
538
  }
530
539
  catch (cause) {
531
540
  // #2994: best-effort local process-cost; never changes deny verdict.
package/dist/index.d.ts CHANGED
@@ -24,8 +24,10 @@ export * as evalReport from "./eval/report.js";
24
24
  export * as evalRun from "./eval/run.js";
25
25
  export * as events from "./events/attribution-ledger.js";
26
26
  export * from "./forward-coverage/evaluate.js";
27
+ export * as freshness from "./freshness/index.js";
27
28
  export { ContainedWriteError, ContainedWriteErrorCode, type ContainedWriteInput, type ContainedWriteMode, type ContainedWriteResult, containedWrite, resolveContainedTarget, } from "./fs/contained-write.js";
28
29
  export { assertDestinationNotSymlink, assertDirectoryNotSymlink, assertProjectionContained, assertWriteTargetSafe, PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, walkDirectoryRejectSymlinks, } from "./fs/projection-containment.js";
30
+ export * as handoffEvidence from "./handoff-evidence/index.js";
29
31
  export * as intake from "./intake/index.js";
30
32
  export * as layout from "./layout/index.js";
31
33
  export * as legacyBridge from "./legacy-bridge/index.js";
package/dist/index.js CHANGED
@@ -24,9 +24,11 @@ export * as evalReport from "./eval/report.js";
24
24
  export * as evalRun from "./eval/run.js";
25
25
  export * as events from "./events/attribution-ledger.js";
26
26
  export * from "./forward-coverage/evaluate.js";
27
+ export * as freshness from "./freshness/index.js";
27
28
  // #2951 Phase 1: contained-write API for product sinks.
28
29
  export { ContainedWriteError, ContainedWriteErrorCode, containedWrite, resolveContainedTarget, } from "./fs/contained-write.js";
29
30
  export { assertDestinationNotSymlink, assertDirectoryNotSymlink, assertProjectionContained, assertWriteTargetSafe, PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, walkDirectoryRejectSymlinks, } from "./fs/projection-containment.js";
31
+ export * as handoffEvidence from "./handoff-evidence/index.js";
30
32
  export * as intake from "./intake/index.js";
31
33
  export * as layout from "./layout/index.js";
32
34
  export * as legacyBridge from "./legacy-bridge/index.js";
@@ -6,7 +6,7 @@ export declare const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
6
6
  export declare const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
7
7
  export declare const AGENT_HOOK_PATHS: readonly [".claude/settings.json", ".grok/hooks/deft.json", ".cursor/hooks.json", ".codex/hooks.json"];
8
8
  export type AgentHookPath = (typeof AGENT_HOOK_PATHS)[number];
9
- export type AgentHookRegistrationStatus = "healthy" | "missing" | "drifted";
9
+ export type AgentHookRegistrationStatus = "healthy" | "disabled" | "missing" | "drifted";
10
10
  /** Whether the host receives a compact/resume hook deposit (#2113). */
11
11
  export type AgentHookCompactSupport = "deposited" | "unsupported";
12
12
  export interface AgentHookInspection {
@@ -312,7 +312,10 @@ function hasNestedRegistration(config, host, options = {}) {
312
312
  const sessionCommand = command(host, "session.start");
313
313
  const toolCommand = command(host, "tool.before");
314
314
  const compactCommand = command(host, "session.compact");
315
- const base = session.some((entry) => nestedCommands(entry).includes(sessionCommand)) &&
315
+ const base = session.some((entry) => {
316
+ const group = object(entry);
317
+ return group?.matcher === undefined && nestedCommands(entry).includes(sessionCommand);
318
+ }) &&
316
319
  preTool.some((entry) => {
317
320
  const group = object(entry);
318
321
  return (group?.matcher === DIRECT_WRITE_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand));
@@ -409,7 +412,7 @@ export function inspectAgentHookDeposit(projectRoot, hostHooksPolicy = loadHostH
409
412
  return {
410
413
  host: definition.host,
411
414
  path: definition.path,
412
- status: "healthy",
415
+ status: "disabled",
413
416
  compactSupport: definition.compactSupport,
414
417
  detail: `plan.policy.hostHooks.${definition.host} is false — Directive hook deposit is skipped for this host.`,
415
418
  };
@@ -5,6 +5,7 @@
5
5
  * scaffold, skills/githooks/#1430 neutralization, and Taskfile wiring.
6
6
  * Refs #1942, #11, #1430.
7
7
  */
8
+ import { type AgentHookReadinessResult } from "../verify-env/agent-hook-readiness.js";
8
9
  import { type LegacyLayoutDetection } from "./legacy-detect.js";
9
10
  import { type InitDepositIo, writeConsumerGitHooks } from "./scaffold.js";
10
11
  export interface InitDepositArgs {
@@ -28,11 +29,16 @@ export interface InitDepositSeams {
28
29
  nowIso?: () => string;
29
30
  gitHooks?: Parameters<typeof writeConsumerGitHooks>[3];
30
31
  detectLegacy?: (projectDir: string) => LegacyLayoutDetection;
32
+ evaluateAgentHookReadiness?: (projectRoot: string) => AgentHookReadinessResult;
31
33
  }
32
34
  export declare function parseInitArgv(canonicalArgv: readonly string[], userArgv?: readonly string[]): InitDepositArgs;
33
35
  export declare function userConfigDir(): string;
34
36
  export declare function createUserConfigDir(io: InitDepositIo): string;
35
- export declare function buildInstallSummaryJson(result: InitDepositResult, options: InitDepositArgs): Record<string, unknown>;
37
+ export declare function buildInstallSummaryJson(input: {
38
+ result: InitDepositResult;
39
+ options: InitDepositArgs;
40
+ readiness: AgentHookReadinessResult | undefined;
41
+ }): Record<string, unknown>;
36
42
  export declare function printNextSteps(result: InitDepositResult, io: InitDepositIo): void;
37
43
  /**
38
44
  * Ensure a minimal render-ready PROJECT-DEFINITION exists after lifecycle dirs
@@ -13,8 +13,10 @@ import { copyTree } from "../deposit/copy-tree.js";
13
13
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
14
14
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
15
15
  import { readCorePackageVersion } from "../engine-version.js";
16
+ import { stampLiveGeneration } from "../freshness/generation.js";
16
17
  import { renderProjectDefinition } from "../render/project-render.js";
17
18
  import { depositOpenClawL2ProductCommands } from "../slash/openclaw-deposit.js";
19
+ import { agentHookReadinessJson, evaluateAgentHookReadiness, evaluateAgentHookReadinessSafely, } from "../verify-env/agent-hook-readiness.js";
18
20
  import { removeStaleMigratedFrameworkNarrative } from "../xbrief-migrate/migrate-project.js";
19
21
  import { writeAgentHookDeposit } from "./agent-hooks.js";
20
22
  import { ensureInitGitignoreLines, reconstituteDepositFromContent } from "./gitignore.js";
@@ -81,9 +83,12 @@ function readContentVersion(contentRoot, readVersion = readCorePackageVersion) {
81
83
  }
82
84
  return readVersion();
83
85
  }
84
- export function buildInstallSummaryJson(result, options) {
86
+ export function buildInstallSummaryJson(input) {
87
+ const { result, options, readiness } = input;
85
88
  return {
86
- success: true,
89
+ success: readiness ? readiness.code === 0 : true,
90
+ deposit_completed: true,
91
+ ...(readiness ? { agent_hook_readiness: agentHookReadinessJson(readiness) } : {}),
87
92
  action: "install",
88
93
  version: readCorePackageVersion(),
89
94
  project_dir: result.projectDir,
@@ -210,6 +215,14 @@ export async function runInitDeposit(args, io, seams = {}) {
210
215
  includeTaskfile: taskfileWired,
211
216
  });
212
217
  printCommitGuidance(io, stagePaths, staged);
218
+ // #3117: stamp live generation only after required init projections succeed.
219
+ // Stamping earlier would advance authority for a failed/partial init (Greptile).
220
+ stampLiveGeneration(projectDir, {
221
+ contentVersion: version,
222
+ stampedBy: "directive-init",
223
+ increment: true,
224
+ nowIso: nowIso(),
225
+ });
213
226
  return {
214
227
  projectDir,
215
228
  deftDir,
@@ -234,14 +247,21 @@ export async function runInitDepositCli(options) {
234
247
  };
235
248
  try {
236
249
  const result = await runInitDeposit(options, io, options.seams);
250
+ const readiness = evaluateAgentHookReadinessSafely(result.projectDir, options.seams?.evaluateAgentHookReadiness ?? evaluateAgentHookReadiness);
237
251
  if (options.jsonOut) {
238
- options.writeOut(`${JSON.stringify(buildInstallSummaryJson(result, options), null, 2)}\n`);
252
+ options.writeOut(`${JSON.stringify(buildInstallSummaryJson({ result, options, readiness }), null, 2)}\n`);
239
253
  printNextSteps(result, { printf: options.writeErr });
240
254
  }
241
255
  else {
242
256
  printNextSteps(result, io);
243
257
  }
244
- return 0;
258
+ const readinessOut = options.jsonOut
259
+ ? options.writeErr
260
+ : readiness.stream === "stderr"
261
+ ? options.writeErr
262
+ : options.writeOut;
263
+ readinessOut(`\n${readiness.message}\n`);
264
+ return readiness.code;
245
265
  }
246
266
  catch (cause) {
247
267
  if (cause instanceof LegacyLayoutRefusedError) {
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { ResolutionFacts, ResolutionPlan } from "@deftai/directive-types";
11
11
  import { type ClassifySeams, type EngineInstallRunner, type EngineResolution, type LadderFacts, type ReprojectRunner } from "../resolution/index.js";
12
+ import { type AgentHookReadinessResult } from "../verify-env/agent-hook-readiness.js";
12
13
  import { type GitLsFiles } from "./gitignore.js";
13
14
  import { type InitDepositArgs } from "./init-deposit.js";
14
15
  import { type LegacyLayoutDetection } from "./legacy-detect.js";
@@ -49,6 +50,8 @@ export interface RefreshDepositSeams {
49
50
  gitHooks?: GitHooksSeams;
50
51
  /** #2822: optional seam for trusted-org policy force-on migration. */
51
52
  runOrgForceOn?: (projectRoot: string) => void;
53
+ /** Post-deposit functional readiness gate (#3100). */
54
+ evaluateAgentHookReadiness?: (projectRoot: string) => AgentHookReadinessResult;
52
55
  }
53
56
  /**
54
57
  * The four states `directive update` classifies an EXISTING install into BEFORE
@@ -123,7 +126,12 @@ export interface RefreshSideEffects {
123
126
  export declare function frameworkRefreshSideEffects(projectDir: string, options?: FrameworkRefreshSideEffectsOptions): RefreshSideEffects;
124
127
  export declare function printRefreshSideEffects(io: InitDepositIo, effects: RefreshSideEffects): void;
125
128
  export declare function buildVersionSkewNotice(engineVersion: string, contentVersion: string, previousDepositVersion: string | null): string | null;
126
- export declare function buildUpdateSummaryJson(result: RefreshDepositResult, options: RefreshDepositArgs, updateState?: UpdateState): Record<string, unknown>;
129
+ export declare function buildUpdateSummaryJson(input: {
130
+ result: RefreshDepositResult;
131
+ options: RefreshDepositArgs;
132
+ updateState: UpdateState | undefined;
133
+ readiness: AgentHookReadinessResult | undefined;
134
+ }): Record<string, unknown>;
127
135
  export declare function printUpdateComplete(result: RefreshDepositResult, io: InitDepositIo, updateState?: UpdateState): void;
128
136
  export declare function runRefreshDeposit(args: RefreshDepositArgs, io: InitDepositIo, seams?: RefreshDepositSeams): Promise<RefreshDepositResult>;
129
137
  export interface RunRefreshDepositCliOptions extends RefreshDepositArgs {