@deftai/directive-core 0.107.0 → 0.108.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 (167) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/named-cause.js +18 -3
  4. package/dist/codebase/provider.js +7 -1
  5. package/dist/delivery-attempt/handoff.js +1 -1
  6. package/dist/design-critique/citation-grammar.d.ts +53 -0
  7. package/dist/design-critique/citation-grammar.js +303 -0
  8. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  9. package/dist/design-critique/completed-arc-record.js +232 -0
  10. package/dist/freshness/bind.d.ts +32 -3
  11. package/dist/freshness/bind.js +58 -12
  12. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  13. package/dist/hooks/classify/host-session-identity.js +470 -0
  14. package/dist/hooks/classify/index.d.ts +3 -2
  15. package/dist/hooks/classify/index.js +3 -2
  16. package/dist/hooks/classify/paths.d.ts +7 -0
  17. package/dist/hooks/classify/paths.js +35 -0
  18. package/dist/hooks/classify/stdin.d.ts +5 -0
  19. package/dist/hooks/classify/stdin.js +27 -0
  20. package/dist/hooks/dispatcher.d.ts +52 -1
  21. package/dist/hooks/dispatcher.js +538 -45
  22. package/dist/hooks/fixtures/cases.d.ts +16 -2
  23. package/dist/hooks/fixtures/cases.js +175 -0
  24. package/dist/hooks/scope.js +3 -1
  25. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  26. package/dist/init-deposit/agent-hooks.js +8 -1
  27. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  28. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  29. package/dist/intake/clause-derivation.js +9 -3
  30. package/dist/intake/issue-ingest.js +62 -2
  31. package/dist/intake/platform-capabilities.d.ts +9 -2
  32. package/dist/intake/platform-capabilities.js +38 -9
  33. package/dist/orphan-active/evaluate.d.ts +25 -0
  34. package/dist/orphan-active/evaluate.js +193 -128
  35. package/dist/orphan-active/index.d.ts +1 -0
  36. package/dist/orphan-active/index.js +1 -0
  37. package/dist/orphan-active/issue-state.d.ts +121 -0
  38. package/dist/orphan-active/issue-state.js +278 -0
  39. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  40. package/dist/platform/cursor-managed-runtime.js +241 -0
  41. package/dist/platform/index.d.ts +1 -0
  42. package/dist/platform/index.js +1 -0
  43. package/dist/platform/platform-capabilities.d.ts +23 -1
  44. package/dist/platform/platform-capabilities.js +55 -15
  45. package/dist/policy/ceremony-dial.js +5 -9
  46. package/dist/policy/host-hooks.js +4 -9
  47. package/dist/policy/org-force-on-migration.js +5 -10
  48. package/dist/policy/plan-extensions.d.ts +14 -2
  49. package/dist/policy/plan-extensions.js +24 -3
  50. package/dist/policy/product-signal.js +5 -10
  51. package/dist/policy/require-human-merge.js +5 -9
  52. package/dist/policy/resolve.js +77 -16
  53. package/dist/policy/value-feedback.js +9 -18
  54. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  55. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  56. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  57. package/dist/pr-closeout-attestable/index.js +2 -0
  58. package/dist/pr-protected-issues/gh.d.ts +6 -2
  59. package/dist/pr-protected-issues/gh.js +7 -3
  60. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  61. package/dist/pr-wait-mergeable/cascade.js +21 -1
  62. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  63. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  64. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  65. package/dist/preflight/evaluate.d.ts +15 -0
  66. package/dist/preflight/evaluate.js +29 -3
  67. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  68. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  69. package/dist/product-first-done-gate/evaluate.js +17 -7
  70. package/dist/release/cli-drift-report.d.ts +68 -0
  71. package/dist/release/cli-drift-report.js +189 -0
  72. package/dist/release/index.d.ts +1 -0
  73. package/dist/release/index.js +1 -0
  74. package/dist/release/issue-state-fetch.d.ts +5 -3
  75. package/dist/release/issue-state-fetch.js +86 -21
  76. package/dist/release/native-steps.js +1 -0
  77. package/dist/release/pipeline.js +19 -0
  78. package/dist/release/types.d.ts +14 -0
  79. package/dist/render/constants.d.ts +4 -0
  80. package/dist/render/constants.js +11 -8
  81. package/dist/render/export-spec.js +31 -5
  82. package/dist/render/index.d.ts +1 -1
  83. package/dist/render/index.js +1 -1
  84. package/dist/render/prd-render.d.ts +4 -1
  85. package/dist/render/prd-render.js +82 -23
  86. package/dist/render/project-render.js +14 -7
  87. package/dist/render/scope-outlook.d.ts +2 -0
  88. package/dist/render/scope-outlook.js +3 -0
  89. package/dist/render/spec-render.js +2 -2
  90. package/dist/resolution/package-manager.d.ts +33 -4
  91. package/dist/resolution/package-manager.js +210 -9
  92. package/dist/review-monitor/constants.d.ts +7 -0
  93. package/dist/review-monitor/constants.js +7 -0
  94. package/dist/review-monitor/github-lease.d.ts +98 -1
  95. package/dist/review-monitor/github-lease.js +182 -1
  96. package/dist/review-monitor/lease-comment.d.ts +36 -0
  97. package/dist/review-monitor/lease-comment.js +103 -2
  98. package/dist/scm/call.js +12 -3
  99. package/dist/scm/design-critique-chip.d.ts +1 -0
  100. package/dist/scm/design-critique-chip.js +22 -6
  101. package/dist/scm/gh-rest.d.ts +9 -1
  102. package/dist/scm/gh-rest.js +103 -0
  103. package/dist/scm/readiness.d.ts +2 -0
  104. package/dist/scm/readiness.js +28 -4
  105. package/dist/scope/project-definition-sync.js +14 -7
  106. package/dist/session/git.d.ts +24 -0
  107. package/dist/session/git.js +138 -2
  108. package/dist/session/occupancy.d.ts +238 -12
  109. package/dist/session/occupancy.js +815 -29
  110. package/dist/session/ritual-sentinel.d.ts +5 -0
  111. package/dist/session/ritual-sentinel.js +5 -0
  112. package/dist/session/session-ready.d.ts +5 -1
  113. package/dist/session/session-ready.js +98 -8
  114. package/dist/session/session-start.d.ts +5 -1
  115. package/dist/session/session-start.js +32 -17
  116. package/dist/session/toolchain-preflight.d.ts +13 -3
  117. package/dist/session/toolchain-preflight.js +93 -18
  118. package/dist/session/verify-session-ritual.d.ts +11 -0
  119. package/dist/session/verify-session-ritual.js +60 -15
  120. package/dist/slice/constants.d.ts +1 -1
  121. package/dist/spec-authority/constants.d.ts +10 -2
  122. package/dist/spec-authority/constants.js +53 -8
  123. package/dist/spec-authority/resolver.d.ts +3 -0
  124. package/dist/spec-authority/resolver.js +55 -7
  125. package/dist/subprocess/max-buffer.d.ts +14 -0
  126. package/dist/subprocess/max-buffer.js +15 -0
  127. package/dist/swarm/complete-cohort.d.ts +2 -0
  128. package/dist/swarm/complete-cohort.js +21 -13
  129. package/dist/swarm/index.d.ts +1 -0
  130. package/dist/swarm/index.js +1 -0
  131. package/dist/swarm/launch-cli.js +53 -33
  132. package/dist/swarm/launch.d.ts +8 -0
  133. package/dist/swarm/launch.js +63 -43
  134. package/dist/swarm/pre-dispatch-cli.js +2 -1
  135. package/dist/swarm/pre-dispatch.js +10 -1
  136. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  137. package/dist/swarm/subagent-status-dir.js +42 -0
  138. package/dist/swarm/worktrees.d.ts +17 -3
  139. package/dist/swarm/worktrees.js +48 -4
  140. package/dist/triage/scope/mutations-core.d.ts +2 -6
  141. package/dist/triage/scope/mutations-core.js +23 -55
  142. package/dist/triage/scope-drift/add-ignore.js +5 -39
  143. package/dist/triage/subscribe/index.d.ts +2 -6
  144. package/dist/triage/subscribe/index.js +15 -65
  145. package/dist/triage/welcome/writers.js +15 -74
  146. package/dist/vbrief-build/index.d.ts +2 -1
  147. package/dist/vbrief-build/index.js +2 -1
  148. package/dist/vbrief-build/parity-scenarios.js +5 -5
  149. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  150. package/dist/vbrief-build/project-definition-io.js +357 -31
  151. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  152. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  153. package/dist/vbrief-validate/conformance.d.ts +1 -0
  154. package/dist/vbrief-validate/conformance.js +85 -8
  155. package/dist/vbrief-validate/main.js +2 -0
  156. package/dist/vbrief-validate/precutover.js +5 -10
  157. package/dist/verify-ac/clauses.d.ts +62 -2
  158. package/dist/verify-ac/clauses.js +183 -111
  159. package/dist/verify-ac/index.d.ts +1 -1
  160. package/dist/verify-ac/index.js +1 -1
  161. package/dist/verify-env/node-runtime.d.ts +8 -4
  162. package/dist/verify-env/node-runtime.js +9 -6
  163. package/dist/verify-env/toolchain-check.d.ts +35 -3
  164. package/dist/verify-env/toolchain-check.js +155 -36
  165. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  166. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  167. package/package.json +7 -3
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createScopeVbrief, referenceWithDefaultTrust, slugify } from "./build.js";
4
4
  import { pythonJsonPretty } from "./json.js";
5
- import { atomicWriteProjectDefinition, loadProjectDefinitionForMutation, projectDefinitionMutationLock, } from "./project-definition-io.js";
5
+ import { withProjectDefinitionMutation } from "./project-definition-mutation.js";
6
6
  import { buildScopeVbriefFromReconciled, folderForStatus, planStatusMatchesFolder, } from "./routing.js";
7
7
  import { deriveOverviewNarrative, extractTechStack, firstProseParagraph, parseRoadmapItems, resolveRepoUrl, } from "./sources.js";
8
8
  import { createSpeckitScopeVbrief, dependenciesForItem, edgeNodes, migrateSpeckitPlan, speckitIpIndex, speckitIpSlug, } from "./speckit.js";
@@ -209,11 +209,11 @@ export function runParityScenario(name, ctx) {
209
209
  const pdPath = join(vbriefDir, "PROJECT-DEFINITION.xbrief.json");
210
210
  writeFileSync(pdPath, pythonJsonPretty(seed), "utf8");
211
211
  let roundtrip = {};
212
- projectDefinitionMutationLock(ctx.fixtureRoot, () => {
213
- const [data, path] = loadProjectDefinitionForMutation(ctx.fixtureRoot);
212
+ withProjectDefinitionMutation(ctx.fixtureRoot, (mutation) => {
213
+ const data = mutation.load();
214
214
  data.plan.policy = { wipCap: 12 };
215
- atomicWriteProjectDefinition(path, data);
216
- roundtrip = JSON.parse(readFileSync(path, "utf8"));
215
+ mutation.persist(data);
216
+ roundtrip = JSON.parse(readFileSync(mutation.artifactPath, "utf8"));
217
217
  });
218
218
  return { scenario: name, ok: true, payload: roundtrip };
219
219
  }
@@ -1,4 +1,15 @@
1
1
  import type { JsonObject } from "./types.js";
2
+ /** Setup override for a noncanonical PROJECT-DEFINITION path. */
3
+ export declare const ENV_PROJECT_PATH = "DEFT_PROJECT_PATH";
4
+ /**
5
+ * Constant display label for a PROJECT-DEFINITION reached through the
6
+ * `DEFT_PROJECT_PATH` override (#3796). A configured path is operator- or
7
+ * environment-supplied data, so lock and loader diagnostics name this label
8
+ * instead of interpolating the raw path. Matches the label already used by
9
+ * `verify:vbrief-conformance` so one artifact reads the same way on every
10
+ * diagnostic surface.
11
+ */
12
+ export declare const CONFIGURED_PROJECT_DEFINITION_LABEL = "<configured PROJECT-DEFINITION>";
2
13
  /**
3
14
  * Absolute path to the PROJECT-DEFINITION artifact. Layout-aware (#2302):
4
15
  * resolves `xbrief/PROJECT-DEFINITION.xbrief.json` on a migrated tree, else the
@@ -6,14 +17,63 @@ import type { JsonObject } from "./types.js";
6
17
  * name the path that actually applies to the project's layout.
7
18
  */
8
19
  export declare function projectDefinitionPath(projectRoot: string): string;
20
+ /**
21
+ * Control-safe label for an artifact path in a human diagnostic (#3796).
22
+ * A configured artifact collapses to {@link CONFIGURED_PROJECT_DEFINITION_LABEL};
23
+ * a layout-resolved canonical artifact keeps its path because the layout
24
+ * resolver derived it rather than reading it from configuration.
25
+ */
26
+ export declare function projectDefinitionArtifactLabel(artifactPath: string): string;
27
+ /** Result of probing whether a recorded lock owner PID is still running. */
28
+ export type ProcessLiveness = "dead" | "alive" | "unknown";
29
+ /**
30
+ * Why an acquisition attempt could not take the lock. Only `contended` is
31
+ * ordinary traffic; the rest fail closed to manual recovery (#3796).
32
+ */
33
+ export type LockBlockedReason = "contended" | "owner-alive" | "owner-liveness-unknown" | "malformed-lock-directory" | "legacy-file-sidecar";
9
34
  export interface MutationLockDeps {
10
35
  readonly sleepMs?: (ms: number) => void;
11
- readonly now?: () => number;
36
+ /**
37
+ * Monotonic millisecond clock. One budget is derived from a single reading of
38
+ * this clock and shared by every retry and recovery branch (#3796), so a
39
+ * wall-clock step or a recovery detour cannot extend the acquisition window.
40
+ */
41
+ readonly monotonicNowMs?: () => number;
42
+ /** Total acquisition budget in milliseconds. */
43
+ readonly acquisitionBudgetMs?: number;
44
+ /**
45
+ * Three-state liveness oracle. Only `dead` authorises an automatic reap;
46
+ * `alive` (including possible PID reuse) and `unknown` fail closed.
47
+ */
48
+ readonly probeProcess?: (pid: number) => ProcessLiveness;
49
+ readonly writeOwner?: (fd: number, payload: string) => number;
50
+ readonly renameLock?: (source: string, destination: string) => void;
51
+ /** Test seam: fires after the stale owner entry is unlinked, before `rmdir`. */
52
+ readonly beforeLockDirRemove?: (lockPath: string) => void;
12
53
  }
13
- /** Serialise PROJECT-DEFINITION read-modify-write critical sections. */
14
- export declare function projectDefinitionMutationLock<T>(projectRoot: string, fn: () => T, deps?: MutationLockDeps): T;
54
+ /** Acquisition failed within the shared budget. `reason` names the blocker. */
55
+ export declare class ProjectDefinitionLockError extends Error {
56
+ readonly reason: LockBlockedReason;
57
+ constructor(message: string, reason: LockBlockedReason);
58
+ }
59
+ /**
60
+ * Serialise PROJECT-DEFINITION read-modify-write critical sections.
61
+ *
62
+ * Acquisition publishes a fully-materialised, non-empty owner directory by
63
+ * renaming it onto the public lock pathname, so no contender can observe a
64
+ * partial lock. Prefer {@link withProjectDefinitionMutation} in
65
+ * `project-definition-mutation.ts`: it binds load/parse/persist to the captured
66
+ * artifact path so a caller cannot lock one identity and write another (#3796).
67
+ */
68
+ export declare function projectDefinitionMutationLock<T>(projectRoot: string, fn: (artifactPath: string) => T, deps?: MutationLockDeps): T;
15
69
  /** Read PROJECT-DEFINITION.vbrief.json and return ``(data, path)``. */
16
70
  export declare function loadProjectDefinitionForMutation(projectRoot: string): [JsonObject, string];
71
+ /**
72
+ * Load and parse the PROJECT-DEFINITION at an already-captured path (#3796).
73
+ * Diagnostics name the artifact through {@link projectDefinitionArtifactLabel}
74
+ * so a configured path is never interpolated raw.
75
+ */
76
+ export declare function parseProjectDefinitionAt(path: string): JsonObject;
17
77
  /** Atomically write ``data`` to ``path`` as pretty-printed JSON. */
18
78
  export declare function atomicWriteProjectDefinition(path: string, data: JsonObject): void;
19
79
  //# sourceMappingURL=project-definition-io.d.ts.map
@@ -1,11 +1,23 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeSync, } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
+ import { performance } from "node:perf_hooks";
4
5
  import { containedWrite } from "../fs/contained-write.js";
5
6
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
6
7
  import { pythonJsonPretty } from "./json.js";
7
8
  import { ProjectDefinitionIOError } from "./types.js";
8
9
  const mutationThreadLock = { held: false };
10
+ /** Setup override for a noncanonical PROJECT-DEFINITION path. */
11
+ export const ENV_PROJECT_PATH = "DEFT_PROJECT_PATH";
12
+ /**
13
+ * Constant display label for a PROJECT-DEFINITION reached through the
14
+ * `DEFT_PROJECT_PATH` override (#3796). A configured path is operator- or
15
+ * environment-supplied data, so lock and loader diagnostics name this label
16
+ * instead of interpolating the raw path. Matches the label already used by
17
+ * `verify:vbrief-conformance` so one artifact reads the same way on every
18
+ * diagnostic surface.
19
+ */
20
+ export const CONFIGURED_PROJECT_DEFINITION_LABEL = "<configured PROJECT-DEFINITION>";
9
21
  /**
10
22
  * Absolute path to the PROJECT-DEFINITION artifact. Layout-aware (#2302):
11
23
  * resolves `xbrief/PROJECT-DEFINITION.xbrief.json` on a migrated tree, else the
@@ -13,23 +25,209 @@ const mutationThreadLock = { held: false };
13
25
  * name the path that actually applies to the project's layout.
14
26
  */
15
27
  export function projectDefinitionPath(projectRoot) {
28
+ const override = process.env[ENV_PROJECT_PATH]?.trim();
29
+ if (override) {
30
+ const configuredPath = resolve(projectRoot, override);
31
+ return existsSync(configuredPath) ? realpathSync(configuredPath) : configuredPath;
32
+ }
16
33
  return resolveProjectDefinitionPath(resolve(projectRoot));
17
34
  }
35
+ /**
36
+ * Control-safe label for an artifact path in a human diagnostic (#3796).
37
+ * A configured artifact collapses to {@link CONFIGURED_PROJECT_DEFINITION_LABEL};
38
+ * a layout-resolved canonical artifact keeps its path because the layout
39
+ * resolver derived it rather than reading it from configuration.
40
+ */
41
+ export function projectDefinitionArtifactLabel(artifactPath) {
42
+ return process.env[ENV_PROJECT_PATH]?.trim() ? CONFIGURED_PROJECT_DEFINITION_LABEL : artifactPath;
43
+ }
44
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
18
45
  function defaultSleep(ms) {
19
- const end = Date.now() + ms;
20
- while (Date.now() < end) {
21
- /* spin */
46
+ Atomics.wait(sleepCell, 0, 0, ms);
47
+ }
48
+ const DEFAULT_ACQUISITION_BUDGET_MS = 30_000;
49
+ const LOCK_OWNER_ENTRY_RE = /^([1-9]\d*)-([a-f0-9]{32})$/;
50
+ const RENAME_CONTENTION_CODES = new Set([
51
+ "EACCES",
52
+ "EEXIST",
53
+ "EISDIR",
54
+ "ENOTDIR",
55
+ "ENOTEMPTY",
56
+ "EPERM",
57
+ ]);
58
+ /** Acquisition failed within the shared budget. `reason` names the blocker. */
59
+ export class ProjectDefinitionLockError extends Error {
60
+ reason;
61
+ constructor(message, reason) {
62
+ super(message);
63
+ this.name = "ProjectDefinitionLockError";
64
+ this.reason = reason;
65
+ }
66
+ }
67
+ function defaultProbeProcess(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return "alive";
71
+ }
72
+ catch (err) {
73
+ const code = err.code;
74
+ if (code === "ESRCH")
75
+ return "dead";
76
+ // EPERM proves a process holds the PID but not that we may signal it. That
77
+ // is indistinguishable from a live owner, so it is never a reap licence.
78
+ if (code === "EPERM")
79
+ return "alive";
80
+ return "unknown";
81
+ }
82
+ }
83
+ function createAcquisitionBudget(monotonicNowMs, totalMs) {
84
+ const startedAt = monotonicNowMs();
85
+ return {
86
+ expired: () => monotonicNowMs() - startedAt >= totalMs,
87
+ totalMs,
88
+ };
89
+ }
90
+ function lstatIfExists(path) {
91
+ return lstatSync(path, { throwIfNoEntry: false }) ?? null;
92
+ }
93
+ function parseDirectoryLockOwnerEntry(entryName) {
94
+ const match = LOCK_OWNER_ENTRY_RE.exec(entryName);
95
+ if (match === null)
96
+ return null;
97
+ const pid = Number(match[1]);
98
+ if (!Number.isSafeInteger(pid) || pid <= 0 || match[2] === undefined)
99
+ return null;
100
+ return { pid, token: match[2], entryName };
101
+ }
102
+ /**
103
+ * Read the single owner entry of a well-formed lock directory. Any other shape
104
+ * (empty, multiple entries, unparseable name) is deliberately `null` -- the
105
+ * caller must then fail closed rather than guess which entry owns the lock.
106
+ */
107
+ function readDirectoryLockOwner(lockPath) {
108
+ let entries;
109
+ try {
110
+ entries = readdirSync(lockPath);
22
111
  }
112
+ catch (err) {
113
+ if (err.code === "ENOENT")
114
+ return null;
115
+ throw err;
116
+ }
117
+ if (entries.length !== 1)
118
+ return null;
119
+ return parseDirectoryLockOwnerEntry(entries[0] ?? "");
23
120
  }
24
- /** Serialise PROJECT-DEFINITION read-modify-write critical sections. */
121
+ /**
122
+ * Reap a current-version directory lock whose owner is unambiguously dead.
123
+ *
124
+ * The unique owner entry is removed first: exactly one contender can win that
125
+ * unlink, and only the winner may `rmdir`. Because every published lock
126
+ * directory is non-empty at the instant it becomes visible, a delayed `rmdir`
127
+ * from an older generation cannot remove a replacement -- it fails `ENOTEMPTY`.
128
+ *
129
+ * Reaping is automatic only for a `dead` liveness result. `alive` (which cannot
130
+ * be distinguished from PID reuse) and `unknown` fail closed, as does any
131
+ * malformed directory: age is not evidence of safety (#3796).
132
+ */
133
+ function reapDirectoryLock(lockPath, probeProcess, beforeLockDirRemove) {
134
+ const owner = readDirectoryLockOwner(lockPath);
135
+ if (owner === null) {
136
+ return { reaped: false, reason: "malformed-lock-directory" };
137
+ }
138
+ const liveness = probeProcess(owner.pid);
139
+ if (liveness === "alive")
140
+ return { reaped: false, reason: "owner-alive" };
141
+ if (liveness === "unknown")
142
+ return { reaped: false, reason: "owner-liveness-unknown" };
143
+ try {
144
+ unlinkSync(join(lockPath, owner.entryName));
145
+ }
146
+ catch (err) {
147
+ // Another contender won the entry unlink; only that winner may rmdir.
148
+ if (err.code === "ENOENT") {
149
+ return { reaped: false, reason: "contended" };
150
+ }
151
+ throw err;
152
+ }
153
+ beforeLockDirRemove(lockPath);
154
+ try {
155
+ rmdirSync(lockPath);
156
+ return { reaped: true };
157
+ }
158
+ catch (err) {
159
+ const code = err.code;
160
+ if (code === "ENOENT")
161
+ return { reaped: true };
162
+ // A replacement generation was published into this pathname while the
163
+ // rmdir was delayed. It is non-empty, so the rmdir cannot destroy it.
164
+ if (code === "ENOTEMPTY" || code === "EEXIST") {
165
+ return { reaped: false, reason: "contended" };
166
+ }
167
+ throw err;
168
+ }
169
+ }
170
+ function describeLockFailure(artifactLabel, lockPath, reason, budgetMs) {
171
+ // The sidecar name is derived from the artifact name, so naming it would leak
172
+ // a configured filename back into a message that is supposed to be labelled.
173
+ const sidecarName = artifactLabel === CONFIGURED_PROJECT_DEFINITION_LABEL ? ".lock" : basename(lockPath);
174
+ const header = `timed out acquiring the PROJECT-DEFINITION mutation lock for ${artifactLabel} ` +
175
+ `after ${budgetMs}ms (blocked by: ${reason}).`;
176
+ if (reason === "legacy-file-sidecar") {
177
+ return [
178
+ header,
179
+ "A legacy single-file lock sidecar holds the public lock pathname. It is",
180
+ "never removed or moved automatically: releasing that pathname would let a",
181
+ "new waiter enter while the legacy holder's descriptor and critical section",
182
+ "are still live.",
183
+ "Manual recovery:",
184
+ " 1. Stop every legacy deft client that mutates this project.",
185
+ " 2. Confirm no PROJECT-DEFINITION mutation is running.",
186
+ ` 3. Remove the '${sidecarName}' sidecar beside ${artifactLabel}.`,
187
+ ].join("\n");
188
+ }
189
+ if (reason === "malformed-lock-directory" || reason === "owner-liveness-unknown") {
190
+ return [
191
+ header,
192
+ "The lock state cannot be attributed to a known owner, so it is never",
193
+ "recovered automatically -- elapsed time is not evidence that the owner is",
194
+ "gone.",
195
+ "Manual recovery:",
196
+ " 1. Stop every deft client that mutates this project.",
197
+ " 2. Confirm no PROJECT-DEFINITION mutation is running.",
198
+ ` 3. Remove the '${sidecarName}' lock directory beside ${artifactLabel}.`,
199
+ ].join("\n");
200
+ }
201
+ return [
202
+ header,
203
+ "Another PROJECT-DEFINITION mutation still holds the lock. Retry once it",
204
+ "completes; if the owner has gone without releasing, stop all deft clients",
205
+ `and remove the '${sidecarName}' lock directory beside ${artifactLabel}.`,
206
+ ].join("\n");
207
+ }
208
+ /**
209
+ * Serialise PROJECT-DEFINITION read-modify-write critical sections.
210
+ *
211
+ * Acquisition publishes a fully-materialised, non-empty owner directory by
212
+ * renaming it onto the public lock pathname, so no contender can observe a
213
+ * partial lock. Prefer {@link withProjectDefinitionMutation} in
214
+ * `project-definition-mutation.ts`: it binds load/parse/persist to the captured
215
+ * artifact path so a caller cannot lock one identity and write another (#3796).
216
+ */
25
217
  export function projectDefinitionMutationLock(projectRoot, fn, deps = {}) {
26
218
  const sleepMs = deps.sleepMs ?? defaultSleep;
27
- const now = deps.now ?? Date.now;
219
+ const monotonicNowMs = deps.monotonicNowMs ?? (() => performance.now());
220
+ const budgetMs = deps.acquisitionBudgetMs ?? DEFAULT_ACQUISITION_BUDGET_MS;
221
+ const probeProcess = deps.probeProcess ?? defaultProbeProcess;
222
+ const writeOwner = deps.writeOwner ?? writeSync;
223
+ const renameLock = deps.renameLock ?? renameSync;
224
+ const beforeLockDirRemove = deps.beforeLockDirRemove ?? (() => undefined);
28
225
  // Derive the sidecar lock path from the layout-aware resolved PROJECT-DEFINITION
29
226
  // path (xbrief/ when migrated, else vbrief/) so the lock lives next to the real
30
227
  // artifact and every mutator sharing a project root contends on the same lock,
31
228
  // instead of the constant vbrief/ path which would strand a stray lock (#1260).
32
- const path = resolveProjectDefinitionPath(resolve(projectRoot));
229
+ const path = projectDefinitionPath(projectRoot);
230
+ const artifactLabel = projectDefinitionArtifactLabel(path);
33
231
  const lockPath = `${path}.lock`;
34
232
  mkdirSync(dirname(lockPath), { recursive: true });
35
233
  if (mutationThreadLock.held) {
@@ -37,50 +235,178 @@ export function projectDefinitionMutationLock(projectRoot, fn, deps = {}) {
37
235
  }
38
236
  mutationThreadLock.held = true;
39
237
  let fd;
238
+ let ownerEntryPath;
239
+ let ownerToken;
240
+ let preparedEntryPath;
241
+ let preparedLockPath;
242
+ let acquired = false;
40
243
  try {
41
- const deadline = now() + 30_000;
244
+ // Fully materialize owner metadata in a unique sibling directory before
245
+ // publishing it. Renaming the non-empty directory is the exclusive claim: no
246
+ // contender can observe an empty or partial lock, and a stale reaper cannot
247
+ // remove a replacement because replacements are non-empty when they become
248
+ // visible.
249
+ ownerToken = randomBytes(16).toString("hex");
250
+ const ownerEntryName = `${process.pid}-${ownerToken}`;
251
+ const preparedPath = `${lockPath}.claim-${ownerEntryName}`;
252
+ preparedLockPath = preparedPath;
253
+ preparedEntryPath = join(preparedPath, ownerEntryName);
254
+ mkdirSync(preparedPath);
255
+ const payload = `${JSON.stringify({ pid: process.pid, token: ownerToken })}\n`;
256
+ try {
257
+ fd = openSync(preparedEntryPath, "wx");
258
+ const written = writeOwner(fd, payload);
259
+ if (written !== Buffer.byteLength(payload)) {
260
+ throw new Error("short write while recording project definition lock owner");
261
+ }
262
+ closeSync(fd);
263
+ fd = undefined;
264
+ }
265
+ catch (err) {
266
+ if (fd !== undefined) {
267
+ try {
268
+ closeSync(fd);
269
+ }
270
+ catch {
271
+ /* best-effort */
272
+ }
273
+ fd = undefined;
274
+ }
275
+ throw err;
276
+ }
277
+ // One budget, read once from the monotonic clock, shared by every retry and
278
+ // recovery branch below. Callback execution happens after acquisition and is
279
+ // deliberately outside this budget.
280
+ const budget = createAcquisitionBudget(monotonicNowMs, budgetMs);
281
+ // Every blocked branch either yields for a retry or spends the shared budget
282
+ // and reports the blocker it just observed. Keeping that in one place means
283
+ // no branch carries a reason it never reports.
284
+ const yieldOrFail = (reason) => {
285
+ if (budget.expired()) {
286
+ throw new ProjectDefinitionLockError(describeLockFailure(artifactLabel, lockPath, reason, budget.totalMs), reason);
287
+ }
288
+ sleepMs(20);
289
+ };
42
290
  while (true) {
291
+ // Check for a legacy file BEFORE attempting to publish. POSIX `rename`
292
+ // refuses to move a directory onto a file (ENOTDIR), but Windows
293
+ // `MoveFileEx` is called with MOVEFILE_REPLACE_EXISTING and will happily
294
+ // replace the file -- which would destroy the legacy sidecar this
295
+ // protocol exists to preserve. So absence of a non-directory at the
296
+ // public pathname is a precondition of publication, not a side effect of
297
+ // it. (A legacy client that creates the sidecar inside the window between
298
+ // this check and the rename is still a Windows-only residual; nothing in
299
+ // portable Node makes the publish itself conditional on the destination.)
300
+ const existing = lstatIfExists(lockPath);
301
+ if (existing !== null && !existing.isDirectory()) {
302
+ yieldOrFail("legacy-file-sidecar");
303
+ continue;
304
+ }
43
305
  try {
44
- fd = openSync(lockPath, "a+");
45
- const existing = readFileSync(lockPath);
46
- if (existing.length === 0) {
47
- writeSync(fd, Buffer.from("\0"));
48
- }
306
+ renameLock(preparedPath, lockPath);
307
+ acquired = true;
308
+ preparedLockPath = undefined;
309
+ preparedEntryPath = undefined;
310
+ ownerEntryPath = join(lockPath, ownerEntryName);
49
311
  break;
50
312
  }
51
313
  catch (err) {
52
314
  const code = err.code;
53
- if (code !== "EACCES" && code !== "EBUSY") {
315
+ if (code === undefined || !RENAME_CONTENTION_CODES.has(code)) {
54
316
  throw err;
55
317
  }
56
- if (now() > deadline) {
57
- throw err;
318
+ const lockStat = lstatIfExists(lockPath);
319
+ if (lockStat === null) {
320
+ // Windows may surface destination collisions as EACCES/EPERM, but
321
+ // without a destination these are permission failures rather than
322
+ // contention. Propagate them instead of spinning until timeout.
323
+ if (code === "EACCES" || code === "EPERM")
324
+ throw err;
325
+ // The observed owner released between rename and inspection.
326
+ continue;
58
327
  }
59
- sleepMs(20);
328
+ if (lockStat.isDirectory()) {
329
+ const outcome = reapDirectoryLock(lockPath, probeProcess, beforeLockDirRemove);
330
+ if (outcome.reaped)
331
+ continue;
332
+ yieldOrFail(outcome.reason);
333
+ continue;
334
+ }
335
+ // A legacy client took the public lock pathname with a plain file
336
+ // between the pre-check above and this rename. `rename` is atomic name
337
+ // movement, not compare-and-remove: vacating this name -- by unlink or
338
+ // by quarantine -- hands it to a non-cooperating `open(..., "wx")`
339
+ // waiter while the displaced holder's descriptor and critical section
340
+ // stay live. So the file is preserved and recovery is manual (#3796).
341
+ yieldOrFail("legacy-file-sidecar");
60
342
  }
61
343
  }
62
- return fn();
344
+ return fn(path);
63
345
  }
64
346
  finally {
65
- if (fd !== undefined) {
66
- closeSync(fd);
67
- }
68
- mutationThreadLock.held = false;
69
347
  try {
70
- if (existsSync(lockPath)) {
71
- unlinkSync(lockPath);
348
+ if (fd !== undefined) {
349
+ try {
350
+ closeSync(fd);
351
+ }
352
+ catch {
353
+ /* best-effort */
354
+ }
355
+ }
356
+ if (acquired) {
357
+ try {
358
+ if (ownerEntryPath !== undefined && ownerToken !== undefined) {
359
+ const current = readDirectoryLockOwner(lockPath);
360
+ if (current?.token === ownerToken &&
361
+ join(lockPath, current.entryName) === ownerEntryPath) {
362
+ unlinkSync(ownerEntryPath);
363
+ rmdirSync(lockPath);
364
+ }
365
+ }
366
+ }
367
+ catch {
368
+ /* best-effort */
369
+ }
370
+ }
371
+ else {
372
+ // Publication never happened: drop the prepared owner so a crash-free
373
+ // failure path leaves no litter. Surviving litter from a crash stays
374
+ // fail-closed rather than being age-guessed into safety.
375
+ try {
376
+ if (preparedEntryPath !== undefined)
377
+ unlinkSync(preparedEntryPath);
378
+ }
379
+ catch {
380
+ /* best-effort */
381
+ }
382
+ try {
383
+ if (preparedLockPath !== undefined)
384
+ rmdirSync(preparedLockPath);
385
+ }
386
+ catch {
387
+ /* best-effort */
388
+ }
72
389
  }
73
390
  }
74
- catch {
75
- /* best-effort */
391
+ finally {
392
+ mutationThreadLock.held = false;
76
393
  }
77
394
  }
78
395
  }
79
396
  /** Read PROJECT-DEFINITION.vbrief.json and return ``(data, path)``. */
80
397
  export function loadProjectDefinitionForMutation(projectRoot) {
81
398
  const path = projectDefinitionPath(projectRoot);
399
+ return [parseProjectDefinitionAt(path), path];
400
+ }
401
+ /**
402
+ * Load and parse the PROJECT-DEFINITION at an already-captured path (#3796).
403
+ * Diagnostics name the artifact through {@link projectDefinitionArtifactLabel}
404
+ * so a configured path is never interpolated raw.
405
+ */
406
+ export function parseProjectDefinitionAt(path) {
407
+ const label = projectDefinitionArtifactLabel(path);
82
408
  if (!existsSync(path)) {
83
- throw new ProjectDefinitionIOError(`PROJECT-DEFINITION not found at ${path}; run task triage:welcome / ` +
409
+ throw new ProjectDefinitionIOError(`PROJECT-DEFINITION not found at ${label}; run task triage:welcome / ` +
84
410
  "task triage:bootstrap to scaffold one first.");
85
411
  }
86
412
  let raw;
@@ -89,7 +415,7 @@ export function loadProjectDefinitionForMutation(projectRoot) {
89
415
  }
90
416
  catch (err) {
91
417
  const msg = err instanceof Error ? err.message : String(err);
92
- throw new ProjectDefinitionIOError(`Could not read PROJECT-DEFINITION at ${path}: ${msg}`);
418
+ throw new ProjectDefinitionIOError(`Could not read PROJECT-DEFINITION at ${label}: ${msg}`);
93
419
  }
94
420
  let data;
95
421
  try {
@@ -97,12 +423,12 @@ export function loadProjectDefinitionForMutation(projectRoot) {
97
423
  }
98
424
  catch (err) {
99
425
  const msg = err instanceof Error ? err.message : String(err);
100
- throw new ProjectDefinitionIOError(`PROJECT-DEFINITION at ${path} is not valid JSON: ${msg}`);
426
+ throw new ProjectDefinitionIOError(`PROJECT-DEFINITION at ${label} is not valid JSON: ${msg}`);
101
427
  }
102
428
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
103
- throw new ProjectDefinitionIOError(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
429
+ throw new ProjectDefinitionIOError(`PROJECT-DEFINITION at ${label} top-level value is not a JSON object`);
104
430
  }
105
- return [structuredClone(data), path];
431
+ return structuredClone(data);
106
432
  }
107
433
  /** Atomically write ``data`` to ``path`` as pretty-printed JSON. */
108
434
  export function atomicWriteProjectDefinition(path, data) {
@@ -0,0 +1,32 @@
1
+ import { type MutationLockDeps } from "./project-definition-io.js";
2
+ import type { JsonObject } from "./types.js";
3
+ /**
4
+ * Capability handed to a PROJECT-DEFINITION mutation critical section (#3796).
5
+ *
6
+ * The artifact identity is captured once, when the lock is acquired, and every
7
+ * read and write goes through this object. That is what makes "every mutator
8
+ * uses the shared lock" checkable: a caller cannot resolve the path again, so it
9
+ * cannot lock one artifact and then load or persist a different one after a
10
+ * configured path or symlink is retargeted mid-section.
11
+ */
12
+ export interface ProjectDefinitionMutation {
13
+ /** Artifact path captured at lock acquisition. Stable for the section. */
14
+ readonly artifactPath: string;
15
+ /** Control-safe label for this artifact in diagnostics. */
16
+ readonly artifactLabel: string;
17
+ /** Load and parse the captured artifact. Throws `ProjectDefinitionIOError`. */
18
+ load(): JsonObject;
19
+ /** Atomically persist `data` to the captured artifact under containment. */
20
+ persist(data: JsonObject): void;
21
+ }
22
+ /**
23
+ * Run `fn` inside the shared PROJECT-DEFINITION mutation lock with load, parse
24
+ * and persist bound to the artifact identity captured at acquisition.
25
+ *
26
+ * This is the only sanctioned mutation entry point. `verify:*`-adjacent
27
+ * inventory coverage in `project-definition-mutation-inventory.test.ts` fails
28
+ * closed when production code reaches for the raw resolver, the raw lock, or the
29
+ * raw write sink instead (#3796).
30
+ */
31
+ export declare function withProjectDefinitionMutation<T>(projectRoot: string, fn: (mutation: ProjectDefinitionMutation) => T, deps?: MutationLockDeps): T;
32
+ //# sourceMappingURL=project-definition-mutation.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
3
+ import { atomicWriteProjectDefinition, parseProjectDefinitionAt, projectDefinitionArtifactLabel, projectDefinitionMutationLock, } from "./project-definition-io.js";
4
+ /**
5
+ * Containment root for a persist.
6
+ *
7
+ * For an in-tree artifact this is the project root, so a force-added directory
8
+ * symlink anywhere on the way down (`xbrief/` and friends) fails closed before
9
+ * temp+rename -- the #3042 / #3077 guarantee. A `DEFT_PROJECT_PATH` override may
10
+ * legitimately resolve outside the project root, and refusing those writes would
11
+ * be a new restriction rather than a mutation-identity fix, so those fall back to
12
+ * the artifact's own directory and keep the leaf-symlink refusal.
13
+ */
14
+ function containmentRootFor(projectRoot, artifactPath) {
15
+ const rel = relative(projectRoot, resolve(artifactPath));
16
+ const inTree = rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
17
+ return inTree ? projectRoot : dirname(resolve(artifactPath));
18
+ }
19
+ /**
20
+ * Run `fn` inside the shared PROJECT-DEFINITION mutation lock with load, parse
21
+ * and persist bound to the artifact identity captured at acquisition.
22
+ *
23
+ * This is the only sanctioned mutation entry point. `verify:*`-adjacent
24
+ * inventory coverage in `project-definition-mutation-inventory.test.ts` fails
25
+ * closed when production code reaches for the raw resolver, the raw lock, or the
26
+ * raw write sink instead (#3796).
27
+ */
28
+ export function withProjectDefinitionMutation(projectRoot, fn, deps = {}) {
29
+ const root = resolve(projectRoot);
30
+ return projectDefinitionMutationLock(projectRoot, (artifactPath) => {
31
+ const mutation = {
32
+ artifactPath,
33
+ artifactLabel: projectDefinitionArtifactLabel(artifactPath),
34
+ load: () => parseProjectDefinitionAt(artifactPath),
35
+ persist: (data) => {
36
+ assertWriteTargetSafe(containmentRootFor(root, artifactPath), resolve(artifactPath));
37
+ atomicWriteProjectDefinition(artifactPath, data);
38
+ },
39
+ };
40
+ return fn(mutation);
41
+ }, deps);
42
+ }
43
+ //# sourceMappingURL=project-definition-mutation.js.map
@@ -21,5 +21,6 @@ export interface ConformanceEvaluateResult {
21
21
  export declare function evaluateConformance(projectRoot: string, options?: {
22
22
  mode?: ConformanceMode;
23
23
  allowListPath?: string | null;
24
+ projectDefinitionPath?: string | null;
24
25
  }): ConformanceEvaluateResult;
25
26
  //# sourceMappingURL=conformance.d.ts.map