@deftai/directive-core 0.81.0 → 0.82.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 (51) hide show
  1. package/dist/content-contracts/skills/helpers.d.ts +6 -0
  2. package/dist/content-contracts/skills/helpers.js +47 -3
  3. package/dist/content-contracts/skills/skill-external-fetch-gate.d.ts +18 -0
  4. package/dist/content-contracts/skills/skill-external-fetch-gate.js +81 -0
  5. package/dist/eval/crud-telemetry.d.ts +2 -0
  6. package/dist/eval/crud-telemetry.js +30 -0
  7. package/dist/intake/github-body-cli.js +2 -0
  8. package/dist/intake/github-body.d.ts +11 -0
  9. package/dist/intake/github-body.js +95 -24
  10. package/dist/integration-e2e/helpers.js +1 -1
  11. package/dist/issue-sync/sync-from-xbrief.js +3 -0
  12. package/dist/orphan-active/evaluate.d.ts +25 -0
  13. package/dist/orphan-active/evaluate.js +275 -0
  14. package/dist/orphan-active/index.d.ts +3 -0
  15. package/dist/orphan-active/index.js +3 -0
  16. package/dist/orphan-active/refs.d.ts +16 -0
  17. package/dist/orphan-active/refs.js +105 -0
  18. package/dist/pr-wait-mergeable/cascade.d.ts +8 -0
  19. package/dist/pr-wait-mergeable/cascade.js +18 -0
  20. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  21. package/dist/pr-wait-mergeable/main.js +44 -73
  22. package/dist/pr-wait-mergeable/result.d.ts +2 -1
  23. package/dist/pr-wait-mergeable/result.js +4 -0
  24. package/dist/pr-wait-mergeable/semantic-green.d.ts +27 -0
  25. package/dist/pr-wait-mergeable/semantic-green.js +202 -0
  26. package/dist/pr-wait-mergeable/types.d.ts +1 -0
  27. package/dist/pr-watch/constants.d.ts +4 -4
  28. package/dist/pr-watch/constants.js +4 -4
  29. package/dist/pr-watch/watch.js +5 -3
  30. package/dist/release/native-steps.js +11 -4
  31. package/dist/render/framework-commands.js +6 -0
  32. package/dist/scope/brief-io.d.ts +26 -0
  33. package/dist/scope/brief-io.js +77 -0
  34. package/dist/scope/decompose.js +2 -2
  35. package/dist/scope/decomposed-refs.js +3 -3
  36. package/dist/scope/demote.js +2 -2
  37. package/dist/scope/project-definition-sync.d.ts +2 -2
  38. package/dist/scope/project-definition-sync.js +15 -3
  39. package/dist/scope/registry-artifact-sync.d.ts +6 -1
  40. package/dist/scope/registry-artifact-sync.js +31 -13
  41. package/dist/scope/transition.js +23 -15
  42. package/dist/scope/undo.js +2 -2
  43. package/dist/scope/vbrief-json.d.ts +1 -2
  44. package/dist/scope/vbrief-json.js +1 -4
  45. package/dist/staleness-tickler/state.js +19 -2
  46. package/dist/triage/queue/cache.js +5 -0
  47. package/dist/verify-source/index.d.ts +1 -0
  48. package/dist/verify-source/index.js +1 -0
  49. package/dist/verify-source/skill-external-fetch-gate.d.ts +21 -0
  50. package/dist/verify-source/skill-external-fetch-gate.js +71 -0
  51. package/package.json +7 -3
@@ -11,6 +11,9 @@ export function toResultDict(result) {
11
11
  if (Object.keys(result.protectedCheck).length > 0) {
12
12
  payload.protected_check = result.protectedCheck;
13
13
  }
14
+ if (Object.keys(result.semanticGreen).length > 0) {
15
+ payload.semantic_green = result.semanticGreen;
16
+ }
14
17
  if (result.mergeStdout.length > 0) {
15
18
  payload.merge_stdout = result.mergeStdout;
16
19
  }
@@ -26,6 +29,7 @@ export function makeResult(fields) {
26
29
  return {
27
30
  monitorResult: fields.monitorResult ?? {},
28
31
  protectedCheck: fields.protectedCheck ?? {},
32
+ semanticGreen: fields.semanticGreen ?? {},
29
33
  mergeStdout: fields.mergeStdout ?? "",
30
34
  mergeStderr: fields.mergeStderr ?? "",
31
35
  prNumber: fields.prNumber,
@@ -0,0 +1,27 @@
1
+ import type { RunGhFn } from "../pr-merge-readiness/types.js";
2
+ export interface SemanticGreenOptions {
3
+ /** Enable merge-cascade semantic-green checks (#2385). */
4
+ readonly cascadeMode?: boolean;
5
+ /** After a prior cascade merge, require target-branch CI green at HEAD before the next merge. */
6
+ readonly requireMasterCiGreen?: boolean;
7
+ /** Target branch for stale-base comparison (defaults to PR base.ref). */
8
+ readonly baseBranch?: string | null;
9
+ readonly runGh?: RunGhFn;
10
+ }
11
+ export interface SemanticGreenPayload {
12
+ readonly pr_base_sha: string | null;
13
+ readonly target_branch: string | null;
14
+ readonly target_head_sha: string | null;
15
+ readonly master_ci: Record<string, unknown>;
16
+ }
17
+ export interface SemanticGreenResult {
18
+ readonly ok: boolean;
19
+ readonly outcome: string | null;
20
+ readonly exitCode: number | null;
21
+ readonly error: string | null;
22
+ readonly payload: SemanticGreenPayload;
23
+ }
24
+ /** Pre-merge semantic-green gate for merge cascades (#2385). */
25
+ export declare function evaluateSemanticGreen(prNumber: number, repo: string, options?: SemanticGreenOptions): SemanticGreenResult;
26
+ export type SemanticGreenFn = typeof evaluateSemanticGreen;
27
+ //# sourceMappingURL=semantic-green.d.ts.map
@@ -0,0 +1,202 @@
1
+ import { buildCiSummaryLine, evaluateCiGate } from "../pr-merge-readiness/ci-gate.js";
2
+ import { defaultRunGh, fetchCheckRunsRest } from "../pr-merge-readiness/gh.js";
3
+ import { EXIT_CONFIG_ERROR, EXIT_TIMEOUT_OR_ESCALATION } from "./constants.js";
4
+ function emptyPayload() {
5
+ return {
6
+ pr_base_sha: null,
7
+ target_branch: null,
8
+ target_head_sha: null,
9
+ master_ci: {},
10
+ };
11
+ }
12
+ function fetchPrBase(prNumber, repo, runGh) {
13
+ const rc = runGh(["gh", "api", `repos/${repo}/pulls/${prNumber}`]);
14
+ if (rc.returncode !== 0) {
15
+ return {
16
+ baseRef: null,
17
+ baseSha: null,
18
+ error: `gh api /pulls/${prNumber} failed: ${rc.stderr.trim()}`,
19
+ };
20
+ }
21
+ if (!rc.stdout.trim()) {
22
+ return { baseRef: null, baseSha: null, error: "empty body from gh api /pulls/<N>" };
23
+ }
24
+ let payload;
25
+ try {
26
+ payload = JSON.parse(rc.stdout);
27
+ }
28
+ catch (exc) {
29
+ const message = exc instanceof Error ? exc.message : String(exc);
30
+ return { baseRef: null, baseSha: null, error: `could not parse PR JSON: ${message}` };
31
+ }
32
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
33
+ return { baseRef: null, baseSha: null, error: "unexpected PR JSON shape (not a dict)" };
34
+ }
35
+ const base = payload.base;
36
+ if (base === null || typeof base !== "object" || Array.isArray(base)) {
37
+ return { baseRef: null, baseSha: null, error: "PR JSON missing base object" };
38
+ }
39
+ const baseRecord = base;
40
+ const baseRef = typeof baseRecord.ref === "string" && baseRecord.ref.length > 0 ? baseRecord.ref : null;
41
+ const baseSha = typeof baseRecord.sha === "string" && baseRecord.sha.length > 0 ? baseRecord.sha : null;
42
+ if (baseRef === null || baseSha === null) {
43
+ return { baseRef, baseSha, error: "PR JSON missing base.ref or base.sha" };
44
+ }
45
+ return { baseRef, baseSha, error: null };
46
+ }
47
+ function fetchBranchHeadSha(repo, branch, runGh) {
48
+ const encoded = branch.split("/").map(encodeURIComponent).join("/");
49
+ const rc = runGh(["gh", "api", `repos/${repo}/git/ref/heads/${encoded}`]);
50
+ if (rc.returncode !== 0) {
51
+ return {
52
+ sha: null,
53
+ error: `gh api /git/ref/heads/${branch} failed: ${rc.stderr.trim()}`,
54
+ };
55
+ }
56
+ if (!rc.stdout.trim()) {
57
+ return { sha: null, error: `empty body from gh api /git/ref/heads/${branch}` };
58
+ }
59
+ let payload;
60
+ try {
61
+ payload = JSON.parse(rc.stdout);
62
+ }
63
+ catch (exc) {
64
+ const message = exc instanceof Error ? exc.message : String(exc);
65
+ return { sha: null, error: `could not parse branch ref JSON: ${message}` };
66
+ }
67
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
68
+ return { sha: null, error: "unexpected branch ref JSON shape (not a dict)" };
69
+ }
70
+ const object = payload.object;
71
+ if (object === null || typeof object !== "object" || Array.isArray(object)) {
72
+ return { sha: null, error: "branch ref JSON missing object.sha" };
73
+ }
74
+ const sha = object.sha;
75
+ if (typeof sha !== "string" || sha.length === 0) {
76
+ return { sha: null, error: "branch ref JSON missing object.sha" };
77
+ }
78
+ return { sha, error: null };
79
+ }
80
+ function evaluateTargetBranchCi(repo, headSha, runGh) {
81
+ const check = fetchCheckRunsRest(headSha, repo, runGh);
82
+ if (check.error.length > 0) {
83
+ return {
84
+ ok: false,
85
+ error: "Required CI check-runs on target branch HEAD could not be fetched; " +
86
+ `fail closed (#2385). ${check.error}`,
87
+ masterCi: { ready_state: "blocked", error: check.error },
88
+ };
89
+ }
90
+ const ciResult = evaluateCiGate(check.checkRuns);
91
+ const masterCi = {
92
+ ...ciResult.summary,
93
+ summary_line: buildCiSummaryLine(ciResult.summary),
94
+ };
95
+ if (ciResult.summary.ready_state !== "ready") {
96
+ const detail = ciResult.failures.length > 0
97
+ ? ciResult.failures.join(" ")
98
+ : `target branch CI ready_state=${ciResult.summary.ready_state}`;
99
+ return {
100
+ ok: false,
101
+ error: "Target branch CI is not green at current HEAD; block cascade merge until master is green (#2385). " +
102
+ detail,
103
+ masterCi,
104
+ };
105
+ }
106
+ return { ok: true, error: null, masterCi };
107
+ }
108
+ /** Pre-merge semantic-green gate for merge cascades (#2385). */
109
+ export function evaluateSemanticGreen(prNumber, repo, options = {}) {
110
+ if (options.cascadeMode !== true) {
111
+ return { ok: true, outcome: null, exitCode: null, error: null, payload: emptyPayload() };
112
+ }
113
+ const runGh = options.runGh ?? defaultRunGh;
114
+ const prBase = fetchPrBase(prNumber, repo, runGh);
115
+ if (prBase.error !== null) {
116
+ return {
117
+ ok: false,
118
+ outcome: "config-error",
119
+ exitCode: EXIT_CONFIG_ERROR,
120
+ error: prBase.error,
121
+ payload: {
122
+ ...emptyPayload(),
123
+ pr_base_sha: prBase.baseSha,
124
+ target_branch: prBase.baseRef,
125
+ },
126
+ };
127
+ }
128
+ const targetBranch = options.baseBranch ?? prBase.baseRef;
129
+ if (targetBranch === null || targetBranch.length === 0) {
130
+ return {
131
+ ok: false,
132
+ outcome: "config-error",
133
+ exitCode: EXIT_CONFIG_ERROR,
134
+ error: "Could not resolve target branch for semantic-green gate (--base-branch or PR base.ref).",
135
+ payload: {
136
+ pr_base_sha: prBase.baseSha,
137
+ target_branch: null,
138
+ target_head_sha: null,
139
+ master_ci: {},
140
+ },
141
+ };
142
+ }
143
+ const branchHead = fetchBranchHeadSha(repo, targetBranch, runGh);
144
+ if (branchHead.error !== null) {
145
+ return {
146
+ ok: false,
147
+ outcome: "config-error",
148
+ exitCode: EXIT_CONFIG_ERROR,
149
+ error: branchHead.error,
150
+ payload: {
151
+ pr_base_sha: prBase.baseSha,
152
+ target_branch: targetBranch,
153
+ target_head_sha: null,
154
+ master_ci: {},
155
+ },
156
+ };
157
+ }
158
+ const payload = {
159
+ pr_base_sha: prBase.baseSha,
160
+ target_branch: targetBranch,
161
+ target_head_sha: branchHead.sha,
162
+ master_ci: {},
163
+ };
164
+ if (prBase.baseSha !== branchHead.sha) {
165
+ return {
166
+ ok: false,
167
+ outcome: "semantic-stale-base",
168
+ exitCode: EXIT_TIMEOUT_OR_ESCALATION,
169
+ error: "PR base SHA is behind the current target branch HEAD (merge-tree-clean but semantically stale). " +
170
+ `Rebase/update-branch onto ${targetBranch} at ${branchHead.sha} and wait for fresh green CI before cascade merge (#2385). ` +
171
+ `pr_base=${prBase.baseSha} target_head=${branchHead.sha}`,
172
+ payload,
173
+ };
174
+ }
175
+ if (options.requireMasterCiGreen === true) {
176
+ const ci = evaluateTargetBranchCi(repo, branchHead.sha, runGh);
177
+ if (!ci.ok) {
178
+ return {
179
+ ok: false,
180
+ outcome: "master-ci-not-green",
181
+ exitCode: EXIT_TIMEOUT_OR_ESCALATION,
182
+ error: ci.error,
183
+ payload: {
184
+ ...payload,
185
+ master_ci: ci.masterCi,
186
+ },
187
+ };
188
+ }
189
+ return {
190
+ ok: true,
191
+ outcome: null,
192
+ exitCode: null,
193
+ error: null,
194
+ payload: {
195
+ ...payload,
196
+ master_ci: ci.masterCi,
197
+ },
198
+ };
199
+ }
200
+ return { ok: true, outcome: null, exitCode: null, error: null, payload };
201
+ }
202
+ //# sourceMappingURL=semantic-green.js.map
@@ -5,6 +5,7 @@ export interface WaitMergeableResult {
5
5
  readonly exitCode: number;
6
6
  readonly monitorResult: Record<string, unknown>;
7
7
  readonly protectedCheck: Record<string, unknown>;
8
+ readonly semanticGreen: Record<string, unknown>;
8
9
  readonly mergeStdout: string;
9
10
  readonly mergeStderr: string;
10
11
  readonly error: string | null;
@@ -39,10 +39,10 @@ export declare const DEFAULT_POLL_SECONDS = 90;
39
39
  */
40
40
  export declare const WATCH_HELP: string;
41
41
  /**
42
- * Consecutive polls with a review PRESENT but stuck on a stale (non-HEAD)
43
- * commit before the loop surfaces STALL instead of waiting the full cap. Keeps
44
- * a Greptile review that never re-reviews the new HEAD from silently eating the
45
- * whole 30-minute budget (#1039 STALL terminal).
42
+ * Consecutive polls where the CLEAN gate is wedged on HEAD (!has_blocking &&
43
+ * !is_clean with a holdout other than sha_match) before STALL (#1039). Stale-SHA
44
+ * reads (sha_match holdout) do NOT advance this counter re-review in flight
45
+ * waits until max-wait cap (#2313 / #1259 INCOMPLETE_BUT_RATED).
46
46
  */
47
47
  export declare const DEFAULT_STALL_THRESHOLD = 3;
48
48
  /**
@@ -61,10 +61,10 @@ export const WATCH_HELP = "usage: task pr:watch -- <pr_number> [options]\n" +
61
61
  " 1 NEW_P0_P1 Blocking findings on the current (SHA-matched) review\n" +
62
62
  " 2 ERRORED | STALL | TIMEOUT | CI_BLOCKED | RUNNER_CAPACITY_STALL | config / usage error\n";
63
63
  /**
64
- * Consecutive polls with a review PRESENT but stuck on a stale (non-HEAD)
65
- * commit before the loop surfaces STALL instead of waiting the full cap. Keeps
66
- * a Greptile review that never re-reviews the new HEAD from silently eating the
67
- * whole 30-minute budget (#1039 STALL terminal).
64
+ * Consecutive polls where the CLEAN gate is wedged on HEAD (!has_blocking &&
65
+ * !is_clean with a holdout other than sha_match) before STALL (#1039). Stale-SHA
66
+ * reads (sha_match holdout) do NOT advance this counter re-review in flight
67
+ * waits until max-wait cap (#2313 / #1259 INCOMPLETE_BUT_RATED).
68
68
  */
69
69
  export const DEFAULT_STALL_THRESHOLD = 3;
70
70
  /**
@@ -99,9 +99,11 @@ export function watch(prNumber, repo, options = {}) {
99
99
  if (!oneShot && ciBlockedStreak >= DEFAULT_CI_BLOCKED_THRESHOLD) {
100
100
  return build(VERDICT_CI_BLOCKED, EXIT_TERMINAL_ERROR, probe, poll);
101
101
  }
102
- // Stall = a review IS present but stuck on a non-HEAD commit; surface it
103
- // rather than burning the whole cap waiting for a re-review that isn't coming.
104
- if (probe.found && !probe.shaMatch) {
102
+ // STALL (#1039): wedged CLEAN-gate on HEAD !has_blocking && !is_clean for N
103
+ // consecutive polls with a holdout OTHER than sha_match. Stale-SHA reads
104
+ // (clean_gate_holdout=sha_match) are INCOMPLETE for HEAD per #1259 / #2313 —
105
+ // keep polling until cap, not early STALL while re-review is in flight.
106
+ if (!probe.hasBlocking && !probe.isClean && probe.cleanGateHoldout !== "sha_match") {
105
107
  stallStreak += 1;
106
108
  }
107
109
  else {
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { isTerminalLifecyclePath, reconcile, scanVbriefDir } from "../intake/reconcile-issues.js";
9
+ import { buildLifecycleReport, isTerminalLifecyclePath, scanLifecycleAnchors, } from "../intake/reconcile-issues.js";
10
10
  import { LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleFolder, resolveLifecycleRoot, } from "../layout/resolve.js";
11
11
  import { renderRoadmap } from "../render/roadmap-render.js";
12
12
  import { fetchIssueStatesForRelease } from "./issue-state-fetch.js";
@@ -35,15 +35,22 @@ export function checkVbriefLifecycleSyncNative(projectRoot, repo) {
35
35
  vbriefDir = join(projectRoot, MIGRATED_ARTIFACT_DIR);
36
36
  }
37
37
  try {
38
- const issueToVbriefs = scanVbriefDir(vbriefDir);
39
- const fetchResult = fetchIssueStatesForRelease(repo, new Set(issueToVbriefs.keys()), {
38
+ const anchors = scanLifecycleAnchors(vbriefDir);
39
+ const anchorIssueNumbers = new Set();
40
+ for (const anchor of anchors) {
41
+ const num = anchor.issue_number;
42
+ if (num !== null) {
43
+ anchorIssueNumbers.add(num);
44
+ }
45
+ }
46
+ const fetchResult = fetchIssueStatesForRelease(repo, anchorIssueNumbers, {
40
47
  cwd: projectRoot,
41
48
  });
42
49
  if (!fetchResult.ok) {
43
50
  return [false, -1, fetchResult.reason];
44
51
  }
45
52
  const issueStateMap = fetchResult.states;
46
- const report = reconcile(issueToVbriefs, issueStateMap);
53
+ const report = buildLifecycleReport(anchors, issueStateMap, false);
47
54
  const mismatches = [];
48
55
  for (const entry of report.no_open_issue) {
49
56
  const files = (entry.vbrief_files ?? []);
@@ -82,6 +82,9 @@ export const COMMANDS = {
82
82
  "verify:wip-cap": spec("verify:wip-cap", "preflight_wip_cap:main", {
83
83
  projectRootArg: "--project-root",
84
84
  }),
85
+ "verify:orphan-active": spec("verify:orphan-active", "verify_orphan_active:main", {
86
+ projectRootArg: "--project-root",
87
+ }),
85
88
  "verify:pack-drift": spec("verify:pack-drift", "pack_render:main", {
86
89
  defaultArgs: ["--check"],
87
90
  cwd: "framework",
@@ -102,6 +105,7 @@ export const COMMANDS = {
102
105
  "verify:branch",
103
106
  "verify:cache-fresh",
104
107
  "verify:wip-cap",
108
+ "verify:orphan-active",
105
109
  "vbrief:validate",
106
110
  "verify-strategy-output",
107
111
  ]),
@@ -120,6 +124,7 @@ export const COMMANDS = {
120
124
  "verify:cache-fresh",
121
125
  "verify:pack-drift",
122
126
  "verify:wip-cap",
127
+ "verify:orphan-active",
123
128
  "vbrief:validate",
124
129
  "verify-strategy-output",
125
130
  ]),
@@ -293,6 +298,7 @@ const ENTRYPOINT_VERB = {
293
298
  "verify_no_task_runtime:main": "verify-no-task-runtime",
294
299
  "preflight_cache:main": "preflight-cache",
295
300
  "preflight_wip_cap:main": "verify-wip-cap",
301
+ "verify_orphan_active:main": "verify-orphan-active",
296
302
  "pack_render:main": "pack-render",
297
303
  "validate_strategy_output:main": "validate-strategy-output",
298
304
  "vbrief_validate:main": "vbrief-validate",
@@ -0,0 +1,26 @@
1
+ import type { JsonObject } from "../vbrief-build/types.js";
2
+ export declare class BriefIOError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Canonical brief serialization (single source of truth with vbrief-build). */
6
+ export declare function formatBriefJson(data: unknown): string;
7
+ export type ReadBriefResult = {
8
+ readonly ok: true;
9
+ readonly data: JsonObject;
10
+ } | {
11
+ readonly ok: false;
12
+ readonly message: string;
13
+ };
14
+ /** Read a scope brief for in-memory mutation (parse only; no validation gate on read). */
15
+ export declare function readBriefForMutation(filePath: string): ReadBriefResult;
16
+ /** Fail-closed schema + folder/status validation before persist. */
17
+ export declare function validateBriefForPersist(filePath: string, data: JsonObject, vbriefRoot: string): string | null;
18
+ export type AtomicWriteBriefResult = {
19
+ readonly ok: true;
20
+ } | {
21
+ readonly ok: false;
22
+ readonly message: string;
23
+ };
24
+ /** Validate then atomically persist a brief (temp file + rename). */
25
+ export declare function atomicWriteBrief(filePath: string, data: JsonObject, vbriefRoot: string): AtomicWriteBriefResult;
26
+ //# sourceMappingURL=brief-io.d.ts.map
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { atomicWriteText } from "../cache/io.js";
4
+ import { hasArtifactSuffix } from "../layout/resolve.js";
5
+ import { pythonJsonPretty } from "../vbrief-build/json.js";
6
+ import { validateFolderStatus } from "../vbrief-validate/folder-status.js";
7
+ import { validateVbriefSchema } from "../vbrief-validate/schema.js";
8
+ export class BriefIOError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "BriefIOError";
12
+ }
13
+ }
14
+ /** Canonical brief serialization (single source of truth with vbrief-build). */
15
+ export function formatBriefJson(data) {
16
+ return pythonJsonPretty(data);
17
+ }
18
+ /** Read a scope brief for in-memory mutation (parse only; no validation gate on read). */
19
+ export function readBriefForMutation(filePath) {
20
+ const resolvedPath = resolve(filePath);
21
+ if (!existsSync(resolvedPath)) {
22
+ return { ok: false, message: `File not found: ${resolvedPath}` };
23
+ }
24
+ const basename = resolvedPath.split(/[/\\]/).pop() ?? "";
25
+ if (!hasArtifactSuffix(basename)) {
26
+ return {
27
+ ok: false,
28
+ message: `Not a vBRIEF file (expected .vbrief.json or .xbrief.json): ${basename}`,
29
+ };
30
+ }
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(resolvedPath, "utf8");
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ return { ok: false, message: `Could not read ${resolvedPath}: ${msg}` };
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ }
43
+ catch (err) {
44
+ return { ok: false, message: `Invalid JSON in ${resolvedPath}: ${String(err)}` };
45
+ }
46
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
47
+ return { ok: false, message: `Top-level value in ${resolvedPath} is not a JSON object` };
48
+ }
49
+ return { ok: true, data: parsed };
50
+ }
51
+ /** Fail-closed schema + folder/status validation before persist. */
52
+ export function validateBriefForPersist(filePath, data, vbriefRoot) {
53
+ const errors = [
54
+ ...validateVbriefSchema(data, filePath),
55
+ ...validateFolderStatus(filePath, data, vbriefRoot),
56
+ ];
57
+ if (errors.length === 0) {
58
+ return null;
59
+ }
60
+ return errors.join("; ");
61
+ }
62
+ /** Validate then atomically persist a brief (temp file + rename). */
63
+ export function atomicWriteBrief(filePath, data, vbriefRoot) {
64
+ const validationError = validateBriefForPersist(filePath, data, vbriefRoot);
65
+ if (validationError !== null) {
66
+ return { ok: false, message: validationError };
67
+ }
68
+ try {
69
+ atomicWriteText(filePath, formatBriefJson(data));
70
+ return { ok: true };
71
+ }
72
+ catch (err) {
73
+ const msg = err instanceof Error ? err.message : String(err);
74
+ return { ok: false, message: `Failed to write ${filePath}: ${msg}` };
75
+ }
76
+ }
77
+ //# sourceMappingURL=brief-io.js.map
@@ -12,7 +12,7 @@ import { referenceTypeMatches } from "@deftai/directive-types";
12
12
  import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleRoot, } from "../layout/resolve.js";
13
13
  import { referenceWithDefaultTrust, slugify } from "../vbrief-build/build.js";
14
14
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
15
- import { formatVbriefJson } from "./vbrief-json.js";
15
+ import { formatBriefJson } from "./vbrief-json.js";
16
16
  // ---------------------------------------------------------------------------
17
17
  // Constants
18
18
  // ---------------------------------------------------------------------------
@@ -54,7 +54,7 @@ function loadJson(path) {
54
54
  }
55
55
  function writeJson(path, data) {
56
56
  mkdirSync(dirname(path), { recursive: true });
57
- writeFileSync(path, formatVbriefJson(data), "utf8");
57
+ writeFileSync(path, formatBriefJson(data), "utf8");
58
58
  }
59
59
  // ---------------------------------------------------------------------------
60
60
  // Story quality helpers (ported from _vbrief_story_quality.py)
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, relative, resolve } from "node:path";
3
3
  import { referenceTypeMatches } from "@deftai/directive-types";
4
- import { formatVbriefJson } from "./vbrief-json.js";
4
+ import { formatBriefJson } from "./vbrief-json.js";
5
5
  import { collectChildUris, collectPlanRefs, resolveVbriefRef } from "./vbrief-ref.js";
6
6
  function rewriteOnePlanRef(value, oldParentResolved, newParentRel, vbriefDir) {
7
7
  if (typeof value !== "string" || value.length === 0) {
@@ -53,7 +53,7 @@ function rewriteParentChildReference(parentPath, oldChildResolved, newChildRel,
53
53
  }
54
54
  if (changed) {
55
55
  try {
56
- writeFileSync(parentPath, formatVbriefJson(parentData), "utf8");
56
+ writeFileSync(parentPath, formatBriefJson(parentData), "utf8");
57
57
  }
58
58
  catch {
59
59
  return false;
@@ -127,7 +127,7 @@ function rewriteChildParentReference(childPath, oldParentResolved, newParentRel,
127
127
  }
128
128
  if (changed) {
129
129
  try {
130
- writeFileSync(childPath, formatVbriefJson(childData), "utf8");
130
+ writeFileSync(childPath, formatBriefJson(childData), "utf8");
131
131
  }
132
132
  catch {
133
133
  return false;
@@ -5,7 +5,7 @@ import { hasArtifactSuffix, resolveLifecycleFolder } from "../layout/resolve.js"
5
5
  import { stripTrailingPathSeparators } from "../text/redos-safe.js";
6
6
  import { append, canonicalLogPath, latestForPath, newDecisionId } from "./audit-log.js";
7
7
  import { resolveProjectRoot } from "./project-context.js";
8
- import { formatVbriefJson, utcNowIso } from "./vbrief-json.js";
8
+ import { formatBriefJson, utcNowIso } from "./vbrief-json.js";
9
9
  import { canonicalRelpath } from "./vbrief-ref.js";
10
10
  export const DEFAULT_OLDER_THAN_DAYS = 45;
11
11
  export const SOURCE_FOLDER = "pending";
@@ -85,7 +85,7 @@ export function demoteOne(filePath, projectRoot, reason, options = {}) {
85
85
  const timestamp = utcNowIso(now);
86
86
  planObj.status = TARGET_STATUS;
87
87
  planObj.updated = timestamp;
88
- writeFileSync(resolved, formatVbriefJson(data), "utf8");
88
+ writeFileSync(resolved, formatBriefJson(data), "utf8");
89
89
  const vbriefRoot = dirname(dirname(resolved));
90
90
  const targetDir = join(vbriefRoot, TARGET_FOLDER);
91
91
  mkdirSync(targetDir, { recursive: true });
@@ -1,3 +1,3 @@
1
- /** Best-effort sync of PROJECT-DEFINITION after a lifecycle move (#1527). */
2
- export declare function syncProjectDefinitionAfterScopeMove(scopeData: Record<string, unknown>, oldPath: string, newPath: string, vbriefRoot: string, targetStatus: string): void;
1
+ /** Fail-closed sync of PROJECT-DEFINITION after a lifecycle move (#1527 / #2131). */
2
+ export declare function syncProjectDefinitionAfterScopeMove(scopeData: Record<string, unknown>, oldPath: string, newPath: string, vbriefRoot: string, targetStatus: string): string | null;
3
3
  //# sourceMappingURL=project-definition-sync.d.ts.map
@@ -1,10 +1,22 @@
1
1
  import { dirname, resolve } from "node:path";
2
2
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
3
+ import { atomicWriteProjectDefinition, loadProjectDefinitionForMutation, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
3
4
  import { syncRegistryArtifactAfterScopeMove } from "./registry-artifact-sync.js";
4
- /** Best-effort sync of PROJECT-DEFINITION after a lifecycle move (#1527). */
5
+ /** Fail-closed sync of PROJECT-DEFINITION after a lifecycle move (#1527 / #2131). */
5
6
  export function syncProjectDefinitionAfterScopeMove(scopeData, oldPath, newPath, vbriefRoot, targetStatus) {
6
7
  const projectRoot = dirname(resolve(vbriefRoot));
7
- const projectDefPath = resolveProjectDefinitionPath(projectRoot);
8
- syncRegistryArtifactAfterScopeMove(projectDefPath, scopeData, oldPath, newPath, vbriefRoot, targetStatus);
8
+ try {
9
+ projectDefinitionMutationLock(projectRoot, () => {
10
+ const projectDefPath = resolveProjectDefinitionPath(projectRoot);
11
+ syncRegistryArtifactAfterScopeMove(projectDefPath, scopeData, oldPath, newPath, vbriefRoot, targetStatus, {
12
+ loadForMutation: () => loadProjectDefinitionForMutation(projectRoot),
13
+ persist: (path, data) => atomicWriteProjectDefinition(path, data),
14
+ });
15
+ });
16
+ return null;
17
+ }
18
+ catch (err) {
19
+ return err instanceof Error ? err.message : String(err);
20
+ }
9
21
  }
10
22
  //# sourceMappingURL=project-definition-sync.js.map
@@ -1,3 +1,8 @@
1
+ import type { JsonObject } from "../vbrief-build/types.js";
2
+ export interface RegistryArtifactPersistHooks {
3
+ readonly loadForMutation?: () => [JsonObject, string];
4
+ readonly persist?: (path: string, data: JsonObject) => void;
5
+ }
1
6
  /** Best-effort sync of a registry artifact (PROJECT-DEFINITION, specification, etc.) after a lifecycle move. */
2
- export declare function syncRegistryArtifactAfterScopeMove(registryPath: string, scopeData: Record<string, unknown>, oldPath: string, newPath: string, vbriefRoot: string, targetStatus: string): void;
7
+ export declare function syncRegistryArtifactAfterScopeMove(registryPath: string, scopeData: Record<string, unknown>, oldPath: string, newPath: string, vbriefRoot: string, targetStatus: string, hooks?: RegistryArtifactPersistHooks): void;
3
8
  //# sourceMappingURL=registry-artifact-sync.d.ts.map