@deftai/directive-core 0.87.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 (193) 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/scanner.d.ts +11 -1
  28. package/dist/cache/scanner.js +29 -4
  29. package/dist/cache/task-cache/store.js +11 -7
  30. package/dist/capacity/backfill.js +10 -4
  31. package/dist/category-b-namespace/index.js +10 -4
  32. package/dist/check/gate-lists.js +1 -0
  33. package/dist/codebase/default-extractor.js +3 -0
  34. package/dist/codebase/map.js +11 -4
  35. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  36. package/dist/content-contracts/skills/helpers.js +35 -0
  37. package/dist/deposit/copy-tree.d.ts +19 -1
  38. package/dist/deposit/copy-tree.js +134 -5
  39. package/dist/doctor/doctor-state.js +28 -4
  40. package/dist/doctor/main.d.ts +10 -0
  41. package/dist/doctor/main.js +208 -0
  42. package/dist/doctor/types.d.ts +11 -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/fs/projection-containment.d.ts +18 -0
  77. package/dist/fs/projection-containment.js +40 -0
  78. package/dist/hooks/classify/classify.d.ts +10 -0
  79. package/dist/hooks/classify/classify.js +37 -0
  80. package/dist/hooks/classify/index.d.ts +14 -0
  81. package/dist/hooks/classify/index.js +13 -0
  82. package/dist/hooks/classify/paths.d.ts +22 -0
  83. package/dist/hooks/classify/paths.js +72 -0
  84. package/dist/hooks/classify/payload.d.ts +16 -0
  85. package/dist/hooks/classify/payload.js +45 -0
  86. package/dist/hooks/classify/stdin.d.ts +12 -0
  87. package/dist/hooks/classify/stdin.js +63 -0
  88. package/dist/hooks/classify/tool-name.d.ts +18 -0
  89. package/dist/hooks/classify/tool-name.js +85 -0
  90. package/dist/hooks/classify/types.d.ts +41 -0
  91. package/dist/hooks/classify/types.js +7 -0
  92. package/dist/hooks/dispatcher.d.ts +30 -15
  93. package/dist/hooks/dispatcher.js +326 -134
  94. package/dist/hooks/fixtures/cases.d.ts +44 -0
  95. package/dist/hooks/fixtures/cases.js +595 -0
  96. package/dist/hooks/fixtures/index.d.ts +2 -0
  97. package/dist/hooks/fixtures/index.js +2 -0
  98. package/dist/hooks/index.d.ts +2 -0
  99. package/dist/hooks/index.js +2 -0
  100. package/dist/hooks/tools.d.ts +31 -0
  101. package/dist/hooks/tools.js +74 -0
  102. package/dist/index.d.ts +4 -0
  103. package/dist/index.js +5 -0
  104. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  105. package/dist/init-deposit/agent-hooks.js +65 -10
  106. package/dist/init-deposit/gitignore.js +11 -3
  107. package/dist/init-deposit/headless-manifest.js +12 -4
  108. package/dist/init-deposit/hygiene.d.ts +16 -0
  109. package/dist/init-deposit/hygiene.js +26 -0
  110. package/dist/init-deposit/init-dispatch.js +28 -0
  111. package/dist/init-deposit/migrate.d.ts +1 -1
  112. package/dist/init-deposit/migrate.js +13 -3
  113. package/dist/init-deposit/prettierignore.js +12 -5
  114. package/dist/init-deposit/refresh.js +38 -7
  115. package/dist/init-deposit/scaffold.js +42 -29
  116. package/dist/init-deposit/xbrief-projections.js +17 -10
  117. package/dist/intake/candidates-log.js +46 -35
  118. package/dist/intake/github-body-cli.d.ts +6 -0
  119. package/dist/intake/github-body-cli.js +17 -0
  120. package/dist/intake/github-body.d.ts +46 -3
  121. package/dist/intake/github-body.js +191 -20
  122. package/dist/intake/issue-emit.d.ts +1 -0
  123. package/dist/intake/issue-emit.js +41 -23
  124. package/dist/intake/issue-ingest.js +11 -2
  125. package/dist/lifecycle/event-detect.js +12 -4
  126. package/dist/lifecycle/events.js +29 -20
  127. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  128. package/dist/packs/pack-render.d.ts +33 -0
  129. package/dist/packs/pack-render.js +172 -13
  130. package/dist/packs/quarantine-ext.d.ts +10 -0
  131. package/dist/packs/quarantine-ext.js +26 -2
  132. package/dist/plan-sequence/store.js +11 -4
  133. package/dist/policy/hotfix-criteria.d.ts +79 -0
  134. package/dist/policy/hotfix-criteria.js +197 -0
  135. package/dist/policy/index.d.ts +5 -0
  136. package/dist/policy/index.js +32 -1
  137. package/dist/policy/intent-ceiling.d.ts +79 -0
  138. package/dist/policy/intent-ceiling.js +181 -0
  139. package/dist/policy/no-deft-directive.d.ts +59 -0
  140. package/dist/policy/no-deft-directive.js +110 -0
  141. package/dist/policy/org-force-on-migration.d.ts +52 -0
  142. package/dist/policy/org-force-on-migration.js +269 -27
  143. package/dist/policy/require-human-merge.d.ts +75 -0
  144. package/dist/policy/require-human-merge.js +324 -0
  145. package/dist/policy/resolve.js +17 -6
  146. package/dist/policy/runtime-authority.d.ts +56 -2
  147. package/dist/policy/runtime-authority.js +297 -3
  148. package/dist/policy/write-fence.d.ts +78 -0
  149. package/dist/policy/write-fence.js +164 -0
  150. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  151. package/dist/pr-wait-mergeable/cascade.js +28 -0
  152. package/dist/preflight/evaluate.js +10 -0
  153. package/dist/product-signal/consent.js +21 -5
  154. package/dist/product-signal/submit.js +22 -12
  155. package/dist/release/build-dist-runner.js +5 -2
  156. package/dist/release/build-dist.d.ts +19 -1
  157. package/dist/release/build-dist.js +50 -2
  158. package/dist/release/native-steps.js +7 -2
  159. package/dist/release-publish/pipeline.d.ts +13 -0
  160. package/dist/release-publish/pipeline.js +46 -1
  161. package/dist/scope/audit-log.js +19 -24
  162. package/dist/scope/decompose.js +10 -5
  163. package/dist/scope/decomposed-refs.js +18 -3
  164. package/dist/scope/demote.js +9 -2
  165. package/dist/scope/undo.js +9 -2
  166. package/dist/session/release-availability.js +26 -6
  167. package/dist/session/ritual-sentinel.js +19 -12
  168. package/dist/session/session-start-hook.d.ts +3 -0
  169. package/dist/session/session-start-hook.js +15 -0
  170. package/dist/session/session-start.js +37 -0
  171. package/dist/staleness-tickler/state.js +26 -6
  172. package/dist/swarm/launch.js +13 -3
  173. package/dist/swarm/routing.js +9 -3
  174. package/dist/task-surface/index.js +24 -9
  175. package/dist/triage/bootstrap/gitignore.js +53 -10
  176. package/dist/triage/cache-path.js +10 -3
  177. package/dist/triage/welcome/summary.js +11 -4
  178. package/dist/triage/welcome/writers.js +45 -16
  179. package/dist/validate-content/validate-links.js +5 -0
  180. package/dist/value/readback.js +11 -6
  181. package/dist/vbrief-activate/activate.js +12 -4
  182. package/dist/vbrief-build/project-definition-io.js +14 -6
  183. package/dist/verify-source/contained-writes.d.ts +59 -0
  184. package/dist/verify-source/contained-writes.js +272 -0
  185. package/dist/verify-source/cursor-tier1.js +7 -2
  186. package/dist/verify-source/index.d.ts +1 -0
  187. package/dist/verify-source/index.js +1 -0
  188. package/dist/verify-source/openclaw-tier1.js +19 -1
  189. package/dist/verify-source/verify-stubs.js +3 -0
  190. package/dist/xbrief-migrate/migrate-project.js +26 -12
  191. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  192. package/dist/xbrief-migrate/transforms.js +37 -14
  193. package/package.json +16 -3
@@ -5,11 +5,12 @@
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";
12
- import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
11
+ import { join, relative, resolve } from "node:path";
12
+ import { containedWrite } from "../fs/contained-write.js";
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";
15
16
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
@@ -17,12 +18,29 @@ import { installerManagedGuardEre } from "./hygiene.js";
17
18
  import { syncConsumerXbriefSchemas } from "./xbrief-projections.js";
18
19
  export { CANONICAL_INSTALL_ROOT };
19
20
  export const CORE_GLOB = ".deft/core/**";
20
- /** Refuse init/update projection writes that escape via repo-controlled symlinks (#2446). */
21
+ /**
22
+ * Refuse init/update projection writes that escape via repo-controlled symlinks
23
+ * (#2446) OR that would follow an in-tree destination symlink on the write path
24
+ * (#2912). Every consumer projection sink in this module routes through here.
25
+ */
21
26
  function projectionTarget(projectDir, ...relSegments) {
22
27
  const target = join(projectDir, ...relSegments);
23
- assertProjectionContained(projectDir, target);
28
+ assertDestinationNotSymlink(projectDir, target);
24
29
  return target;
25
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
+ }
26
44
  const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
27
45
  const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
28
46
  const FRAMEWORK_SELF_TEST_REL = ".deft/core/tests";
@@ -215,9 +233,9 @@ export function writeInstallManifest(projectDir, deftDir, fields) {
215
233
  relative(projectDir, deftDir).split("\\").join("/") ||
216
234
  CANONICAL_INSTALL_ROOT;
217
235
  const body = buildInstallManifestText({ ...fields, installRoot });
218
- mkdirSync(deftDir, { recursive: true });
219
236
  const path = join(deftDir, "VERSION");
220
- writeFileSync(path, body, "utf8");
237
+ // #2980 wave A: product write sink routes through containedWrite.
238
+ containedProjectWrite(projectDir, path, body);
221
239
  return path;
222
240
  }
223
241
  /** Canonical committed pin dependency name (mirrors resolution/pin.ts). */
@@ -271,7 +289,7 @@ export function ensurePackageJsonPin(projectDir, version, io) {
271
289
  }
272
290
  devDeps[PIN_DEPENDENCY_NAME] = pinVersion;
273
291
  pkg.devDependencies = devDeps;
274
- writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
292
+ containedProjectWrite(projectDir, path, `${JSON.stringify(pkg, null, 2)}\n`);
275
293
  io.printf(existed
276
294
  ? `package.json updated: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact).\n`
277
295
  : `package.json created: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact, private).\n`);
@@ -292,7 +310,7 @@ export function writeAgentsMd(projectDir, deftDir, io) {
292
310
  throw new Error("AGENTS.md render produced no content");
293
311
  }
294
312
  const path = projectionTarget(projectDir, "AGENTS.md");
295
- writeFileSync(path, newContent, "utf8");
313
+ containedProjectWrite(projectDir, path, newContent);
296
314
  if (state === "absent") {
297
315
  io.printf("AGENTS.md created.\n");
298
316
  }
@@ -316,7 +334,7 @@ async function ensureVbriefLifecycleDirs(projectDir) {
316
334
  const entries = await readdir(dir);
317
335
  if (entries.length > 0)
318
336
  continue;
319
- await writeFile(gitkeep, VBRIEF_LIFECYCLE_GITKEEP, "utf8");
337
+ containedProjectWrite(projectDir, gitkeep, VBRIEF_LIFECYCLE_GITKEEP);
320
338
  }
321
339
  }
322
340
  function vbriefLifecycleDirsPresent(consumerVbrief) {
@@ -350,7 +368,7 @@ export async function writeConsumerVbrief(projectDir, deftDir, io) {
350
368
  copyFileSync(fwVbriefMd, vbriefMdDst);
351
369
  }
352
370
  else {
353
- writeFileSync(vbriefMdDst, VBRIEF_README_BODY, "utf8");
371
+ containedProjectWrite(projectDir, vbriefMdDst, VBRIEF_README_BODY);
354
372
  }
355
373
  }
356
374
  await ensureVbriefLifecycleDirs(projectDir);
@@ -365,12 +383,10 @@ export function writeAgentsSkills(projectDir, io) {
365
383
  return false;
366
384
  }
367
385
  for (const skill of AGENTS_SKILLS) {
368
- const dir = projectionTarget(projectDir, ".agents", "skills", skill.dir);
369
- mkdirSync(dir, { recursive: true });
370
386
  const path = projectionTarget(projectDir, ".agents", "skills", skill.dir, "SKILL.md");
371
387
  if (existsSync(path))
372
388
  continue;
373
- writeFileSync(path, skill.content, "utf8");
389
+ containedProjectWrite(projectDir, path, skill.content);
374
390
  }
375
391
  io.printf(".agents/skills/ created — deft skills will be auto-discovered.\n");
376
392
  return true;
@@ -455,21 +471,20 @@ export function ensureTaskfile(projectDir, io) {
455
471
  " optional: true\n";
456
472
  io.printf("Appended new `includes:` block with deft entry to Taskfile.yml (Epic-4).\n");
457
473
  }
458
- writeFileSync(path, resultText, "utf8");
474
+ containedProjectWrite(projectDir, path, resultText);
459
475
  return true;
460
476
  }
461
477
  const HOOK_FILENAMES = ["pre-commit", "pre-push"];
462
478
  const HOOK_SUPPORT_FILENAMES = ["_deft-run.sh"];
463
479
  const HOOK_FILE_MODE = 0o755;
464
- const HOOK_SUPPORT_FILE_MODE = 0o644;
465
480
  export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
466
481
  const srcDir = join(deftDir, ".githooks");
467
482
  if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
468
483
  io.printf(`git hooks source ${srcDir} absent — skipping hook wiring.\n`);
469
484
  return false;
470
485
  }
471
- const dstDir = projectionTarget(projectDir, ".githooks");
472
- mkdirSync(dstDir, { recursive: true });
486
+ // Ensure parent path is containment-checked before any file write.
487
+ projectionTarget(projectDir, ".githooks");
473
488
  let filesDeposited = false;
474
489
  for (const name of [...HOOK_FILENAMES, ...HOOK_SUPPORT_FILENAMES]) {
475
490
  const src = join(srcDir, name);
@@ -480,8 +495,8 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
480
495
  const existing = existsSync(dst) ? readFileSync(dst) : null;
481
496
  const isHookScript = HOOK_FILENAMES.includes(name);
482
497
  if (!existing?.equals(data)) {
483
- const mode = isHookScript ? HOOK_FILE_MODE : HOOK_SUPPORT_FILE_MODE;
484
- writeFileSync(dst, data, { mode });
498
+ // #2980 wave A: containedWrite (mode is applied via chmod below for hooks).
499
+ containedProjectWrite(projectDir, dst, data);
485
500
  filesDeposited = true;
486
501
  }
487
502
  if (platform() !== "win32" && isHookScript) {
@@ -678,7 +693,7 @@ export function ensureGitattributes(projectDir, io) {
678
693
  for (const add of additions) {
679
694
  body += `${add}\n`;
680
695
  }
681
- writeFileSync(path, body, "utf8");
696
+ containedProjectWrite(projectDir, path, body);
682
697
  io.printf(`.gitattributes updated with Deft core markers: ${additions.join(", ")}\n`);
683
698
  return true;
684
699
  }
@@ -721,7 +736,7 @@ export function ensureGreptileIgnore(projectDir, io) {
721
736
  return false;
722
737
  }
723
738
  obj.ignorePatterns = appendGreptilePattern(patterns, CORE_GLOB);
724
- writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
739
+ containedProjectWrite(projectDir, path, `${JSON.stringify(obj, null, 2)}\n`);
725
740
  io.printf(fileExisted
726
741
  ? `greptile.json updated: bot review now ignores ${CORE_GLOB}.\n`
727
742
  : `greptile.json created: bot review ignores ${CORE_GLOB}.\n`);
@@ -770,8 +785,7 @@ function insertCodeqlPathsIgnore(content, glob) {
770
785
  export function ensureCodeqlPathsIgnore(projectDir, io) {
771
786
  const path = projectionTarget(projectDir, CODEQL_CONFIG_REL);
772
787
  if (!existsSync(path)) {
773
- mkdirSync(dirname(path), { recursive: true });
774
- writeFileSync(path, codeqlConfigDefault(), "utf8");
788
+ containedProjectWrite(projectDir, path, codeqlConfigDefault());
775
789
  io.printf(`${CODEQL_CONFIG_REL} created: CodeQL ignores ${CORE_GLOB}.\n`);
776
790
  return true;
777
791
  }
@@ -784,7 +798,7 @@ export function ensureCodeqlPathsIgnore(projectDir, io) {
784
798
  const updated = inserted.ok
785
799
  ? inserted.content
786
800
  : `${existing}${existing.endsWith("\n") ? "" : "\n"}paths-ignore:\n - '${CORE_GLOB}'\n`;
787
- writeFileSync(path, updated, "utf8");
801
+ containedProjectWrite(projectDir, path, updated);
788
802
  io.printf(`${CODEQL_CONFIG_REL} updated: CodeQL now ignores ${CORE_GLOB}.\n`);
789
803
  return true;
790
804
  }
@@ -802,12 +816,11 @@ export function ensureCoreGuardWorkflow(projectDir, io) {
802
816
  io.printf(`${CORE_GUARD_WORKFLOW_REL} already current — skipping.\n`);
803
817
  return false;
804
818
  }
805
- writeFileSync(path, refreshed, "utf8");
819
+ containedProjectWrite(projectDir, path, refreshed);
806
820
  io.printf(`${CORE_GUARD_WORKFLOW_REL} refreshed: deft-core-guard allowlist updated (#1478).\n`);
807
821
  return true;
808
822
  }
809
- mkdirSync(dirname(path), { recursive: true });
810
- writeFileSync(path, desired, "utf8");
823
+ containedProjectWrite(projectDir, path, desired);
811
824
  io.printf(`${CORE_GUARD_WORKFLOW_REL} created: CI refuses PRs mixing ${CORE_GLOB} with app files.\n`);
812
825
  return true;
813
826
  }
@@ -5,9 +5,10 @@
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";
10
- import { assertProjectionContained } from "../fs/projection-containment.js";
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";
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";
13
14
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
@@ -54,7 +55,7 @@ export function rewriteProjectedSchemaContent(content) {
54
55
  * Upstream vbrief/schemas/ may keep legacy paths; consumer copies must not (#2670).
55
56
  */
56
57
  export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationDir) {
57
- assertProjectionContained(projectDir, destinationDir);
58
+ assertDestinationNotSymlink(projectDir, destinationDir);
58
59
  if (!isDirectory(destinationDir))
59
60
  return;
60
61
  for (const rel of collectSchemaFiles(destinationDir)) {
@@ -66,7 +67,8 @@ export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationD
66
67
  }
67
68
  }
68
69
  function writeFileIfChanged(projectDir, target, content) {
69
- assertProjectionContained(projectDir, target);
70
+ // Keep early containment so ProjectionContainmentError type is preserved for callers/tests.
71
+ assertDestinationNotSymlink(projectDir, target);
70
72
  const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
71
73
  try {
72
74
  if (readFileSync(target).equals(desired))
@@ -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
  /**
@@ -91,7 +98,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
91
98
  throw new Error(`cannot project xbrief schemas: framework payload is missing ${CURRENT_CORE_SCHEMA}`);
92
99
  }
93
100
  const destinationDir = join(projectDir, MIGRATED_ARTIFACT_DIR, "schemas");
94
- assertProjectionContained(projectDir, destinationDir);
101
+ assertDestinationNotSymlink(projectDir, destinationDir);
95
102
  mkdirSync(destinationDir, { recursive: true });
96
103
  let changed = false;
97
104
  for (const rel of collectSchemaFiles(sourceDir)) {
@@ -103,7 +110,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
103
110
  changed = writeFileIfChanged(projectDir, destination, projected) || changed;
104
111
  }
105
112
  const obsoleteDestination = join(destinationDir, OBSOLETE_CORE_SCHEMA);
106
- assertProjectionContained(projectDir, obsoleteDestination);
113
+ assertDestinationNotSymlink(projectDir, obsoleteDestination);
107
114
  if (existsSync(obsoleteDestination)) {
108
115
  rmSync(obsoleteDestination, { force: true });
109
116
  changed = true;
@@ -117,7 +124,7 @@ function syncBareVersionMarkerWithPolicy(projectDir, version, allowRootFallback)
117
124
  return false;
118
125
  const canonicalRoot = join(projectDir, MIGRATED_ARTIFACT_DIR);
119
126
  if (existsSync(canonicalRoot)) {
120
- assertProjectionContained(projectDir, join(canonicalRoot, ".deft-version"));
127
+ assertDestinationNotSymlink(projectDir, join(canonicalRoot, ".deft-version"));
121
128
  }
122
129
  let targetDir = projectDir;
123
130
  try {
@@ -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