@deftai/directive-core 0.88.0 → 0.90.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 (202) 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/constants.d.ts +1 -1
  33. package/dist/doctor/constants.js +2 -0
  34. package/dist/doctor/doctor-state.js +28 -4
  35. package/dist/doctor/flags.js +21 -1
  36. package/dist/doctor/index.d.ts +1 -0
  37. package/dist/doctor/index.js +1 -0
  38. package/dist/doctor/main.d.ts +10 -0
  39. package/dist/doctor/main.js +182 -0
  40. package/dist/doctor/openclaw-skills.d.ts +109 -0
  41. package/dist/doctor/openclaw-skills.js +463 -0
  42. package/dist/doctor/types.d.ts +22 -0
  43. package/dist/escalation/actions.d.ts +63 -0
  44. package/dist/escalation/actions.js +137 -0
  45. package/dist/escalation/index.d.ts +8 -0
  46. package/dist/escalation/index.js +8 -0
  47. package/dist/escalation/paths.d.ts +3 -0
  48. package/dist/escalation/paths.js +10 -0
  49. package/dist/escalation/store.d.ts +17 -0
  50. package/dist/escalation/store.js +172 -0
  51. package/dist/escalation/types.d.ts +73 -0
  52. package/dist/escalation/types.js +43 -0
  53. package/dist/eval/crud-telemetry.js +30 -10
  54. package/dist/eval/health.js +22 -7
  55. package/dist/eval/readback.js +11 -10
  56. package/dist/eval/run.js +32 -16
  57. package/dist/events/attribution-enrichment.js +10 -4
  58. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  59. package/dist/finish-loop/directive-finish-loop.js +239 -0
  60. package/dist/finish-loop/grant-gate.d.ts +31 -0
  61. package/dist/finish-loop/grant-gate.js +169 -0
  62. package/dist/finish-loop/index.d.ts +11 -0
  63. package/dist/finish-loop/index.js +11 -0
  64. package/dist/finish-loop/main.d.ts +35 -0
  65. package/dist/finish-loop/main.js +365 -0
  66. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  67. package/dist/finish-loop/pr-finish-loop.js +217 -0
  68. package/dist/finish-loop/progress.d.ts +17 -0
  69. package/dist/finish-loop/progress.js +45 -0
  70. package/dist/finish-loop/queue.d.ts +16 -0
  71. package/dist/finish-loop/queue.js +65 -0
  72. package/dist/finish-loop/types.d.ts +52 -0
  73. package/dist/finish-loop/types.js +10 -0
  74. package/dist/fs/contained-write.d.ts +101 -0
  75. package/dist/fs/contained-write.js +281 -0
  76. package/dist/hooks/classify/classify.d.ts +10 -0
  77. package/dist/hooks/classify/classify.js +37 -0
  78. package/dist/hooks/classify/index.d.ts +14 -0
  79. package/dist/hooks/classify/index.js +13 -0
  80. package/dist/hooks/classify/paths.d.ts +22 -0
  81. package/dist/hooks/classify/paths.js +72 -0
  82. package/dist/hooks/classify/payload.d.ts +16 -0
  83. package/dist/hooks/classify/payload.js +45 -0
  84. package/dist/hooks/classify/stdin.d.ts +12 -0
  85. package/dist/hooks/classify/stdin.js +63 -0
  86. package/dist/hooks/classify/tool-name.d.ts +18 -0
  87. package/dist/hooks/classify/tool-name.js +85 -0
  88. package/dist/hooks/classify/types.d.ts +41 -0
  89. package/dist/hooks/classify/types.js +7 -0
  90. package/dist/hooks/dispatcher.d.ts +21 -18
  91. package/dist/hooks/dispatcher.js +232 -168
  92. package/dist/hooks/fixtures/cases.d.ts +44 -0
  93. package/dist/hooks/fixtures/cases.js +595 -0
  94. package/dist/hooks/fixtures/index.d.ts +2 -0
  95. package/dist/hooks/fixtures/index.js +2 -0
  96. package/dist/hooks/index.d.ts +2 -0
  97. package/dist/hooks/index.js +2 -0
  98. package/dist/index.d.ts +5 -0
  99. package/dist/index.js +6 -0
  100. package/dist/init-deposit/agent-hooks.js +27 -8
  101. package/dist/init-deposit/gitignore.js +11 -3
  102. package/dist/init-deposit/headless-manifest.js +12 -4
  103. package/dist/init-deposit/migrate.d.ts +1 -1
  104. package/dist/init-deposit/migrate.js +13 -3
  105. package/dist/init-deposit/prettierignore.js +10 -3
  106. package/dist/init-deposit/scaffold.js +35 -26
  107. package/dist/init-deposit/xbrief-projections.js +11 -4
  108. package/dist/intake/candidates-log.js +46 -35
  109. package/dist/intake/github-body-cli.d.ts +6 -0
  110. package/dist/intake/github-body-cli.js +17 -0
  111. package/dist/intake/github-body.d.ts +46 -3
  112. package/dist/intake/github-body.js +191 -20
  113. package/dist/intake/issue-emit.d.ts +1 -0
  114. package/dist/intake/issue-emit.js +41 -23
  115. package/dist/lifecycle/event-detect.js +12 -4
  116. package/dist/lifecycle/events.js +31 -20
  117. package/dist/lifecycle/index.d.ts +1 -0
  118. package/dist/lifecycle/index.js +1 -0
  119. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  120. package/dist/lifecycle/stats.d.ts +53 -0
  121. package/dist/lifecycle/stats.js +286 -0
  122. package/dist/packs/pack-render.js +19 -6
  123. package/dist/plan-sequence/store.js +11 -4
  124. package/dist/policy/hotfix-criteria.d.ts +79 -0
  125. package/dist/policy/hotfix-criteria.js +197 -0
  126. package/dist/policy/index.d.ts +4 -0
  127. package/dist/policy/index.js +31 -1
  128. package/dist/policy/intent-ceiling.d.ts +79 -0
  129. package/dist/policy/intent-ceiling.js +181 -0
  130. package/dist/policy/no-deft-directive.js +10 -3
  131. package/dist/policy/org-force-on-migration.js +9 -5
  132. package/dist/policy/require-human-merge.d.ts +75 -0
  133. package/dist/policy/require-human-merge.js +324 -0
  134. package/dist/policy/resolve.js +17 -6
  135. package/dist/policy/runtime-authority.d.ts +15 -2
  136. package/dist/policy/runtime-authority.js +23 -3
  137. package/dist/policy/write-fence.d.ts +78 -0
  138. package/dist/policy/write-fence.js +164 -0
  139. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  140. package/dist/pr-wait-mergeable/cascade.js +28 -0
  141. package/dist/preflight/evaluate.js +10 -0
  142. package/dist/product-signal/consent.js +21 -5
  143. package/dist/product-signal/submit.js +22 -12
  144. package/dist/release/build-dist-runner.js +5 -2
  145. package/dist/release/build-dist.d.ts +19 -1
  146. package/dist/release/build-dist.js +50 -2
  147. package/dist/release/native-steps.js +7 -2
  148. package/dist/release/spawn.js +13 -0
  149. package/dist/release-e2e/git-ops.d.ts +7 -0
  150. package/dist/release-e2e/git-ops.js +35 -2
  151. package/dist/release-publish/pipeline.d.ts +13 -0
  152. package/dist/release-publish/pipeline.js +46 -1
  153. package/dist/scope/audit-log.js +19 -24
  154. package/dist/scope/decompose.js +10 -5
  155. package/dist/scope/decomposed-refs.js +18 -3
  156. package/dist/scope/demote.js +9 -2
  157. package/dist/scope/undo.js +9 -2
  158. package/dist/session/index.d.ts +2 -0
  159. package/dist/session/index.js +2 -0
  160. package/dist/session/process-cost-constants.d.ts +9 -0
  161. package/dist/session/process-cost-constants.js +11 -0
  162. package/dist/session/process-cost.d.ts +53 -0
  163. package/dist/session/process-cost.js +82 -0
  164. package/dist/session/release-availability.js +26 -6
  165. package/dist/session/ritual-sentinel.d.ts +5 -0
  166. package/dist/session/ritual-sentinel.js +31 -13
  167. package/dist/session/session-ready.d.ts +67 -0
  168. package/dist/session/session-ready.js +264 -0
  169. package/dist/session/session-start.d.ts +56 -1
  170. package/dist/session/session-start.js +384 -23
  171. package/dist/session/verify-session-ritual.d.ts +8 -0
  172. package/dist/session/verify-session-ritual.js +95 -35
  173. package/dist/staleness-tickler/state.js +26 -6
  174. package/dist/swarm/launch.js +13 -3
  175. package/dist/swarm/routing.js +9 -3
  176. package/dist/task-surface/index.js +24 -9
  177. package/dist/tool-events/classify.d.ts +20 -0
  178. package/dist/tool-events/classify.js +634 -0
  179. package/dist/tool-events/index.d.ts +10 -0
  180. package/dist/tool-events/index.js +10 -0
  181. package/dist/tool-events/summarize.d.ts +34 -0
  182. package/dist/tool-events/summarize.js +93 -0
  183. package/dist/tool-events/types.d.ts +52 -0
  184. package/dist/tool-events/types.js +13 -0
  185. package/dist/triage/bootstrap/gitignore.js +53 -10
  186. package/dist/triage/cache-path.js +10 -3
  187. package/dist/triage/welcome/summary.js +11 -4
  188. package/dist/triage/welcome/writers.js +45 -16
  189. package/dist/validate-content/validate-links.js +5 -0
  190. package/dist/value/readback.js +11 -6
  191. package/dist/vbrief-activate/activate.js +12 -4
  192. package/dist/vbrief-build/project-definition-io.js +14 -6
  193. package/dist/verify-source/contained-writes.d.ts +59 -0
  194. package/dist/verify-source/contained-writes.js +272 -0
  195. package/dist/verify-source/index.d.ts +1 -0
  196. package/dist/verify-source/index.js +1 -0
  197. package/dist/verify-source/openclaw-tier1.js +14 -1
  198. package/dist/verify-source/verify-stubs.js +3 -0
  199. package/dist/xbrief-migrate/migrate-project.js +26 -12
  200. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  201. package/dist/xbrief-migrate/transforms.js +37 -14
  202. package/package.json +19 -3
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { assertDepositContained } from "../deposit/contain.js";
4
+ import { containedWrite } from "../fs/contained-write.js";
4
5
  import { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
6
  import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
6
7
  export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
@@ -184,14 +185,32 @@ function mergeCursorConfig(config, path) {
184
185
  hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
185
186
  return { ...config, version: 1, hooks };
186
187
  }
187
- function writeJsonIfChanged(path, payload) {
188
+ function writeJsonIfChanged(projectRoot, path, payload) {
188
189
  const next = `${JSON.stringify(payload, null, 2)}\n`;
189
190
  if (existsSync(path) && readFileSync(path, "utf8") === next)
190
191
  return false;
191
- mkdirSync(dirname(path), { recursive: true });
192
- const temporary = `${path}.deft-${process.pid}.tmp`;
193
- writeFileSync(temporary, next, "utf8");
194
- renameSync(temporary, path);
192
+ // Atomic replace via temp under project root (#2951 / #2980 wave A).
193
+ const parent = dirname(path);
194
+ const tmpName = `${basename(path)}.deft-${process.pid}.tmp`;
195
+ const temporary = join(parent, tmpName);
196
+ try {
197
+ containedWrite({
198
+ root: resolve(projectRoot),
199
+ target: temporary,
200
+ data: next,
201
+ mode: "replace",
202
+ });
203
+ renameSync(temporary, path);
204
+ }
205
+ catch (err) {
206
+ try {
207
+ rmSync(temporary, { force: true });
208
+ }
209
+ catch {
210
+ /* best-effort cleanup */
211
+ }
212
+ throw err;
213
+ }
195
214
  return true;
196
215
  }
197
216
  /** Merge Directive-owned project hook entries without replacing user configuration. */
@@ -247,7 +266,7 @@ export function writeAgentHookDeposit(projectRoot, io = { printf: () => undefine
247
266
  for (const item of prepared) {
248
267
  if (item.mode === "skip")
249
268
  continue;
250
- if (writeJsonIfChanged(item.absolute, item.payload)) {
269
+ if (writeJsonIfChanged(projectRoot, item.absolute, item.payload)) {
251
270
  if (item.mode === "strip")
252
271
  strippedPaths.push(item.path);
253
272
  else
@@ -9,8 +9,9 @@
9
9
  * Refs #1942, #1941, #1015, #1464, #1672.
10
10
  */
11
11
  import { execFileSync } from "node:child_process";
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { containedWrite } from "../fs/contained-write.js";
14
15
  import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
15
16
  import { FORBIDDEN_BLANKET_EVAL_LINES, stripGitignoreInlineComment, } from "../triage/bootstrap/gitignore.js";
16
17
  /** Directory ignore entry for the hybrid deposit (greenfield only). */
@@ -196,8 +197,15 @@ function reconcileGitignoreFile(projectDir, targetLines, includeDeftCoreRational
196
197
  }
197
198
  }
198
199
  try {
200
+ // Keep early containment so ProjectionContainmentError type is preserved for callers/tests.
199
201
  assertWriteTargetSafe(projectDir, path);
200
- writeFileSync(path, body, { encoding: "utf8", mode: 0o644 });
202
+ // #2980 wave A: product write sink routes through containedWrite.
203
+ containedWrite({
204
+ root: resolve(projectDir),
205
+ target: path,
206
+ data: body,
207
+ mode: "replace",
208
+ });
201
209
  }
202
210
  catch (cause) {
203
211
  if (cause instanceof ProjectionContainmentError) {
@@ -22,10 +22,11 @@
22
22
  * emits a JSON error object so the calling backend can treat it as non-fatal
23
23
  * (never a crash or a partial write).
24
24
  */
25
- import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
26
- import { dirname, join, relative, resolve, sep } from "node:path";
25
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
26
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
27
27
  import { ContentPackageNotFoundError, resolveInstalledContentRoot, } from "../deposit/resolve-content.js";
28
28
  import { readCorePackageVersion } from "../engine-version.js";
29
+ import { containedWrite } from "../fs/contained-write.js";
29
30
  import { agentsRefreshPlan } from "../platform/agents-md.js";
30
31
  import { plan } from "../resolution/plan.js";
31
32
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
@@ -233,8 +234,15 @@ export async function runInitHeadlessCli(options) {
233
234
  const abs = resolve(options.outputPath);
234
235
  const writeFile = options.writeFile ??
235
236
  ((path, data) => {
236
- mkdirSync(dirname(path), { recursive: true });
237
- writeFileSync(path, data);
237
+ // Contain under the output parent (arbitrary --output path; #2980 wave A).
238
+ const parent = dirname(path);
239
+ mkdirSync(parent, { recursive: true });
240
+ containedWrite({
241
+ root: resolve(parent),
242
+ target: basename(path),
243
+ data,
244
+ mode: "replace",
245
+ });
238
246
  });
239
247
  writeFile(abs, serialized);
240
248
  options.writeErr(`directive init --headless: wrote ${manifest.files.length}-file manifest (v${manifest.version}) to ${abs}\n`);
@@ -49,7 +49,7 @@ export interface MigrateSeams {
49
49
  isFile?: (path: string) => boolean;
50
50
  /** Text read returning null on failure (default: node:fs readFileSync, utf8). */
51
51
  readText?: (path: string) => string | null;
52
- /** Text write (default: node:fs writeFileSync, utf8). */
52
+ /** Text write (default: containedWrite under projectRoot; #2980 wave A). */
53
53
  writeText?: (path: string, text: string) => void;
54
54
  /** ISO-8601 UTC timestamp source for the backup filename (default: Date.now). */
55
55
  nowIso?: () => string;
@@ -24,11 +24,12 @@
24
24
  *
25
25
  * Refs #1941, #1912 (freeze prerequisite b), #1933 (never-first-start), #1670.
26
26
  */
27
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
28
- import { dirname, join } from "node:path";
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { resolveContentPackageRoot } from "../content-root.js";
31
31
  import { locateManifest, parseInstallManifest } from "../doctor/manifest.js";
32
+ import { containedWrite } from "../fs/contained-write.js";
32
33
  import { CANONICAL_INSTALL_ROOT } from "./scaffold.js";
33
34
  /**
34
35
  * The npm-managed handshake sentinel. `directive migrate` adds this key/value to
@@ -99,7 +100,16 @@ function engineMissingMessage() {
99
100
  export function runMigrate(projectRoot, seams = {}) {
100
101
  const isFile = seams.isFile ?? existsSync;
101
102
  const readText = seams.readText ?? defaultReadText;
102
- const writeText = seams.writeText ?? ((path, text) => writeFileSync(path, text, "utf8"));
103
+ const writeText = seams.writeText ??
104
+ ((path, text) => {
105
+ // #2980 wave A: product write sink routes through containedWrite.
106
+ containedWrite({
107
+ root: resolve(projectRoot),
108
+ target: path,
109
+ data: text,
110
+ mode: "replace",
111
+ });
112
+ });
103
113
  const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
104
114
  const resolveEngine = seams.resolveEngine ?? defaultResolveEngine;
105
115
  const manifestPath = detectCanonicalVendoredManifest(projectRoot, isFile);
@@ -7,8 +7,9 @@
7
7
  *
8
8
  * Refs #2534, #670.
9
9
  */
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { containedWrite } from "../fs/contained-write.js";
12
13
  import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
13
14
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
14
15
  /** Directory ignore entry for the hybrid deposit. */
@@ -72,7 +73,13 @@ export function ensurePrettierIgnoreLines(projectDir, io) {
72
73
  body += `${add}\n`;
73
74
  }
74
75
  try {
75
- writeFileSync(path, body, { encoding: "utf8", mode: 0o644 });
76
+ // #2980 wave A: product write sink routes through containedWrite.
77
+ containedWrite({
78
+ root: resolve(projectDir),
79
+ target: path,
80
+ data: body,
81
+ mode: "replace",
82
+ });
76
83
  }
77
84
  catch (cause) {
78
85
  throw new Error(`could not write .prettierignore: ${String(cause)}`);
@@ -5,10 +5,11 @@
5
5
  * Refs #1942, #1430, #1463, #1179.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
9
- import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
8
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
9
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
10
10
  import { platform } from "node:os";
11
- import { dirname, join, relative } from "node:path";
11
+ import { join, relative, resolve } from "node:path";
12
+ import { containedWrite } from "../fs/contained-write.js";
12
13
  import { assertDestinationNotSymlink, ProjectionContainmentError, } from "../fs/projection-containment.js";
13
14
  import { agentsRefreshPlan } from "../platform/agents-md.js";
14
15
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
@@ -27,6 +28,19 @@ function projectionTarget(projectDir, ...relSegments) {
27
28
  assertDestinationNotSymlink(projectDir, target);
28
29
  return target;
29
30
  }
31
+ /**
32
+ * Product write sink: containment root is the project, then containedWrite
33
+ * (#2951 / #2980 wave A). Call after projectionTarget / assertDestinationNotSymlink
34
+ * when the early ProjectionContainmentError type is required by tests.
35
+ */
36
+ function containedProjectWrite(projectDir, target, data) {
37
+ containedWrite({
38
+ root: resolve(projectDir),
39
+ target,
40
+ data,
41
+ mode: "replace",
42
+ });
43
+ }
30
44
  const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
31
45
  const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
32
46
  const FRAMEWORK_SELF_TEST_REL = ".deft/core/tests";
@@ -219,9 +233,9 @@ export function writeInstallManifest(projectDir, deftDir, fields) {
219
233
  relative(projectDir, deftDir).split("\\").join("/") ||
220
234
  CANONICAL_INSTALL_ROOT;
221
235
  const body = buildInstallManifestText({ ...fields, installRoot });
222
- mkdirSync(deftDir, { recursive: true });
223
236
  const path = join(deftDir, "VERSION");
224
- writeFileSync(path, body, "utf8");
237
+ // #2980 wave A: product write sink routes through containedWrite.
238
+ containedProjectWrite(projectDir, path, body);
225
239
  return path;
226
240
  }
227
241
  /** Canonical committed pin dependency name (mirrors resolution/pin.ts). */
@@ -275,7 +289,7 @@ export function ensurePackageJsonPin(projectDir, version, io) {
275
289
  }
276
290
  devDeps[PIN_DEPENDENCY_NAME] = pinVersion;
277
291
  pkg.devDependencies = devDeps;
278
- writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
292
+ containedProjectWrite(projectDir, path, `${JSON.stringify(pkg, null, 2)}\n`);
279
293
  io.printf(existed
280
294
  ? `package.json updated: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact).\n`
281
295
  : `package.json created: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact, private).\n`);
@@ -296,7 +310,7 @@ export function writeAgentsMd(projectDir, deftDir, io) {
296
310
  throw new Error("AGENTS.md render produced no content");
297
311
  }
298
312
  const path = projectionTarget(projectDir, "AGENTS.md");
299
- writeFileSync(path, newContent, "utf8");
313
+ containedProjectWrite(projectDir, path, newContent);
300
314
  if (state === "absent") {
301
315
  io.printf("AGENTS.md created.\n");
302
316
  }
@@ -320,7 +334,7 @@ async function ensureVbriefLifecycleDirs(projectDir) {
320
334
  const entries = await readdir(dir);
321
335
  if (entries.length > 0)
322
336
  continue;
323
- await writeFile(gitkeep, VBRIEF_LIFECYCLE_GITKEEP, "utf8");
337
+ containedProjectWrite(projectDir, gitkeep, VBRIEF_LIFECYCLE_GITKEEP);
324
338
  }
325
339
  }
326
340
  function vbriefLifecycleDirsPresent(consumerVbrief) {
@@ -354,7 +368,7 @@ export async function writeConsumerVbrief(projectDir, deftDir, io) {
354
368
  copyFileSync(fwVbriefMd, vbriefMdDst);
355
369
  }
356
370
  else {
357
- writeFileSync(vbriefMdDst, VBRIEF_README_BODY, "utf8");
371
+ containedProjectWrite(projectDir, vbriefMdDst, VBRIEF_README_BODY);
358
372
  }
359
373
  }
360
374
  await ensureVbriefLifecycleDirs(projectDir);
@@ -369,12 +383,10 @@ export function writeAgentsSkills(projectDir, io) {
369
383
  return false;
370
384
  }
371
385
  for (const skill of AGENTS_SKILLS) {
372
- const dir = projectionTarget(projectDir, ".agents", "skills", skill.dir);
373
- mkdirSync(dir, { recursive: true });
374
386
  const path = projectionTarget(projectDir, ".agents", "skills", skill.dir, "SKILL.md");
375
387
  if (existsSync(path))
376
388
  continue;
377
- writeFileSync(path, skill.content, "utf8");
389
+ containedProjectWrite(projectDir, path, skill.content);
378
390
  }
379
391
  io.printf(".agents/skills/ created — deft skills will be auto-discovered.\n");
380
392
  return true;
@@ -459,21 +471,20 @@ export function ensureTaskfile(projectDir, io) {
459
471
  " optional: true\n";
460
472
  io.printf("Appended new `includes:` block with deft entry to Taskfile.yml (Epic-4).\n");
461
473
  }
462
- writeFileSync(path, resultText, "utf8");
474
+ containedProjectWrite(projectDir, path, resultText);
463
475
  return true;
464
476
  }
465
477
  const HOOK_FILENAMES = ["pre-commit", "pre-push"];
466
478
  const HOOK_SUPPORT_FILENAMES = ["_deft-run.sh"];
467
479
  const HOOK_FILE_MODE = 0o755;
468
- const HOOK_SUPPORT_FILE_MODE = 0o644;
469
480
  export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
470
481
  const srcDir = join(deftDir, ".githooks");
471
482
  if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
472
483
  io.printf(`git hooks source ${srcDir} absent — skipping hook wiring.\n`);
473
484
  return false;
474
485
  }
475
- const dstDir = projectionTarget(projectDir, ".githooks");
476
- mkdirSync(dstDir, { recursive: true });
486
+ // Ensure parent path is containment-checked before any file write.
487
+ projectionTarget(projectDir, ".githooks");
477
488
  let filesDeposited = false;
478
489
  for (const name of [...HOOK_FILENAMES, ...HOOK_SUPPORT_FILENAMES]) {
479
490
  const src = join(srcDir, name);
@@ -484,8 +495,8 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
484
495
  const existing = existsSync(dst) ? readFileSync(dst) : null;
485
496
  const isHookScript = HOOK_FILENAMES.includes(name);
486
497
  if (!existing?.equals(data)) {
487
- const mode = isHookScript ? HOOK_FILE_MODE : HOOK_SUPPORT_FILE_MODE;
488
- writeFileSync(dst, data, { mode });
498
+ // #2980 wave A: containedWrite (mode is applied via chmod below for hooks).
499
+ containedProjectWrite(projectDir, dst, data);
489
500
  filesDeposited = true;
490
501
  }
491
502
  if (platform() !== "win32" && isHookScript) {
@@ -682,7 +693,7 @@ export function ensureGitattributes(projectDir, io) {
682
693
  for (const add of additions) {
683
694
  body += `${add}\n`;
684
695
  }
685
- writeFileSync(path, body, "utf8");
696
+ containedProjectWrite(projectDir, path, body);
686
697
  io.printf(`.gitattributes updated with Deft core markers: ${additions.join(", ")}\n`);
687
698
  return true;
688
699
  }
@@ -725,7 +736,7 @@ export function ensureGreptileIgnore(projectDir, io) {
725
736
  return false;
726
737
  }
727
738
  obj.ignorePatterns = appendGreptilePattern(patterns, CORE_GLOB);
728
- writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
739
+ containedProjectWrite(projectDir, path, `${JSON.stringify(obj, null, 2)}\n`);
729
740
  io.printf(fileExisted
730
741
  ? `greptile.json updated: bot review now ignores ${CORE_GLOB}.\n`
731
742
  : `greptile.json created: bot review ignores ${CORE_GLOB}.\n`);
@@ -774,8 +785,7 @@ function insertCodeqlPathsIgnore(content, glob) {
774
785
  export function ensureCodeqlPathsIgnore(projectDir, io) {
775
786
  const path = projectionTarget(projectDir, CODEQL_CONFIG_REL);
776
787
  if (!existsSync(path)) {
777
- mkdirSync(dirname(path), { recursive: true });
778
- writeFileSync(path, codeqlConfigDefault(), "utf8");
788
+ containedProjectWrite(projectDir, path, codeqlConfigDefault());
779
789
  io.printf(`${CODEQL_CONFIG_REL} created: CodeQL ignores ${CORE_GLOB}.\n`);
780
790
  return true;
781
791
  }
@@ -788,7 +798,7 @@ export function ensureCodeqlPathsIgnore(projectDir, io) {
788
798
  const updated = inserted.ok
789
799
  ? inserted.content
790
800
  : `${existing}${existing.endsWith("\n") ? "" : "\n"}paths-ignore:\n - '${CORE_GLOB}'\n`;
791
- writeFileSync(path, updated, "utf8");
801
+ containedProjectWrite(projectDir, path, updated);
792
802
  io.printf(`${CODEQL_CONFIG_REL} updated: CodeQL now ignores ${CORE_GLOB}.\n`);
793
803
  return true;
794
804
  }
@@ -806,12 +816,11 @@ export function ensureCoreGuardWorkflow(projectDir, io) {
806
816
  io.printf(`${CORE_GUARD_WORKFLOW_REL} already current — skipping.\n`);
807
817
  return false;
808
818
  }
809
- writeFileSync(path, refreshed, "utf8");
819
+ containedProjectWrite(projectDir, path, refreshed);
810
820
  io.printf(`${CORE_GUARD_WORKFLOW_REL} refreshed: deft-core-guard allowlist updated (#1478).\n`);
811
821
  return true;
812
822
  }
813
- mkdirSync(dirname(path), { recursive: true });
814
- writeFileSync(path, desired, "utf8");
823
+ containedProjectWrite(projectDir, path, desired);
815
824
  io.printf(`${CORE_GUARD_WORKFLOW_REL} created: CI refuses PRs mixing ${CORE_GLOB} with app files.\n`);
816
825
  return true;
817
826
  }
@@ -5,8 +5,9 @@
5
5
  * freshness. A current `.deft/core/VERSION` therefore cannot short-circuit
6
6
  * these repairs.
7
7
  */
8
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
9
- import { join, relative } from "node:path";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
9
+ import { join, relative, resolve } from "node:path";
10
+ import { containedWrite } from "../fs/contained-write.js";
10
11
  import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
11
12
  import { resolveLifecycleRoot } from "../layout/resolve.js";
12
13
  import { DEV_FALLBACK } from "../platform/constants.js";
@@ -66,6 +67,7 @@ export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationD
66
67
  }
67
68
  }
68
69
  function writeFileIfChanged(projectDir, target, content) {
70
+ // Keep early containment so ProjectionContainmentError type is preserved for callers/tests.
69
71
  assertDestinationNotSymlink(projectDir, target);
70
72
  const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
71
73
  try {
@@ -75,8 +77,13 @@ function writeFileIfChanged(projectDir, target, content) {
75
77
  catch {
76
78
  // Missing or unreadable target is replaced below.
77
79
  }
78
- mkdirSync(join(target, ".."), { recursive: true });
79
- writeFileSync(target, desired);
80
+ // #2980 wave A: product write sink routes through containedWrite.
81
+ containedWrite({
82
+ root: resolve(projectDir),
83
+ target,
84
+ data: desired,
85
+ mode: "replace",
86
+ });
80
87
  return true;
81
88
  }
82
89
  /**
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync, } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { ContainedWriteError, ContainedWriteErrorCode, containedWrite, } from "../fs/contained-write.js";
4
5
  import { pyRepr } from "../scm/py-format.js";
5
6
  import { resolveCandidatesLogPath } from "../triage/cache-path.js";
6
7
  function defaultLogPath() {
@@ -110,40 +111,45 @@ function validateEntry(entry) {
110
111
  function resolvePath(path) {
111
112
  return path !== undefined && path !== null ? resolve(path) : defaultLogPath();
112
113
  }
113
- function acquireAppendLock(logPath) {
114
+ /**
115
+ * Cross-process exclusive lock via containedWrite mode=create (O_EXCL).
116
+ * #2980 wave B: lock + product appends route through containedWrite.
117
+ */
118
+ function acquireAppendLock(logPath, containmentRoot) {
114
119
  while (threadLocked) {
115
120
  // spin-wait for in-process serialization
116
121
  }
117
122
  threadLocked = true;
118
- const lockPath = join(dirname(logPath), `${logPath.split(/[/\\]/).pop()}.lock`);
123
+ const lockPath = resolve(dirname(logPath), `${basename(logPath)}.lock`);
119
124
  mkdirSync(dirname(lockPath), { recursive: true });
120
125
  const deadline = Date.now() + 30_000;
121
- let fd = null;
122
- while (fd === null) {
126
+ while (true) {
123
127
  try {
124
- fd = openSync(lockPath, "wx");
125
- }
126
- catch {
127
- if (Date.now() > deadline) {
128
- threadLocked = false;
129
- throw new CandidatesLogError("timed out acquiring candidates log lock");
130
- }
131
- const spinEnd = Date.now() + 20;
132
- while (Date.now() < spinEnd) {
133
- // brief spin
128
+ containedWrite({
129
+ root: containmentRoot,
130
+ target: lockPath,
131
+ data: "\0",
132
+ mode: "create",
133
+ });
134
+ break;
135
+ }
136
+ catch (err) {
137
+ if (err instanceof ContainedWriteError && err.code === ContainedWriteErrorCode.EXISTS) {
138
+ if (Date.now() > deadline) {
139
+ threadLocked = false;
140
+ throw new CandidatesLogError("timed out acquiring candidates log lock");
141
+ }
142
+ const spinEnd = Date.now() + 20;
143
+ while (Date.now() < spinEnd) {
144
+ // brief spin
145
+ }
146
+ continue;
134
147
  }
148
+ threadLocked = false;
149
+ throw err;
135
150
  }
136
151
  }
137
- writeSync(fd, Buffer.from("\0"));
138
152
  return () => {
139
- try {
140
- if (fd !== null) {
141
- closeSync(fd);
142
- }
143
- }
144
- catch {
145
- // ignore
146
- }
147
153
  try {
148
154
  unlinkSync(lockPath);
149
155
  }
@@ -153,24 +159,29 @@ function acquireAppendLock(logPath) {
153
159
  threadLocked = false;
154
160
  };
155
161
  }
162
+ /** Ensure parent exists and return it as the containment root for log writes. */
163
+ function containmentRootForLog(logPath) {
164
+ const parent = dirname(logPath);
165
+ mkdirSync(parent, { recursive: true });
166
+ return resolve(parent);
167
+ }
156
168
  export function validateCandidatesEntry(entry) {
157
169
  validateEntry(entry);
158
170
  }
159
171
  export function append(entry, options = {}) {
160
172
  validateEntry(entry);
161
173
  const logPath = resolvePath(options.path);
162
- mkdirSync(dirname(logPath), { recursive: true });
174
+ const root = containmentRootForLog(logPath);
163
175
  const line = JSON.stringify(entry, Object.keys(entry).sort());
164
- const release = acquireAppendLock(logPath);
176
+ const release = acquireAppendLock(logPath, root);
165
177
  try {
166
- const fd = openSync(logPath, "a");
167
- try {
168
- writeSync(fd, Buffer.from(`${line}\n`, "utf8"));
169
- fsyncSync(fd);
170
- }
171
- finally {
172
- closeSync(fd);
173
- }
178
+ // #2980 wave B: product write sink routes through containedWrite.
179
+ containedWrite({
180
+ root,
181
+ target: logPath,
182
+ data: `${line}\n`,
183
+ mode: "append",
184
+ });
174
185
  }
175
186
  finally {
176
187
  release();
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * Production entry for `task scm:body:*` / `directive github-body <subcommand>`.
4
+ * Dispatch peels the `github-body` verb; argv[0] is the subcommand
5
+ * (`issue-lint`, `pr-lint`, `issue-edit`, …). Wired through githubBodyMain
6
+ * so create/edit fail closed and lint commands share one encoding gate (#2960).
7
+ */
2
8
  export declare function mainEntry(argv?: string[]): number;
3
9
  //# sourceMappingURL=github-body-cli.d.ts.map
@@ -19,9 +19,26 @@ function parseArgs(argv) {
19
19
  out.bodyFile = argv[++i];
20
20
  else if (arg === "--out-file")
21
21
  out.outFile = argv[++i];
22
+ else if (/^\d+$/.test(arg)) {
23
+ // Positional number: issue-lint / issue-* use --issue; pr-lint / pr-* use --pr (#2960).
24
+ const n = Number.parseInt(arg, 10);
25
+ if (out.command.startsWith("pr-")) {
26
+ if (out.pr === undefined)
27
+ out.pr = n;
28
+ }
29
+ else if (out.issue === undefined) {
30
+ out.issue = n;
31
+ }
32
+ }
22
33
  }
23
34
  return out;
24
35
  }
36
+ /**
37
+ * Production entry for `task scm:body:*` / `directive github-body <subcommand>`.
38
+ * Dispatch peels the `github-body` verb; argv[0] is the subcommand
39
+ * (`issue-lint`, `pr-lint`, `issue-edit`, …). Wired through githubBodyMain
40
+ * so create/edit fail closed and lint commands share one encoding gate (#2960).
41
+ */
25
42
  export function mainEntry(argv = process.argv.slice(2)) {
26
43
  const parsed = parseArgs(argv);
27
44
  return githubBodyMain(parsed);
@@ -1,6 +1,12 @@
1
+ import { type Finding } from "../encoding/scan.js";
1
2
  export declare const DEFAULT_TIMEOUT_SECONDS = 60;
3
+ /** Stable error code for CP1252/CP437-as-UTF-8 / U+FFFD body corruption (#2960). */
4
+ export declare const SCM_BODY_ENCODING_CODE = "scm-body-encoding";
2
5
  export declare class GitHubBodyError extends Error {
3
- constructor(message: string);
6
+ readonly code?: string;
7
+ constructor(message: string, options?: {
8
+ code?: string;
9
+ });
4
10
  }
5
11
  export type RunGhApiFn = (args: readonly string[], options?: {
6
12
  inputText?: string | null;
@@ -9,7 +15,22 @@ export type RunGhApiFn = (args: readonly string[], options?: {
9
15
  /** Resolve the live GitHub CLI used for writes and mutation read-back. */
10
16
  export declare function resolveLiveGh(): string;
11
17
  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). */
18
+ /** Strip a leading UTF-8 BOM so body-file writers stay UTF-8 no BOM (#2960 / #798). */
19
+ export declare function stripUtf8Bom(text: string): string;
20
+ /**
21
+ * Scan body text with the same mojibake pattern set as verify:encoding
22
+ * (`packages/core/src/encoding/patterns.ts` / scanLine). Issue/PR bodies are
23
+ * Markdown: strip fenced blocks and inline code spans so documented examples
24
+ * of the corruption class (e.g. `#2960` fixtures) are not false positives —
25
+ * matches `scanFile` .md behavior.
26
+ */
27
+ export declare function scanBodyText(body: string, source?: string): Finding[];
28
+ /**
29
+ * Fail closed when body text already contains CP1252/CP437-as-UTF-8 mojibake
30
+ * or U+FFFD (before PATCH and as a live-body diagnostic).
31
+ */
32
+ export declare function assertBodyEncoding(body: string, phase: string): void;
33
+ /** Fail closed when live read-back body is flattened or mojibaked vs intended payload (#2607 / #2960). */
13
34
  export declare function verifyBodyPostcondition(intended: string, live: string): void;
14
35
  export declare function createIssue(repo: string, options: {
15
36
  title: string;
@@ -41,10 +62,29 @@ export declare function fetchIssueBody(repo: string, issue: number, options?: {
41
62
  binary?: string | null;
42
63
  runFn?: RunGhApiFn;
43
64
  }): string;
65
+ export declare function fetchPrBody(repo: string, pr: number, options?: {
66
+ binary?: string | null;
67
+ runFn?: RunGhApiFn;
68
+ }): string;
44
69
  export declare function writeIssueBodyToFile(repo: string, issue: number, outFile: string, options?: {
45
70
  binary?: string | null;
46
71
  runFn?: RunGhApiFn;
47
72
  }): string;
73
+ /**
74
+ * Lint a live issue body for mojibake. Returns findings (empty = clean).
75
+ * Does not throw on hits — callers decide exit code.
76
+ */
77
+ export declare function lintIssueBody(repo: string, issue: number, options?: {
78
+ binary?: string | null;
79
+ runFn?: RunGhApiFn;
80
+ }): Finding[];
81
+ /**
82
+ * Lint a live PR body for mojibake. Returns findings (empty = clean).
83
+ */
84
+ export declare function lintPrBody(repo: string, pr: number, options?: {
85
+ binary?: string | null;
86
+ runFn?: RunGhApiFn;
87
+ }): Finding[];
48
88
  export interface GitHubBodyCliArgs {
49
89
  command: string;
50
90
  repo?: string;
@@ -55,5 +95,8 @@ export interface GitHubBodyCliArgs {
55
95
  bodyFile?: string;
56
96
  outFile?: string;
57
97
  }
58
- export declare function githubBodyMain(args: GitHubBodyCliArgs): number;
98
+ export declare function githubBodyMain(args: GitHubBodyCliArgs, options?: {
99
+ runFn?: RunGhApiFn;
100
+ binary?: string | null;
101
+ }): number;
59
102
  //# sourceMappingURL=github-body.d.ts.map