@deftai/directive-core 0.79.4 → 0.81.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 (84) hide show
  1. package/dist/doctor/checks.d.ts +6 -0
  2. package/dist/doctor/checks.js +139 -0
  3. package/dist/doctor/main.js +2 -1
  4. package/dist/hooks/dispatcher.d.ts +18 -3
  5. package/dist/hooks/dispatcher.js +173 -62
  6. package/dist/hooks/readonly.d.ts +7 -0
  7. package/dist/hooks/readonly.js +83 -0
  8. package/dist/hooks/tools.d.ts +7 -0
  9. package/dist/hooks/tools.js +16 -0
  10. package/dist/init-deposit/agent-hooks.d.ts +4 -1
  11. package/dist/init-deposit/agent-hooks.js +77 -21
  12. package/dist/policy/index.d.ts +3 -0
  13. package/dist/policy/index.js +45 -1
  14. package/dist/policy/product-signal.d.ts +45 -0
  15. package/dist/policy/product-signal.js +210 -0
  16. package/dist/policy/runtime-authority.d.ts +54 -0
  17. package/dist/policy/runtime-authority.js +180 -0
  18. package/dist/policy/staleness-tickler.d.ts +20 -0
  19. package/dist/policy/staleness-tickler.js +151 -0
  20. package/dist/pr-merge-readiness/ci-gate.d.ts +7 -1
  21. package/dist/pr-merge-readiness/ci-gate.js +38 -5
  22. package/dist/pr-merge-readiness/gh.d.ts +4 -0
  23. package/dist/pr-merge-readiness/gh.js +10 -3
  24. package/dist/pr-merge-readiness/index.d.ts +2 -1
  25. package/dist/pr-merge-readiness/index.js +2 -1
  26. package/dist/pr-merge-readiness/runner-capacity-stall.d.ts +35 -0
  27. package/dist/pr-merge-readiness/runner-capacity-stall.js +46 -0
  28. package/dist/pr-watch/constants.d.ts +5 -0
  29. package/dist/pr-watch/constants.js +6 -1
  30. package/dist/pr-watch/main.js +8 -0
  31. package/dist/pr-watch/probe.js +8 -0
  32. package/dist/pr-watch/types.d.ts +7 -0
  33. package/dist/pr-watch/watch.js +8 -1
  34. package/dist/product-signal/actor-name.d.ts +18 -0
  35. package/dist/product-signal/actor-name.js +94 -0
  36. package/dist/product-signal/consent.d.ts +30 -0
  37. package/dist/product-signal/consent.js +116 -0
  38. package/dist/product-signal/gates.d.ts +17 -0
  39. package/dist/product-signal/gates.js +66 -0
  40. package/dist/product-signal/github-private-sink-adapter.d.ts +28 -0
  41. package/dist/product-signal/github-private-sink-adapter.js +274 -0
  42. package/dist/product-signal/headless.d.ts +8 -0
  43. package/dist/product-signal/headless.js +32 -0
  44. package/dist/product-signal/install-context.d.ts +13 -0
  45. package/dist/product-signal/install-context.js +41 -0
  46. package/dist/product-signal/local-signal-summary.d.ts +7 -0
  47. package/dist/product-signal/local-signal-summary.js +173 -0
  48. package/dist/product-signal/payload.d.ts +90 -0
  49. package/dist/product-signal/payload.js +124 -0
  50. package/dist/product-signal/sink-bootstrap.d.ts +29 -0
  51. package/dist/product-signal/sink-bootstrap.js +108 -0
  52. package/dist/product-signal/submit-adapter.d.ts +14 -0
  53. package/dist/product-signal/submit-adapter.js +2 -0
  54. package/dist/product-signal/submit.d.ts +54 -0
  55. package/dist/product-signal/submit.js +290 -0
  56. package/dist/scm/gh-rest.d.ts +3 -1
  57. package/dist/scm/gh-rest.js +22 -0
  58. package/dist/scope/main.js +12 -0
  59. package/dist/session/release-availability.d.ts +21 -0
  60. package/dist/session/release-availability.js +109 -0
  61. package/dist/session/ritual-sentinel.d.ts +14 -0
  62. package/dist/session/ritual-sentinel.js +28 -0
  63. package/dist/session/session-start.d.ts +10 -0
  64. package/dist/session/session-start.js +18 -0
  65. package/dist/staleness-tickler/escalation.d.ts +14 -0
  66. package/dist/staleness-tickler/escalation.js +106 -0
  67. package/dist/staleness-tickler/idle.d.ts +18 -0
  68. package/dist/staleness-tickler/idle.js +47 -0
  69. package/dist/staleness-tickler/index.d.ts +8 -0
  70. package/dist/staleness-tickler/index.js +8 -0
  71. package/dist/staleness-tickler/probe-directive.d.ts +32 -0
  72. package/dist/staleness-tickler/probe-directive.js +106 -0
  73. package/dist/staleness-tickler/probe-xbrief.d.ts +12 -0
  74. package/dist/staleness-tickler/probe-xbrief.js +103 -0
  75. package/dist/staleness-tickler/run.d.ts +31 -0
  76. package/dist/staleness-tickler/run.js +227 -0
  77. package/dist/staleness-tickler/state.d.ts +10 -0
  78. package/dist/staleness-tickler/state.js +46 -0
  79. package/dist/staleness-tickler/types.d.ts +85 -0
  80. package/dist/staleness-tickler/types.js +5 -0
  81. package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
  82. package/dist/vbrief-validate/plan-hooks.js +50 -0
  83. package/dist/verify-env/agent-hooks.js +4 -2
  84. package/package.json +3 -3
@@ -39,6 +39,12 @@ export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
39
39
  * `directive update` instead. Advisory only (exit-exempt).
40
40
  */
41
41
  export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
42
+ /**
43
+ * #2591: advisory hint when a TypeScript project pins bare `typescript@7` alongside
44
+ * typescript-eslint without the `@typescript/typescript6` alias. Exit-exempt (like
45
+ * gitignore-coverage) — adoption guidance, not a broken install.
46
+ */
47
+ export declare function checkTypescript7SideBySide(projectRoot: string, seams?: CheckSeams): CheckResult;
42
48
  /**
43
49
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
44
50
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -523,6 +523,142 @@ export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
523
523
  },
524
524
  };
525
525
  }
526
+ const TS7_SIDE_BY_SIDE_CHECK = "typescript-7-side-by-side";
527
+ function depKeyIsTypescriptEslint(key) {
528
+ return key === "typescript-eslint" || key.startsWith("@typescript-eslint/");
529
+ }
530
+ function depKeysIncludeTypescriptEslint(keys) {
531
+ return keys.some(depKeyIsTypescriptEslint);
532
+ }
533
+ function depKeysIncludeEslint(keys) {
534
+ return keys.includes("eslint");
535
+ }
536
+ function typescriptValueIs7Bound(value) {
537
+ const v = value.trim();
538
+ if (!v || v.includes("@typescript/typescript6")) {
539
+ return false;
540
+ }
541
+ if (/npm:typescript@(?:[\^~>=<]*|\d*)7/i.test(v)) {
542
+ return true;
543
+ }
544
+ if (/^[\^~>=<]*7(?:\.\d|$)/.test(v)) {
545
+ return true;
546
+ }
547
+ if (/^7\.\d/.test(v)) {
548
+ return true;
549
+ }
550
+ return false;
551
+ }
552
+ function resolveTypescriptDepValue(deps, devDeps) {
553
+ if (typeof devDeps.typescript === "string") {
554
+ return devDeps.typescript;
555
+ }
556
+ if (typeof deps.typescript === "string") {
557
+ return deps.typescript;
558
+ }
559
+ return null;
560
+ }
561
+ /**
562
+ * #2591: advisory hint when a TypeScript project pins bare `typescript@7` alongside
563
+ * typescript-eslint without the `@typescript/typescript6` alias. Exit-exempt (like
564
+ * gitignore-coverage) — adoption guidance, not a broken install.
565
+ */
566
+ export function checkTypescript7SideBySide(projectRoot, seams = {}) {
567
+ const packageJsonPath = join(projectRoot, "package.json");
568
+ const text = readText(packageJsonPath, seams);
569
+ if (text === null) {
570
+ return {
571
+ name: TS7_SIDE_BY_SIDE_CHECK,
572
+ status: "skip",
573
+ detail: "package.json not found; nothing to inspect for TypeScript side-by-side layout.",
574
+ data: { package_json_path: packageJsonPath },
575
+ };
576
+ }
577
+ let parsed;
578
+ try {
579
+ parsed = JSON.parse(text);
580
+ }
581
+ catch {
582
+ return {
583
+ name: TS7_SIDE_BY_SIDE_CHECK,
584
+ status: "skip",
585
+ detail: "package.json is unreadable; skipping TypeScript side-by-side check.",
586
+ data: { package_json_path: packageJsonPath },
587
+ };
588
+ }
589
+ if (typeof parsed !== "object" || parsed === null) {
590
+ return {
591
+ name: TS7_SIDE_BY_SIDE_CHECK,
592
+ status: "skip",
593
+ detail: "package.json has no dependency sections; skipping TypeScript side-by-side check.",
594
+ data: { package_json_path: packageJsonPath },
595
+ };
596
+ }
597
+ const record = parsed;
598
+ const deps = typeof record.dependencies === "object" && record.dependencies !== null
599
+ ? record.dependencies
600
+ : {};
601
+ const devDeps = typeof record.devDependencies === "object" && record.devDependencies !== null
602
+ ? record.devDependencies
603
+ : {};
604
+ const depKeys = [...Object.keys(deps), ...Object.keys(devDeps)];
605
+ if (depKeys.length === 0) {
606
+ return {
607
+ name: TS7_SIDE_BY_SIDE_CHECK,
608
+ status: "skip",
609
+ detail: "package.json has no dependency sections; skipping TypeScript side-by-side check.",
610
+ data: { package_json_path: packageJsonPath },
611
+ };
612
+ }
613
+ if (!depKeysIncludeTypescriptEslint(depKeys)) {
614
+ return {
615
+ name: TS7_SIDE_BY_SIDE_CHECK,
616
+ status: "pass",
617
+ detail: "No typescript-eslint packages declared; TypeScript 7 side-by-side alias not required.",
618
+ data: { package_json_path: packageJsonPath },
619
+ };
620
+ }
621
+ if (!depKeysIncludeEslint(depKeys)) {
622
+ return {
623
+ name: TS7_SIDE_BY_SIDE_CHECK,
624
+ status: "pass",
625
+ detail: "eslint is not declared; TypeScript 7 side-by-side alias not required.",
626
+ data: { package_json_path: packageJsonPath },
627
+ };
628
+ }
629
+ const typescriptValue = resolveTypescriptDepValue(deps, devDeps);
630
+ if (typescriptValue === null) {
631
+ return {
632
+ name: TS7_SIDE_BY_SIDE_CHECK,
633
+ status: "pass",
634
+ detail: "No typescript dependency declared; TypeScript 7 side-by-side alias not required.",
635
+ data: { package_json_path: packageJsonPath },
636
+ };
637
+ }
638
+ if (!typescriptValueIs7Bound(typescriptValue)) {
639
+ return {
640
+ name: TS7_SIDE_BY_SIDE_CHECK,
641
+ status: "pass",
642
+ detail: "typescript is not pinned to 7.x (or uses the @typescript/typescript6 alias); side-by-side layout not required.",
643
+ data: {
644
+ package_json_path: packageJsonPath,
645
+ typescript: typescriptValue,
646
+ },
647
+ };
648
+ }
649
+ return {
650
+ name: TS7_SIDE_BY_SIDE_CHECK,
651
+ status: "fail",
652
+ detail: "typescript resolves to 7.x without the @typescript/typescript6 alias while typescript-eslint and eslint are present. " +
653
+ "Until TS 7.1, use the side-by-side alias pattern in languages/typescript.md § TypeScript 7 side-by-side (pre-7.1) " +
654
+ "(Cartograph #111: keep typescript on npm:@typescript/typescript6 and install TS 7 under @typescript/native).",
655
+ data: {
656
+ package_json_path: packageJsonPath,
657
+ typescript: typescriptValue,
658
+ suggested_fix: "languages/typescript.md#typescript-7-side-by-side-pre-71",
659
+ },
660
+ };
661
+ }
526
662
  /**
527
663
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
528
664
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -580,6 +716,7 @@ export function deriveExitCode(checks, errors) {
580
716
  "manifest-version-reportable",
581
717
  "gitignore-coverage",
582
718
  "stale-xbrief-schema-deposit",
719
+ "typescript-7-side-by-side",
583
720
  ]);
584
721
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
585
722
  return 2;
@@ -621,6 +758,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
621
758
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
622
759
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
623
760
  checks.push(checkGitignoreCoverage(projectRoot, seams));
761
+ checks.push(checkTypescript7SideBySide(projectRoot, seams));
624
762
  return {
625
763
  projectRoot,
626
764
  installRoot: null,
@@ -638,6 +776,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
638
776
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
639
777
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
640
778
  checks.push(checkGitignoreCoverage(projectRoot, seams));
779
+ checks.push(checkTypescript7SideBySide(projectRoot, seams));
641
780
  return {
642
781
  projectRoot,
643
782
  installRoot,
@@ -424,7 +424,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
424
424
  name === "canonical-vendored-npm-signpost" ||
425
425
  name === "manifest-version-reportable" ||
426
426
  name === "gitignore-coverage" ||
427
- name === "stale-xbrief-schema-deposit") &&
427
+ name === "stale-xbrief-schema-deposit" ||
428
+ name === "typescript-7-side-by-side") &&
428
429
  status === "fail") {
429
430
  sink.warn(`${name}: ${detail}`);
430
431
  addFinding({
@@ -1,12 +1,19 @@
1
+ import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
1
2
  import { type VerifyResult } from "../session/verify-session-ritual.js";
2
3
  import { type ActiveScopeInspection } from "./scope.js";
3
- export { DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool } from "./tools.js";
4
+ export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
5
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isSpawnTool, READ_ONLY_HOOK_ENV, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
4
6
  export declare const HOOK_HOSTS: readonly ["claude", "grok", "cursor", "codex"];
5
7
  export type HookHost = (typeof HOOK_HOSTS)[number];
6
- export declare const HOOK_EVENTS: readonly ["session.start", "tool.before"];
8
+ export declare const HOOK_EVENTS: readonly ["session.start", "session.compact", "tool.before"];
7
9
  export type HookEvent = (typeof HOOK_EVENTS)[number];
10
+ /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
11
+ export declare const COMPACT_HOOK_HOSTS: readonly ["claude", "grok", "cursor"];
12
+ export type CompactHookHost = (typeof COMPACT_HOOK_HOSTS)[number];
13
+ /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
14
+ export declare const COMPACT_HOOK_SKIP_HOSTS: readonly ["codex"];
8
15
  export type HookVerdict = "allow" | "deny";
9
- export type HookDecisionCode = "session-start" | "session-start-degraded" | "not-direct-write" | "invalid-input" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready";
16
+ export type HookDecisionCode = "session-start" | "session-start-degraded" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input" | "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";
10
17
  export interface HookDecision {
11
18
  readonly verdict: HookVerdict;
12
19
  readonly code: HookDecisionCode;
@@ -28,6 +35,8 @@ export interface HookDispatchInput {
28
35
  readonly projectRoot: string;
29
36
  readonly payload: unknown;
30
37
  readonly payloadContext?: HookPayloadContext;
38
+ /** Injected for tests; defaults to `process.env`. */
39
+ readonly environ?: NodeJS.ProcessEnv;
31
40
  }
32
41
  export interface HookPolicySeams {
33
42
  readonly inspectRitual?: (projectRoot: string) => VerifyResult;
@@ -37,6 +46,12 @@ export interface HookPolicySeams {
37
46
  stdout: string;
38
47
  stderr: string;
39
48
  };
49
+ readonly markCompactStale?: (projectRoot: string) => {
50
+ changed: boolean;
51
+ statePath: string;
52
+ message: string;
53
+ };
54
+ readonly loadRuntimeAuthority?: (projectRoot: string) => RuntimeAuthorityPolicy;
40
55
  }
41
56
  export declare function hookPayloadTopLevelKeys(payload: unknown): string[];
42
57
  export declare function hookToolName(payload: unknown, host?: HookHost): string | null;
@@ -1,12 +1,20 @@
1
1
  import { relative, resolve, sep } from "node:path";
2
2
  import { hasArtifactSuffix } from "../layout/resolve.js";
3
+ import { evaluateRuntimeAuthorityDirectWrite, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
4
+ import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
3
5
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
4
6
  import { inspectSessionRitual } from "../session/verify-session-ritual.js";
7
+ import { isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
5
8
  import { inspectActiveScope } from "./scope.js";
6
- import { isDirectWriteTool } from "./tools.js";
7
- export { DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool } from "./tools.js";
9
+ import { isDirectWriteTool, isSpawnTool } from "./tools.js";
10
+ export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
11
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isSpawnTool, READ_ONLY_HOOK_ENV, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
8
12
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
9
- export const HOOK_EVENTS = ["session.start", "tool.before"];
13
+ export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
14
+ /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
15
+ export const COMPACT_HOOK_HOSTS = ["claude", "grok", "cursor"];
16
+ /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
17
+ export const COMPACT_HOOK_SKIP_HOSTS = ["codex"];
10
18
  function record(value) {
11
19
  return value !== null && typeof value === "object" && !Array.isArray(value)
12
20
  ? value
@@ -167,7 +175,7 @@ export function isHookHost(value) {
167
175
  export function isHookEvent(value) {
168
176
  return HOOK_EVENTS.includes(value);
169
177
  }
170
- function deny(input, code, toolName, message) {
178
+ function deny(input, code, toolName, message, scopePath = null) {
171
179
  return {
172
180
  verdict: "deny",
173
181
  code,
@@ -176,12 +184,144 @@ function deny(input, code, toolName, message) {
176
184
  toolName,
177
185
  projectRoot: resolve(input.projectRoot),
178
186
  message,
179
- scopePath: null,
187
+ scopePath,
188
+ };
189
+ }
190
+ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
191
+ const projectRoot = resolve(input.projectRoot);
192
+ let policy;
193
+ try {
194
+ policy = (seams.loadRuntimeAuthority ?? loadRuntimeAuthorityFromProject)(projectRoot);
195
+ }
196
+ catch {
197
+ // Fail-open on policy load crash — host behavior; ritual/scope gates already passed.
198
+ return null;
199
+ }
200
+ const writeTarget = hookWriteTargetPath(input.payload);
201
+ const relPath = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
202
+ const verdict = evaluateRuntimeAuthorityDirectWrite({ policy, relPathPosix: relPath });
203
+ if (verdict.allowed)
204
+ return null;
205
+ return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under runtime authority policy.", scopePath);
206
+ }
207
+ function inspectMutationGates(input, toolName, seams, options) {
208
+ const projectRoot = resolve(input.projectRoot);
209
+ let ritual;
210
+ try {
211
+ ritual = (seams.inspectRitual ??
212
+ ((root) => inspectSessionRitual(root, { tier: "gated", posture: "mutation" })))(projectRoot);
213
+ }
214
+ catch (cause) {
215
+ return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
216
+ "Run `deft session:start`, then `deft verify:session-ritual -- --tier=gated`.");
217
+ }
218
+ if (ritual.code !== 0) {
219
+ return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ` +
220
+ "Recovery: run `deft session:start`, then " +
221
+ "`deft verify:session-ritual -- --tier=gated`.");
222
+ }
223
+ if (options.proposedLifecycleExempt) {
224
+ const writeTarget = hookWriteTargetPath(input.payload);
225
+ if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
226
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, null);
227
+ if (runtimeDeny !== null)
228
+ return runtimeDeny;
229
+ return {
230
+ verdict: "allow",
231
+ code: "write-propose-ready",
232
+ event: input.event,
233
+ host: input.host,
234
+ toolName,
235
+ projectRoot,
236
+ message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
237
+ "(planning write; active scope not required).",
238
+ scopePath: null,
239
+ };
240
+ }
241
+ }
242
+ let scope;
243
+ try {
244
+ scope = (seams.inspectScope ?? inspectActiveScope)(projectRoot);
245
+ }
246
+ catch (cause) {
247
+ scope = { ready: false, path: null, message: String(cause) };
248
+ }
249
+ if (!scope.ready) {
250
+ const writeTarget = hookWriteTargetPath(input.payload);
251
+ const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
252
+ const proposedPathHint = options.proposedLifecycleExempt &&
253
+ relTarget !== null &&
254
+ (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
255
+ ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
256
+ "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
257
+ "planning writes (#2625)."
258
+ : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
259
+ (options.proposedLifecycleExempt
260
+ ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
261
+ : "then re-run the pre-start_agent gate stack.");
262
+ const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
263
+ return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
264
+ }
265
+ const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
266
+ if (!isSpawnTool(toolName)) {
267
+ const runtimeDeny = runtimeAuthorityForDirectWrite(input, toolName, seams, scope.path);
268
+ if (runtimeDeny !== null)
269
+ return runtimeDeny;
270
+ }
271
+ return {
272
+ verdict: "allow",
273
+ code: allowCode,
274
+ event: input.event,
275
+ host: input.host,
276
+ toolName,
277
+ projectRoot,
278
+ message: `Directive ${isSpawnTool(toolName) ? "spawn" : "write"} gate passed for ${toolName}.`,
279
+ scopePath: scope.path,
180
280
  };
181
281
  }
182
282
  /** Decide a normalized event using only the P0 direct-write policy. */
183
283
  export function decideHook(input, seams = {}) {
184
284
  const projectRoot = resolve(input.projectRoot);
285
+ if (input.event === "session.compact") {
286
+ try {
287
+ const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
288
+ if (!result.changed) {
289
+ return {
290
+ verdict: "allow",
291
+ code: "session-compact-noop",
292
+ event: input.event,
293
+ host: input.host,
294
+ toolName: null,
295
+ projectRoot,
296
+ message: result.message,
297
+ scopePath: null,
298
+ };
299
+ }
300
+ return {
301
+ verdict: "allow",
302
+ code: "session-compact-rearm",
303
+ event: input.event,
304
+ host: input.host,
305
+ toolName: null,
306
+ projectRoot,
307
+ message: result.message,
308
+ scopePath: null,
309
+ };
310
+ }
311
+ catch (cause) {
312
+ return {
313
+ verdict: "allow",
314
+ code: "session-compact-rearm-degraded",
315
+ event: input.event,
316
+ host: input.host,
317
+ toolName: null,
318
+ projectRoot,
319
+ message: "Directive compact re-arm bookkeeping failed on its non-blocking path: " +
320
+ `${String(cause)}`,
321
+ scopePath: null,
322
+ };
323
+ }
324
+ }
185
325
  if (input.event === "session.start") {
186
326
  try {
187
327
  const result = (seams.sessionStart ?? runSessionStartHookWrite)(projectRoot);
@@ -231,74 +371,45 @@ export function decideHook(input, seams = {}) {
231
371
  context: input.payloadContext,
232
372
  }));
233
373
  }
234
- if (!isDirectWriteTool(toolName)) {
235
- return {
236
- verdict: "allow",
237
- code: "not-direct-write",
238
- event: input.event,
239
- host: input.host,
240
- toolName,
241
- projectRoot,
242
- message: `${toolName} is outside the P0 direct-write enforcement slice.`,
243
- scopePath: null,
244
- };
245
- }
246
- let ritual;
247
- try {
248
- ritual = (seams.inspectRitual ??
249
- ((root) => inspectSessionRitual(root, { tier: "gated", posture: "mutation" })))(projectRoot);
250
- }
251
- catch (cause) {
252
- return deny(input, "ritual-not-ready", toolName, `Directive could not inspect the gated session ritual: ${String(cause)}. ` +
253
- "Run `deft session:start`, then `deft verify:session-ritual -- --tier=gated`.");
374
+ const environ = input.environ ?? process.env;
375
+ const readOnly = isReadOnlyHookContext(input.payload, environ);
376
+ if (readOnly && isDirectWriteTool(toolName)) {
377
+ return deny(input, "read-only-deny", toolName, `Directive denied ${toolName}: read-only explore posture blocks direct writes. ` +
378
+ 'Use Grok `default_capability_mode = "read-only"` for role-based explore agents, ' +
379
+ "or set explore subagent_type / DEFT_HOOK_READ_ONLY=1 when the harness supports it.");
254
380
  }
255
- if (ritual.code !== 0) {
256
- return deny(input, "ritual-not-ready", toolName, `Directive denied ${toolName}: ${ritual.message} ` +
257
- "Recovery: run `deft session:start`, then " +
258
- "`deft verify:session-ritual -- --tier=gated`.");
381
+ if (isSpawnTool(toolName)) {
382
+ if (readOnly && !isExploreSpawn(input.payload)) {
383
+ return deny(input, "read-only-deny", toolName, `Directive denied ${toolName}: read-only posture blocks implementation sub-agent spawns. ` +
384
+ "Use subagent_type explore for read-only research spawns.");
385
+ }
386
+ if (isExploreSpawn(input.payload)) {
387
+ return {
388
+ verdict: "allow",
389
+ code: "spawn-explore-ready",
390
+ event: input.event,
391
+ host: input.host,
392
+ toolName,
393
+ projectRoot,
394
+ message: `Directive allowed explore ${toolName} spawn without implementation gates.`,
395
+ scopePath: null,
396
+ };
397
+ }
398
+ return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
259
399
  }
260
- const writeTarget = hookWriteTargetPath(input.payload);
261
- if (isProposedLifecycleWrite(projectRoot, writeTarget)) {
400
+ if (!isDirectWriteTool(toolName)) {
262
401
  return {
263
402
  verdict: "allow",
264
- code: "write-propose-ready",
403
+ code: "not-direct-write",
265
404
  event: input.event,
266
405
  host: input.host,
267
406
  toolName,
268
407
  projectRoot,
269
- message: `Directive write gate allowed ${toolName} for a proposed lifecycle xBRIEF ` +
270
- "(planning write; active scope not required).",
408
+ message: `${toolName} is outside the P0 direct-write/spawn enforcement slice.`,
271
409
  scopePath: null,
272
410
  };
273
411
  }
274
- let scope;
275
- try {
276
- scope = (seams.inspectScope ?? inspectActiveScope)(projectRoot);
277
- }
278
- catch (cause) {
279
- scope = { ready: false, path: null, message: String(cause) };
280
- }
281
- if (!scope.ready) {
282
- const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
283
- const proposedPathHint = relTarget !== null &&
284
- (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
285
- ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
286
- "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
287
- "planning writes (#2625)."
288
- : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
289
- "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption).";
290
- return deny(input, "scope-not-ready", toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
291
- }
292
- return {
293
- verdict: "allow",
294
- code: "write-ready",
295
- event: input.event,
296
- host: input.host,
297
- toolName,
298
- projectRoot,
299
- message: `Directive write gate passed for ${toolName}.`,
300
- scopePath: scope.path,
301
- };
412
+ return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: true });
302
413
  }
303
414
  /** Render only authoritative denials; allow preserves the host's own permission flow. */
304
415
  export function renderHostDecision(host, decision) {
@@ -0,0 +1,7 @@
1
+ /** Best-effort read-only explore signal from host payload (#1185). */
2
+ export declare function hookReadOnlyFromPayload(payload: unknown): boolean;
3
+ /** Read-only hook context: env override or host payload marker (#1185). */
4
+ export declare function isReadOnlyHookContext(payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
5
+ /** Explore sub-agent spawns are exempt from the implementation gate stack (#1185). */
6
+ export declare function isExploreSpawn(payload: unknown): boolean;
7
+ //# sourceMappingURL=readonly.d.ts.map
@@ -0,0 +1,83 @@
1
+ import { READ_ONLY_HOOK_ENV } from "./tools.js";
2
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
3
+ function record(value) {
4
+ return value !== null && typeof value === "object" && !Array.isArray(value)
5
+ ? value
6
+ : null;
7
+ }
8
+ function fieldString(input, key) {
9
+ const value = input[key];
10
+ if (typeof value === "string" && value.trim().length > 0)
11
+ return value.trim();
12
+ return null;
13
+ }
14
+ function toolInputRecord(payload) {
15
+ const toolCall = record(payload.tool_call) ?? record(payload.toolCall);
16
+ return (record(payload.tool_input) ??
17
+ record(payload.toolInput) ??
18
+ record(payload.input) ??
19
+ record(payload.arguments) ??
20
+ (toolCall !== null ? record(toolCall.arguments) : null));
21
+ }
22
+ function envTruthy(environ, name) {
23
+ return TRUTHY.has((environ[name] ?? "").trim().toLowerCase());
24
+ }
25
+ function isReadOnlyCapability(value) {
26
+ if (value === null)
27
+ return false;
28
+ const normalized = value.toLowerCase().replace(/[_\s-]/g, "");
29
+ return normalized === "readonly";
30
+ }
31
+ /** Best-effort read-only explore signal from host payload (#1185). */
32
+ export function hookReadOnlyFromPayload(payload) {
33
+ const input = record(payload);
34
+ if (input === null)
35
+ return false;
36
+ const toolInput = toolInputRecord(input) ?? input;
37
+ const capability = fieldString(toolInput, "capability_mode") ??
38
+ fieldString(toolInput, "capabilityMode") ??
39
+ fieldString(toolInput, "default_capability_mode") ??
40
+ fieldString(toolInput, "defaultCapabilityMode") ??
41
+ fieldString(input, "capability_mode") ??
42
+ fieldString(input, "capabilityMode") ??
43
+ fieldString(input, "default_capability_mode") ??
44
+ fieldString(input, "defaultCapabilityMode");
45
+ if (isReadOnlyCapability(capability))
46
+ return true;
47
+ const posture = fieldString(toolInput, "posture") ??
48
+ fieldString(input, "posture") ??
49
+ fieldString(input, "session_posture") ??
50
+ fieldString(input, "sessionPosture");
51
+ if (isReadOnlyCapability(posture))
52
+ return true;
53
+ if (toolInput.readOnly === true || toolInput.read_only === true)
54
+ return true;
55
+ if (input.readOnly === true || input.read_only === true)
56
+ return true;
57
+ return false;
58
+ }
59
+ /** Read-only hook context: env override or host payload marker (#1185). */
60
+ export function isReadOnlyHookContext(payload, environ = process.env) {
61
+ if (envTruthy(environ, READ_ONLY_HOOK_ENV))
62
+ return true;
63
+ return hookReadOnlyFromPayload(payload);
64
+ }
65
+ /** Explore sub-agent spawns are exempt from the implementation gate stack (#1185). */
66
+ export function isExploreSpawn(payload) {
67
+ const input = record(payload);
68
+ if (input === null)
69
+ return false;
70
+ const toolInput = toolInputRecord(input) ?? input;
71
+ const subagentType = fieldString(toolInput, "subagent_type") ??
72
+ fieldString(toolInput, "subagentType") ??
73
+ fieldString(input, "subagent_type") ??
74
+ fieldString(input, "subagentType");
75
+ if (subagentType?.toLowerCase() === "explore")
76
+ return true;
77
+ const workerRole = fieldString(toolInput, "worker_role") ??
78
+ fieldString(toolInput, "workerRole") ??
79
+ fieldString(input, "worker_role") ??
80
+ fieldString(input, "workerRole");
81
+ return workerRole?.toLowerCase() === "explore";
82
+ }
83
+ //# sourceMappingURL=readonly.js.map
@@ -1,4 +1,11 @@
1
1
  /** Host spellings that install-time matchers and runtime classification share. */
2
2
  export declare const DIRECT_WRITE_TOOL_NAMES: readonly ["Edit", "Write", "WriteFile", "CreateFile", "MultiEdit", "NotebookEdit", "StrReplace", "SearchReplace", "Delete", "DeleteFile", "ApplyPatch", "apply_patch"];
3
+ /** PreToolUse spawn / sub-agent dispatch tools (#1185 / #2437). */
4
+ export declare const SPAWN_TOOL_NAMES: readonly ["Task", "SubagentStart", "spawn_subagent", "start_agent", "CreateAgent"];
5
+ /** Env override forcing hook-level read-only write denial (#1185). */
6
+ export declare const READ_ONLY_HOOK_ENV = "DEFT_HOOK_READ_ONLY";
3
7
  export declare function isDirectWriteTool(toolName: string): boolean;
8
+ export declare function isSpawnTool(toolName: string): boolean;
9
+ export declare const DIRECT_WRITE_HOOK_MATCHER: string;
10
+ export declare const SPAWN_HOOK_MATCHER: string;
4
11
  //# sourceMappingURL=tools.d.ts.map
@@ -13,11 +13,27 @@ export const DIRECT_WRITE_TOOL_NAMES = [
13
13
  "ApplyPatch",
14
14
  "apply_patch",
15
15
  ];
16
+ /** PreToolUse spawn / sub-agent dispatch tools (#1185 / #2437). */
17
+ export const SPAWN_TOOL_NAMES = [
18
+ "Task",
19
+ "SubagentStart",
20
+ "spawn_subagent",
21
+ "start_agent",
22
+ "CreateAgent",
23
+ ];
24
+ /** Env override forcing hook-level read-only write denial (#1185). */
25
+ export const READ_ONLY_HOOK_ENV = "DEFT_HOOK_READ_ONLY";
16
26
  function normalizedToolName(toolName) {
17
27
  return toolName.toLowerCase().replace(/[^a-z0-9]/g, "");
18
28
  }
19
29
  const DIRECT_WRITE_TOOLS = new Set(DIRECT_WRITE_TOOL_NAMES.map(normalizedToolName));
30
+ const SPAWN_TOOLS = new Set(SPAWN_TOOL_NAMES.map(normalizedToolName));
20
31
  export function isDirectWriteTool(toolName) {
21
32
  return DIRECT_WRITE_TOOLS.has(normalizedToolName(toolName));
22
33
  }
34
+ export function isSpawnTool(toolName) {
35
+ return SPAWN_TOOLS.has(normalizedToolName(toolName));
36
+ }
37
+ export const DIRECT_WRITE_HOOK_MATCHER = DIRECT_WRITE_TOOL_NAMES.join("|");
38
+ export const SPAWN_HOOK_MATCHER = SPAWN_TOOL_NAMES.join("|");
23
39
  //# sourceMappingURL=tools.js.map