@deftai/directive-core 0.82.0 → 0.84.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 (64) hide show
  1. package/dist/cache/main.js +36 -2
  2. package/dist/cache/task-cache/constants.d.ts +4 -0
  3. package/dist/cache/task-cache/constants.js +4 -0
  4. package/dist/cache/task-cache/executor.d.ts +9 -0
  5. package/dist/cache/task-cache/executor.js +51 -0
  6. package/dist/cache/task-cache/hash.d.ts +17 -0
  7. package/dist/cache/task-cache/hash.js +92 -0
  8. package/dist/cache/task-cache/index.d.ts +14 -0
  9. package/dist/cache/task-cache/index.js +15 -0
  10. package/dist/cache/task-cache/lint.d.ts +4 -0
  11. package/dist/cache/task-cache/lint.js +55 -0
  12. package/dist/cache/task-cache/registry.d.ts +7 -0
  13. package/dist/cache/task-cache/registry.js +67 -0
  14. package/dist/cache/task-cache/store.d.ts +10 -0
  15. package/dist/cache/task-cache/store.js +48 -0
  16. package/dist/cache/task-cache/types.d.ts +48 -0
  17. package/dist/cache/task-cache/types.js +3 -0
  18. package/dist/check/cached-orchestrator.d.ts +16 -0
  19. package/dist/check/cached-orchestrator.js +76 -0
  20. package/dist/check/context.d.ts +30 -0
  21. package/dist/check/context.js +28 -0
  22. package/dist/check/gate-lists.d.ts +18 -0
  23. package/dist/check/gate-lists.js +68 -0
  24. package/dist/check/index.d.ts +4 -1
  25. package/dist/check/index.js +3 -0
  26. package/dist/check/orchestrator.d.ts +3 -45
  27. package/dist/check/orchestrator.js +8 -46
  28. package/dist/check/runner-detect.d.ts +20 -0
  29. package/dist/check/runner-detect.js +131 -0
  30. package/dist/eval/readback.js +6 -1
  31. package/dist/hooks/cursor-hooks.d.ts +23 -0
  32. package/dist/hooks/cursor-hooks.js +95 -0
  33. package/dist/hooks/dispatcher.d.ts +10 -1
  34. package/dist/hooks/dispatcher.js +66 -6
  35. package/dist/hooks/index.d.ts +1 -0
  36. package/dist/hooks/index.js +1 -0
  37. package/dist/init-deposit/agent-hooks.d.ts +4 -2
  38. package/dist/init-deposit/agent-hooks.js +137 -14
  39. package/dist/init-deposit/gitignore.js +1 -0
  40. package/dist/init-deposit/hygiene.js +1 -0
  41. package/dist/lifecycle/events.d.ts +1 -0
  42. package/dist/lifecycle/events.js +59 -9
  43. package/dist/policy/host-hooks.d.ts +21 -0
  44. package/dist/policy/host-hooks.js +96 -0
  45. package/dist/policy/index.d.ts +1 -0
  46. package/dist/policy/index.js +15 -1
  47. package/dist/product-signal/consent.d.ts +20 -3
  48. package/dist/product-signal/consent.js +63 -6
  49. package/dist/product-signal/gates.d.ts +1 -1
  50. package/dist/product-signal/submit.d.ts +5 -1
  51. package/dist/product-signal/submit.js +42 -18
  52. package/dist/scope/decompose.js +9 -3
  53. package/dist/session/git.d.ts +2 -0
  54. package/dist/session/git.js +14 -0
  55. package/dist/session/verify-session-ritual.js +39 -5
  56. package/dist/swarm/routing-set-cli.js +16 -5
  57. package/dist/swarm/routing.d.ts +1 -1
  58. package/dist/swarm/routing.js +3 -1
  59. package/dist/value/readback.js +6 -1
  60. package/dist/vbrief-validate/plan-hooks.d.ts +2 -0
  61. package/dist/vbrief-validate/plan-hooks.js +25 -0
  62. package/dist/verify-env/agent-hooks.d.ts +2 -1
  63. package/dist/verify-env/agent-hooks.js +3 -2
  64. package/package.json +7 -3
@@ -1,10 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_PRODUCT_SIGNAL_SINK_REPO } from "../policy/product-signal.js";
4
5
  import { platformUserConfigDir } from "../user-config/resolve-user-md.js";
5
6
  export const PRODUCT_SIGNAL_CONSENT_FILENAME = "product-signal-consent.json";
6
- /** Consent record schema version (#2693 D2). */
7
- export const PRODUCT_SIGNAL_CONSENT_VERSION = 1;
7
+ /** Consent record schema version (#2693 D2, #2767 v2 sink binding). */
8
+ export const PRODUCT_SIGNAL_CONSENT_VERSION = 2;
9
+ /** Legacy consent schema — authorizes default sink only (#2767). */
10
+ const PRODUCT_SIGNAL_CONSENT_VERSION_V1 = 1;
8
11
  /** Phase-1 consent tier permitting qualitative outbound (#2693 D2). */
9
12
  export const PRODUCT_SIGNAL_CONSENT_TIER = "product-signal";
10
13
  function resolveHomeDirForConsent(options) {
@@ -32,25 +35,76 @@ export function resolveProductSignalConsentPath(options = {}) {
32
35
  const homeDir = resolveHomeDirForConsent(options);
33
36
  return join(platformUserConfigDir(platform, env, homeDir), PRODUCT_SIGNAL_CONSENT_FILENAME);
34
37
  }
38
+ /** Normalize sinkRepo to lowercase owner/repo (#2767). */
39
+ export function normalizeProductSignalSinkRepo(raw) {
40
+ const sink = raw.trim().replace(/^https?:\/\/github\.com\//i, "");
41
+ return sink.replace(/\/+$/, "").toLowerCase();
42
+ }
35
43
  function parseConsentRecord(raw) {
36
44
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
37
45
  return null;
38
46
  }
39
47
  const rec = raw;
40
- if (typeof rec.consentVersion !== "number" || typeof rec.grantedAt !== "string") {
48
+ if (typeof rec.consentVersion !== "number" ||
49
+ typeof rec.grantedAt !== "string" ||
50
+ typeof rec.tier !== "string") {
41
51
  return null;
42
52
  }
43
- if (typeof rec.tier !== "string") {
53
+ const revokedAt = typeof rec.revokedAt === "string" ? rec.revokedAt : undefined;
54
+ let sinkRepo;
55
+ if (rec.consentVersion >= PRODUCT_SIGNAL_CONSENT_VERSION) {
56
+ if (typeof rec.sinkRepo !== "string" || rec.sinkRepo.trim().length === 0) {
57
+ return null;
58
+ }
59
+ sinkRepo = normalizeProductSignalSinkRepo(rec.sinkRepo);
60
+ if (sinkRepo.length === 0) {
61
+ return null;
62
+ }
63
+ }
64
+ else if (rec.consentVersion !== PRODUCT_SIGNAL_CONSENT_VERSION_V1) {
44
65
  return null;
45
66
  }
46
- const revokedAt = typeof rec.revokedAt === "string" ? rec.revokedAt : undefined;
47
67
  return {
48
68
  consentVersion: rec.consentVersion,
49
69
  grantedAt: rec.grantedAt,
50
70
  tier: rec.tier,
71
+ sinkRepo,
51
72
  revokedAt,
52
73
  };
53
74
  }
75
+ /** Resolve the sink authorized by a consent record (#2767). */
76
+ export function resolveConsentedProductSignalSink(consent) {
77
+ if (consent === null) {
78
+ return null;
79
+ }
80
+ if (consent.consentVersion >= PRODUCT_SIGNAL_CONSENT_VERSION) {
81
+ return consent.sinkRepo ?? null;
82
+ }
83
+ if (consent.consentVersion === PRODUCT_SIGNAL_CONSENT_VERSION_V1) {
84
+ return normalizeProductSignalSinkRepo(DEFAULT_PRODUCT_SIGNAL_SINK_REPO);
85
+ }
86
+ return null;
87
+ }
88
+ /** Authorize configured sink against install consent (#2767). */
89
+ export function authorizeProductSignalSink(configuredSink, consent) {
90
+ const configured = normalizeProductSignalSinkRepo(configuredSink);
91
+ const consented = resolveConsentedProductSignalSink(consent);
92
+ const sinksMatch = consented !== null && configured === consented;
93
+ let message = "sink authorized";
94
+ if (!sinksMatch) {
95
+ message =
96
+ consented === null
97
+ ? "product-signal requires consent (`task product-signal:consent -- --grant`)."
98
+ : `product-signal skipped (sink-unconsented): configured sink=${configured} does not match consented sink=${consented}. Re-run \`task product-signal:consent -- --grant\` after confirming the destination.`;
99
+ }
100
+ return {
101
+ authorized: sinksMatch,
102
+ configuredSink: configured,
103
+ consentedSink: consented,
104
+ sinksMatch,
105
+ message,
106
+ };
107
+ }
54
108
  /** Read consent file; returns null when absent, invalid, or revoked. */
55
109
  export function readProductSignalConsent(options = {}) {
56
110
  const path = resolveProductSignalConsentPath(options);
@@ -76,13 +130,16 @@ export function readProductSignalConsent(options = {}) {
76
130
  export function isProductSignalConsented(options = {}) {
77
131
  return readProductSignalConsent(options) !== null;
78
132
  }
79
- /** Write a fresh consent grant (#2693 D17 yes path). */
133
+ /** Write a fresh consent grant (#2693 D17 yes path, #2767 v2 sink binding). */
80
134
  export function grantProductSignalConsent(options = {}) {
81
135
  const now = options.now ?? new Date();
136
+ const normalizedSink = normalizeProductSignalSinkRepo((options.sinkRepo ?? DEFAULT_PRODUCT_SIGNAL_SINK_REPO).trim());
137
+ const sinkRepo = normalizedSink || normalizeProductSignalSinkRepo(DEFAULT_PRODUCT_SIGNAL_SINK_REPO);
82
138
  const record = {
83
139
  consentVersion: PRODUCT_SIGNAL_CONSENT_VERSION,
84
140
  grantedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
85
141
  tier: PRODUCT_SIGNAL_CONSENT_TIER,
142
+ sinkRepo,
86
143
  };
87
144
  const path = resolveProductSignalConsentPath(options);
88
145
  mkdirSync(dirname(path), { recursive: true });
@@ -1,4 +1,4 @@
1
- export type ProductSignalOutcome = "submitted" | "dry-run" | "disabled" | "no-consent" | "no-network" | "non-interactive" | "sink-unreachable" | "sink-unauthorized" | "validation" | "error-config";
1
+ export type ProductSignalOutcome = "submitted" | "dry-run" | "disabled" | "no-consent" | "no-network" | "non-interactive" | "sink-unconsented" | "sink-unreachable" | "sink-unauthorized" | "validation" | "error-config";
2
2
  export interface GateEvaluation {
3
3
  readonly allowed: boolean;
4
4
  readonly outcome: ProductSignalOutcome;
@@ -32,7 +32,11 @@ export declare function runProductSignalEnable(projectRoot: string | null, confi
32
32
  exitCode: 0 | 1 | 2;
33
33
  text: string;
34
34
  };
35
- export declare function runProductSignalConsent(action: "grant" | "revoke"): {
35
+ export interface ProductSignalConsentRunOptions {
36
+ readonly action: "grant" | "revoke";
37
+ readonly projectRoot?: string | null;
38
+ }
39
+ export declare function runProductSignalConsent(options: ProductSignalConsentRunOptions): {
36
40
  exitCode: 0 | 1;
37
41
  text: string;
38
42
  };
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { enableProductSignal, formatProductSignalStatusLine, resolveProductSignal, } from "../policy/product-signal.js";
4
4
  import { resolveProjectRoot } from "../scope/project-context.js";
5
5
  import { resolveActorName } from "./actor-name.js";
6
- import { grantProductSignalConsent, isProductSignalConsented, readProductSignalConsent, revokeProductSignalConsent, } from "./consent.js";
6
+ import { authorizeProductSignalSink, grantProductSignalConsent, readProductSignalConsent, revokeProductSignalConsent, } from "./consent.js";
7
7
  import { evaluateProductSignalGates } from "./gates.js";
8
8
  import { GitHubPrivateSinkAdapter } from "./github-private-sink-adapter.js";
9
9
  import { collectInstallContext } from "./install-context.js";
@@ -105,15 +105,25 @@ export async function submitProductSignal(options) {
105
105
  payload,
106
106
  };
107
107
  }
108
+ const policy = resolveProductSignal(root);
109
+ const consent = readProductSignalConsent();
110
+ const sinkAuth = authorizeProductSignalSink(policy.sinkRepo, consent);
111
+ if (!sinkAuth.authorized) {
112
+ return {
113
+ outcome: "sink-unconsented",
114
+ exitCode: 0,
115
+ message: `${sinkAuth.message}\n`,
116
+ payload,
117
+ };
118
+ }
108
119
  if (options.dryRun) {
109
120
  return {
110
121
  outcome: "dry-run",
111
122
  exitCode: 0,
112
- message: `[dry-run] payload valid for ${payload.surface}\n`,
123
+ message: `[dry-run] payload valid for ${payload.surface} (sink=${sinkAuth.configuredSink})\n`,
113
124
  payload,
114
125
  };
115
126
  }
116
- const policy = resolveProductSignal(root);
117
127
  const adapter = new GitHubPrivateSinkAdapter({ sinkRepo: policy.sinkRepo });
118
128
  const result = await adapter.submit(payload, { gapText: options.gapText });
119
129
  if (result.outcome === "submitted") {
@@ -131,11 +141,13 @@ export async function submitProductSignal(options) {
131
141
  export function runProductSignalStatus(projectRoot) {
132
142
  const root = resolveProjectRoot(projectRoot ?? undefined) ?? process.cwd();
133
143
  const policy = resolveProductSignal(root);
134
- const consented = isProductSignalConsented();
144
+ const consent = readProductSignalConsent();
145
+ const configuredSink = policy.sinkRepo;
146
+ const sinkAuth = authorizeProductSignalSink(configuredSink, consent);
135
147
  const last = readLastSubmitSummary(root);
136
148
  const lines = [
137
149
  formatProductSignalStatusLine(policy),
138
- `[deft product-signal] consented=${String(consented)}`,
150
+ `[deft product-signal] consented=${String(consent !== null)} configuredSink=${configuredSink} consentedSink=${sinkAuth.consentedSink ?? "none"} sinksMatch=${String(sinkAuth.sinksMatch)}`,
139
151
  last ? `[deft product-signal] ${last}` : "[deft product-signal] last submit: none",
140
152
  ];
141
153
  return { exitCode: 0, text: `${lines.join("\n")}\n` };
@@ -148,12 +160,15 @@ export function runProductSignalEnable(projectRoot, confirm) {
148
160
  const result = enableProductSignal(root, { confirm });
149
161
  return { exitCode: result.exitCode, text: result.stdout };
150
162
  }
151
- export function runProductSignalConsent(action) {
152
- if (action === "grant") {
153
- const record = grantProductSignalConsent();
163
+ export function runProductSignalConsent(options) {
164
+ if (options.action === "grant") {
165
+ const root = resolveProjectRoot(options.projectRoot ?? undefined);
166
+ const sinkRepo = root !== null ? resolveProductSignal(root).sinkRepo : undefined;
167
+ const record = grantProductSignalConsent({ sinkRepo });
154
168
  return {
155
169
  exitCode: 0,
156
- text: `product-signal consent granted (tier=${record.tier}, version=${record.consentVersion}).\n`,
170
+ text: `product-signal consent granted (tier=${record.tier}, version=${record.consentVersion}, ` +
171
+ `sinkRepo=${record.sinkRepo ?? "unknown"}).\n`,
157
172
  };
158
173
  }
159
174
  const ok = revokeProductSignalConsent();
@@ -216,24 +231,29 @@ export function parseProductSignalSubmitArgs(argv) {
216
231
  }
217
232
  return { surface, dryRun, json, projectRoot, nps };
218
233
  }
234
+ function parseOptionalProjectRootArg(argv, fallback) {
235
+ const rootIdx = argv.indexOf("--project-root");
236
+ if (rootIdx >= 0) {
237
+ return argv[rootIdx + 1] ?? fallback;
238
+ }
239
+ const eqArg = argv.find((a) => a.startsWith("--project-root="));
240
+ if (eqArg !== undefined) {
241
+ return eqArg.slice("--project-root=".length) || fallback;
242
+ }
243
+ return fallback;
244
+ }
219
245
  /** CLI module entrypoint for dispatch (#2693). */
220
246
  export async function productSignalMain(argv = process.argv.slice(2)) {
221
247
  const sub = argv[0];
222
248
  if (sub === "status") {
223
- const rootIdx = argv.indexOf("--project-root");
224
- const root = rootIdx >= 0
225
- ? (argv[rootIdx + 1] ?? ".")
226
- : (argv.find((a) => a.startsWith("--project-root="))?.split("=")[1] ?? ".");
249
+ const root = parseOptionalProjectRootArg(argv, ".") ?? ".";
227
250
  const result = runProductSignalStatus(root);
228
251
  process.stdout.write(result.text);
229
252
  return result.exitCode;
230
253
  }
231
254
  if (sub === "enable") {
232
255
  const confirm = argv.includes("--confirm");
233
- const rootIdx = argv.indexOf("--project-root");
234
- const root = rootIdx >= 0
235
- ? (argv[rootIdx + 1] ?? ".")
236
- : (argv.find((a) => a.startsWith("--project-root="))?.split("=")[1] ?? ".");
256
+ const root = parseOptionalProjectRootArg(argv, ".") ?? ".";
237
257
  const result = runProductSignalEnable(root, confirm);
238
258
  process.stdout.write(result.text);
239
259
  return result.exitCode;
@@ -245,7 +265,11 @@ export async function productSignalMain(argv = process.argv.slice(2)) {
245
265
  process.stderr.write("usage: product-signal consent -- --grant|--revoke\n");
246
266
  return 1;
247
267
  }
248
- const result = runProductSignalConsent(grant ? "grant" : "revoke");
268
+ const root = parseOptionalProjectRootArg(argv, null);
269
+ const result = runProductSignalConsent({
270
+ action: grant ? "grant" : "revoke",
271
+ projectRoot: root,
272
+ });
249
273
  process.stdout.write(result.text);
250
274
  return result.exitCode;
251
275
  }
@@ -9,6 +9,7 @@
9
9
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
11
11
  import { referenceTypeMatches } from "@deftai/directive-types";
12
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
12
13
  import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleRoot, } from "../layout/resolve.js";
13
14
  import { referenceWithDefaultTrust, slugify } from "../vbrief-build/build.js";
14
15
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
@@ -52,7 +53,8 @@ function loadJson(path) {
52
53
  }
53
54
  return data;
54
55
  }
55
- function writeJson(path, data) {
56
+ function writeJson(projectRoot, path, data) {
57
+ assertWriteTargetSafe(projectRoot, path);
56
58
  mkdirSync(dirname(path), { recursive: true });
57
59
  writeFileSync(path, formatBriefJson(data), "utf8");
58
60
  }
@@ -871,7 +873,7 @@ export function applyDecomposition(opts) {
871
873
  }
872
874
  for (let i = 0; i < childPaths.length; i += 1) {
873
875
  // biome-ignore lint/style/noNonNullAssertion: loop bound ensures these exist
874
- writeJson(childPaths[i].target, childDocs[i]);
876
+ writeJson(projectRoot, childPaths[i].target, childDocs[i]);
875
877
  }
876
878
  let parentPlan = parent.plan;
877
879
  if (parentPlan === null || parentPlan === undefined) {
@@ -911,7 +913,7 @@ export function applyDecomposition(opts) {
911
913
  planObj.references = dedupeReferences(references
912
914
  .filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
913
915
  .map((r) => r));
914
- writeJson(parentPath, parent);
916
+ writeJson(projectRoot, parentPath, parent);
915
917
  actions.push(`UPDATE ${parentRel} references`);
916
918
  return actions;
917
919
  }
@@ -1022,6 +1024,10 @@ export function decomposeMain(argv) {
1022
1024
  process.stderr.write(`ERROR: ${err.message}\n`);
1023
1025
  return 1;
1024
1026
  }
1027
+ if (err instanceof ProjectionContainmentError) {
1028
+ process.stderr.write(`ERROR: ${err.message}\n`);
1029
+ return 2;
1030
+ }
1025
1031
  process.stderr.write(`ERROR: ${String(err)}\n`);
1026
1032
  return 1;
1027
1033
  }
@@ -10,5 +10,7 @@ export declare function gitHead(projectRoot: string, runGit?: GitRunner): {
10
10
  error: string | null;
11
11
  };
12
12
  export declare function worktreePath(projectRoot: string, runGit?: GitRunner): string;
13
+ /** True when `ancestor` is reachable from `descendant` (same commit counts). */
14
+ export declare function gitIsAncestor(projectRoot: string, ancestor: string, descendant: string, runGit?: GitRunner): boolean | null;
13
15
  export declare function detectBranch(projectRoot: string, runGit?: GitRunner): string | null;
14
16
  //# sourceMappingURL=git.d.ts.map
@@ -35,6 +35,20 @@ export function worktreePath(projectRoot, runGit = defaultGitRunner) {
35
35
  }
36
36
  return resolve(projectRoot);
37
37
  }
38
+ /** True when `ancestor` is reachable from `descendant` (same commit counts). */
39
+ export function gitIsAncestor(projectRoot, ancestor, descendant, runGit = defaultGitRunner) {
40
+ if (ancestor === descendant) {
41
+ return true;
42
+ }
43
+ const { code } = runGit(projectRoot, ["merge-base", "--is-ancestor", ancestor, descendant]);
44
+ if (code === 0) {
45
+ return true;
46
+ }
47
+ if (code === 1) {
48
+ return false;
49
+ }
50
+ return null;
51
+ }
38
52
  export function detectBranch(projectRoot, runGit = defaultGitRunner) {
39
53
  const sym = runGit(projectRoot, ["symbolic-ref", "--short", "HEAD"]);
40
54
  if (sym.code === 0 && sym.stdout.trim()) {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { formatFrameworkCommand } from "../render/framework-commands.js";
3
- import { defaultGitRunner, gitHead, worktreePath } from "./git.js";
3
+ import { defaultGitRunner, gitHead, gitIsAncestor, worktreePath } from "./git.js";
4
4
  import { pythonJsonDump } from "./json.js";
5
5
  import { ENV_SESSION_POSTURE, readOnlyPostureMessage, resolveSessionPosture, ritualStateIsPostureAuthority, } from "./posture.js";
6
6
  import { defaultRitualRunner } from "./ritual-entrypoint.js";
@@ -59,6 +59,10 @@ function runGatedStep(projectRoot, payload, stepName, runner, now) {
59
59
  }
60
60
  return null;
61
61
  }
62
+ function headDriftRecoveryMessage() {
63
+ return (`session ritual state is stale because git HEAD changed discontinuously. ` +
64
+ `Run \`${formatFrameworkCommand(["session:start"])}\` again.`);
65
+ }
62
66
  function evaluateLoadedState(projectRoot, state, input) {
63
67
  const runGit = input.runGit ?? defaultGitRunner;
64
68
  const { head: currentHead, error: headError } = gitHead(projectRoot, runGit);
@@ -73,10 +77,22 @@ function evaluateLoadedState(projectRoot, state, input) {
73
77
  ];
74
78
  }
75
79
  if (state.gitHead !== currentHead) {
76
- return [
77
- 1,
78
- `session ritual state is stale because git HEAD changed. Run \`${formatFrameworkCommand(["session:start"])}\` again.`,
79
- ];
80
+ const forward = gitIsAncestor(projectRoot, state.gitHead, currentHead, runGit);
81
+ if (forward === null) {
82
+ return [2, "could not verify git history for session ritual"];
83
+ }
84
+ if (!forward) {
85
+ return [1, headDriftRecoveryMessage()];
86
+ }
87
+ if (input.rebindForwardHead) {
88
+ const payload = { ...state.raw, git_head: currentHead };
89
+ try {
90
+ writeRitualState(projectRoot, payload);
91
+ }
92
+ catch (exc) {
93
+ return [2, `could not rebind session ritual git HEAD: ${String(exc)}`];
94
+ }
95
+ }
80
96
  }
81
97
  const staleness = resolveSessionRitualStalenessHours(projectRoot);
82
98
  if (staleness.source === "default-on-error") {
@@ -157,6 +173,7 @@ export function inspectSessionRitual(projectRoot, options = {}) {
157
173
  tier,
158
174
  now: options.now ?? new Date(),
159
175
  runGit: options.runGit,
176
+ rebindForwardHead: false,
160
177
  });
161
178
  return {
162
179
  code,
@@ -246,6 +263,7 @@ export function verifySessionRitual(projectRoot, options = {}) {
246
263
  tier: "quick",
247
264
  now: instant,
248
265
  runGit: options.runGit,
266
+ rebindForwardHead: true,
249
267
  });
250
268
  if (precheckCode !== 0) {
251
269
  return {
@@ -259,6 +277,21 @@ export function verifySessionRitual(projectRoot, options = {}) {
259
277
  ritualStateRequired,
260
278
  };
261
279
  }
280
+ const reloadedAfterPrecheck = readRitualState(projectRoot);
281
+ state = reloadedAfterPrecheck[0];
282
+ err = reloadedAfterPrecheck[1];
283
+ if (state === null) {
284
+ return {
285
+ code: 2,
286
+ message: err ?? "ritual state invalid after precheck",
287
+ tier,
288
+ statePath,
289
+ bypassed: false,
290
+ wouldFailCode: null,
291
+ posture,
292
+ ritualStateRequired,
293
+ };
294
+ }
262
295
  const payload = { ...state.raw };
263
296
  const gated = { ...payload.gated_steps };
264
297
  payload.gated_steps = gated;
@@ -303,6 +336,7 @@ export function verifySessionRitual(projectRoot, options = {}) {
303
336
  tier,
304
337
  now: instant,
305
338
  runGit: options.runGit,
339
+ rebindForwardHead: true,
306
340
  });
307
341
  if (isBypassed) {
308
342
  return {
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE, ProjectionContainmentError, } from "../fs/projection-containment.js";
4
5
  import { getPlatformCapabilities } from "../intake/platform-capabilities.js";
5
6
  import { EXIT_CONFIG_ERROR, EXIT_OK } from "./constants.js";
6
7
  import { dispatchProviderFromRuntime, HARNESS_BOUND_PROVIDERS, ROUTING_MODE_HARNESS_DEFAULT, ROUTING_MODE_PINNED, resolveRoutingPath, SWARM_WORKER_ROLES, writeModelDecision, } from "./routing.js";
@@ -66,11 +67,21 @@ export function routingSetMain(argv = process.argv.slice(2)) {
66
67
  "so only --harness-default is recordable here.\n");
67
68
  return EXIT_CONFIG_ERROR;
68
69
  }
69
- const path = resolveRoutingPath(resolve(projectRoot));
70
- writeModelDecision(path, resolvedProvider, role, {
71
- model,
72
- mode: harnessDefault ? ROUTING_MODE_HARNESS_DEFAULT : ROUTING_MODE_PINNED,
73
- });
70
+ const root = resolve(projectRoot);
71
+ const path = resolveRoutingPath(root);
72
+ try {
73
+ writeModelDecision(root, path, resolvedProvider, role, {
74
+ model,
75
+ mode: harnessDefault ? ROUTING_MODE_HARNESS_DEFAULT : ROUTING_MODE_PINNED,
76
+ });
77
+ }
78
+ catch (err) {
79
+ if (err instanceof ProjectionContainmentError) {
80
+ process.stderr.write(`ERROR: ${err.message}\n`);
81
+ return PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE;
82
+ }
83
+ throw err;
84
+ }
74
85
  const modelText = model ?? "<harness default>";
75
86
  process.stdout.write(`Recorded route: provider '${resolvedProvider}', role '${role}' -> model ${modelText}.\n` +
76
87
  `Route file: ${path}\n`);
@@ -62,5 +62,5 @@ export declare function resolveDispatchProvider(environ?: NodeJS.ProcessEnv): st
62
62
  * `decidedAt` when the caller did not supply one. Used by the interactive
63
63
  * resolver path (resolver step 5) and the `swarm:routing-set` task.
64
64
  */
65
- export declare function writeModelDecision(path: string, provider: string, role: string, decision: RouteDecision): void;
65
+ export declare function writeModelDecision(projectRoot: string, path: string, provider: string, role: string, decision: RouteDecision): void;
66
66
  //# sourceMappingURL=routing.d.ts.map
@@ -16,6 +16,7 @@
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { dirname, isAbsolute, join, resolve } from "node:path";
19
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
19
20
  /**
20
21
  * The fixed worker-role vocabulary (reused from #1531). No separate tier
21
22
  * vocabulary to start; decisions are strictly per-role.
@@ -201,9 +202,10 @@ function assertSafeRoutingKey(kind, key) {
201
202
  * `decidedAt` when the caller did not supply one. Used by the interactive
202
203
  * resolver path (resolver step 5) and the `swarm:routing-set` task.
203
204
  */
204
- export function writeModelDecision(path, provider, role, decision) {
205
+ export function writeModelDecision(projectRoot, path, provider, role, decision) {
205
206
  assertSafeRoutingKey("provider", provider);
206
207
  assertSafeRoutingKey("role", role);
208
+ assertWriteTargetSafe(projectRoot, path);
207
209
  const { data } = loadRoutingFile(path);
208
210
  // Null-prototype write targets so a computed provider/role key can only ever
209
211
  // set an own property and can never reach `Object.prototype`, even if the
@@ -2,6 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { runningInsideDeftRepo } from "../doctor/paths.js";
4
4
  import { ALL_ATTRIBUTION_EVENT_NAMES } from "../events/attribution-constants.js";
5
+ import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
5
6
  import { DEFAULT_EVENT_LOG, readEvents } from "../lifecycle/events.js";
6
7
  import { policyColonInvocation } from "../policy/policy-invocation.js";
7
8
  import { isValueFeedbackPathAllowed, resolveValueFeedback, } from "../policy/value-feedback.js";
@@ -229,10 +230,14 @@ function appendReadbackHistory(projectRoot, eventId, line, options = {}) {
229
230
  line,
230
231
  };
231
232
  try {
233
+ assertWriteTargetSafe(projectRoot, path);
232
234
  mkdirSync(join(path, ".."), { recursive: true });
233
235
  appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
234
236
  }
235
- catch {
237
+ catch (err) {
238
+ if (err instanceof ProjectionContainmentError) {
239
+ throw err;
240
+ }
236
241
  // observability only
237
242
  }
238
243
  }
@@ -6,6 +6,8 @@ export declare function validateSessionRitualStalenessHoursOnPlan(plan: unknown,
6
6
  export declare function validateTriageRankingLabelsOnPlan(plan: unknown, filepath: string): string[];
7
7
  /** vbrief_validate hook: validate ``plan.policy.runtimeAuthority`` (#1394). */
8
8
  export declare function validateRuntimeAuthorityOnPlan(plan: unknown, filepath: string): string[];
9
+ /** vbrief_validate hook: validate ``plan.policy.hostHooks`` (#2752). */
10
+ export declare function validateHostHooksOnPlan(plan: unknown, filepath: string): string[];
9
11
  /** vbrief_validate hook: validate ``plan.policy.stalenessTickler`` (#2489). */
10
12
  export declare function validateStalenessTicklerOnPlan(plan: unknown, filepath: string): string[];
11
13
  /** Run all PROJECT-DEFINITION policy hooks (mirrors lazy-import block in Python). */
@@ -1,3 +1,4 @@
1
+ import { validateHostHooks } from "../policy/host-hooks.js";
1
2
  import { readPlanPolicy } from "../policy/plan-extensions.js";
2
3
  import { validateRuntimeAuthority } from "../policy/runtime-authority.js";
3
4
  import { validateStalenessTickler } from "../policy/staleness-tickler.js";
@@ -121,6 +122,24 @@ export function validateRuntimeAuthorityOnPlan(plan, filepath) {
121
122
  }
122
123
  return out;
123
124
  }
125
+ /** vbrief_validate hook: validate ``plan.policy.hostHooks`` (#2752). */
126
+ export function validateHostHooksOnPlan(plan, filepath) {
127
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
128
+ return [];
129
+ }
130
+ const policy = readPlanPolicy(plan);
131
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
132
+ return [];
133
+ }
134
+ if (!("hostHooks" in policy)) {
135
+ return [];
136
+ }
137
+ const out = [];
138
+ for (const err of validateHostHooks(policy.hostHooks)) {
139
+ out.push(`${filepath}: ${err} (#2752)`);
140
+ }
141
+ return out;
142
+ }
124
143
  /** vbrief_validate hook: validate ``plan.policy.stalenessTickler`` (#2489). */
125
144
  export function validateStalenessTicklerOnPlan(plan, filepath) {
126
145
  if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
@@ -191,6 +210,12 @@ export function runProjectDefinitionHooks(plan, filepath) {
191
210
  catch {
192
211
  /* hook must not break validation */
193
212
  }
213
+ try {
214
+ errors.push(...validateHostHooksOnPlan(plan, filepath));
215
+ }
216
+ catch {
217
+ /* hook must not break validation */
218
+ }
194
219
  return errors;
195
220
  }
196
221
  //# sourceMappingURL=plan-hooks.js.map
@@ -1,4 +1,5 @@
1
1
  import { type AgentHookInspection } from "../init-deposit/agent-hooks.js";
2
+ import type { HostHooksPolicy } from "../policy/host-hooks.js";
2
3
  import type { OutputStream } from "./verify-hooks-installed.js";
3
4
  export interface AgentHookHealthResult {
4
5
  readonly code: 0 | 1 | 2;
@@ -7,5 +8,5 @@ export interface AgentHookHealthResult {
7
8
  readonly registrations: readonly AgentHookInspection[];
8
9
  }
9
10
  /** Read-only P0 agent-host registration health, independent of git hooks. */
10
- export declare function evaluateAgentHooks(projectRoot: string): AgentHookHealthResult;
11
+ export declare function evaluateAgentHooks(projectRoot: string, hostHooksPolicy?: HostHooksPolicy): AgentHookHealthResult;
11
12
  //# sourceMappingURL=agent-hooks.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { inspectAgentHookDeposit } from "../init-deposit/agent-hooks.js";
4
+ import { loadHostHooksPolicyFromProject } from "../policy/host-hooks.js";
4
5
  function isDirectory(path) {
5
6
  try {
6
7
  return statSync(path).isDirectory();
@@ -10,7 +11,7 @@ function isDirectory(path) {
10
11
  }
11
12
  }
12
13
  /** Read-only P0 agent-host registration health, independent of git hooks. */
13
- export function evaluateAgentHooks(projectRoot) {
14
+ export function evaluateAgentHooks(projectRoot, hostHooksPolicy = loadHostHooksPolicyFromProject(projectRoot)) {
14
15
  const root = resolve(projectRoot);
15
16
  if (!isDirectory(root)) {
16
17
  return {
@@ -20,7 +21,7 @@ export function evaluateAgentHooks(projectRoot) {
20
21
  registrations: [],
21
22
  };
22
23
  }
23
- const registrations = inspectAgentHookDeposit(root);
24
+ const registrations = inspectAgentHookDeposit(root, hostHooksPolicy);
24
25
  const unhealthy = registrations.filter((entry) => entry.status !== "healthy");
25
26
  if (unhealthy.length > 0) {
26
27
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.82.0",
3
+ "version": "0.84.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -86,6 +86,10 @@
86
86
  "types": "./dist/cache/index.d.ts",
87
87
  "default": "./dist/cache/index.js"
88
88
  },
89
+ "./cache/task-cache": {
90
+ "types": "./dist/cache/task-cache/index.d.ts",
91
+ "default": "./dist/cache/task-cache/index.js"
92
+ },
89
93
  "./doctor": {
90
94
  "types": "./dist/doctor/index.d.ts",
91
95
  "default": "./dist/doctor/index.js"
@@ -313,8 +317,8 @@
313
317
  "provenance": true
314
318
  },
315
319
  "dependencies": {
316
- "@deftai/directive-content": "^0.82.0",
317
- "@deftai/directive-types": "^0.82.0",
320
+ "@deftai/directive-content": "^0.84.0",
321
+ "@deftai/directive-types": "^0.84.0",
318
322
  "archiver": "^8.0.0"
319
323
  },
320
324
  "scripts": {