@deftai/directive-core 0.67.0 → 0.68.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 (90) hide show
  1. package/dist/check/index.d.ts +1 -1
  2. package/dist/check/index.js +1 -1
  3. package/dist/check/orchestrator.d.ts +14 -4
  4. package/dist/check/orchestrator.js +30 -6
  5. package/dist/doctor/checks.js +11 -11
  6. package/dist/doctor/constants.d.ts +2 -0
  7. package/dist/doctor/constants.js +2 -0
  8. package/dist/doctor/payload-staleness.js +15 -6
  9. package/dist/forward-coverage/evaluate.d.ts +55 -0
  10. package/dist/forward-coverage/evaluate.js +254 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/init-deposit/constants.d.ts +14 -0
  14. package/dist/init-deposit/constants.js +10 -0
  15. package/dist/init-deposit/hygiene.d.ts +50 -0
  16. package/dist/init-deposit/hygiene.js +176 -0
  17. package/dist/init-deposit/init-deposit.d.ts +1 -0
  18. package/dist/init-deposit/init-deposit.js +7 -1
  19. package/dist/init-deposit/refresh.d.ts +2 -0
  20. package/dist/init-deposit/refresh.js +48 -30
  21. package/dist/init-deposit/scaffold.d.ts +3 -4
  22. package/dist/init-deposit/scaffold.js +3 -24
  23. package/dist/integration-e2e/helpers.d.ts +0 -1
  24. package/dist/integration-e2e/helpers.js +3 -7
  25. package/dist/layout/lifecycle-ref.d.ts +15 -0
  26. package/dist/layout/lifecycle-ref.js +55 -0
  27. package/dist/platform/agents-md.d.ts +28 -0
  28. package/dist/platform/agents-md.js +34 -0
  29. package/dist/policy/resolve.js +56 -46
  30. package/dist/pr-merge-readiness/compute.d.ts +10 -0
  31. package/dist/pr-merge-readiness/compute.js +53 -17
  32. package/dist/pr-merge-readiness/index.d.ts +2 -1
  33. package/dist/pr-merge-readiness/index.js +1 -0
  34. package/dist/pr-merge-readiness/mergeability.d.ts +43 -0
  35. package/dist/pr-merge-readiness/mergeability.js +105 -0
  36. package/dist/pr-monitor/index.d.ts +1 -1
  37. package/dist/pr-monitor/index.js +1 -1
  38. package/dist/pr-monitor/monitor.d.ts +8 -2
  39. package/dist/pr-monitor/monitor.js +35 -6
  40. package/dist/pr-wait-mergeable/wrappers.d.ts +12 -1
  41. package/dist/pr-wait-mergeable/wrappers.js +13 -5
  42. package/dist/pr-watch/constants.d.ts +33 -0
  43. package/dist/pr-watch/constants.js +33 -0
  44. package/dist/pr-watch/index.d.ts +6 -0
  45. package/dist/pr-watch/index.js +6 -0
  46. package/dist/pr-watch/main.d.ts +21 -0
  47. package/dist/pr-watch/main.js +206 -0
  48. package/dist/pr-watch/probe.d.ts +13 -0
  49. package/dist/pr-watch/probe.js +100 -0
  50. package/dist/pr-watch/types.d.ts +47 -0
  51. package/dist/pr-watch/types.js +2 -0
  52. package/dist/pr-watch/watch.d.ts +12 -0
  53. package/dist/pr-watch/watch.js +122 -0
  54. package/dist/render/project-render.js +114 -89
  55. package/dist/scope/capacity-stamp.d.ts +24 -2
  56. package/dist/scope/capacity-stamp.js +80 -28
  57. package/dist/scope/decomposed-refs.js +2 -2
  58. package/dist/scope/project-definition-sync.js +3 -3
  59. package/dist/scope/vbrief-ref.d.ts +2 -1
  60. package/dist/scope/vbrief-ref.js +3 -2
  61. package/dist/spec-authority/constants.d.ts +6 -0
  62. package/dist/spec-authority/constants.js +11 -0
  63. package/dist/spec-authority/resolver.js +3 -3
  64. package/dist/swarm/finalize-cohort-cli.d.ts +5 -0
  65. package/dist/swarm/finalize-cohort-cli.js +100 -0
  66. package/dist/swarm/finalize-cohort.d.ts +39 -0
  67. package/dist/swarm/finalize-cohort.js +589 -0
  68. package/dist/swarm/index.d.ts +2 -0
  69. package/dist/swarm/index.js +2 -0
  70. package/dist/swarm/launch.d.ts +7 -0
  71. package/dist/swarm/launch.js +17 -5
  72. package/dist/triage/queue/index.d.ts +1 -0
  73. package/dist/triage/queue/index.js +1 -0
  74. package/dist/triage/queue/project.d.ts +6 -1
  75. package/dist/triage/queue/project.js +9 -4
  76. package/dist/triage/queue/reconcile-live-state.d.ts +42 -0
  77. package/dist/triage/queue/reconcile-live-state.js +54 -0
  78. package/dist/triage/scope/mutations-core.js +91 -81
  79. package/dist/triage/scope/resolve.js +4 -3
  80. package/dist/triage/scope-drift/add-ignore.js +33 -32
  81. package/dist/triage/scope-drift/scope-rules.js +3 -3
  82. package/dist/triage/subscribe/index.js +51 -45
  83. package/dist/triage/welcome/writers.js +79 -64
  84. package/dist/vbrief-build/project-definition-io.js +8 -3
  85. package/dist/vbrief-validate/paths.js +8 -4
  86. package/dist/vbrief-validate/precutover.d.ts +1 -1
  87. package/dist/vbrief-validate/precutover.js +12 -10
  88. package/package.json +7 -7
  89. package/dist/install-upgrade/index.d.ts +0 -13
  90. package/dist/install-upgrade/index.js +0 -251
@@ -126,17 +126,17 @@ function projectRel(projectRoot, path) {
126
126
  return path.replace(/\\/g, "/");
127
127
  }
128
128
  }
129
- function indexActiveStories(projectRoot) {
130
- const activeDir = resolveLifecycleFolder(projectRoot, "active");
129
+ function indexStoriesInFolder(projectRoot, folder) {
130
+ const dir = resolveLifecycleFolder(projectRoot, folder);
131
131
  const index = [];
132
- if (!existsSync(activeDir)) {
132
+ if (!existsSync(dir)) {
133
133
  return index;
134
134
  }
135
- for (const name of readdirSync(activeDir).sort()) {
135
+ for (const name of readdirSync(dir).sort()) {
136
136
  if (!hasArtifactSuffix(name)) {
137
137
  continue;
138
138
  }
139
- const path = join(activeDir, name);
139
+ const path = join(dir, name);
140
140
  const data = loadJson(path);
141
141
  if (data === null) {
142
142
  continue;
@@ -146,6 +146,18 @@ function indexActiveStories(projectRoot) {
146
146
  }
147
147
  return index;
148
148
  }
149
+ function indexActiveStories(projectRoot) {
150
+ return indexStoriesInFolder(projectRoot, "active");
151
+ }
152
+ /**
153
+ * True when a brief in `xbrief/completed/` (or the legacy `vbrief/completed/`)
154
+ * references the given issue number in its `references[]` / `Traces`. Used by
155
+ * finalize-cohort (#2247) to classify an incidental closing ref to an
156
+ * already-completed issue as a benign skip rather than a hard error.
157
+ */
158
+ export function completedBriefReferencesIssue(projectRoot, issue) {
159
+ return indexStoriesInFolder(projectRoot, "completed").some((s) => s.issues.has(issue));
160
+ }
149
161
  export function looksLikePath(token) {
150
162
  return (token.endsWith(".json") ||
151
163
  token.includes("/") ||
@@ -4,6 +4,7 @@ export * from "./cache.js";
4
4
  export * from "./constants.js";
5
5
  export * from "./derive-group.js";
6
6
  export * from "./ranking-labels.js";
7
+ export * from "./reconcile-live-state.js";
7
8
  export * from "./render.js";
8
9
  export * from "./repo.js";
9
10
  export * from "./scope-ignores-filter.js";
@@ -4,6 +4,7 @@ export * from "./cache.js";
4
4
  export * from "./constants.js";
5
5
  export * from "./derive-group.js";
6
6
  export * from "./ranking-labels.js";
7
+ export * from "./reconcile-live-state.js";
7
8
  export * from "./render.js";
8
9
  export * from "./repo.js";
9
10
  export * from "./scope-ignores-filter.js";
@@ -1,6 +1,11 @@
1
1
  export interface ProjectDefinition {
2
2
  readonly plan?: unknown;
3
3
  }
4
- /** Read vbrief/PROJECT-DEFINITION.vbrief.json. Returns null if absent/invalid. */
4
+ /**
5
+ * Read the layout-resolved PROJECT-DEFINITION artifact (#2207). Resolves the
6
+ * `xbrief/` layout on migrated trees and falls back to `vbrief/` otherwise, so
7
+ * `plan.policy.triageRankingLabels` load correctly after the #2109 rename.
8
+ * Returns null if absent/invalid.
9
+ */
5
10
  export declare function loadProjectDefinition(projectRoot: string): ProjectDefinition | null;
6
11
  //# sourceMappingURL=project.d.ts.map
@@ -1,9 +1,14 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
3
- import { PROJECT_DEFINITION_REL_PATH } from "./constants.js";
4
- /** Read vbrief/PROJECT-DEFINITION.vbrief.json. Returns null if absent/invalid. */
2
+ import { resolve } from "node:path";
3
+ import { resolveProjectDefinitionPath } from "../../layout/resolve.js";
4
+ /**
5
+ * Read the layout-resolved PROJECT-DEFINITION artifact (#2207). Resolves the
6
+ * `xbrief/` layout on migrated trees and falls back to `vbrief/` otherwise, so
7
+ * `plan.policy.triageRankingLabels` load correctly after the #2109 rename.
8
+ * Returns null if absent/invalid.
9
+ */
5
10
  export function loadProjectDefinition(projectRoot) {
6
- const path = join(resolve(projectRoot), PROJECT_DEFINITION_REL_PATH);
11
+ const path = resolveProjectDefinitionPath(resolve(projectRoot));
7
12
  if (!existsSync(path)) {
8
13
  return null;
9
14
  }
@@ -0,0 +1,42 @@
1
+ import type { CachedIssue } from "./types.js";
2
+ /**
3
+ * Resolve the set of issue numbers that are currently OPEN on the live SCM.
4
+ *
5
+ * Returns `null` when the live state could not be determined (offline, gh
6
+ * missing / unauthenticated, REST error). Callers MUST treat `null` as "do not
7
+ * reconcile" and fall back to the cached view -- a transient read failure must
8
+ * never empty the queue.
9
+ */
10
+ export type LiveOpenIssuesReader = (repo: string) => ReadonlySet<number> | null;
11
+ /**
12
+ * Default reader: one batched, paginated REST list (`state=open`, PRs excluded)
13
+ * routed through the scm gh-rest shim. Prefers REST over GraphQL and issues a
14
+ * single logical lookup for the whole open set rather than N per-issue calls.
15
+ * Any failure is swallowed to `null` so the queue degrades to cached rendering.
16
+ *
17
+ * Latency ceiling: no explicit `limit` is passed ON PURPOSE. The reconcile drops
18
+ * any candidate absent from this set, so a truncated page would incorrectly drop
19
+ * genuinely-open issues (a false-negative is worse than the latency). The set is
20
+ * therefore the COMPLETE live-open list, bounded only by `restIssueListPaginated`'s
21
+ * pagination cap (`REST_PAGINATION_MAX_PAGES` * per-page = 100 * 100 = 10,000 open
22
+ * issues); a repo with more open issues than that raises rather than silently
23
+ * truncating. In practice open-issue counts are 10^1-10^2, so this is 1-2 REST
24
+ * pages. Batch here, never per-candidate.
25
+ */
26
+ export declare function defaultLiveOpenIssuesReader(repo: string): ReadonlySet<number> | null;
27
+ /**
28
+ * Reconcile cached candidate issues against live open/closed state before the
29
+ * queue renders (#2238). The cached candidate set records `state`, but that
30
+ * value goes stale as soon as an issue is closed/merged and the cache has not
31
+ * yet refreshed -- so honoring the cached flag alone is insufficient.
32
+ *
33
+ * Drops any candidate whose number is NOT in the live-open set. When the reader
34
+ * returns `null` (state undeterminable) the candidates pass through unchanged so
35
+ * a network / auth failure never silently empties the queue.
36
+ *
37
+ * `reader` defaults to {@link defaultLiveOpenIssuesReader}; tests (and the CLI)
38
+ * inject a stub via this optional parameter -- there is no module-level mutable
39
+ * seam, so nothing test-only leaks into the package's public API.
40
+ */
41
+ export declare function reconcileLiveOpenState(issues: readonly CachedIssue[], repo: string, reader?: LiveOpenIssuesReader): readonly CachedIssue[];
42
+ //# sourceMappingURL=reconcile-live-state.d.ts.map
@@ -0,0 +1,54 @@
1
+ import { restIssueListPaginated } from "../../scm/gh-rest.js";
2
+ /**
3
+ * Default reader: one batched, paginated REST list (`state=open`, PRs excluded)
4
+ * routed through the scm gh-rest shim. Prefers REST over GraphQL and issues a
5
+ * single logical lookup for the whole open set rather than N per-issue calls.
6
+ * Any failure is swallowed to `null` so the queue degrades to cached rendering.
7
+ *
8
+ * Latency ceiling: no explicit `limit` is passed ON PURPOSE. The reconcile drops
9
+ * any candidate absent from this set, so a truncated page would incorrectly drop
10
+ * genuinely-open issues (a false-negative is worse than the latency). The set is
11
+ * therefore the COMPLETE live-open list, bounded only by `restIssueListPaginated`'s
12
+ * pagination cap (`REST_PAGINATION_MAX_PAGES` * per-page = 100 * 100 = 10,000 open
13
+ * issues); a repo with more open issues than that raises rather than silently
14
+ * truncating. In practice open-issue counts are 10^1-10^2, so this is 1-2 REST
15
+ * pages. Batch here, never per-candidate.
16
+ */
17
+ export function defaultLiveOpenIssuesReader(repo) {
18
+ try {
19
+ const rows = restIssueListPaginated(repo, { state: "open", excludePulls: true });
20
+ const open = new Set();
21
+ for (const row of rows) {
22
+ const n = row.number;
23
+ if (typeof n === "number" && Number.isInteger(n)) {
24
+ open.add(n);
25
+ }
26
+ }
27
+ return open;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * Reconcile cached candidate issues against live open/closed state before the
35
+ * queue renders (#2238). The cached candidate set records `state`, but that
36
+ * value goes stale as soon as an issue is closed/merged and the cache has not
37
+ * yet refreshed -- so honoring the cached flag alone is insufficient.
38
+ *
39
+ * Drops any candidate whose number is NOT in the live-open set. When the reader
40
+ * returns `null` (state undeterminable) the candidates pass through unchanged so
41
+ * a network / auth failure never silently empties the queue.
42
+ *
43
+ * `reader` defaults to {@link defaultLiveOpenIssuesReader}; tests (and the CLI)
44
+ * inject a stub via this optional parameter -- there is no module-level mutable
45
+ * seam, so nothing test-only leaks into the package's public API.
46
+ */
47
+ export function reconcileLiveOpenState(issues, repo, reader = defaultLiveOpenIssuesReader) {
48
+ const liveOpen = reader(repo);
49
+ if (liveOpen === null) {
50
+ return issues;
51
+ }
52
+ return issues.filter((issue) => liveOpen.has(issue.number));
53
+ }
54
+ //# sourceMappingURL=reconcile-live-state.js.map
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { resolveEvalPath, resolveProjectDefinitionPath } from "../../layout/resolve.js";
6
6
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY } from "../../policy/plan-extensions.js";
7
+ import { projectDefinitionMutationLock } from "../../vbrief-build/project-definition-io.js";
7
8
  import { SUBSCRIPTION_HISTORY_SCHEMA } from "./constants.js";
8
9
  import { pyStrRepr } from "./python-repr.js";
9
10
  import { utcIso } from "./time.js";
@@ -128,93 +129,102 @@ export function subscribe(projectRoot, options) {
128
129
  if (chosen.length !== 1) {
129
130
  throw new Error(`subscribe() requires exactly one of --label / --milestone / --issue; got ${JSON.stringify(chosen)}`);
130
131
  }
131
- const [data, path] = loadProjectDefinitionForMutation(projectRoot);
132
- const plan = data.plan;
133
- if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
134
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
135
- }
136
- const planRec = plan;
137
- migrateLegacyPolicyKey(planRec);
138
- if (planRec[PLAN_POLICY_KEY] === undefined)
139
- planRec[PLAN_POLICY_KEY] = {};
140
- const policy = planRec[PLAN_POLICY_KEY];
141
- if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
142
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
143
- }
144
- const policyRec = policy;
145
- if (policyRec.triageScope === undefined)
146
- policyRec.triageScope = [];
147
- const rules = policyRec.triageScope;
148
- if (!Array.isArray(rules)) {
149
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScope'`);
150
- }
151
- const before = snapshotRules(rules);
152
- let changed;
153
- let message;
154
- if (options.label !== undefined) {
155
- [changed, message] = applySubscribeLabel(rules, options.label);
156
- }
157
- else if (options.milestone !== undefined) {
158
- [changed, message] = applySubscribeMilestone(rules, options.milestone);
159
- }
160
- else {
161
- throw new Error("subscribe() requires exactly one of label or milestone");
162
- }
163
- if (!changed)
164
- return [false, message];
165
- atomicWriteProjectDefinition(path, data);
166
- recordSubscriptionChange(projectRoot, {
167
- op: "subscribe",
168
- label: options.label ?? null,
169
- milestone: options.milestone ?? null,
170
- before,
171
- after: snapshotRules(rules),
172
- actor: options.actor,
132
+ // Serialise the read-modify-write + subscription-history append under the
133
+ // shared PROJECT-DEFINITION mutation lock so concurrent mutators cannot lose
134
+ // an update or emit out-of-order audit rows (#1260).
135
+ return projectDefinitionMutationLock(projectRoot, () => {
136
+ const [data, path] = loadProjectDefinitionForMutation(projectRoot);
137
+ const plan = data.plan;
138
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
139
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
140
+ }
141
+ const planRec = plan;
142
+ migrateLegacyPolicyKey(planRec);
143
+ if (planRec[PLAN_POLICY_KEY] === undefined)
144
+ planRec[PLAN_POLICY_KEY] = {};
145
+ const policy = planRec[PLAN_POLICY_KEY];
146
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
147
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
148
+ }
149
+ const policyRec = policy;
150
+ if (policyRec.triageScope === undefined)
151
+ policyRec.triageScope = [];
152
+ const rules = policyRec.triageScope;
153
+ if (!Array.isArray(rules)) {
154
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScope'`);
155
+ }
156
+ const before = snapshotRules(rules);
157
+ let changed;
158
+ let message;
159
+ if (options.label !== undefined) {
160
+ [changed, message] = applySubscribeLabel(rules, options.label);
161
+ }
162
+ else if (options.milestone !== undefined) {
163
+ [changed, message] = applySubscribeMilestone(rules, options.milestone);
164
+ }
165
+ else {
166
+ throw new Error("subscribe() requires exactly one of label or milestone");
167
+ }
168
+ if (!changed)
169
+ return [false, message];
170
+ atomicWriteProjectDefinition(path, data);
171
+ recordSubscriptionChange(projectRoot, {
172
+ op: "subscribe",
173
+ label: options.label ?? null,
174
+ milestone: options.milestone ?? null,
175
+ before,
176
+ after: snapshotRules(rules),
177
+ actor: options.actor,
178
+ });
179
+ return [true, message];
173
180
  });
174
- return [true, message];
175
181
  }
176
182
  export function addIgnore(projectRoot, label) {
177
183
  if (!label.trim())
178
184
  throw new Error(`label must be a non-empty string; got ${JSON.stringify(label)}`);
179
- const [data, path] = loadProjectDefinitionForMutation(projectRoot);
180
- const plan = data.plan;
181
- if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
182
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
183
- }
184
- const planRec = plan;
185
- migrateLegacyPolicyKey(planRec);
186
- if (planRec[PLAN_POLICY_KEY] === undefined)
187
- planRec[PLAN_POLICY_KEY] = {};
188
- const policy = planRec[PLAN_POLICY_KEY];
189
- if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
190
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
191
- }
192
- const policyRec = policy;
193
- if (policyRec.triageScopeIgnores === undefined)
194
- policyRec.triageScopeIgnores = [];
195
- const raw = policyRec.triageScopeIgnores;
196
- if (!Array.isArray(raw)) {
197
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScopeIgnores'`);
198
- }
199
- const before = snapshotRules(raw);
200
- for (const entry of raw) {
201
- if (typeof entry === "object" &&
202
- entry !== null &&
203
- !Array.isArray(entry) &&
204
- entry.label === label) {
205
- return [false, `already-ignored (label=${label})`];
185
+ // Serialise the read-modify-write + subscription-history append under the
186
+ // shared PROJECT-DEFINITION mutation lock (#1260).
187
+ return projectDefinitionMutationLock(projectRoot, () => {
188
+ const [data, path] = loadProjectDefinitionForMutation(projectRoot);
189
+ const plan = data.plan;
190
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
191
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
206
192
  }
207
- }
208
- raw.push({ label });
209
- atomicWriteProjectDefinition(path, data);
210
- const after = snapshotRules(raw);
211
- recordSubscriptionChange(projectRoot, {
212
- op: "ignore-label",
213
- label,
214
- before,
215
- after,
216
- actor: null,
193
+ const planRec = plan;
194
+ migrateLegacyPolicyKey(planRec);
195
+ if (planRec[PLAN_POLICY_KEY] === undefined)
196
+ planRec[PLAN_POLICY_KEY] = {};
197
+ const policy = planRec[PLAN_POLICY_KEY];
198
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
199
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
200
+ }
201
+ const policyRec = policy;
202
+ if (policyRec.triageScopeIgnores === undefined)
203
+ policyRec.triageScopeIgnores = [];
204
+ const raw = policyRec.triageScopeIgnores;
205
+ if (!Array.isArray(raw)) {
206
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScopeIgnores'`);
207
+ }
208
+ const before = snapshotRules(raw);
209
+ for (const entry of raw) {
210
+ if (typeof entry === "object" &&
211
+ entry !== null &&
212
+ !Array.isArray(entry) &&
213
+ entry.label === label) {
214
+ return [false, `already-ignored (label=${label})`];
215
+ }
216
+ }
217
+ raw.push({ label });
218
+ atomicWriteProjectDefinition(path, data);
219
+ const after = snapshotRules(raw);
220
+ recordSubscriptionChange(projectRoot, {
221
+ op: "ignore-label",
222
+ label,
223
+ before,
224
+ after,
225
+ actor: null,
226
+ });
227
+ return [true, `added ignore (label=${label})`];
217
228
  });
218
- return [true, `added ignore (label=${label})`];
219
229
  }
220
230
  //# sourceMappingURL=mutations-core.js.map
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { resolve } from "node:path";
3
+ import { resolveProjectDefinitionPath } from "../../layout/resolve.js";
3
4
  import { readPlanPolicy } from "../../policy/plan-extensions.js";
4
- import { DEFAULT_TRIAGE_SCOPE, PROJECT_DEFINITION_REL_PATH } from "./constants.js";
5
+ import { DEFAULT_TRIAGE_SCOPE } from "./constants.js";
5
6
  export function projectDefinitionPath(projectRoot) {
6
- return join(resolve(projectRoot), PROJECT_DEFINITION_REL_PATH);
7
+ return resolveProjectDefinitionPath(resolve(projectRoot));
7
8
  }
8
9
  export function loadProjectDefinition(projectRoot) {
9
10
  const path = projectDefinitionPath(projectRoot);
@@ -1,13 +1,10 @@
1
- import { existsSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
2
  import { dirname, join, resolve } from "node:path";
3
+ import { resolveProjectDefinitionPath } from "../../layout/resolve.js";
4
4
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY } from "../../policy/plan-extensions.js";
5
- const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
6
- function projectDefinitionPath(projectRoot) {
7
- return join(resolve(projectRoot), PROJECT_DEFINITION_REL_PATH);
8
- }
5
+ import { projectDefinitionMutationLock } from "../../vbrief-build/project-definition-io.js";
9
6
  function loadForMutation(projectRoot) {
10
- const path = projectDefinitionPath(projectRoot);
7
+ const path = resolveProjectDefinitionPath(resolve(projectRoot));
11
8
  if (!existsSync(path)) {
12
9
  throw new Error(`PROJECT-DEFINITION not found at ${path}; run task triage:welcome / task triage:bootstrap to scaffold one first.`);
13
10
  }
@@ -49,32 +46,36 @@ export function addIgnore(projectRoot, options) {
49
46
  if (typeof value !== "string" || !value.trim()) {
50
47
  throw new Error(`${key} must be a non-empty string; got ${JSON.stringify(value)}`);
51
48
  }
52
- const [data, path] = loadForMutation(projectRoot);
53
- const plan = data.plan;
54
- if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
55
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
56
- }
57
- const planRec = plan;
58
- migrateLegacyPolicyKey(planRec);
59
- let policy = planRec[PLAN_POLICY_KEY];
60
- if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
61
- policy = {};
62
- planRec[PLAN_POLICY_KEY] = policy;
63
- }
64
- const policyRec = policy;
65
- const raw = Array.isArray(policyRec.triageScopeIgnores)
66
- ? policyRec.triageScopeIgnores
67
- : [];
68
- policyRec.triageScopeIgnores = raw;
69
- for (const entry of raw) {
70
- if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
71
- if (entry[key] === value) {
72
- return { changed: false, message: `already-ignored (${key}=${value})` };
49
+ // Serialise the read-modify-write under the shared PROJECT-DEFINITION
50
+ // mutation lock so concurrent mutators cannot lose an update (#1260).
51
+ return projectDefinitionMutationLock(projectRoot, () => {
52
+ const [data, path] = loadForMutation(projectRoot);
53
+ const plan = data.plan;
54
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
55
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
56
+ }
57
+ const planRec = plan;
58
+ migrateLegacyPolicyKey(planRec);
59
+ let policy = planRec[PLAN_POLICY_KEY];
60
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
61
+ policy = {};
62
+ planRec[PLAN_POLICY_KEY] = policy;
63
+ }
64
+ const policyRec = policy;
65
+ const raw = Array.isArray(policyRec.triageScopeIgnores)
66
+ ? policyRec.triageScopeIgnores
67
+ : [];
68
+ policyRec.triageScopeIgnores = raw;
69
+ for (const entry of raw) {
70
+ if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
71
+ if (entry[key] === value) {
72
+ return { changed: false, message: `already-ignored (${key}=${value})` };
73
+ }
73
74
  }
74
75
  }
75
- }
76
- raw.push({ [key]: value });
77
- atomicWrite(path, data);
78
- return { changed: true, message: `added ignore (${key}=${value})` };
76
+ raw.push({ [key]: value });
77
+ atomicWrite(path, data);
78
+ return { changed: true, message: `added ignore (${key}=${value})` };
79
+ });
79
80
  }
80
81
  //# sourceMappingURL=add-ignore.js.map
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { resolve } from "node:path";
3
+ import { resolveProjectDefinitionPath } from "../../layout/resolve.js";
3
4
  import { readPlanPolicy } from "../../policy/plan-extensions.js";
4
- const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
5
5
  const DEFAULT_TRIAGE_SCOPE = [{ rule: "all-open" }];
6
6
  function loadProjectDefinition(projectRoot) {
7
- const path = join(resolve(projectRoot), PROJECT_DEFINITION_REL_PATH);
7
+ const path = resolveProjectDefinitionPath(resolve(projectRoot));
8
8
  if (!existsSync(path))
9
9
  return null;
10
10
  try {
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlink
3
3
  import { basename, join } from "node:path";
4
4
  import { resolveEvalPath, resolveProjectDefinitionPath } from "../../layout/resolve.js";
5
5
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY } from "../../policy/plan-extensions.js";
6
+ import { projectDefinitionMutationLock } from "../../vbrief-build/project-definition-io.js";
6
7
  export const SUBSCRIPTION_HISTORY_REL_PATH = "vbrief/.eval/subscription-history.jsonl";
7
8
  export const SUBSCRIPTION_HISTORY_SCHEMA = "deft.triage.subscription-change.v1";
8
9
  export const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
@@ -246,57 +247,62 @@ function mutate(projectRoot, options) {
246
247
  if (chosen.length !== 1) {
247
248
  throw new Error(`${options.op}() requires exactly one of --label / --milestone / --issue; got ${JSON.stringify(chosen)}`);
248
249
  }
249
- const [data, path] = loadProjectDefinitionForMutation(projectRoot);
250
- if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
251
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
252
- }
253
- const plan = data.plan;
254
- migrateLegacyPolicyKey(plan);
255
- const existingPolicy = plan[PLAN_POLICY_KEY];
256
- if (typeof existingPolicy !== "object" ||
257
- existingPolicy === null ||
258
- Array.isArray(existingPolicy)) {
259
- if (existingPolicy === undefined) {
260
- plan[PLAN_POLICY_KEY] = {};
250
+ // Serialise the read-modify-write + subscription-history append under the
251
+ // shared PROJECT-DEFINITION mutation lock so concurrent mutators cannot lose
252
+ // an update or emit out-of-order audit rows (#1260).
253
+ return projectDefinitionMutationLock(projectRoot, () => {
254
+ const [data, path] = loadProjectDefinitionForMutation(projectRoot);
255
+ if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
256
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan' key`);
261
257
  }
262
- else {
263
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
258
+ const plan = data.plan;
259
+ migrateLegacyPolicyKey(plan);
260
+ const existingPolicy = plan[PLAN_POLICY_KEY];
261
+ if (typeof existingPolicy !== "object" ||
262
+ existingPolicy === null ||
263
+ Array.isArray(existingPolicy)) {
264
+ if (existingPolicy === undefined) {
265
+ plan[PLAN_POLICY_KEY] = {};
266
+ }
267
+ else {
268
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-object 'plan.policy' key`);
269
+ }
264
270
  }
265
- }
266
- const policy = plan[PLAN_POLICY_KEY];
267
- if (!Array.isArray(policy.triageScope)) {
268
- if (policy.triageScope === undefined) {
269
- policy.triageScope = [];
271
+ const policy = plan[PLAN_POLICY_KEY];
272
+ if (!Array.isArray(policy.triageScope)) {
273
+ if (policy.triageScope === undefined) {
274
+ policy.triageScope = [];
275
+ }
276
+ else {
277
+ throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScope'`);
278
+ }
279
+ }
280
+ const rules = policy.triageScope;
281
+ const before = snapshotRules(rules);
282
+ let changed;
283
+ let message;
284
+ if (options.op === "subscribe") {
285
+ [changed, message] = applySubscribe(rules, options.label, options.milestone, options.issue, options.issueNote ?? "added via task triage:subscribe");
270
286
  }
271
287
  else {
272
- throw new Error(`PROJECT-DEFINITION at ${path} has a non-list 'plan.policy.triageScope'`);
288
+ [changed, message] = applyUnsubscribe(rules, options.label, options.milestone, options.issue);
273
289
  }
274
- }
275
- const rules = policy.triageScope;
276
- const before = snapshotRules(rules);
277
- let changed;
278
- let message;
279
- if (options.op === "subscribe") {
280
- [changed, message] = applySubscribe(rules, options.label, options.milestone, options.issue, options.issueNote ?? "added via task triage:subscribe");
281
- }
282
- else {
283
- [changed, message] = applyUnsubscribe(rules, options.label, options.milestone, options.issue);
284
- }
285
- if (!changed) {
286
- return [false, message];
287
- }
288
- atomicWriteProjectDefinition(path, data);
289
- const after = snapshotRules(rules);
290
- recordSubscriptionChange(projectRoot, {
291
- op: options.op,
292
- label: options.label,
293
- milestone: options.milestone,
294
- issue: options.issue,
295
- before,
296
- after,
297
- actor: options.actor,
290
+ if (!changed) {
291
+ return [false, message];
292
+ }
293
+ atomicWriteProjectDefinition(path, data);
294
+ const after = snapshotRules(rules);
295
+ recordSubscriptionChange(projectRoot, {
296
+ op: options.op,
297
+ label: options.label,
298
+ milestone: options.milestone,
299
+ issue: options.issue,
300
+ before,
301
+ after,
302
+ actor: options.actor,
303
+ });
304
+ return [true, message];
298
305
  });
299
- return [true, message];
300
306
  }
301
307
  export function subscribe(projectRoot, options = {}) {
302
308
  return mutate(projectRoot, { op: "subscribe", ...options });