@deftai/directive-core 0.81.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.
Files changed (75) hide show
  1. package/dist/content-contracts/skills/helpers.d.ts +6 -0
  2. package/dist/content-contracts/skills/helpers.js +47 -3
  3. package/dist/content-contracts/skills/skill-external-fetch-gate.d.ts +18 -0
  4. package/dist/content-contracts/skills/skill-external-fetch-gate.js +81 -0
  5. package/dist/eval/crud-telemetry.d.ts +2 -0
  6. package/dist/eval/crud-telemetry.js +30 -0
  7. package/dist/hooks/cursor-hooks.d.ts +23 -0
  8. package/dist/hooks/cursor-hooks.js +95 -0
  9. package/dist/hooks/dispatcher.d.ts +8 -1
  10. package/dist/hooks/dispatcher.js +23 -5
  11. package/dist/hooks/index.d.ts +1 -0
  12. package/dist/hooks/index.js +1 -0
  13. package/dist/init-deposit/agent-hooks.d.ts +4 -2
  14. package/dist/init-deposit/agent-hooks.js +137 -14
  15. package/dist/init-deposit/hygiene.js +1 -0
  16. package/dist/intake/github-body-cli.js +2 -0
  17. package/dist/intake/github-body.d.ts +11 -0
  18. package/dist/intake/github-body.js +95 -24
  19. package/dist/integration-e2e/helpers.js +1 -1
  20. package/dist/issue-sync/sync-from-xbrief.js +3 -0
  21. package/dist/lifecycle/events.d.ts +1 -0
  22. package/dist/lifecycle/events.js +59 -9
  23. package/dist/orphan-active/evaluate.d.ts +25 -0
  24. package/dist/orphan-active/evaluate.js +275 -0
  25. package/dist/orphan-active/index.d.ts +3 -0
  26. package/dist/orphan-active/index.js +3 -0
  27. package/dist/orphan-active/refs.d.ts +16 -0
  28. package/dist/orphan-active/refs.js +105 -0
  29. package/dist/policy/host-hooks.d.ts +21 -0
  30. package/dist/policy/host-hooks.js +96 -0
  31. package/dist/policy/index.d.ts +1 -0
  32. package/dist/policy/index.js +15 -1
  33. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  34. package/dist/pr-wait-mergeable/cascade.js +18 -0
  35. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  36. package/dist/pr-wait-mergeable/main.js +44 -73
  37. package/dist/pr-wait-mergeable/result.d.ts +2 -1
  38. package/dist/pr-wait-mergeable/result.js +4 -0
  39. package/dist/pr-wait-mergeable/semantic-green.d.ts +27 -0
  40. package/dist/pr-wait-mergeable/semantic-green.js +202 -0
  41. package/dist/pr-wait-mergeable/types.d.ts +1 -0
  42. package/dist/pr-watch/constants.d.ts +4 -4
  43. package/dist/pr-watch/constants.js +4 -4
  44. package/dist/pr-watch/watch.js +5 -3
  45. package/dist/product-signal/consent.d.ts +20 -3
  46. package/dist/product-signal/consent.js +63 -6
  47. package/dist/product-signal/gates.d.ts +1 -1
  48. package/dist/product-signal/submit.d.ts +5 -1
  49. package/dist/product-signal/submit.js +42 -18
  50. package/dist/release/native-steps.js +11 -4
  51. package/dist/render/framework-commands.js +6 -0
  52. package/dist/scope/brief-io.d.ts +26 -0
  53. package/dist/scope/brief-io.js +77 -0
  54. package/dist/scope/decompose.js +2 -2
  55. package/dist/scope/decomposed-refs.js +3 -3
  56. package/dist/scope/demote.js +2 -2
  57. package/dist/scope/project-definition-sync.d.ts +2 -2
  58. package/dist/scope/project-definition-sync.js +15 -3
  59. package/dist/scope/registry-artifact-sync.d.ts +6 -1
  60. package/dist/scope/registry-artifact-sync.js +31 -13
  61. package/dist/scope/transition.js +23 -15
  62. package/dist/scope/undo.js +2 -2
  63. package/dist/scope/vbrief-json.d.ts +1 -2
  64. package/dist/scope/vbrief-json.js +1 -4
  65. package/dist/staleness-tickler/state.js +19 -2
  66. package/dist/triage/queue/cache.js +5 -0
  67. package/dist/vbrief-validate/plan-hooks.d.ts +2 -0
  68. package/dist/vbrief-validate/plan-hooks.js +25 -0
  69. package/dist/verify-env/agent-hooks.d.ts +2 -1
  70. package/dist/verify-env/agent-hooks.js +3 -2
  71. package/dist/verify-source/index.d.ts +1 -0
  72. package/dist/verify-source/index.js +1 -0
  73. package/dist/verify-source/skill-external-fetch-gate.d.ts +21 -0
  74. package/dist/verify-source/skill-external-fetch-gate.js +71 -0
  75. package/package.json +7 -3
@@ -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
- return typeof entry?.command === "string" && entry.command.includes(DEFT_HOOK_COMMAND_MARKER);
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: DIRECT_WRITE_HOOK_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
- const merged = definition.merge(readConfig(absolute), absolute);
156
- return { ...definition, absolute, merged };
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 definition of prepared) {
159
- if (writeJsonIfChanged(definition.absolute, definition.merged)) {
160
- changedPaths.push(definition.path);
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
- else {
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 { changed: changedPaths.length > 0, changedPaths };
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 === DIRECT_WRITE_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" },
@@ -17,6 +17,8 @@ function parseArgs(argv) {
17
17
  out.pr = Number.parseInt(argv[++i], 10);
18
18
  else if (arg === "--body-file")
19
19
  out.bodyFile = argv[++i];
20
+ else if (arg === "--out-file")
21
+ out.outFile = argv[++i];
20
22
  }
21
23
  return out;
22
24
  }
@@ -9,6 +9,8 @@ export type RunGhApiFn = (args: readonly string[], options?: {
9
9
  /** Resolve the live GitHub CLI used for writes and mutation read-back. */
10
10
  export declare function resolveLiveGh(): string;
11
11
  export declare function readBody(bodyFile: string, stdinText?: string | null): string;
12
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607). */
13
+ export declare function verifyBodyPostcondition(intended: string, live: string): void;
12
14
  export declare function createIssue(repo: string, options: {
13
15
  title: string;
14
16
  body: string;
@@ -35,6 +37,14 @@ export declare function editPrBody(repo: string, pr: number, options: {
35
37
  binary?: string | null;
36
38
  runFn?: RunGhApiFn;
37
39
  }): Record<string, unknown>;
40
+ export declare function fetchIssueBody(repo: string, issue: number, options?: {
41
+ binary?: string | null;
42
+ runFn?: RunGhApiFn;
43
+ }): string;
44
+ export declare function writeIssueBodyToFile(repo: string, issue: number, outFile: string, options?: {
45
+ binary?: string | null;
46
+ runFn?: RunGhApiFn;
47
+ }): string;
38
48
  export interface GitHubBodyCliArgs {
39
49
  command: string;
40
50
  repo?: string;
@@ -43,6 +53,7 @@ export interface GitHubBodyCliArgs {
43
53
  comment?: number;
44
54
  pr?: number;
45
55
  bodyFile?: string;
56
+ outFile?: string;
46
57
  }
47
58
  export declare function githubBodyMain(args: GitHubBodyCliArgs): number;
48
59
  //# sourceMappingURL=github-body.d.ts.map
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { defaultWhich } from "../scm/binary.js";
3
3
  import { call } from "../scm/call.js";
4
4
  export const DEFAULT_TIMEOUT_SECONDS = 60;
@@ -73,14 +73,62 @@ function requireIntField(obj, field) {
73
73
  }
74
74
  return value;
75
75
  }
76
+ function requireStringField(obj, field) {
77
+ const value = obj[field];
78
+ if (typeof value !== "string") {
79
+ throw new GitHubBodyError(`response did not include string field ${JSON.stringify(field)}`);
80
+ }
81
+ return value;
82
+ }
83
+ function countNewlines(text) {
84
+ let count = 0;
85
+ for (const ch of text) {
86
+ if (ch === "\n")
87
+ count += 1;
88
+ }
89
+ return count;
90
+ }
91
+ function normalizeNewlines(text) {
92
+ return text.replace(/\r\n/g, "\n");
93
+ }
94
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607). */
95
+ export function verifyBodyPostcondition(intended, live) {
96
+ if (intended === live) {
97
+ return;
98
+ }
99
+ if (normalizeNewlines(intended) === normalizeNewlines(live)) {
100
+ return;
101
+ }
102
+ const diagnoses = [];
103
+ if (live.includes("\uFFFD") && !intended.includes("\uFFFD")) {
104
+ diagnoses.push("live body contains U+FFFD replacement character not present in intended payload");
105
+ }
106
+ const intendedNl = countNewlines(intended);
107
+ const liveNl = countNewlines(live);
108
+ if (intendedNl > 0 && liveNl < intendedNl) {
109
+ diagnoses.push(`newline count mismatch (intended ${intendedNl}, live ${liveNl})`);
110
+ }
111
+ if (intended.includes("\n") && intended.replace(/\n/g, " ") === live) {
112
+ diagnoses.push("live body looks like intended newlines were collapsed to spaces (PowerShell string[] join)");
113
+ }
114
+ if (diagnoses.length > 0) {
115
+ throw new GitHubBodyError(`body postcondition failed: ${diagnoses.join("; ")}`);
116
+ }
117
+ throw new GitHubBodyError(`body postcondition failed: re-fetched body does not match intended payload (length intended=${intended.length}, live=${live.length})`);
118
+ }
76
119
  function mutateWithReadback(mutationEndpoint, method, payload, readbackEndpoint, options = {}) {
120
+ const intendedBody = typeof payload.body === "string" ? payload.body : undefined;
77
121
  const mutation = runGhApiJson([mutationEndpoint, "--method", method, "--input", "-"], {
78
122
  inputText: jsonInput(payload),
79
123
  binary: options.binary,
80
124
  runFn: options.runFn,
81
125
  });
82
126
  const endpoint = typeof readbackEndpoint === "function" ? readbackEndpoint(mutation) : readbackEndpoint;
83
- return runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
127
+ const readback = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
128
+ if (intendedBody !== undefined) {
129
+ verifyBodyPostcondition(intendedBody, requireStringField(readback, "body"));
130
+ }
131
+ return readback;
84
132
  }
85
133
  export function createIssue(repo, options) {
86
134
  const [owner, name] = splitRepo(repo);
@@ -116,32 +164,55 @@ export function editPrBody(repo, pr, options) {
116
164
  runFn: options.runFn,
117
165
  });
118
166
  }
167
+ export function fetchIssueBody(repo, issue, options = {}) {
168
+ const [owner, name] = splitRepo(repo);
169
+ const endpoint = `repos/${owner}/${name}/issues/${issue}`;
170
+ const response = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
171
+ return requireStringField(response, "body");
172
+ }
173
+ export function writeIssueBodyToFile(repo, issue, outFile, options = {}) {
174
+ const body = fetchIssueBody(repo, issue, options);
175
+ writeFileSync(outFile, body, { encoding: "utf8" });
176
+ return body;
177
+ }
119
178
  export function githubBodyMain(args) {
120
179
  try {
121
- const body = readBody(args.bodyFile ?? "-");
122
- let result;
123
180
  switch (args.command) {
124
- case "issue-create":
125
- result = createIssue(args.repo, { title: args.title, body });
126
- break;
127
- case "issue-edit":
128
- result = editIssueBody(args.repo, args.issue, { body });
129
- break;
130
- case "comment-create":
131
- result = createIssueComment(args.repo, args.issue, { body });
132
- break;
133
- case "comment-edit":
134
- result = editIssueCommentBody(args.repo, args.comment, { body });
135
- break;
136
- case "pr-edit":
137
- result = editPrBody(args.repo, args.pr, { body });
138
- break;
139
- default:
140
- process.stderr.write(`error: unknown command ${JSON.stringify(args.command)}\n`);
141
- return 1;
181
+ case "issue-fetch": {
182
+ if (args.repo === undefined || args.issue === undefined || args.outFile === undefined) {
183
+ process.stderr.write("error: issue-fetch requires --repo, --issue, and --out-file\n");
184
+ return 1;
185
+ }
186
+ writeIssueBodyToFile(args.repo, args.issue, args.outFile);
187
+ return 0;
188
+ }
189
+ default: {
190
+ const body = readBody(args.bodyFile ?? "-");
191
+ let result;
192
+ switch (args.command) {
193
+ case "issue-create":
194
+ result = createIssue(args.repo, { title: args.title, body });
195
+ break;
196
+ case "issue-edit":
197
+ result = editIssueBody(args.repo, args.issue, { body });
198
+ break;
199
+ case "comment-create":
200
+ result = createIssueComment(args.repo, args.issue, { body });
201
+ break;
202
+ case "comment-edit":
203
+ result = editIssueCommentBody(args.repo, args.comment, { body });
204
+ break;
205
+ case "pr-edit":
206
+ result = editPrBody(args.repo, args.pr, { body });
207
+ break;
208
+ default:
209
+ process.stderr.write(`error: unknown command ${JSON.stringify(args.command)}\n`);
210
+ return 1;
211
+ }
212
+ process.stdout.write(`${JSON.stringify(result)}\n`);
213
+ return 0;
214
+ }
142
215
  }
143
- process.stdout.write(`${JSON.stringify(result)}\n`);
144
- return 0;
145
216
  }
146
217
  catch (exc) {
147
218
  process.stderr.write(`error: ${String(exc)}\n`);
@@ -98,7 +98,7 @@ export function createConsumerProject(parent) {
98
98
  export function writeScopeVbrief(projectRoot, folder, filename, status = "proposed") {
99
99
  const target = join(projectRoot, "xbrief", folder, filename);
100
100
  writeFileSync(target, `${JSON.stringify({
101
- vBRIEFInfo: { version: "0.5" },
101
+ xBRIEFInfo: { version: "0.8" },
102
102
  plan: {
103
103
  title: "Consumer fixture scope",
104
104
  status,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
4
5
  import { createIssueComment } from "../intake/github-body.js";
5
6
  import { provenanceIssueNumber, repoSlugFromUrl } from "../intake/issue-ingest.js";
6
7
  import { extractReferencesFromVbrief, GITHUB_ISSUE_REF_TYPES, parseIssueNumber, } from "../intake/reconcile-issues.js";
@@ -192,9 +193,11 @@ export function syncFromXbrief(options) {
192
193
  writeOut(`issue:sync-from-xbrief: posted comment to ${origin.repo}#${origin.number} (id: ${commentResult.id}).`);
193
194
  const writeFingerprint = options.writeFingerprint ??
194
195
  ((targetPath, stamped) => {
196
+ assertWriteTargetSafe(projectRoot, targetPath);
195
197
  writeFileSync(targetPath, `${JSON.stringify(stamped, null, 2)}\n`, "utf8");
196
198
  });
197
199
  try {
200
+ assertWriteTargetSafe(projectRoot, absPath);
198
201
  writeFingerprint(absPath, stampIssueSyncFingerprint(data, origin));
199
202
  }
200
203
  catch (exc) {
@@ -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[];
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
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 target = resolveLogPath(options.logPath);
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
- appendFileSync(target, `${jsonStringifySorted(record)}\n`, "utf8");
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,25 @@
1
+ import type { RunGhFn } from "../pr-protected-issues/types.js";
2
+ export type OutputStream = "stdout" | "stderr" | "none";
3
+ export interface OrphanActiveBrief {
4
+ readonly path: string;
5
+ readonly reason: string;
6
+ }
7
+ export interface EvaluateResult {
8
+ readonly code: 0 | 1 | 2;
9
+ readonly message: string;
10
+ readonly stream: OutputStream;
11
+ readonly orphans: readonly OrphanActiveBrief[];
12
+ }
13
+ export interface EvaluateOptions {
14
+ readonly quiet?: boolean;
15
+ readonly repo?: string | null;
16
+ readonly runGh?: RunGhFn;
17
+ readonly skipGh?: boolean;
18
+ }
19
+ /**
20
+ * Pure evaluator for orphaned active/running xBRIEF detection (#2321).
21
+ * Fails when active/ briefs with plan.status==running reference only closed
22
+ * issues and/or a merged PR — the stop-at:pr-open orphan signature.
23
+ */
24
+ export declare function evaluate(projectRoot: string, options?: EvaluateOptions): EvaluateResult;
25
+ //# sourceMappingURL=evaluate.d.ts.map