@deftai/directive-core 0.82.0 → 0.83.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.
- package/dist/hooks/cursor-hooks.d.ts +23 -0
- package/dist/hooks/cursor-hooks.js +95 -0
- package/dist/hooks/dispatcher.d.ts +8 -1
- package/dist/hooks/dispatcher.js +23 -5
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/init-deposit/agent-hooks.d.ts +4 -2
- package/dist/init-deposit/agent-hooks.js +137 -14
- package/dist/init-deposit/hygiene.js +1 -0
- package/dist/lifecycle/events.d.ts +1 -0
- package/dist/lifecycle/events.js +59 -9
- package/dist/policy/host-hooks.d.ts +21 -0
- package/dist/policy/host-hooks.js +96 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +15 -1
- package/dist/product-signal/consent.d.ts +20 -3
- package/dist/product-signal/consent.js +63 -6
- package/dist/product-signal/gates.d.ts +1 -1
- package/dist/product-signal/submit.d.ts +5 -1
- package/dist/product-signal/submit.js +42 -18
- package/dist/vbrief-validate/plan-hooks.d.ts +2 -0
- package/dist/vbrief-validate/plan-hooks.js +25 -0
- package/dist/verify-env/agent-hooks.d.ts +2 -1
- package/dist/verify-env/agent-hooks.js +3 -2
- package/package.json +3 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Cursor ApplyPatch spellings handled by the project adapter, not generic write dispatch. */
|
|
2
|
+
export declare const APPLY_PATCH_TOOL_NAMES: readonly ["ApplyPatch", "apply_patch"];
|
|
3
|
+
export declare const APPLY_PATCH_HOOK_MATCHER: string;
|
|
4
|
+
/** Generic Cursor write matcher excludes ApplyPatch — adapter owns it (#2764). */
|
|
5
|
+
export declare const CURSOR_GENERIC_WRITE_TOOL_NAMES: ("Edit" | "Write" | "WriteFile" | "CreateFile" | "MultiEdit" | "NotebookEdit" | "StrReplace" | "SearchReplace" | "Delete" | "DeleteFile" | "ApplyPatch" | "apply_patch")[];
|
|
6
|
+
export declare const CURSOR_GENERIC_WRITE_HOOK_MATCHER: string;
|
|
7
|
+
export declare const CURSOR_APPLY_PATCH_ADAPTER_RELATIVE = ".cursor/hooks/deft-cursor-hook-adapter.mjs";
|
|
8
|
+
export declare const DEFT_CURSOR_ADAPTER_COMMAND_MARKER = "deft-cursor-hook-adapter.mjs";
|
|
9
|
+
export declare const CURSOR_APPLY_PATCH_ADAPTER_COMMAND = "node .cursor/hooks/deft-cursor-hook-adapter.mjs ApplyPatch";
|
|
10
|
+
/** Deposited adapter forwards free-form ApplyPatch stdin to hook:dispatch with explicit project root. */
|
|
11
|
+
export declare const CURSOR_APPLY_PATCH_ADAPTER_SOURCE = "#!/usr/bin/env node\nimport { spawnSync } from \"node:child_process\";\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nconst projectRoot = resolve(process.cwd());\nconst stdin = readFileSync(0, \"utf8\");\n\nfunction deftCommand() {\n for (const candidate of [\"deft\", \"directive\"]) {\n const probe = spawnSync(candidate, [\"--version\"], {\n encoding: \"utf8\",\n stdio: \"ignore\",\n shell: process.platform === \"win32\",\n windowsHide: true,\n });\n if (probe.error === undefined && probe.status === 0) return candidate;\n }\n process.stderr.write(\n \"Directive ApplyPatch adapter: neither deft nor directive is on PATH.\\n\",\n );\n process.exit(2);\n}\n\nconst cli = deftCommand();\nconst result = spawnSync(\n cli,\n [\n \"hook:dispatch\",\n \"--host\",\n \"cursor\",\n \"--event\",\n \"tool.before\",\n \"--project-root\",\n projectRoot,\n ],\n {\n input: stdin,\n encoding: \"utf8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n shell: process.platform === \"win32\",\n windowsHide: true,\n },\n);\n\nif (result.stderr) process.stderr.write(result.stderr);\nif (result.error) {\n process.stderr.write(String(result.error));\n process.exit(2);\n}\nif (result.status !== 0 && result.status !== null) {\n if (result.stdout) process.stdout.write(result.stdout);\n process.exit(result.status);\n}\n// Cursor failClosed treats empty stdout as failure \u2014 normalize allow.\nconst out = (result.stdout ?? \"\").trim();\nprocess.stdout.write((out.length > 0 ? out : '{\"permission\":\"allow\"}') + \"\\n\");\nprocess.exit(0);\n";
|
|
12
|
+
export interface CursorPreToolUseEntry {
|
|
13
|
+
readonly command: string;
|
|
14
|
+
readonly matcher?: string;
|
|
15
|
+
readonly failClosed?: boolean;
|
|
16
|
+
readonly timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function cursorApplyPatchAdapterEntry(): CursorPreToolUseEntry;
|
|
19
|
+
/** True when generic and adapter matchers share no tool tokens. */
|
|
20
|
+
export declare function cursorApplyPatchMatchersDisjoint(): boolean;
|
|
21
|
+
/** Fail closed when Cursor hook projection would double-dispatch ApplyPatch (#2764). */
|
|
22
|
+
export declare function assertCursorApplyPatchMatchersDisjoint(): void;
|
|
23
|
+
//# sourceMappingURL=cursor-hooks.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DIRECT_WRITE_TOOL_NAMES } from "./tools.js";
|
|
2
|
+
/** Cursor ApplyPatch spellings handled by the project adapter, not generic write dispatch. */
|
|
3
|
+
export const APPLY_PATCH_TOOL_NAMES = ["ApplyPatch", "apply_patch"];
|
|
4
|
+
export const APPLY_PATCH_HOOK_MATCHER = APPLY_PATCH_TOOL_NAMES.join("|");
|
|
5
|
+
const APPLY_PATCH_TOOLS = new Set(APPLY_PATCH_TOOL_NAMES);
|
|
6
|
+
/** Generic Cursor write matcher excludes ApplyPatch — adapter owns it (#2764). */
|
|
7
|
+
export const CURSOR_GENERIC_WRITE_TOOL_NAMES = DIRECT_WRITE_TOOL_NAMES.filter((name) => !APPLY_PATCH_TOOLS.has(name));
|
|
8
|
+
export const CURSOR_GENERIC_WRITE_HOOK_MATCHER = CURSOR_GENERIC_WRITE_TOOL_NAMES.join("|");
|
|
9
|
+
export const CURSOR_APPLY_PATCH_ADAPTER_RELATIVE = ".cursor/hooks/deft-cursor-hook-adapter.mjs";
|
|
10
|
+
export const DEFT_CURSOR_ADAPTER_COMMAND_MARKER = "deft-cursor-hook-adapter.mjs";
|
|
11
|
+
export const CURSOR_APPLY_PATCH_ADAPTER_COMMAND = `node ${CURSOR_APPLY_PATCH_ADAPTER_RELATIVE} ApplyPatch`;
|
|
12
|
+
/** Deposited adapter forwards free-form ApplyPatch stdin to hook:dispatch with explicit project root. */
|
|
13
|
+
export const CURSOR_APPLY_PATCH_ADAPTER_SOURCE = `#!/usr/bin/env node
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
const projectRoot = resolve(process.cwd());
|
|
19
|
+
const stdin = readFileSync(0, "utf8");
|
|
20
|
+
|
|
21
|
+
function deftCommand() {
|
|
22
|
+
for (const candidate of ["deft", "directive"]) {
|
|
23
|
+
const probe = spawnSync(candidate, ["--version"], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
stdio: "ignore",
|
|
26
|
+
shell: process.platform === "win32",
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
});
|
|
29
|
+
if (probe.error === undefined && probe.status === 0) return candidate;
|
|
30
|
+
}
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
"Directive ApplyPatch adapter: neither deft nor directive is on PATH.\\n",
|
|
33
|
+
);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cli = deftCommand();
|
|
38
|
+
const result = spawnSync(
|
|
39
|
+
cli,
|
|
40
|
+
[
|
|
41
|
+
"hook:dispatch",
|
|
42
|
+
"--host",
|
|
43
|
+
"cursor",
|
|
44
|
+
"--event",
|
|
45
|
+
"tool.before",
|
|
46
|
+
"--project-root",
|
|
47
|
+
projectRoot,
|
|
48
|
+
],
|
|
49
|
+
{
|
|
50
|
+
input: stdin,
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
shell: process.platform === "win32",
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
59
|
+
if (result.error) {
|
|
60
|
+
process.stderr.write(String(result.error));
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
if (result.status !== 0 && result.status !== null) {
|
|
64
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
65
|
+
process.exit(result.status);
|
|
66
|
+
}
|
|
67
|
+
// Cursor failClosed treats empty stdout as failure — normalize allow.
|
|
68
|
+
const out = (result.stdout ?? "").trim();
|
|
69
|
+
process.stdout.write((out.length > 0 ? out : '{"permission":"allow"}') + "\\n");
|
|
70
|
+
process.exit(0);
|
|
71
|
+
`;
|
|
72
|
+
export function cursorApplyPatchAdapterEntry() {
|
|
73
|
+
return {
|
|
74
|
+
command: CURSOR_APPLY_PATCH_ADAPTER_COMMAND,
|
|
75
|
+
matcher: APPLY_PATCH_HOOK_MATCHER,
|
|
76
|
+
failClosed: true,
|
|
77
|
+
timeout: 5,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** True when generic and adapter matchers share no tool tokens. */
|
|
81
|
+
export function cursorApplyPatchMatchersDisjoint() {
|
|
82
|
+
const generic = new Set(CURSOR_GENERIC_WRITE_HOOK_MATCHER.split("|"));
|
|
83
|
+
for (const token of APPLY_PATCH_HOOK_MATCHER.split("|")) {
|
|
84
|
+
if (generic.has(token))
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** Fail closed when Cursor hook projection would double-dispatch ApplyPatch (#2764). */
|
|
90
|
+
export function assertCursorApplyPatchMatchersDisjoint() {
|
|
91
|
+
if (!cursorApplyPatchMatchersDisjoint()) {
|
|
92
|
+
throw new Error("Cursor ApplyPatch and generic direct-write matchers overlap — refusing hook deposit (#2764).");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=cursor-hooks.js.map
|
|
@@ -72,6 +72,13 @@ export declare function isHookHost(value: string): value is HookHost;
|
|
|
72
72
|
export declare function isHookEvent(value: string): value is HookEvent;
|
|
73
73
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
74
74
|
export declare function decideHook(input: HookDispatchInput, seams?: HookPolicySeams): HookDecision;
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Render host-facing hook output.
|
|
77
|
+
*
|
|
78
|
+
* Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
|
|
79
|
+
* hook failure and blocks the tool — so Cursor allows must emit explicit
|
|
80
|
+
* `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
|
|
81
|
+
* flow is unchanged.
|
|
82
|
+
*/
|
|
76
83
|
export declare function renderHostDecision(host: HookHost, decision: HookDecision): string;
|
|
77
84
|
//# sourceMappingURL=dispatcher.d.ts.map
|
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -161,13 +161,20 @@ export function isProposedLifecycleWrite(projectRoot, targetPath) {
|
|
|
161
161
|
return false;
|
|
162
162
|
return posix.startsWith("xbrief/proposed/") || posix.startsWith("vbrief/proposed/");
|
|
163
163
|
}
|
|
164
|
+
function isWindowsDriveOnlyRoot(value) {
|
|
165
|
+
return /^[A-Za-z]:[/\\]?$/.test(value.trim());
|
|
166
|
+
}
|
|
164
167
|
export function projectRootFromHookPayload(payload, fallback) {
|
|
165
168
|
const input = record(payload);
|
|
169
|
+
const fallbackResolved = resolve(fallback);
|
|
166
170
|
if (input === null)
|
|
167
|
-
return
|
|
171
|
+
return fallbackResolved;
|
|
168
172
|
const workspaceRoots = input.workspace_roots;
|
|
169
|
-
const
|
|
170
|
-
|
|
173
|
+
const candidate = firstString(input.workspaceRoot, input.workspace_root, Array.isArray(workspaceRoots) ? workspaceRoots[0] : null, input.cwd);
|
|
174
|
+
if (candidate === null || isWindowsDriveOnlyRoot(candidate)) {
|
|
175
|
+
return fallbackResolved;
|
|
176
|
+
}
|
|
177
|
+
return resolve(candidate);
|
|
171
178
|
}
|
|
172
179
|
export function isHookHost(value) {
|
|
173
180
|
return HOOK_HOSTS.includes(value);
|
|
@@ -411,10 +418,21 @@ export function decideHook(input, seams = {}) {
|
|
|
411
418
|
}
|
|
412
419
|
return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: true });
|
|
413
420
|
}
|
|
414
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Render host-facing hook output.
|
|
423
|
+
*
|
|
424
|
+
* Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
|
|
425
|
+
* hook failure and blocks the tool — so Cursor allows must emit explicit
|
|
426
|
+
* `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
|
|
427
|
+
* flow is unchanged.
|
|
428
|
+
*/
|
|
415
429
|
export function renderHostDecision(host, decision) {
|
|
416
|
-
if (decision.verdict === "allow")
|
|
430
|
+
if (decision.verdict === "allow") {
|
|
431
|
+
if (host === "cursor") {
|
|
432
|
+
return JSON.stringify({ permission: "allow" });
|
|
433
|
+
}
|
|
417
434
|
return "";
|
|
435
|
+
}
|
|
418
436
|
switch (host) {
|
|
419
437
|
case "claude":
|
|
420
438
|
case "codex":
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { HookHost } from "../hooks/dispatcher.js";
|
|
2
|
+
import { type HostHooksPolicy } from "../policy/host-hooks.js";
|
|
2
3
|
import type { InitDepositIo } from "./constants.js";
|
|
4
|
+
export { CURSOR_GENERIC_WRITE_HOOK_MATCHER, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
|
|
3
5
|
export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
|
|
4
6
|
export declare const DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
|
|
5
7
|
export declare const AGENT_HOOK_PATHS: readonly [".claude/settings.json", ".grok/hooks/deft.json", ".cursor/hooks.json", ".codex/hooks.json"];
|
|
@@ -19,7 +21,7 @@ export interface AgentHookDepositResult {
|
|
|
19
21
|
readonly changedPaths: AgentHookPath[];
|
|
20
22
|
}
|
|
21
23
|
/** Merge Directive-owned project hook entries without replacing user configuration. */
|
|
22
|
-
export declare function writeAgentHookDeposit(projectRoot: string, io?: InitDepositIo): AgentHookDepositResult;
|
|
24
|
+
export declare function writeAgentHookDeposit(projectRoot: string, io?: InitDepositIo, hostHooksPolicy?: HostHooksPolicy): AgentHookDepositResult;
|
|
23
25
|
/** Read-only registration probe shared by verify and doctor. */
|
|
24
|
-
export declare function inspectAgentHookDeposit(projectRoot: string): AgentHookInspection[];
|
|
26
|
+
export declare function inspectAgentHookDeposit(projectRoot: string, hostHooksPolicy?: HostHooksPolicy): AgentHookInspection[];
|
|
25
27
|
//# sourceMappingURL=agent-hooks.d.ts.map
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { assertDepositContained } from "../deposit/contain.js";
|
|
4
|
+
import { assertCursorApplyPatchMatchersDisjoint, CURSOR_APPLY_PATCH_ADAPTER_RELATIVE, CURSOR_APPLY_PATCH_ADAPTER_SOURCE, CURSOR_GENERIC_WRITE_HOOK_MATCHER, cursorApplyPatchAdapterEntry, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
|
|
4
5
|
import { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
|
|
6
|
+
import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
|
|
7
|
+
export { CURSOR_GENERIC_WRITE_HOOK_MATCHER, DEFT_CURSOR_ADAPTER_COMMAND_MARKER, } from "../hooks/cursor-hooks.js";
|
|
5
8
|
export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
|
|
6
9
|
export const DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
|
|
7
10
|
export const AGENT_HOOK_PATHS = [
|
|
@@ -63,9 +66,18 @@ function nestedCommands(value) {
|
|
|
63
66
|
function isManagedNestedGroup(value) {
|
|
64
67
|
return nestedCommands(value).some((value) => value.includes(DEFT_HOOK_COMMAND_MARKER));
|
|
65
68
|
}
|
|
69
|
+
function isManagedNestedGroupForHost(value, host) {
|
|
70
|
+
return nestedCommands(value).some((command) => command.includes(DEFT_HOOK_COMMAND_MARKER) && command.includes(`--host ${host}`));
|
|
71
|
+
}
|
|
66
72
|
function isManagedCursorEntry(value) {
|
|
67
73
|
const entry = object(value);
|
|
68
|
-
|
|
74
|
+
if (typeof entry?.command !== "string")
|
|
75
|
+
return false;
|
|
76
|
+
return (entry.command.includes(DEFT_HOOK_COMMAND_MARKER) ||
|
|
77
|
+
entry.command.includes(DEFT_CURSOR_ADAPTER_COMMAND_MARKER));
|
|
78
|
+
}
|
|
79
|
+
function isManagedCursorEntryForHost(value) {
|
|
80
|
+
return isManagedCursorEntry(value);
|
|
69
81
|
}
|
|
70
82
|
function nestedGroup(host, event, matcher) {
|
|
71
83
|
return {
|
|
@@ -97,7 +109,40 @@ function mergeNestedConfig(config, path, host, options = {}) {
|
|
|
97
109
|
}
|
|
98
110
|
return { ...config, hooks };
|
|
99
111
|
}
|
|
112
|
+
function stripManagedNestedConfig(config, path, host) {
|
|
113
|
+
const hooks = hooksObject(config, path);
|
|
114
|
+
const nextHooks = {};
|
|
115
|
+
for (const key of ["SessionStart", "PreToolUse", "PreCompact", "PostCompact"]) {
|
|
116
|
+
if (!(key in hooks))
|
|
117
|
+
continue;
|
|
118
|
+
const filtered = eventArray(hooks, key, path).filter((entry) => !isManagedNestedGroupForHost(entry, host));
|
|
119
|
+
if (filtered.length > 0)
|
|
120
|
+
nextHooks[key] = filtered;
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(nextHooks).length === 0) {
|
|
123
|
+
const { hooks: _hooks, ...rest } = config;
|
|
124
|
+
return rest;
|
|
125
|
+
}
|
|
126
|
+
return { ...config, hooks: nextHooks };
|
|
127
|
+
}
|
|
128
|
+
function stripManagedCursorConfig(config, path) {
|
|
129
|
+
const hooks = hooksObject(config, path);
|
|
130
|
+
const nextHooks = {};
|
|
131
|
+
for (const key of ["sessionStart", "preToolUse", "preCompact"]) {
|
|
132
|
+
if (!(key in hooks))
|
|
133
|
+
continue;
|
|
134
|
+
const filtered = eventArray(hooks, key, path).filter((entry) => !isManagedCursorEntryForHost(entry));
|
|
135
|
+
if (filtered.length > 0)
|
|
136
|
+
nextHooks[key] = filtered;
|
|
137
|
+
}
|
|
138
|
+
if (Object.keys(nextHooks).length === 0) {
|
|
139
|
+
const { hooks: _hooks, version: _version, ...rest } = config;
|
|
140
|
+
return rest;
|
|
141
|
+
}
|
|
142
|
+
return { ...config, version: 1, hooks: nextHooks };
|
|
143
|
+
}
|
|
100
144
|
function mergeCursorConfig(config, path) {
|
|
145
|
+
assertCursorApplyPatchMatchersDisjoint();
|
|
101
146
|
const hooks = hooksObject(config, path);
|
|
102
147
|
const session = eventArray(hooks, "sessionStart", path).filter((entry) => !isManagedCursorEntry(entry));
|
|
103
148
|
const preTool = eventArray(hooks, "preToolUse", path).filter((entry) => !isManagedCursorEntry(entry));
|
|
@@ -105,9 +150,10 @@ function mergeCursorConfig(config, path) {
|
|
|
105
150
|
hooks.sessionStart = [...session, { command: command("cursor", "session.start"), timeout: 5 }];
|
|
106
151
|
hooks.preToolUse = [
|
|
107
152
|
...preTool,
|
|
153
|
+
cursorApplyPatchAdapterEntry(),
|
|
108
154
|
{
|
|
109
155
|
command: command("cursor", "tool.before"),
|
|
110
|
-
matcher:
|
|
156
|
+
matcher: CURSOR_GENERIC_WRITE_HOOK_MATCHER,
|
|
111
157
|
failClosed: true,
|
|
112
158
|
timeout: 5,
|
|
113
159
|
},
|
|
@@ -121,6 +167,15 @@ function mergeCursorConfig(config, path) {
|
|
|
121
167
|
hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
|
|
122
168
|
return { ...config, version: 1, hooks };
|
|
123
169
|
}
|
|
170
|
+
function writeTextIfChanged(path, contents) {
|
|
171
|
+
if (existsSync(path) && readFileSync(path, "utf8") === contents)
|
|
172
|
+
return false;
|
|
173
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
174
|
+
const temporary = `${path}.deft-${process.pid}.tmp`;
|
|
175
|
+
writeFileSync(temporary, contents, "utf8");
|
|
176
|
+
renameSync(temporary, path);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
124
179
|
function writeJsonIfChanged(path, payload) {
|
|
125
180
|
const next = `${JSON.stringify(payload, null, 2)}\n`;
|
|
126
181
|
if (existsSync(path) && readFileSync(path, "utf8") === next)
|
|
@@ -132,41 +187,93 @@ function writeJsonIfChanged(path, payload) {
|
|
|
132
187
|
return true;
|
|
133
188
|
}
|
|
134
189
|
/** Merge Directive-owned project hook entries without replacing user configuration. */
|
|
135
|
-
export function writeAgentHookDeposit(projectRoot, io = { printf: () => undefined }) {
|
|
190
|
+
export function writeAgentHookDeposit(projectRoot, io = { printf: () => undefined }, hostHooksPolicy = loadHostHooksPolicyFromProject(projectRoot)) {
|
|
136
191
|
const changedPaths = [];
|
|
192
|
+
const strippedPaths = [];
|
|
137
193
|
const definitions = [
|
|
138
194
|
{
|
|
195
|
+
host: "claude",
|
|
139
196
|
path: AGENT_HOOK_PATHS[0],
|
|
140
197
|
merge: (config, path) => mergeNestedConfig(config, path, "claude", { compact: true }),
|
|
198
|
+
strip: (config, path) => stripManagedNestedConfig(config, path, "claude"),
|
|
141
199
|
},
|
|
142
200
|
{
|
|
201
|
+
host: "grok",
|
|
143
202
|
path: AGENT_HOOK_PATHS[1],
|
|
144
203
|
merge: (config, path) => mergeNestedConfig(config, path, "grok", { compact: true }),
|
|
204
|
+
strip: (config, path) => stripManagedNestedConfig(config, path, "grok"),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
host: "cursor",
|
|
208
|
+
path: AGENT_HOOK_PATHS[2],
|
|
209
|
+
merge: mergeCursorConfig,
|
|
210
|
+
strip: stripManagedCursorConfig,
|
|
145
211
|
},
|
|
146
|
-
{ path: AGENT_HOOK_PATHS[2], merge: mergeCursorConfig },
|
|
147
212
|
{
|
|
213
|
+
host: "codex",
|
|
148
214
|
path: AGENT_HOOK_PATHS[3],
|
|
149
215
|
merge: (config, path) => mergeNestedConfig(config, path, "codex", { compact: false }),
|
|
216
|
+
strip: (config, path) => stripManagedNestedConfig(config, path, "codex"),
|
|
150
217
|
},
|
|
151
218
|
];
|
|
152
219
|
const prepared = definitions.map((definition) => {
|
|
153
220
|
const absolute = join(projectRoot, definition.path);
|
|
154
221
|
assertDepositContained(projectRoot, absolute);
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
if (!isHostHookDepositEnabled(definition.host, hostHooksPolicy)) {
|
|
223
|
+
if (!existsSync(absolute))
|
|
224
|
+
return { mode: "skip" };
|
|
225
|
+
return {
|
|
226
|
+
mode: "strip",
|
|
227
|
+
absolute,
|
|
228
|
+
path: definition.path,
|
|
229
|
+
payload: definition.strip(readConfig(absolute), absolute),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
mode: "merge",
|
|
234
|
+
absolute,
|
|
235
|
+
path: definition.path,
|
|
236
|
+
payload: definition.merge(readConfig(absolute), absolute),
|
|
237
|
+
};
|
|
157
238
|
});
|
|
158
|
-
for (const
|
|
159
|
-
if (
|
|
160
|
-
|
|
239
|
+
for (const item of prepared) {
|
|
240
|
+
if (item.mode === "skip")
|
|
241
|
+
continue;
|
|
242
|
+
if (writeJsonIfChanged(item.absolute, item.payload)) {
|
|
243
|
+
if (item.mode === "strip")
|
|
244
|
+
strippedPaths.push(item.path);
|
|
245
|
+
else
|
|
246
|
+
changedPaths.push(item.path);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const adapterAbsolute = join(projectRoot, CURSOR_APPLY_PATCH_ADAPTER_RELATIVE);
|
|
250
|
+
assertDepositContained(projectRoot, adapterAbsolute);
|
|
251
|
+
if (isHostHookDepositEnabled("cursor", hostHooksPolicy)) {
|
|
252
|
+
if (writeTextIfChanged(adapterAbsolute, CURSOR_APPLY_PATCH_ADAPTER_SOURCE)) {
|
|
253
|
+
if (!changedPaths.includes(AGENT_HOOK_PATHS[2])) {
|
|
254
|
+
changedPaths.push(AGENT_HOOK_PATHS[2]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (existsSync(adapterAbsolute)) {
|
|
259
|
+
rmSync(adapterAbsolute, { force: true });
|
|
260
|
+
if (!strippedPaths.includes(AGENT_HOOK_PATHS[2])) {
|
|
261
|
+
strippedPaths.push(AGENT_HOOK_PATHS[2]);
|
|
161
262
|
}
|
|
162
263
|
}
|
|
163
264
|
if (changedPaths.length > 0) {
|
|
164
265
|
io.printf(`Installed Directive agent hooks: ${changedPaths.join(", ")}\n`);
|
|
165
266
|
}
|
|
166
|
-
|
|
267
|
+
if (strippedPaths.length > 0) {
|
|
268
|
+
io.printf(`Removed Directive-managed agent hooks (plan.policy.hostHooks opt-out): ${strippedPaths.join(", ")}\n`);
|
|
269
|
+
}
|
|
270
|
+
if (changedPaths.length === 0 && strippedPaths.length === 0) {
|
|
167
271
|
io.printf("Directive agent hooks already current.\n");
|
|
168
272
|
}
|
|
169
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
changed: changedPaths.length + strippedPaths.length > 0,
|
|
275
|
+
changedPaths: [...changedPaths, ...strippedPaths],
|
|
276
|
+
};
|
|
170
277
|
}
|
|
171
278
|
function hasNestedRegistration(config, host, options = {}) {
|
|
172
279
|
const hooks = object(config.hooks);
|
|
@@ -202,11 +309,18 @@ function hasCursorRegistration(config) {
|
|
|
202
309
|
const session = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
|
|
203
310
|
const preTool = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
204
311
|
const preCompact = Array.isArray(hooks.preCompact) ? hooks.preCompact : [];
|
|
312
|
+
const adapter = cursorApplyPatchAdapterEntry();
|
|
205
313
|
return (session.some((entry) => object(entry)?.command === command("cursor", "session.start")) &&
|
|
206
314
|
preTool.some((entry) => {
|
|
207
315
|
const hook = object(entry);
|
|
208
316
|
return (hook?.command === command("cursor", "tool.before") &&
|
|
209
|
-
hook.matcher ===
|
|
317
|
+
hook.matcher === CURSOR_GENERIC_WRITE_HOOK_MATCHER &&
|
|
318
|
+
hook.failClosed === true);
|
|
319
|
+
}) &&
|
|
320
|
+
preTool.some((entry) => {
|
|
321
|
+
const hook = object(entry);
|
|
322
|
+
return (hook?.command === adapter.command &&
|
|
323
|
+
hook.matcher === adapter.matcher &&
|
|
210
324
|
hook.failClosed === true);
|
|
211
325
|
}) &&
|
|
212
326
|
preTool.some((entry) => {
|
|
@@ -218,7 +332,7 @@ function hasCursorRegistration(config) {
|
|
|
218
332
|
preCompact.some((entry) => object(entry)?.command === command("cursor", "session.compact")));
|
|
219
333
|
}
|
|
220
334
|
/** Read-only registration probe shared by verify and doctor. */
|
|
221
|
-
export function inspectAgentHookDeposit(projectRoot) {
|
|
335
|
+
export function inspectAgentHookDeposit(projectRoot, hostHooksPolicy = loadHostHooksPolicyFromProject(projectRoot)) {
|
|
222
336
|
const definitions = [
|
|
223
337
|
{
|
|
224
338
|
host: "claude",
|
|
@@ -250,6 +364,15 @@ export function inspectAgentHookDeposit(projectRoot) {
|
|
|
250
364
|
const compactNote = definition.compactSupport === "unsupported"
|
|
251
365
|
? " Compact re-arm is not deposited for Codex (no native compact hook surface)."
|
|
252
366
|
: " PreCompact/PostCompact or preCompact compact re-arm is deposited.";
|
|
367
|
+
if (!isHostHookDepositEnabled(definition.host, hostHooksPolicy)) {
|
|
368
|
+
return {
|
|
369
|
+
host: definition.host,
|
|
370
|
+
path: definition.path,
|
|
371
|
+
status: "healthy",
|
|
372
|
+
compactSupport: definition.compactSupport,
|
|
373
|
+
detail: `plan.policy.hostHooks.${definition.host} is false — Directive hook deposit is skipped for this host.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
253
376
|
if (!existsSync(absolute)) {
|
|
254
377
|
return {
|
|
255
378
|
host: definition.host,
|
|
@@ -24,6 +24,7 @@ export function installerManagedMatchers() {
|
|
|
24
24
|
{ exact: ".claude/settings.json" },
|
|
25
25
|
{ exact: ".grok/hooks/deft.json" },
|
|
26
26
|
{ exact: ".cursor/hooks.json" },
|
|
27
|
+
{ exact: ".cursor/hooks/deft-cursor-hook-adapter.mjs" },
|
|
27
28
|
{ exact: ".codex/hooks.json" },
|
|
28
29
|
{ exact: ".gitattributes" },
|
|
29
30
|
{ exact: ".gitignore" },
|
|
@@ -32,6 +32,7 @@ export interface BehavioralEventRecord {
|
|
|
32
32
|
export declare function emit(name: string, payload: Record<string, unknown>, options?: {
|
|
33
33
|
logPath?: string | null;
|
|
34
34
|
detectedAt?: string | null;
|
|
35
|
+
projectRoot?: string | null;
|
|
35
36
|
}): BehavioralEventRecord;
|
|
36
37
|
/** Return all events from the log in emission order. */
|
|
37
38
|
export declare function readEvents(logPath?: string | null): BehavioralEventRecord[];
|
package/dist/lifecycle/events.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { contentRoot } from "../content-root.js";
|
|
6
6
|
import { ATTRIBUTION_REQUIRED_PAYLOAD } from "../events/attribution-constants.js";
|
|
7
|
+
import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
|
|
7
8
|
/** Default event log location (project-local). */
|
|
8
9
|
export const DEFAULT_EVENT_LOG = join(".deft-cache", "events.jsonl");
|
|
9
10
|
const BEHAVIORAL_CATEGORY = "behavioral";
|
|
@@ -125,15 +126,62 @@ function sortKeysDeep(value) {
|
|
|
125
126
|
function jsonStringifySorted(value) {
|
|
126
127
|
return JSON.stringify(sortKeysDeep(value));
|
|
127
128
|
}
|
|
128
|
-
function resolveLogPath(logPath) {
|
|
129
|
+
function resolveLogPath(logPath, projectRoot) {
|
|
129
130
|
if (logPath !== undefined && logPath !== null) {
|
|
130
|
-
return resolve(logPath);
|
|
131
|
+
return isAbsolute(logPath) ? resolve(logPath) : resolve(projectRoot, logPath);
|
|
131
132
|
}
|
|
132
133
|
const envPath = process.env.DEFT_EVENT_LOG;
|
|
133
134
|
if (envPath !== undefined && envPath.length > 0) {
|
|
134
|
-
return resolve(envPath);
|
|
135
|
+
return isAbsolute(envPath) ? resolve(envPath) : resolve(projectRoot, envPath);
|
|
136
|
+
}
|
|
137
|
+
return resolve(projectRoot, DEFAULT_EVENT_LOG);
|
|
138
|
+
}
|
|
139
|
+
function isNestedUnder(parent, child) {
|
|
140
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
141
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
142
|
+
}
|
|
143
|
+
function assertPathComponentsNotSymlinks(projectDir, targetPath) {
|
|
144
|
+
const targetAbs = resolve(targetPath);
|
|
145
|
+
const projectAbs = resolve(projectDir);
|
|
146
|
+
let current = targetAbs;
|
|
147
|
+
const chain = [];
|
|
148
|
+
while (true) {
|
|
149
|
+
chain.unshift(current);
|
|
150
|
+
const parent = dirname(current);
|
|
151
|
+
if (parent === current) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
current = parent;
|
|
155
|
+
}
|
|
156
|
+
for (const segment of chain) {
|
|
157
|
+
let info;
|
|
158
|
+
try {
|
|
159
|
+
info = lstatSync(segment);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (info.isSymbolicLink()) {
|
|
165
|
+
throw new ProjectionContainmentError(`projection write refused: ${segment} is a symlink on the write path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: segment });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Refuse symlink write targets for project-owned and explicit event logs (#2766). */
|
|
170
|
+
function assertEventLogTargetSafe(projectRoot, targetPath) {
|
|
171
|
+
if (isNestedUnder(projectRoot, targetPath)) {
|
|
172
|
+
assertWriteTargetSafe(projectRoot, targetPath);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
assertPathComponentsNotSymlinks(projectRoot, targetPath);
|
|
176
|
+
}
|
|
177
|
+
function appendLineNoFollow(target, line) {
|
|
178
|
+
const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o644);
|
|
179
|
+
try {
|
|
180
|
+
writeSync(fd, Buffer.from(line, "utf8"));
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
closeSync(fd);
|
|
135
184
|
}
|
|
136
|
-
return resolve(DEFAULT_EVENT_LOG);
|
|
137
185
|
}
|
|
138
186
|
function newEventId() {
|
|
139
187
|
const wallNs = BigInt(Date.now()) * 1000000n;
|
|
@@ -160,14 +208,16 @@ export function emit(name, payload, options = {}) {
|
|
|
160
208
|
detected_at: options.detectedAt ?? `${iso.slice(0, 19)}Z`,
|
|
161
209
|
payload: { ...payload },
|
|
162
210
|
};
|
|
163
|
-
const
|
|
211
|
+
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
212
|
+
const target = resolveLogPath(options.logPath, projectRoot);
|
|
213
|
+
assertEventLogTargetSafe(projectRoot, target);
|
|
164
214
|
mkdirSync(dirname(target), { recursive: true });
|
|
165
|
-
|
|
215
|
+
appendLineNoFollow(target, `${jsonStringifySorted(record)}\n`);
|
|
166
216
|
return record;
|
|
167
217
|
}
|
|
168
218
|
/** Return all events from the log in emission order. */
|
|
169
219
|
export function readEvents(logPath) {
|
|
170
|
-
const target = resolveLogPath(logPath);
|
|
220
|
+
const target = resolveLogPath(logPath, process.cwd());
|
|
171
221
|
if (!existsSync(target)) {
|
|
172
222
|
return [];
|
|
173
223
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HookHost } from "../hooks/dispatcher.js";
|
|
2
|
+
export declare const FIELD_HOST_HOOKS = "plan.policy.hostHooks";
|
|
3
|
+
export declare const FIELD_HOST_HOOKS_CLI_ALIAS = "hostHooks";
|
|
4
|
+
/** Per-host Directive hook deposit toggles (#2752). */
|
|
5
|
+
export type HostHooksPolicy = Record<HookHost, boolean>;
|
|
6
|
+
export declare const DEFAULT_HOST_HOOKS_POLICY: HostHooksPolicy;
|
|
7
|
+
export interface HostHooksPolicyField {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly current: HostHooksPolicy;
|
|
10
|
+
readonly default: HostHooksPolicy;
|
|
11
|
+
readonly source: string;
|
|
12
|
+
}
|
|
13
|
+
/** Resolve typed host hook deposit policy from raw PROJECT-DEFINITION value. */
|
|
14
|
+
export declare function resolveHostHooksPolicy(raw: unknown): HostHooksPolicy;
|
|
15
|
+
export declare function validateHostHooks(value: unknown): string[];
|
|
16
|
+
export declare function isHostHookDepositEnabled(host: HookHost, policy?: HostHooksPolicy): boolean;
|
|
17
|
+
/** Inspector row for `policy:show --field=hostHooks`. */
|
|
18
|
+
export declare function inspectHostHooks(data: Record<string, unknown> | null): HostHooksPolicyField;
|
|
19
|
+
/** Resolve host hook deposit policy from PROJECT-DEFINITION on disk. */
|
|
20
|
+
export declare function loadHostHooksPolicyFromProject(projectRoot: string): HostHooksPolicy;
|
|
21
|
+
//# sourceMappingURL=host-hooks.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { HOOK_HOSTS } from "../hooks/dispatcher.js";
|
|
2
|
+
import { readPlanPolicy } from "./plan-extensions.js";
|
|
3
|
+
import { loadProjectDefinition } from "./resolve.js";
|
|
4
|
+
export const FIELD_HOST_HOOKS = "plan.policy.hostHooks";
|
|
5
|
+
export const FIELD_HOST_HOOKS_CLI_ALIAS = "hostHooks";
|
|
6
|
+
export const DEFAULT_HOST_HOOKS_POLICY = {
|
|
7
|
+
claude: true,
|
|
8
|
+
cursor: true,
|
|
9
|
+
grok: true,
|
|
10
|
+
codex: true,
|
|
11
|
+
};
|
|
12
|
+
function readHostBoolean(rec, host, fallback) {
|
|
13
|
+
if (host in rec && typeof rec[host] === "boolean") {
|
|
14
|
+
return rec[host];
|
|
15
|
+
}
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
/** Resolve typed host hook deposit policy from raw PROJECT-DEFINITION value. */
|
|
19
|
+
export function resolveHostHooksPolicy(raw) {
|
|
20
|
+
if (raw === null || raw === undefined) {
|
|
21
|
+
return { ...DEFAULT_HOST_HOOKS_POLICY };
|
|
22
|
+
}
|
|
23
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
24
|
+
return { ...DEFAULT_HOST_HOOKS_POLICY };
|
|
25
|
+
}
|
|
26
|
+
const rec = raw;
|
|
27
|
+
return {
|
|
28
|
+
claude: readHostBoolean(rec, "claude", DEFAULT_HOST_HOOKS_POLICY.claude),
|
|
29
|
+
cursor: readHostBoolean(rec, "cursor", DEFAULT_HOST_HOOKS_POLICY.cursor),
|
|
30
|
+
grok: readHostBoolean(rec, "grok", DEFAULT_HOST_HOOKS_POLICY.grok),
|
|
31
|
+
codex: readHostBoolean(rec, "codex", DEFAULT_HOST_HOOKS_POLICY.codex),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function validateHostHooks(value) {
|
|
35
|
+
if (value === null || value === undefined) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
39
|
+
return [`${FIELD_HOST_HOOKS} must be an object; got ${typeof value}`];
|
|
40
|
+
}
|
|
41
|
+
const rec = value;
|
|
42
|
+
const errors = [];
|
|
43
|
+
for (const host of HOOK_HOSTS) {
|
|
44
|
+
if (host in rec && typeof rec[host] !== "boolean") {
|
|
45
|
+
errors.push(`${FIELD_HOST_HOOKS}.${host} must be a boolean`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.keys(rec)) {
|
|
49
|
+
if (!HOOK_HOSTS.includes(key)) {
|
|
50
|
+
errors.push(`${FIELD_HOST_HOOKS}.${key} is not a deposited host (${HOOK_HOSTS.join(", ")})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return errors;
|
|
54
|
+
}
|
|
55
|
+
export function isHostHookDepositEnabled(host, policy = DEFAULT_HOST_HOOKS_POLICY) {
|
|
56
|
+
return policy[host];
|
|
57
|
+
}
|
|
58
|
+
function fieldFromResolved(resolved, source) {
|
|
59
|
+
return {
|
|
60
|
+
name: FIELD_HOST_HOOKS,
|
|
61
|
+
current: resolved,
|
|
62
|
+
default: DEFAULT_HOST_HOOKS_POLICY,
|
|
63
|
+
source,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Inspector row for `policy:show --field=hostHooks`. */
|
|
67
|
+
export function inspectHostHooks(data) {
|
|
68
|
+
if (data === null) {
|
|
69
|
+
return fieldFromResolved(DEFAULT_HOST_HOOKS_POLICY, "default");
|
|
70
|
+
}
|
|
71
|
+
const policyBlock = readPlanPolicy(data.plan);
|
|
72
|
+
if (typeof policyBlock !== "object" ||
|
|
73
|
+
policyBlock === null ||
|
|
74
|
+
Array.isArray(policyBlock) ||
|
|
75
|
+
!("hostHooks" in policyBlock)) {
|
|
76
|
+
return fieldFromResolved(DEFAULT_HOST_HOOKS_POLICY, "default");
|
|
77
|
+
}
|
|
78
|
+
const resolved = resolveHostHooksPolicy(policyBlock.hostHooks);
|
|
79
|
+
return fieldFromResolved(resolved, "typed");
|
|
80
|
+
}
|
|
81
|
+
/** Resolve host hook deposit policy from PROJECT-DEFINITION on disk. */
|
|
82
|
+
export function loadHostHooksPolicyFromProject(projectRoot) {
|
|
83
|
+
const [data] = loadProjectDefinition(projectRoot);
|
|
84
|
+
if (data === null) {
|
|
85
|
+
return { ...DEFAULT_HOST_HOOKS_POLICY };
|
|
86
|
+
}
|
|
87
|
+
const policyBlock = readPlanPolicy(data.plan);
|
|
88
|
+
if (typeof policyBlock !== "object" ||
|
|
89
|
+
policyBlock === null ||
|
|
90
|
+
Array.isArray(policyBlock) ||
|
|
91
|
+
!("hostHooks" in policyBlock)) {
|
|
92
|
+
return { ...DEFAULT_HOST_HOOKS_POLICY };
|
|
93
|
+
}
|
|
94
|
+
return resolveHostHooksPolicy(policyBlock.hostHooks);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=host-hooks.js.map
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./autonomy.js";
|
|
|
3
3
|
export * from "./capacity.js";
|
|
4
4
|
export * from "./decisions.js";
|
|
5
5
|
export * from "./disclosure.js";
|
|
6
|
+
export * from "./host-hooks.js";
|
|
6
7
|
export * from "./plan-extensions.js";
|
|
7
8
|
export * from "./policy-invocation.js";
|
|
8
9
|
export * from "./product-signal.js";
|
package/dist/policy/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FIELD_HOST_HOOKS, FIELD_HOST_HOOKS_CLI_ALIAS, inspectHostHooks } from "./host-hooks.js";
|
|
1
2
|
import { readPlanPolicy } from "./plan-extensions.js";
|
|
2
3
|
import { FIELD_PRODUCT_SIGNAL, FIELD_PRODUCT_SIGNAL_CLI_ALIAS, inspectProductSignal, } from "./product-signal.js";
|
|
3
4
|
import { coerceLegacyNarrative, LEGACY_NARRATIVE_KEY, loadProjectDefinition } from "./resolve.js";
|
|
@@ -10,6 +11,7 @@ export * from "./autonomy.js";
|
|
|
10
11
|
export * from "./capacity.js";
|
|
11
12
|
export * from "./decisions.js";
|
|
12
13
|
export * from "./disclosure.js";
|
|
14
|
+
export * from "./host-hooks.js";
|
|
13
15
|
export * from "./plan-extensions.js";
|
|
14
16
|
export * from "./policy-invocation.js";
|
|
15
17
|
export * from "./product-signal.js";
|
|
@@ -258,6 +260,15 @@ function inspectRuntimeAuthorityField(data) {
|
|
|
258
260
|
source: field.source,
|
|
259
261
|
};
|
|
260
262
|
}
|
|
263
|
+
function inspectHostHooksField(data) {
|
|
264
|
+
const field = inspectHostHooks(data);
|
|
265
|
+
return {
|
|
266
|
+
name: field.name,
|
|
267
|
+
current: field.current,
|
|
268
|
+
default: field.default,
|
|
269
|
+
source: field.source,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
261
272
|
const REGISTERED_POLICIES = [
|
|
262
273
|
inspectAllowDirectCommits,
|
|
263
274
|
inspectWipCap,
|
|
@@ -270,6 +281,7 @@ const REGISTERED_POLICIES = [
|
|
|
270
281
|
emptyIsTyped: true,
|
|
271
282
|
}),
|
|
272
283
|
inspectSwarmSubagentBackend,
|
|
284
|
+
inspectHostHooksField,
|
|
273
285
|
inspectStalenessTicklerField,
|
|
274
286
|
inspectRuntimeAuthorityField,
|
|
275
287
|
inspectProductSignalField,
|
|
@@ -290,7 +302,9 @@ export function inspectOnePolicy(name, projectRoot) {
|
|
|
290
302
|
? FIELD_STALENESS_TICKLER
|
|
291
303
|
: name === FIELD_RUNTIME_AUTHORITY_CLI_ALIAS
|
|
292
304
|
? FIELD_RUNTIME_AUTHORITY
|
|
293
|
-
: name
|
|
305
|
+
: name === FIELD_HOST_HOOKS_CLI_ALIAS
|
|
306
|
+
? FIELD_HOST_HOOKS
|
|
307
|
+
: name;
|
|
294
308
|
for (const field of inspectAllPolicies(projectRoot)) {
|
|
295
309
|
if (field.name === normalized)
|
|
296
310
|
return field;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export declare const PRODUCT_SIGNAL_CONSENT_FILENAME = "product-signal-consent.json";
|
|
2
|
-
/** Consent record schema version (#2693 D2). */
|
|
3
|
-
export declare const PRODUCT_SIGNAL_CONSENT_VERSION =
|
|
2
|
+
/** Consent record schema version (#2693 D2, #2767 v2 sink binding). */
|
|
3
|
+
export declare const PRODUCT_SIGNAL_CONSENT_VERSION = 2;
|
|
4
4
|
/** Phase-1 consent tier permitting qualitative outbound (#2693 D2). */
|
|
5
5
|
export declare const PRODUCT_SIGNAL_CONSENT_TIER = "product-signal";
|
|
6
6
|
export interface ProductSignalConsentRecord {
|
|
7
7
|
readonly consentVersion: number;
|
|
8
8
|
readonly grantedAt: string;
|
|
9
9
|
readonly tier: string;
|
|
10
|
+
/** Normalized owner/repo sink authorized by v2 consent (#2767). */
|
|
11
|
+
readonly sinkRepo?: string;
|
|
10
12
|
readonly revokedAt?: string;
|
|
11
13
|
}
|
|
12
14
|
export interface ResolveConsentPathOptions {
|
|
@@ -14,16 +16,31 @@ export interface ResolveConsentPathOptions {
|
|
|
14
16
|
readonly env?: NodeJS.ProcessEnv;
|
|
15
17
|
readonly homeDir?: string;
|
|
16
18
|
}
|
|
19
|
+
export interface SinkAuthorizationResult {
|
|
20
|
+
readonly authorized: boolean;
|
|
21
|
+
readonly configuredSink: string;
|
|
22
|
+
readonly consentedSink: string | null;
|
|
23
|
+
readonly sinksMatch: boolean;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
}
|
|
17
26
|
/** Platform-config consent path adjacent to USER.md (#2693 D2). */
|
|
18
27
|
export declare function resolveProductSignalConsentPath(options?: ResolveConsentPathOptions): string;
|
|
28
|
+
/** Normalize sinkRepo to lowercase owner/repo (#2767). */
|
|
29
|
+
export declare function normalizeProductSignalSinkRepo(raw: string): string;
|
|
30
|
+
/** Resolve the sink authorized by a consent record (#2767). */
|
|
31
|
+
export declare function resolveConsentedProductSignalSink(consent: ProductSignalConsentRecord | null): string | null;
|
|
32
|
+
/** Authorize configured sink against install consent (#2767). */
|
|
33
|
+
export declare function authorizeProductSignalSink(configuredSink: string, consent: ProductSignalConsentRecord | null): SinkAuthorizationResult;
|
|
19
34
|
/** Read consent file; returns null when absent, invalid, or revoked. */
|
|
20
35
|
export declare function readProductSignalConsent(options?: ResolveConsentPathOptions): ProductSignalConsentRecord | null;
|
|
21
36
|
/** True when a non-revoked consent grant exists. */
|
|
22
37
|
export declare function isProductSignalConsented(options?: ResolveConsentPathOptions): boolean;
|
|
23
38
|
export interface WriteConsentOptions extends ResolveConsentPathOptions {
|
|
24
39
|
readonly now?: Date;
|
|
40
|
+
/** Normalized sink to bind into v2 consent (#2767). Defaults to baked-in sink. */
|
|
41
|
+
readonly sinkRepo?: string;
|
|
25
42
|
}
|
|
26
|
-
/** Write a fresh consent grant (#2693 D17 yes path). */
|
|
43
|
+
/** Write a fresh consent grant (#2693 D17 yes path, #2767 v2 sink binding). */
|
|
27
44
|
export declare function grantProductSignalConsent(options?: WriteConsentOptions): ProductSignalConsentRecord;
|
|
28
45
|
/** Revoke consent by setting revokedAt (#2693 D2). */
|
|
29
46
|
export declare function revokeProductSignalConsent(options?: WriteConsentOptions): boolean;
|
|
@@ -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 =
|
|
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" ||
|
|
48
|
+
if (typeof rec.consentVersion !== "number" ||
|
|
49
|
+
typeof rec.grantedAt !== "string" ||
|
|
50
|
+
typeof rec.tier !== "string") {
|
|
41
51
|
return null;
|
|
42
52
|
}
|
|
43
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
152
|
-
if (action === "grant") {
|
|
153
|
-
const
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"description": "TypeScript engine core for the Directive framework.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -313,8 +313,8 @@
|
|
|
313
313
|
"provenance": true
|
|
314
314
|
},
|
|
315
315
|
"dependencies": {
|
|
316
|
-
"@deftai/directive-content": "^0.
|
|
317
|
-
"@deftai/directive-types": "^0.
|
|
316
|
+
"@deftai/directive-content": "^0.83.0",
|
|
317
|
+
"@deftai/directive-types": "^0.83.0",
|
|
318
318
|
"archiver": "^8.0.0"
|
|
319
319
|
},
|
|
320
320
|
"scripts": {
|