@deftai/directive-core 0.88.0 → 0.89.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 (168) hide show
  1. package/dist/authz/actions.d.ts +55 -0
  2. package/dist/authz/actions.js +125 -0
  3. package/dist/authz/classify.d.ts +23 -0
  4. package/dist/authz/classify.js +217 -0
  5. package/dist/authz/closed-verb.d.ts +42 -0
  6. package/dist/authz/closed-verb.js +279 -0
  7. package/dist/authz/evaluate.d.ts +41 -0
  8. package/dist/authz/evaluate.js +298 -0
  9. package/dist/authz/index.d.ts +15 -0
  10. package/dist/authz/index.js +15 -0
  11. package/dist/authz/origin.d.ts +28 -0
  12. package/dist/authz/origin.js +64 -0
  13. package/dist/authz/paths.d.ts +6 -0
  14. package/dist/authz/paths.js +19 -0
  15. package/dist/authz/store.d.ts +40 -0
  16. package/dist/authz/store.js +317 -0
  17. package/dist/authz/templates.d.ts +139 -0
  18. package/dist/authz/templates.js +241 -0
  19. package/dist/authz/types.d.ts +111 -0
  20. package/dist/authz/types.js +41 -0
  21. package/dist/authz/verb-classification.d.ts +44 -0
  22. package/dist/authz/verb-classification.js +237 -0
  23. package/dist/branch/evaluate.js +6 -1
  24. package/dist/cache/fetch.js +10 -5
  25. package/dist/cache/io.d.ts +9 -2
  26. package/dist/cache/io.js +30 -10
  27. package/dist/cache/task-cache/store.js +11 -7
  28. package/dist/capacity/backfill.js +10 -4
  29. package/dist/category-b-namespace/index.js +10 -4
  30. package/dist/codebase/default-extractor.js +3 -0
  31. package/dist/codebase/map.js +11 -4
  32. package/dist/doctor/doctor-state.js +28 -4
  33. package/dist/doctor/main.d.ts +10 -0
  34. package/dist/doctor/main.js +160 -0
  35. package/dist/doctor/types.d.ts +11 -0
  36. package/dist/escalation/actions.d.ts +63 -0
  37. package/dist/escalation/actions.js +137 -0
  38. package/dist/escalation/index.d.ts +8 -0
  39. package/dist/escalation/index.js +8 -0
  40. package/dist/escalation/paths.d.ts +3 -0
  41. package/dist/escalation/paths.js +10 -0
  42. package/dist/escalation/store.d.ts +17 -0
  43. package/dist/escalation/store.js +172 -0
  44. package/dist/escalation/types.d.ts +73 -0
  45. package/dist/escalation/types.js +43 -0
  46. package/dist/eval/crud-telemetry.js +30 -10
  47. package/dist/eval/health.js +22 -7
  48. package/dist/eval/readback.js +11 -10
  49. package/dist/eval/run.js +32 -16
  50. package/dist/events/attribution-enrichment.js +10 -4
  51. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  52. package/dist/finish-loop/directive-finish-loop.js +239 -0
  53. package/dist/finish-loop/grant-gate.d.ts +31 -0
  54. package/dist/finish-loop/grant-gate.js +169 -0
  55. package/dist/finish-loop/index.d.ts +11 -0
  56. package/dist/finish-loop/index.js +11 -0
  57. package/dist/finish-loop/main.d.ts +35 -0
  58. package/dist/finish-loop/main.js +365 -0
  59. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  60. package/dist/finish-loop/pr-finish-loop.js +217 -0
  61. package/dist/finish-loop/progress.d.ts +17 -0
  62. package/dist/finish-loop/progress.js +45 -0
  63. package/dist/finish-loop/queue.d.ts +16 -0
  64. package/dist/finish-loop/queue.js +65 -0
  65. package/dist/finish-loop/types.d.ts +52 -0
  66. package/dist/finish-loop/types.js +10 -0
  67. package/dist/fs/contained-write.d.ts +101 -0
  68. package/dist/fs/contained-write.js +281 -0
  69. package/dist/hooks/classify/classify.d.ts +10 -0
  70. package/dist/hooks/classify/classify.js +37 -0
  71. package/dist/hooks/classify/index.d.ts +14 -0
  72. package/dist/hooks/classify/index.js +13 -0
  73. package/dist/hooks/classify/paths.d.ts +22 -0
  74. package/dist/hooks/classify/paths.js +72 -0
  75. package/dist/hooks/classify/payload.d.ts +16 -0
  76. package/dist/hooks/classify/payload.js +45 -0
  77. package/dist/hooks/classify/stdin.d.ts +12 -0
  78. package/dist/hooks/classify/stdin.js +63 -0
  79. package/dist/hooks/classify/tool-name.d.ts +18 -0
  80. package/dist/hooks/classify/tool-name.js +85 -0
  81. package/dist/hooks/classify/types.d.ts +41 -0
  82. package/dist/hooks/classify/types.js +7 -0
  83. package/dist/hooks/dispatcher.d.ts +21 -18
  84. package/dist/hooks/dispatcher.js +212 -163
  85. package/dist/hooks/fixtures/cases.d.ts +44 -0
  86. package/dist/hooks/fixtures/cases.js +595 -0
  87. package/dist/hooks/fixtures/index.d.ts +2 -0
  88. package/dist/hooks/fixtures/index.js +2 -0
  89. package/dist/hooks/index.d.ts +2 -0
  90. package/dist/hooks/index.js +2 -0
  91. package/dist/index.d.ts +4 -0
  92. package/dist/index.js +5 -0
  93. package/dist/init-deposit/agent-hooks.js +27 -8
  94. package/dist/init-deposit/gitignore.js +11 -3
  95. package/dist/init-deposit/headless-manifest.js +12 -4
  96. package/dist/init-deposit/migrate.d.ts +1 -1
  97. package/dist/init-deposit/migrate.js +13 -3
  98. package/dist/init-deposit/prettierignore.js +10 -3
  99. package/dist/init-deposit/scaffold.js +35 -26
  100. package/dist/init-deposit/xbrief-projections.js +11 -4
  101. package/dist/intake/candidates-log.js +46 -35
  102. package/dist/intake/github-body-cli.d.ts +6 -0
  103. package/dist/intake/github-body-cli.js +17 -0
  104. package/dist/intake/github-body.d.ts +46 -3
  105. package/dist/intake/github-body.js +191 -20
  106. package/dist/intake/issue-emit.d.ts +1 -0
  107. package/dist/intake/issue-emit.js +41 -23
  108. package/dist/lifecycle/event-detect.js +12 -4
  109. package/dist/lifecycle/events.js +29 -20
  110. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  111. package/dist/packs/pack-render.js +19 -6
  112. package/dist/plan-sequence/store.js +11 -4
  113. package/dist/policy/hotfix-criteria.d.ts +79 -0
  114. package/dist/policy/hotfix-criteria.js +197 -0
  115. package/dist/policy/index.d.ts +4 -0
  116. package/dist/policy/index.js +31 -1
  117. package/dist/policy/intent-ceiling.d.ts +79 -0
  118. package/dist/policy/intent-ceiling.js +181 -0
  119. package/dist/policy/no-deft-directive.js +10 -3
  120. package/dist/policy/org-force-on-migration.js +9 -5
  121. package/dist/policy/require-human-merge.d.ts +75 -0
  122. package/dist/policy/require-human-merge.js +324 -0
  123. package/dist/policy/resolve.js +17 -6
  124. package/dist/policy/runtime-authority.d.ts +15 -2
  125. package/dist/policy/runtime-authority.js +23 -3
  126. package/dist/policy/write-fence.d.ts +78 -0
  127. package/dist/policy/write-fence.js +164 -0
  128. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  129. package/dist/pr-wait-mergeable/cascade.js +28 -0
  130. package/dist/preflight/evaluate.js +10 -0
  131. package/dist/product-signal/consent.js +21 -5
  132. package/dist/product-signal/submit.js +22 -12
  133. package/dist/release/build-dist-runner.js +5 -2
  134. package/dist/release/build-dist.d.ts +19 -1
  135. package/dist/release/build-dist.js +50 -2
  136. package/dist/release/native-steps.js +7 -2
  137. package/dist/release-publish/pipeline.d.ts +13 -0
  138. package/dist/release-publish/pipeline.js +46 -1
  139. package/dist/scope/audit-log.js +19 -24
  140. package/dist/scope/decompose.js +10 -5
  141. package/dist/scope/decomposed-refs.js +18 -3
  142. package/dist/scope/demote.js +9 -2
  143. package/dist/scope/undo.js +9 -2
  144. package/dist/session/release-availability.js +26 -6
  145. package/dist/session/ritual-sentinel.js +19 -12
  146. package/dist/session/session-start.js +7 -0
  147. package/dist/staleness-tickler/state.js +26 -6
  148. package/dist/swarm/launch.js +13 -3
  149. package/dist/swarm/routing.js +9 -3
  150. package/dist/task-surface/index.js +24 -9
  151. package/dist/triage/bootstrap/gitignore.js +53 -10
  152. package/dist/triage/cache-path.js +10 -3
  153. package/dist/triage/welcome/summary.js +11 -4
  154. package/dist/triage/welcome/writers.js +45 -16
  155. package/dist/validate-content/validate-links.js +5 -0
  156. package/dist/value/readback.js +11 -6
  157. package/dist/vbrief-activate/activate.js +12 -4
  158. package/dist/vbrief-build/project-definition-io.js +14 -6
  159. package/dist/verify-source/contained-writes.d.ts +59 -0
  160. package/dist/verify-source/contained-writes.js +272 -0
  161. package/dist/verify-source/index.d.ts +1 -0
  162. package/dist/verify-source/index.js +1 -0
  163. package/dist/verify-source/openclaw-tier1.js +14 -1
  164. package/dist/verify-source/verify-stubs.js +3 -0
  165. package/dist/xbrief-migrate/migrate-project.js +26 -12
  166. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  167. package/dist/xbrief-migrate/transforms.js +37 -14
  168. package/package.json +15 -3
@@ -1,11 +1,19 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { renderFinding, scanLine } from "../encoding/scan.js";
4
+ import { pythonSplitlines, stripMarkdownQuotes } from "../encoding/text.js";
5
+ import { containedWrite } from "../fs/contained-write.js";
2
6
  import { defaultWhich } from "../scm/binary.js";
3
7
  import { call } from "../scm/call.js";
4
8
  export const DEFAULT_TIMEOUT_SECONDS = 60;
9
+ /** Stable error code for CP1252/CP437-as-UTF-8 / U+FFFD body corruption (#2960). */
10
+ export const SCM_BODY_ENCODING_CODE = "scm-body-encoding";
5
11
  export class GitHubBodyError extends Error {
6
- constructor(message) {
12
+ code;
13
+ constructor(message, options) {
7
14
  super(message);
8
15
  this.name = "GitHubBodyError";
16
+ this.code = options?.code;
9
17
  }
10
18
  }
11
19
  function splitRepo(repo) {
@@ -24,9 +32,54 @@ export function resolveLiveGh() {
24
32
  }
25
33
  export function readBody(bodyFile, stdinText) {
26
34
  if (bodyFile === "-") {
27
- return stdinText ?? "";
35
+ return stripUtf8Bom(stdinText ?? "");
28
36
  }
29
- return readFileSync(bodyFile, "utf8");
37
+ return stripUtf8Bom(readFileSync(bodyFile, "utf8"));
38
+ }
39
+ /** Strip a leading UTF-8 BOM so body-file writers stay UTF-8 no BOM (#2960 / #798). */
40
+ export function stripUtf8Bom(text) {
41
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
42
+ }
43
+ /**
44
+ * Scan body text with the same mojibake pattern set as verify:encoding
45
+ * (`packages/core/src/encoding/patterns.ts` / scanLine). Issue/PR bodies are
46
+ * Markdown: strip fenced blocks and inline code spans so documented examples
47
+ * of the corruption class (e.g. `#2960` fixtures) are not false positives —
48
+ * matches `scanFile` .md behavior.
49
+ */
50
+ export function scanBodyText(body, source = "body") {
51
+ const findings = [];
52
+ const scanText = stripMarkdownQuotes(body);
53
+ const originalLines = pythonSplitlines(body);
54
+ const strippedLines = pythonSplitlines(scanText);
55
+ if (originalLines.length === 0 && body.length > 0) {
56
+ findings.push(...scanLine(source, 1, scanText, body));
57
+ return findings;
58
+ }
59
+ // Preserve line alignment when strip blanks fenced blocks (newline-for-newline).
60
+ while (strippedLines.length < originalLines.length) {
61
+ strippedLines.push("");
62
+ }
63
+ originalLines.forEach((orig, idx) => {
64
+ const stripped = strippedLines[idx] ?? "";
65
+ findings.push(...scanLine(source, idx + 1, stripped, orig));
66
+ });
67
+ return findings;
68
+ }
69
+ function formatEncodingFindings(findings) {
70
+ return findings.map((f) => renderFinding(f).trim()).join("; ");
71
+ }
72
+ /**
73
+ * Fail closed when body text already contains CP1252/CP437-as-UTF-8 mojibake
74
+ * or U+FFFD (before PATCH and as a live-body diagnostic).
75
+ */
76
+ export function assertBodyEncoding(body, phase) {
77
+ const findings = scanBodyText(body, phase);
78
+ if (findings.length === 0) {
79
+ return;
80
+ }
81
+ const detail = formatEncodingFindings(findings);
82
+ throw new GitHubBodyError(`body encoding failed (${phase}): ${detail}. Repair: task scm:body:issue:fetch -- --repo OWNER/REPO --issue N --out-file <path>, fix as UTF-8 (no BOM), task scm:body:issue:edit -- --body-file <path> (#2960 / #2948 / #2944).`, { code: SCM_BODY_ENCODING_CODE });
30
83
  }
31
84
  function jsonInput(payload) {
32
85
  return JSON.stringify(payload);
@@ -73,10 +126,14 @@ function requireIntField(obj, field) {
73
126
  }
74
127
  return value;
75
128
  }
76
- function requireStringField(obj, field) {
77
- const value = obj[field];
129
+ /** Body may be null on empty GitHub issues/PRs — treat as empty string for lint/fetch. */
130
+ function bodyFieldOrEmpty(obj) {
131
+ const value = obj.body;
132
+ if (value === null || value === undefined) {
133
+ return "";
134
+ }
78
135
  if (typeof value !== "string") {
79
- throw new GitHubBodyError(`response did not include string field ${JSON.stringify(field)}`);
136
+ throw new GitHubBodyError(`response did not include string field "body"`);
80
137
  }
81
138
  return value;
82
139
  }
@@ -91,7 +148,7 @@ function countNewlines(text) {
91
148
  function normalizeNewlines(text) {
92
149
  return text.replace(/\r\n/g, "\n");
93
150
  }
94
- /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607). */
151
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607 / #2960). */
95
152
  export function verifyBodyPostcondition(intended, live) {
96
153
  if (intended === live) {
97
154
  return;
@@ -103,6 +160,11 @@ export function verifyBodyPostcondition(intended, live) {
103
160
  if (live.includes("\uFFFD") && !intended.includes("\uFFFD")) {
104
161
  diagnoses.push("live body contains U+FFFD replacement character not present in intended payload");
105
162
  }
163
+ const liveFindings = scanBodyText(live, "live");
164
+ const intendedFindings = scanBodyText(intended, "intended");
165
+ if (liveFindings.length > 0 && intendedFindings.length === 0) {
166
+ diagnoses.push(`live body contains mojibake not present in intended payload (${formatEncodingFindings(liveFindings)})`);
167
+ }
106
168
  const intendedNl = countNewlines(intended);
107
169
  const liveNl = countNewlines(live);
108
170
  if (intendedNl > 0 && liveNl < intendedNl) {
@@ -112,12 +174,19 @@ export function verifyBodyPostcondition(intended, live) {
112
174
  diagnoses.push("live body looks like intended newlines were collapsed to spaces (PowerShell string[] join)");
113
175
  }
114
176
  if (diagnoses.length > 0) {
115
- throw new GitHubBodyError(`body postcondition failed: ${diagnoses.join("; ")}`);
177
+ const isEncoding = diagnoses.some((d) => d.includes("U+FFFD") || d.includes("mojibake") || d.includes("encoding"));
178
+ throw new GitHubBodyError(`body postcondition failed: ${diagnoses.join("; ")}`, {
179
+ code: isEncoding ? SCM_BODY_ENCODING_CODE : undefined,
180
+ });
116
181
  }
117
182
  throw new GitHubBodyError(`body postcondition failed: re-fetched body does not match intended payload (length intended=${intended.length}, live=${live.length})`);
118
183
  }
119
184
  function mutateWithReadback(mutationEndpoint, method, payload, readbackEndpoint, options = {}) {
120
185
  const intendedBody = typeof payload.body === "string" ? payload.body : undefined;
186
+ if (intendedBody !== undefined) {
187
+ // Fail closed before PATCH when the intended payload is already corrupted (#2960).
188
+ assertBodyEncoding(intendedBody, "pre-write");
189
+ }
121
190
  const mutation = runGhApiJson([mutationEndpoint, "--method", method, "--input", "-"], {
122
191
  inputText: jsonInput(payload),
123
192
  binary: options.binary,
@@ -126,7 +195,10 @@ function mutateWithReadback(mutationEndpoint, method, payload, readbackEndpoint,
126
195
  const endpoint = typeof readbackEndpoint === "function" ? readbackEndpoint(mutation) : readbackEndpoint;
127
196
  const readback = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
128
197
  if (intendedBody !== undefined) {
129
- verifyBodyPostcondition(intendedBody, requireStringField(readback, "body"));
198
+ const liveBody = bodyFieldOrEmpty(readback);
199
+ verifyBodyPostcondition(intendedBody, liveBody);
200
+ // Defense in depth: live body must also be free of encoding corruption.
201
+ assertBodyEncoding(liveBody, "post-write-readback");
130
202
  }
131
203
  return readback;
132
204
  }
@@ -168,14 +240,52 @@ export function fetchIssueBody(repo, issue, options = {}) {
168
240
  const [owner, name] = splitRepo(repo);
169
241
  const endpoint = `repos/${owner}/${name}/issues/${issue}`;
170
242
  const response = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
171
- return requireStringField(response, "body");
243
+ return bodyFieldOrEmpty(response);
244
+ }
245
+ export function fetchPrBody(repo, pr, options = {}) {
246
+ const [owner, name] = splitRepo(repo);
247
+ const endpoint = `repos/${owner}/${name}/pulls/${pr}`;
248
+ const response = runGhApiJson([endpoint], { binary: options.binary, runFn: options.runFn });
249
+ return bodyFieldOrEmpty(response);
172
250
  }
173
251
  export function writeIssueBodyToFile(repo, issue, outFile, options = {}) {
174
252
  const body = fetchIssueBody(repo, issue, options);
175
- writeFileSync(outFile, body, { encoding: "utf8" });
253
+ // UTF-8 no BOM (Node default for encoding: "utf8").
254
+ // #2980 wave B: product write sink routes through containedWrite under out-file parent.
255
+ const abs = resolve(outFile);
256
+ const parent = dirname(abs);
257
+ mkdirSync(parent, { recursive: true });
258
+ containedWrite({
259
+ root: parent,
260
+ target: abs,
261
+ data: body,
262
+ mode: "replace",
263
+ });
176
264
  return body;
177
265
  }
178
- export function githubBodyMain(args) {
266
+ /**
267
+ * Lint a live issue body for mojibake. Returns findings (empty = clean).
268
+ * Does not throw on hits — callers decide exit code.
269
+ */
270
+ export function lintIssueBody(repo, issue, options = {}) {
271
+ const body = fetchIssueBody(repo, issue, options);
272
+ return scanBodyText(body, `issue/${issue}`);
273
+ }
274
+ /**
275
+ * Lint a live PR body for mojibake. Returns findings (empty = clean).
276
+ */
277
+ export function lintPrBody(repo, pr, options = {}) {
278
+ const body = fetchPrBody(repo, pr, options);
279
+ return scanBodyText(body, `pr/${pr}`);
280
+ }
281
+ function writeLintFailure(kind, id, findings) {
282
+ process.stderr.write(`error [${SCM_BODY_ENCODING_CODE}]: ${kind} #${id} body contains encoding mojibake (${findings.length} hit(s)):\n`);
283
+ for (const f of findings) {
284
+ process.stderr.write(`${renderFinding(f)}\n`);
285
+ }
286
+ process.stderr.write(`Repair: task scm:body:issue:fetch -- --repo OWNER/REPO --issue ${id} --out-file <path>, fix as UTF-8 (no BOM), task scm:body:issue:edit -- --body-file <path>. After non-scm:body mutations, re-run lint (#2960; recurrence #2948/#2944).\n`);
287
+ }
288
+ export function githubBodyMain(args, options = {}) {
179
289
  try {
180
290
  switch (args.command) {
181
291
  case "issue-fetch": {
@@ -183,7 +293,42 @@ export function githubBodyMain(args) {
183
293
  process.stderr.write("error: issue-fetch requires --repo, --issue, and --out-file\n");
184
294
  return 1;
185
295
  }
186
- writeIssueBodyToFile(args.repo, args.issue, args.outFile);
296
+ writeIssueBodyToFile(args.repo, args.issue, args.outFile, {
297
+ runFn: options.runFn,
298
+ binary: options.binary,
299
+ });
300
+ return 0;
301
+ }
302
+ case "issue-lint": {
303
+ if (args.repo === undefined || args.issue === undefined) {
304
+ process.stderr.write("error: issue-lint requires --repo and --issue\n");
305
+ return 1;
306
+ }
307
+ const findings = lintIssueBody(args.repo, args.issue, {
308
+ runFn: options.runFn,
309
+ binary: options.binary,
310
+ });
311
+ if (findings.length > 0) {
312
+ writeLintFailure("issue", args.issue, findings);
313
+ return 1;
314
+ }
315
+ process.stdout.write(`ok: issue #${args.issue} body has no CP1252/CP437-as-UTF-8 mojibake\n`);
316
+ return 0;
317
+ }
318
+ case "pr-lint": {
319
+ if (args.repo === undefined || args.pr === undefined) {
320
+ process.stderr.write("error: pr-lint requires --repo and --pr\n");
321
+ return 1;
322
+ }
323
+ const findings = lintPrBody(args.repo, args.pr, {
324
+ runFn: options.runFn,
325
+ binary: options.binary,
326
+ });
327
+ if (findings.length > 0) {
328
+ writeLintFailure("pr", args.pr, findings);
329
+ return 1;
330
+ }
331
+ process.stdout.write(`ok: pr #${args.pr} body has no CP1252/CP437-as-UTF-8 mojibake\n`);
187
332
  return 0;
188
333
  }
189
334
  default: {
@@ -191,19 +336,40 @@ export function githubBodyMain(args) {
191
336
  let result;
192
337
  switch (args.command) {
193
338
  case "issue-create":
194
- result = createIssue(args.repo, { title: args.title, body });
339
+ result = createIssue(args.repo, {
340
+ title: args.title,
341
+ body,
342
+ runFn: options.runFn,
343
+ binary: options.binary,
344
+ });
195
345
  break;
196
346
  case "issue-edit":
197
- result = editIssueBody(args.repo, args.issue, { body });
347
+ result = editIssueBody(args.repo, args.issue, {
348
+ body,
349
+ runFn: options.runFn,
350
+ binary: options.binary,
351
+ });
198
352
  break;
199
353
  case "comment-create":
200
- result = createIssueComment(args.repo, args.issue, { body });
354
+ result = createIssueComment(args.repo, args.issue, {
355
+ body,
356
+ runFn: options.runFn,
357
+ binary: options.binary,
358
+ });
201
359
  break;
202
360
  case "comment-edit":
203
- result = editIssueCommentBody(args.repo, args.comment, { body });
361
+ result = editIssueCommentBody(args.repo, args.comment, {
362
+ body,
363
+ runFn: options.runFn,
364
+ binary: options.binary,
365
+ });
204
366
  break;
205
367
  case "pr-edit":
206
- result = editPrBody(args.repo, args.pr, { body });
368
+ result = editPrBody(args.repo, args.pr, {
369
+ body,
370
+ runFn: options.runFn,
371
+ binary: options.binary,
372
+ });
207
373
  break;
208
374
  default:
209
375
  process.stderr.write(`error: unknown command ${JSON.stringify(args.command)}\n`);
@@ -215,7 +381,12 @@ export function githubBodyMain(args) {
215
381
  }
216
382
  }
217
383
  catch (exc) {
218
- process.stderr.write(`error: ${String(exc)}\n`);
384
+ if (exc instanceof GitHubBodyError && exc.code !== undefined) {
385
+ process.stderr.write(`error [${exc.code}]: ${exc.message}\n`);
386
+ }
387
+ else {
388
+ process.stderr.write(`error: ${String(exc)}\n`);
389
+ }
219
390
  return 1;
220
391
  }
221
392
  }
@@ -44,6 +44,7 @@ export declare function assertVbriefWriteTargetSafe(path: string, projectRoot?:
44
44
  /**
45
45
  * Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
46
46
  * parent-directory symlink cannot divert the stamped file outside the project (#2869).
47
+ * #2980 wave B: product write sink routes through containedWrite.
47
48
  */
48
49
  export declare function writeVbrief(path: string, data: Record<string, unknown>, projectRoot?: string | null): void;
49
50
  export declare function vbriefTitle(data: Record<string, unknown>): string;
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { chmodSync, closeSync, constants, globSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
2
+ import { chmodSync, globSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { referenceTypeMatches } from "@deftai/directive-types";
6
+ import { containedWrite } from "../fs/contained-write.js";
6
7
  import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
7
8
  import { call } from "../scm/call.js";
8
9
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -83,7 +84,8 @@ export function recoverySidecarPath(vbriefAbsPath) {
83
84
  }
84
85
  /**
85
86
  * Write recovery sidecar without following symlinks (#2880 Greptile P1).
86
- * Trusted per-uid dir (ownership + mode) + O_EXCL|O_NOFOLLOW create.
87
+ * Trusted per-uid dir (ownership + mode) + containedWrite create (O_EXCL|O_NOFOLLOW).
88
+ * #2980 wave B: product write sink routes through containedWrite.
87
89
  */
88
90
  function writeRecoverySidecarSafe(sidePath, payload) {
89
91
  const dir = ensureTrustedRecoveryDir();
@@ -107,17 +109,12 @@ function writeRecoverySidecarSafe(sidePath, payload) {
107
109
  throw err;
108
110
  }
109
111
  }
110
- let flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
111
- if (typeof constants.O_NOFOLLOW === "number") {
112
- flags |= constants.O_NOFOLLOW;
113
- }
114
- const fd = openSync(sidePath, flags, 0o600);
115
- try {
116
- writeSync(fd, payload, undefined, "utf8");
117
- }
118
- finally {
119
- closeSync(fd);
120
- }
112
+ containedWrite({
113
+ root: dir,
114
+ target: sidePath,
115
+ data: payload,
116
+ mode: "create",
117
+ });
121
118
  }
122
119
  /**
123
120
  * Always-on recovery after remote create: process memory + private OS-temp sidecar.
@@ -250,15 +247,20 @@ export function loadPendingEmitUrls(projectRoot) {
250
247
  return {};
251
248
  }
252
249
  export function savePendingEmitUrl(projectRoot, vbriefAbsPath, url) {
250
+ const root = resolve(projectRoot);
253
251
  const ledger = pendingEmitLedgerPath(projectRoot);
254
- assertWriteTargetSafe(projectRoot, ledger);
255
- mkdirSync(dirname(ledger), { recursive: true });
256
252
  const map = loadPendingEmitUrls(projectRoot);
257
253
  map[resolve(vbriefAbsPath)] = url;
258
- assertWriteTargetSafe(projectRoot, ledger);
259
- writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
254
+ // #2980 wave B: product write sink routes through containedWrite.
255
+ containedWrite({
256
+ root,
257
+ target: ledger,
258
+ data: `${JSON.stringify(map, null, 2)}\n`,
259
+ mode: "replace",
260
+ });
260
261
  }
261
262
  export function clearPendingEmitUrl(projectRoot, vbriefAbsPath) {
263
+ const root = resolve(projectRoot);
262
264
  const ledger = pendingEmitLedgerPath(projectRoot);
263
265
  const key = resolve(vbriefAbsPath);
264
266
  const map = loadPendingEmitUrls(projectRoot);
@@ -267,9 +269,13 @@ export function clearPendingEmitUrl(projectRoot, vbriefAbsPath) {
267
269
  }
268
270
  delete map[key];
269
271
  try {
270
- assertWriteTargetSafe(projectRoot, ledger);
271
- mkdirSync(dirname(ledger), { recursive: true });
272
- writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
272
+ // #2980 wave B: product write sink routes through containedWrite.
273
+ containedWrite({
274
+ root,
275
+ target: ledger,
276
+ data: `${JSON.stringify(map, null, 2)}\n`,
277
+ mode: "replace",
278
+ });
273
279
  }
274
280
  catch {
275
281
  // Best-effort clear; next successful stamp still works via existingGithubIssueRef.
@@ -310,10 +316,16 @@ export function assertVbriefWriteTargetSafe(path, projectRoot) {
310
316
  /**
311
317
  * Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
312
318
  * parent-directory symlink cannot divert the stamped file outside the project (#2869).
319
+ * #2980 wave B: product write sink routes through containedWrite.
313
320
  */
314
321
  export function writeVbrief(path, data, projectRoot) {
315
- assertVbriefWriteTargetSafe(path, projectRoot);
316
- writeFileSync(resolve(path), `${JSON.stringify(data, null, 2)}\n`, "utf8");
322
+ const root = assertVbriefWriteTargetSafe(path, projectRoot);
323
+ containedWrite({
324
+ root,
325
+ target: resolve(path),
326
+ data: `${JSON.stringify(data, null, 2)}\n`,
327
+ mode: "replace",
328
+ });
317
329
  }
318
330
  export function vbriefTitle(data) {
319
331
  const plan = (data.plan ?? {});
@@ -417,7 +429,13 @@ export function fileIssue(repo, title, body, scmCall = call) {
417
429
  const tmpDir = mkdtempSync(join(tmpdir(), "deft-issue-emit-"));
418
430
  const bodyPath = join(tmpDir, "body.md");
419
431
  try {
420
- writeFileSync(bodyPath, body, "utf8");
432
+ // #2980 wave B: temp body file via containedWrite under the OS-temp dir.
433
+ containedWrite({
434
+ root: tmpDir,
435
+ target: "body.md",
436
+ data: body,
437
+ mode: "create",
438
+ });
421
439
  const result = scmCall("github-issue", "issue", ["create", "--repo", repo, "--title", title, "--body-file", bodyPath], { timeout: 60 });
422
440
  if (result.returncode !== 0) {
423
441
  throw new IssueEmitError(`gh issue create failed (exit ${result.returncode}): ${(result.stderr ?? "").trim()}`);
@@ -1,7 +1,8 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { contentRoot } from "../content-root.js";
5
+ import { containedWrite } from "../fs/contained-write.js";
5
6
  import { findSkillPathsInText } from "../text/redos-safe.js";
6
7
  /** Sentinel used by SKILL.md redirect stubs. */
7
8
  export const DEPRECATED_SKILL_REDIRECT_SENTINEL = "<!-- deft:deprecated-skill-redirect -->";
@@ -87,9 +88,16 @@ export function emit(name, payload = null, options = {}) {
87
88
  const logTarget = process.env[logPathEnv];
88
89
  if (logTarget !== undefined && logTarget.length > 0) {
89
90
  try {
91
+ // #2980 wave D: product write sink routes through containedWrite.
90
92
  const logPath = resolve(logTarget);
91
- mkdirSync(dirname(logPath), { recursive: true });
92
- appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
93
+ const dir = dirname(logPath);
94
+ mkdirSync(dir, { recursive: true });
95
+ containedWrite({
96
+ root: dir,
97
+ target: basename(logPath),
98
+ data: `${JSON.stringify(record)}\n`,
99
+ mode: "append",
100
+ });
93
101
  }
94
102
  catch {
95
103
  // Swallow log-write failures.
@@ -1,10 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync, } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { basename, 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
+ import { containedWrite } from "../fs/contained-write.js";
8
+ import { ProjectionContainmentError } from "../fs/projection-containment.js";
8
9
  /** Default event log location (project-local). */
9
10
  export const DEFAULT_EVENT_LOG = join(".deft-cache", "events.jsonl");
10
11
  const BEHAVIORAL_CATEGORY = "behavioral";
@@ -166,22 +167,32 @@ function assertPathComponentsNotSymlinks(projectDir, targetPath) {
166
167
  }
167
168
  }
168
169
  }
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);
170
+ /**
171
+ * Append one event log line via containedWrite (#2951 Phase 2).
172
+ * Project-nested logs use projectRoot as containment root; explicit external
173
+ * logs still refuse symlink path components, then contain under the log parent.
174
+ */
175
+ function appendEventLogLine(projectRoot, targetPath, line) {
176
+ const targetAbs = resolve(targetPath);
177
+ if (isNestedUnder(projectRoot, targetAbs)) {
178
+ containedWrite({
179
+ root: resolve(projectRoot),
180
+ target: targetAbs,
181
+ data: line,
182
+ mode: "append",
183
+ });
173
184
  return;
174
185
  }
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);
184
- }
186
+ // Explicit --log / DEFT_EVENT_LOG outside the project tree.
187
+ assertPathComponentsNotSymlinks(projectRoot, targetAbs);
188
+ const parent = dirname(targetAbs);
189
+ mkdirSync(parent, { recursive: true });
190
+ containedWrite({
191
+ root: resolve(parent),
192
+ target: basename(targetAbs),
193
+ data: line,
194
+ mode: "append",
195
+ });
185
196
  }
186
197
  function newEventId() {
187
198
  const wallNs = BigInt(Date.now()) * 1000000n;
@@ -210,9 +221,7 @@ export function emit(name, payload, options = {}) {
210
221
  };
211
222
  const projectRoot = resolve(options.projectRoot ?? process.cwd());
212
223
  const target = resolveLogPath(options.logPath, projectRoot);
213
- assertEventLogTargetSafe(projectRoot, target);
214
- mkdirSync(dirname(target), { recursive: true });
215
- appendLineNoFollow(target, `${jsonStringifySorted(record)}\n`);
224
+ appendEventLogLine(projectRoot, target, `${jsonStringifySorted(record)}\n`);
216
225
  return record;
217
226
  }
218
227
  /** Return all events from the log in emission order. */
@@ -1,7 +1,8 @@
1
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { referenceTypeMatches } from "@deftai/directive-types";
4
4
  import { computeReport } from "../capacity/show.js";
5
+ import { containedWrite } from "../fs/contained-write.js";
5
6
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
6
7
  import { resolveCapacityAllocation } from "../policy/capacity.js";
7
8
  import { readPlanPolicy } from "../policy/plan-extensions.js";
@@ -202,14 +203,19 @@ export function recordTechDebtAcceptance(projectRoot, epic, options) {
202
203
  throw new Error("follow_up_ref must be a non-empty reference string");
203
204
  }
204
205
  const path = techDebtLedgerPath(projectRoot);
205
- mkdirSync(resolve(path, ".."), { recursive: true });
206
206
  const record = {
207
207
  accepted_at: utcIso(options.now ?? null),
208
208
  actor: options.actor ?? DEFAULT_ACTOR,
209
209
  epic: epicKey,
210
210
  follow_up_ref: followUpRef.trim(),
211
211
  };
212
- appendFileSync(path, `${JSON.stringify(sortKeys(record))}\n`, "utf8");
212
+ // #2980 wave D: product write sink routes through containedWrite.
213
+ containedWrite({
214
+ root: resolve(projectRoot),
215
+ target: path,
216
+ data: `${JSON.stringify(sortKeys(record))}\n`,
217
+ mode: "append",
218
+ });
213
219
  return path;
214
220
  }
215
221
  /** Return the set of epic basenames already accepted as tech-debt. */
@@ -1,8 +1,21 @@
1
1
  /** Port of scripts/pack_render.py — content-pack projection renderer (#1294, #1295). */
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { contentRoot } from "../content-root.js";
6
+ import { containedWrite } from "../fs/contained-write.js";
7
+ /** Write under the parent directory as containment root (#2980 wave D). */
8
+ function writeContainedFile(output, text) {
9
+ const abs = resolve(output);
10
+ const dir = dirname(abs);
11
+ mkdirSync(dir, { recursive: true });
12
+ containedWrite({
13
+ root: dir,
14
+ target: basename(abs),
15
+ data: text,
16
+ mode: "replace",
17
+ });
18
+ }
6
19
  export function resolveRepoRoot() {
7
20
  return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
8
21
  }
@@ -382,8 +395,8 @@ export function renderFile(source) {
382
395
  }
383
396
  export function writeRender(source, output) {
384
397
  const text = renderFile(source);
385
- mkdirSync(dirname(output), { recursive: true });
386
- writeFileSync(output, text, "utf8");
398
+ // #2980 wave D: product write sink routes through containedWrite.
399
+ writeContainedFile(output, text);
387
400
  return text;
388
401
  }
389
402
  export function checkDrift(source, output) {
@@ -441,9 +454,9 @@ function runMultipack(packFilter, check) {
441
454
  process.stdout.write(`pack-drift: all ${targets.length} projection(s) in sync.\n`);
442
455
  return 0;
443
456
  }
457
+ // #2980 wave D: product write sinks route through containedWrite.
444
458
  for (const [_name, outPath, text] of targets) {
445
- mkdirSync(dirname(outPath), { recursive: true });
446
- writeFileSync(outPath, text, "utf8");
459
+ writeContainedFile(outPath, text);
447
460
  }
448
461
  process.stdout.write(`Rendered ${targets.length} projection(s) across ${Object.keys(RENDER_REGISTRY).length} pack(s).\n`);
449
462
  return 0;
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { containedWrite } from "../fs/contained-write.js";
3
4
  import { PLAN_SEQUENCE_FILENAME, parsePlanSequence } from "./types.js";
4
5
  export function planSequencePath(projectRoot) {
5
6
  return join(projectRoot, ".deft", PLAN_SEQUENCE_FILENAME);
@@ -14,8 +15,14 @@ export function readPlanSequence(projectRoot) {
14
15
  }
15
16
  export function writePlanSequence(projectRoot, sequence) {
16
17
  const path = planSequencePath(projectRoot);
17
- mkdirSync(dirname(path), { recursive: true });
18
- writeFileSync(path, `${JSON.stringify(sequence, null, 2)}\n`, "utf8");
18
+ // #2980 wave D: product write sink routes through containedWrite.
19
+ const root = resolve(projectRoot);
20
+ containedWrite({
21
+ root,
22
+ target: path,
23
+ data: `${JSON.stringify(sequence, null, 2)}\n`,
24
+ mode: "replace",
25
+ });
19
26
  return path;
20
27
  }
21
28
  export function clearPlanSequence(projectRoot) {