@deftai/directive-core 0.91.0 → 0.93.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 (92) hide show
  1. package/dist/cache/fetch.js +3 -2
  2. package/dist/cache/io.d.ts +13 -2
  3. package/dist/cache/io.js +36 -7
  4. package/dist/cache/operations.js +5 -3
  5. package/dist/doctor/index.d.ts +1 -0
  6. package/dist/doctor/index.js +1 -0
  7. package/dist/doctor/main.js +49 -0
  8. package/dist/doctor/openclaw-l2-adapter.d.ts +26 -0
  9. package/dist/doctor/openclaw-l2-adapter.js +199 -0
  10. package/dist/hooks/dispatcher.d.ts +6 -1
  11. package/dist/hooks/dispatcher.js +30 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +2 -0
  14. package/dist/init-deposit/gitignore.js +3 -0
  15. package/dist/init-deposit/hygiene.d.ts +43 -1
  16. package/dist/init-deposit/hygiene.js +134 -10
  17. package/dist/init-deposit/index.d.ts +3 -0
  18. package/dist/init-deposit/index.js +3 -0
  19. package/dist/init-deposit/init-deposit.js +10 -0
  20. package/dist/init-deposit/refresh.js +11 -0
  21. package/dist/init-deposit/scaffold.js +6 -134
  22. package/dist/init-deposit/skill-discovery-deposit.d.ts +65 -0
  23. package/dist/init-deposit/skill-discovery-deposit.js +287 -0
  24. package/dist/init-deposit/skill-discovery-hosts.d.ts +94 -0
  25. package/dist/init-deposit/skill-discovery-hosts.js +217 -0
  26. package/dist/init-deposit/slash-deposit.d.ts +46 -0
  27. package/dist/init-deposit/slash-deposit.js +174 -0
  28. package/dist/orchestration/probe-session.d.ts +5 -1
  29. package/dist/orchestration/probe-session.js +24 -13
  30. package/dist/platform/agents-md.js +1 -1
  31. package/dist/policy/deft-directive-disable.d.ts +86 -0
  32. package/dist/policy/deft-directive-disable.js +167 -0
  33. package/dist/policy/delivery-branch.d.ts +33 -0
  34. package/dist/policy/delivery-branch.js +124 -0
  35. package/dist/policy/host-slash-commands.d.ts +28 -0
  36. package/dist/policy/host-slash-commands.js +103 -0
  37. package/dist/policy/index.d.ts +3 -0
  38. package/dist/policy/index.js +61 -5
  39. package/dist/scope/brief-io.d.ts +3 -1
  40. package/dist/scope/brief-io.js +5 -3
  41. package/dist/scope/delivery-evidence.d.ts +112 -0
  42. package/dist/scope/delivery-evidence.js +419 -0
  43. package/dist/scope/index.d.ts +1 -0
  44. package/dist/scope/index.js +1 -0
  45. package/dist/scope/main.d.ts +5 -0
  46. package/dist/scope/main.js +98 -3
  47. package/dist/scope/registry-artifact-sync.js +4 -1
  48. package/dist/scope/transition.d.ts +11 -1
  49. package/dist/scope/transition.js +30 -4
  50. package/dist/session/ritual-sentinel.js +21 -12
  51. package/dist/session/session-start-hook.d.ts +3 -0
  52. package/dist/session/session-start-hook.js +21 -0
  53. package/dist/session/session-start.js +31 -0
  54. package/dist/slash/emitters.d.ts +102 -0
  55. package/dist/slash/emitters.js +148 -0
  56. package/dist/slash/generator.d.ts +98 -0
  57. package/dist/slash/generator.js +145 -0
  58. package/dist/slash/index.d.ts +16 -0
  59. package/dist/slash/index.js +16 -0
  60. package/dist/slash/openclaw-adapter.d.ts +64 -0
  61. package/dist/slash/openclaw-adapter.js +198 -0
  62. package/dist/slash/openclaw-deposit.d.ts +73 -0
  63. package/dist/slash/openclaw-deposit.js +279 -0
  64. package/dist/slash/openclaw-slugs.d.ts +52 -0
  65. package/dist/slash/openclaw-slugs.js +126 -0
  66. package/dist/slash/product-set.d.ts +50 -0
  67. package/dist/slash/product-set.js +142 -0
  68. package/dist/swarm/complete-cohort.d.ts +20 -1
  69. package/dist/swarm/complete-cohort.js +56 -9
  70. package/dist/swarm/finalize-cohort-cli.js +9 -0
  71. package/dist/swarm/finalize-cohort.d.ts +8 -0
  72. package/dist/swarm/finalize-cohort.js +217 -21
  73. package/dist/user-config/experimental-rules.d.ts +43 -0
  74. package/dist/user-config/experimental-rules.js +162 -0
  75. package/dist/user-config/index.d.ts +1 -0
  76. package/dist/user-config/index.js +1 -0
  77. package/dist/vbrief-validate/plan-hooks.d.ts +4 -0
  78. package/dist/vbrief-validate/plan-hooks.js +50 -0
  79. package/dist/verify-source/contained-writes.js +1 -1
  80. package/dist/xbrief/create.d.ts +36 -0
  81. package/dist/xbrief/create.js +285 -0
  82. package/dist/xbrief/index.d.ts +14 -0
  83. package/dist/xbrief/index.js +42 -0
  84. package/dist/xbrief/paths.d.ts +37 -0
  85. package/dist/xbrief/paths.js +123 -0
  86. package/dist/xbrief/styles.d.ts +36 -0
  87. package/dist/xbrief/styles.js +235 -0
  88. package/dist/xbrief/types.d.ts +50 -0
  89. package/dist/xbrief/types.js +17 -0
  90. package/dist/xbrief/verify.d.ts +30 -0
  91. package/dist/xbrief/verify.js +251 -0
  92. package/package.json +11 -3
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
4
4
  import { detectLifecycleFolder } from "../scope/decomposed-refs.js";
5
+ import { classifyStoredDeliveryDisposition, } from "../scope/delivery-evidence.js";
5
6
  import { runTransition } from "../scope/transition.js";
6
7
  import { collectChildUris, collectPlanRefs, resolveVbriefRef } from "../scope/vbrief-ref.js";
7
8
  import { MAX_FIXPOINT_PASSES, TERMINAL_FOLDERS } from "./constants.js";
@@ -115,17 +116,38 @@ function parentCandidatesFrom(plan, vbriefDir) {
115
116
  }
116
117
  return out;
117
118
  }
118
- function completeStory(storyPath, vbriefDir, projectRoot, settled, dryRun) {
119
+ function transitionOptionsFor(storyPath, delivery) {
120
+ if (delivery === null || delivery === undefined) {
121
+ return { verifier: "swarm:complete-cohort" };
122
+ }
123
+ const pathKey = resolve(storyPath);
124
+ const evidence = delivery.evidenceByPath?.get(pathKey) ??
125
+ delivery.evidenceByPath?.get(storyPath) ??
126
+ delivery.defaultEvidence ??
127
+ null;
128
+ return {
129
+ deliveryEvidence: evidence,
130
+ nonDeliveryDisposition: delivery.nonDeliveryDisposition,
131
+ assumeEvidenceValidated: delivery.assumeEvidenceValidated,
132
+ verifier: delivery.verifier ?? "swarm:complete-cohort",
133
+ };
134
+ }
135
+ function completeStory(args) {
136
+ const { storyPath, vbriefDir, projectRoot, settled, dryRun, delivery } = args;
119
137
  const folder = detectLifecycleFolder(storyPath);
120
138
  const relpath = rel(storyPath, projectRoot);
121
139
  if (folder !== null && TERMINAL_FOLDERS.includes(folder)) {
122
140
  settled.add(resolve(storyPath));
141
+ const plan = loadPlan(storyPath);
142
+ const disposition = folder === "completed" && plan !== null ? classifyStoredDeliveryDisposition(plan) : "unknown";
123
143
  return {
124
144
  kind: "story",
125
145
  path: relpath,
126
146
  action: "noop",
127
147
  ok: true,
128
- detail: `already in ${folder}/`,
148
+ detail: folder === "completed"
149
+ ? `already in ${folder}/ (deliveryDisposition=${disposition})`
150
+ : `already in ${folder}/`,
129
151
  };
130
152
  }
131
153
  if (folder !== "active") {
@@ -147,7 +169,7 @@ function completeStory(storyPath, vbriefDir, projectRoot, settled, dryRun) {
147
169
  detail: "would complete active/ -> completed/",
148
170
  };
149
171
  }
150
- const result = runTransition("complete", storyPath);
172
+ const result = runTransition("complete", storyPath, new Date(), transitionOptionsFor(storyPath, delivery));
151
173
  if (result.ok) {
152
174
  settled.add(resolve(join(vbriefDir, "completed", storyPath.split(/[/\\]/).pop() ?? "")));
153
175
  }
@@ -159,7 +181,8 @@ function completeStory(storyPath, vbriefDir, projectRoot, settled, dryRun) {
159
181
  detail: result.message,
160
182
  };
161
183
  }
162
- function completeParent(parentPath, vbriefDir, projectRoot, settled, dryRun) {
184
+ function completeParent(args) {
185
+ const { parentPath, vbriefDir, projectRoot, settled, dryRun, delivery } = args;
163
186
  const folder = detectLifecycleFolder(parentPath);
164
187
  const relpath = rel(parentPath, projectRoot);
165
188
  if (folder !== null && TERMINAL_FOLDERS.includes(folder)) {
@@ -217,7 +240,7 @@ function completeParent(parentPath, vbriefDir, projectRoot, settled, dryRun) {
217
240
  }
218
241
  current = join(vbriefDir, "active", parentPath.split(/[/\\]/).pop() ?? "");
219
242
  }
220
- const completeResult = runTransition("complete", current);
243
+ const completeResult = runTransition("complete", current, new Date(), transitionOptionsFor(current, delivery));
221
244
  if (completeResult.ok) {
222
245
  settled.add(resolve(join(vbriefDir, "completed", parentPath.split(/[/\\]/).pop() ?? "")));
223
246
  }
@@ -229,7 +252,12 @@ function completeParent(parentPath, vbriefDir, projectRoot, settled, dryRun) {
229
252
  detail: completeResult.message,
230
253
  };
231
254
  }
232
- export function sweepCohort(storyPaths, projectRoot, dryRun) {
255
+ export function sweepCohort(storyPaths, projectRoot, dryRun, delivery) {
256
+ return sweepCohortWithArgs({ storyPaths, projectRoot, dryRun, delivery: delivery ?? null });
257
+ }
258
+ function sweepCohortWithArgs(args) {
259
+ const { storyPaths, projectRoot, dryRun } = args;
260
+ const delivery = args.delivery ?? null;
233
261
  let vbriefDir;
234
262
  try {
235
263
  vbriefDir = resolveLifecycleRoot(projectRoot);
@@ -275,7 +303,14 @@ export function sweepCohort(storyPaths, projectRoot, dryRun) {
275
303
  }
276
304
  }
277
305
  }
278
- result.stories.push(completeStory(storyPath, vbriefDir, projectRoot, settled, dryRun));
306
+ result.stories.push(completeStory({
307
+ storyPath,
308
+ vbriefDir,
309
+ projectRoot,
310
+ settled,
311
+ dryRun,
312
+ delivery,
313
+ }));
279
314
  }
280
315
  const finalized = new Set();
281
316
  let passes = 0;
@@ -294,7 +329,14 @@ export function sweepCohort(storyPaths, projectRoot, dryRun) {
294
329
  if (!allChildrenSettled(parentPlan, vbriefDir, settled, dryRun)) {
295
330
  continue;
296
331
  }
297
- const record = completeParent(candidate, vbriefDir, projectRoot, settled, dryRun);
332
+ const record = completeParent({
333
+ parentPath: candidate,
334
+ vbriefDir,
335
+ projectRoot,
336
+ settled,
337
+ dryRun,
338
+ delivery,
339
+ });
298
340
  result.parents.push(record);
299
341
  finalized.add(candidate);
300
342
  progressed = true;
@@ -403,7 +445,12 @@ export function completeCohort(args) {
403
445
  }
404
446
  return { exitCode: 2, stdout: "", stderr };
405
447
  }
406
- const result = sweepCohort(paths, projectRoot, args.dryRun ?? false);
448
+ const result = sweepCohortWithArgs({
449
+ storyPaths: paths,
450
+ projectRoot,
451
+ dryRun: args.dryRun ?? false,
452
+ delivery: args.delivery ?? null,
453
+ });
407
454
  result.errors.push(...errors);
408
455
  if (args.emitJson) {
409
456
  return {
@@ -7,6 +7,7 @@ export function parseFinalizeCohortArgv(argv) {
7
7
  let repo = null;
8
8
  let projectRoot = ".";
9
9
  let baseBranch = "master";
10
+ let deliveryBranch = null;
10
11
  let label = null;
11
12
  let dryRun = false;
12
13
  let noCommit = false;
@@ -51,6 +52,13 @@ export function parseFinalizeCohortArgv(argv) {
51
52
  baseBranch = next;
52
53
  i += 1;
53
54
  }
55
+ else if (arg === "--delivery-branch" && next !== undefined) {
56
+ deliveryBranch = next;
57
+ i += 1;
58
+ }
59
+ else if (arg?.startsWith("--delivery-branch=")) {
60
+ deliveryBranch = arg.slice("--delivery-branch=".length);
61
+ }
54
62
  else if (arg === "--label" && next !== undefined) {
55
63
  label = next;
56
64
  i += 1;
@@ -77,6 +85,7 @@ export function parseFinalizeCohortArgv(argv) {
77
85
  repo,
78
86
  projectRoot,
79
87
  baseBranch,
88
+ deliveryBranch,
80
89
  label,
81
90
  dryRun,
82
91
  noCommit,
@@ -12,6 +12,8 @@ export interface FinalizeCohortResult {
12
12
  readonly commit_sha: string | null;
13
13
  readonly branch: string | null;
14
14
  readonly pr_url: string | null;
15
+ readonly delivery_branch: string | null;
16
+ readonly delivery_errors: readonly string[];
15
17
  readonly errors: readonly string[];
16
18
  readonly warnings: readonly string[];
17
19
  readonly ok: boolean;
@@ -21,7 +23,13 @@ export interface FinalizeCohortArgs {
21
23
  readonly storyTokens?: readonly string[];
22
24
  readonly repo?: string | null;
23
25
  readonly projectRoot?: string;
26
+ /**
27
+ * Swarm/PR base for the post-merge lifecycle sweep PR only.
28
+ * Distinct from plan.policy.deliveryBranch (#3041).
29
+ */
24
30
  readonly baseBranch?: string;
31
+ /** Override delivery branch for this run (defaults to policy/git default). */
32
+ readonly deliveryBranch?: string | null;
25
33
  readonly label?: string | null;
26
34
  readonly dryRun?: boolean;
27
35
  readonly noCommit?: boolean;
@@ -1,8 +1,11 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { evaluate as evaluateBranchPolicy } from "../branch/evaluate.js";
4
+ import { extractIssueRef } from "../capacity/backfill.js";
4
5
  import { resolveLifecycleRoot } from "../layout/resolve.js";
6
+ import { resolveDeliveryBranch } from "../policy/delivery-branch.js";
5
7
  import { defaultRunGh, fetchClosingIssuesReferences } from "../pr-protected-issues/gh.js";
8
+ import { classifyStoredDeliveryDisposition, evidenceFromPrPayload, verifyDeliveryAncestry, } from "../scope/delivery-evidence.js";
6
9
  import { completeCohort } from "./complete-cohort.js";
7
10
  import { EXIT_CONFIG_ERROR, EXIT_GATE_FAILED, EXIT_OK } from "./constants.js";
8
11
  import { completedBriefReferencesIssue, resolveStories } from "./launch.js";
@@ -55,45 +58,73 @@ function parseRepo(repo) {
55
58
  }
56
59
  return { owner: repo.slice(0, slash), name: repo.slice(slash + 1) };
57
60
  }
58
- function fetchPrMergedAt(prNumber, repo, runGh) {
61
+ function fetchPrDeliverySnapshot(prNumber, repo, deliveryBranch, runGh) {
59
62
  const parsed = parseRepo(repo);
60
63
  if (parsed === null) {
61
- return { mergedAt: null, error: `invalid --repo value: ${JSON.stringify(repo)}` };
64
+ return { snapshot: null, error: `invalid --repo value: ${JSON.stringify(repo)}` };
62
65
  }
63
66
  const path = `repos/${parsed.owner}/${parsed.name}/pulls/${prNumber}`;
64
67
  const result = runGh(["gh", "api", path]);
65
68
  if (result.returncode !== 0) {
66
69
  return {
67
- mergedAt: null,
70
+ snapshot: null,
68
71
  error: `gh api ${path} failed: ${result.stderr.trim() || result.stdout.trim()}`,
69
72
  };
70
73
  }
71
74
  try {
72
- const parsed = JSON.parse(result.stdout);
73
- if (parsed === null || typeof parsed !== "object") {
75
+ const body = JSON.parse(result.stdout);
76
+ if (body === null || typeof body !== "object") {
74
77
  return {
75
- mergedAt: null,
78
+ snapshot: null,
76
79
  error: `unexpected gh api response for PR #${prNumber}: not a JSON object`,
77
80
  };
78
81
  }
79
- const payload = parsed;
82
+ const payload = body;
80
83
  const mergedAt = payload.merged_at;
81
- if (mergedAt === null) {
82
- return { mergedAt: null, error: `PR #${prNumber} is not merged yet.` };
84
+ if (mergedAt === null || typeof mergedAt !== "string" || mergedAt.length === 0) {
85
+ return { snapshot: null, error: `PR #${prNumber} is not merged yet.` };
83
86
  }
84
- if (typeof mergedAt !== "string" || mergedAt.length === 0) {
85
- return { mergedAt: null, error: `PR #${prNumber} is not merged yet.` };
86
- }
87
- return { mergedAt, error: null };
87
+ const base = payload.base;
88
+ const prBase = typeof base === "object" &&
89
+ base !== null &&
90
+ !Array.isArray(base) &&
91
+ typeof base.ref === "string"
92
+ ? String(base.ref)
93
+ : null;
94
+ const mergeCommitSha = typeof payload.merge_commit_sha === "string" && payload.merge_commit_sha.length > 0
95
+ ? payload.merge_commit_sha
96
+ : null;
97
+ const evidence = evidenceFromPrPayload(payload, prNumber, repo, deliveryBranch);
98
+ return {
99
+ snapshot: {
100
+ mergedAt,
101
+ prBase,
102
+ mergeCommitSha,
103
+ payload,
104
+ evidence,
105
+ },
106
+ error: null,
107
+ };
88
108
  }
89
109
  catch (exc) {
90
110
  const message = exc instanceof Error ? exc.message : String(exc);
91
111
  return {
92
- mergedAt: null,
112
+ snapshot: null,
93
113
  error: `failed to parse gh api response for PR #${prNumber}: ${message}`,
94
114
  };
95
115
  }
96
116
  }
117
+ /** Adapt swarm runText to the session GitRunner shape used by delivery-evidence. */
118
+ function asGitRunner(runGit) {
119
+ return (projectRoot, args) => {
120
+ const result = runGit(["git", ...args], { cwd: projectRoot });
121
+ return {
122
+ code: result.returncode,
123
+ stdout: result.stdout,
124
+ stderr: result.stderr,
125
+ };
126
+ };
127
+ }
97
128
  function fetchClosingIssues(prNumber, repo, runGh) {
98
129
  const parsed = parseRepo(repo);
99
130
  if (parsed === null) {
@@ -280,14 +311,34 @@ export function finalizeCohort(args) {
280
311
  const projectRoot = resolve(args.projectRoot ?? process.cwd());
281
312
  const runGh = args.runGh ?? defaultRunGh;
282
313
  const runGit = args.runGit ?? runText;
314
+ const gitRunner = asGitRunner(runGit);
283
315
  const prNumbers = [...(args.prNumbers ?? [])].sort((a, b) => a - b);
284
316
  const storyTokens = splitCsv(args.storyTokens ?? []);
285
317
  const repo = args.repo ?? process.env.GH_REPO ?? null;
318
+ // baseBranch is for the lifecycle sweep PR only — not delivery proof (#3041).
286
319
  const baseBranch = args.baseBranch ?? "master";
287
320
  const dryRun = args.dryRun ?? false;
288
321
  const noCommit = args.noCommit ?? false;
289
322
  const noOpenPr = args.noOpenPr ?? false;
290
323
  const errors = [];
324
+ const deliveryErrors = [];
325
+ // plan.policy.deliveryBranch (or git default) is SoT. CLI may only fill when policy is
326
+ // not typed — never redefine a typed delivery branch to an integration target (#3041).
327
+ const policyDelivery = resolveDeliveryBranch(projectRoot, gitRunner);
328
+ const cliDelivery = args.deliveryBranch !== null &&
329
+ args.deliveryBranch !== undefined &&
330
+ args.deliveryBranch.trim().length > 0
331
+ ? args.deliveryBranch.trim()
332
+ : null;
333
+ if (cliDelivery !== null && cliDelivery !== policyDelivery.branch) {
334
+ if (policyDelivery.source === "typed") {
335
+ errors.push(`--delivery-branch '${cliDelivery}' conflicts with plan.policy.deliveryBranch ` +
336
+ `'${policyDelivery.branch}'. Typed policy wins; do not redefine delivery via CLI (#3041).`);
337
+ }
338
+ }
339
+ const deliveryBranch = policyDelivery.source === "typed"
340
+ ? policyDelivery.branch
341
+ : (cliDelivery ?? policyDelivery.branch);
291
342
  if (!existsSync(projectRoot)) {
292
343
  return buildResponse({
293
344
  projectRoot,
@@ -300,6 +351,8 @@ export function finalizeCohort(args) {
300
351
  commitSha: null,
301
352
  branch: null,
302
353
  prUrl: null,
354
+ deliveryBranch,
355
+ deliveryErrors: [],
303
356
  errors: [`project root does not exist: ${projectRoot}`],
304
357
  warnings: [],
305
358
  ok: false,
@@ -319,6 +372,8 @@ export function finalizeCohort(args) {
319
372
  commitSha: null,
320
373
  branch: null,
321
374
  prUrl: null,
375
+ deliveryBranch,
376
+ deliveryErrors: [],
322
377
  errors: [`no xbrief/ directory under project root: ${projectRoot}`],
323
378
  warnings: [],
324
379
  ok: false,
@@ -327,27 +382,63 @@ export function finalizeCohort(args) {
327
382
  });
328
383
  }
329
384
  const closingIssues = new Set();
330
- if (prNumbers.length > 0) {
385
+ /** Per-closing-issue delivery evidence so multi-PR cohorts do not collapse provenance (#3041). */
386
+ const evidenceByIssue = new Map();
387
+ const validatedPrs = [];
388
+ if (prNumbers.length > 0 && errors.length === 0) {
331
389
  if (repo === null || repo.length === 0) {
332
390
  errors.push("--repo OWNER/REPO is required when --pr is supplied (or set $GH_REPO).");
333
391
  }
334
392
  else {
335
393
  for (const prNumber of prNumbers) {
336
- const merged = fetchPrMergedAt(prNumber, repo, runGh);
337
- if (merged.error !== null) {
338
- errors.push(merged.error);
394
+ const fetched = fetchPrDeliverySnapshot(prNumber, repo, deliveryBranch, runGh);
395
+ if (fetched.error !== null || fetched.snapshot === null) {
396
+ errors.push(fetched.error ?? `PR #${prNumber}: delivery snapshot unavailable`);
339
397
  continue;
340
398
  }
399
+ const snap = fetched.snapshot;
400
+ // base.ref must equal configured deliveryBranch — integration bases fail closed (#3041).
401
+ if (snap.prBase === null || snap.prBase.length === 0) {
402
+ deliveryErrors.push(`PR #${prNumber}: missing base.ref; cannot verify delivery branch (#3041).`);
403
+ continue;
404
+ }
405
+ if (snap.prBase !== deliveryBranch) {
406
+ deliveryErrors.push(`PR #${prNumber}: base.ref '${snap.prBase}' is not the delivery branch ` +
407
+ `'${deliveryBranch}'. Merged-to-integration is not delivery evidence (#3041). ` +
408
+ `(Note: --base-branch only controls the lifecycle sweep PR target, not delivery.)`);
409
+ continue;
410
+ }
411
+ if (snap.mergeCommitSha === null) {
412
+ deliveryErrors.push(`PR #${prNumber}: missing merge_commit_sha; cannot prove delivery ancestry (#3041).`);
413
+ continue;
414
+ }
415
+ // Refresh remote delivery ref and require merge commit ancestry (#3041).
416
+ const ancestry = verifyDeliveryAncestry(projectRoot, snap.mergeCommitSha, deliveryBranch, gitRunner);
417
+ if (!ancestry.ok) {
418
+ deliveryErrors.push(`PR #${prNumber}: ${ancestry.error}`);
419
+ continue;
420
+ }
421
+ validatedPrs.push(prNumber);
422
+ const prEvidence = {
423
+ ...snap.evidence,
424
+ deliveryBranch,
425
+ deliveryCommit: ancestry.remoteTip,
426
+ verifier: "swarm:finalize-cohort",
427
+ };
341
428
  const closing = fetchClosingIssues(prNumber, repo, runGh);
342
429
  if (closing.error !== null) {
343
430
  errors.push(closing.error);
344
431
  }
345
432
  for (const issue of closing.issues) {
346
433
  closingIssues.add(issue);
434
+ evidenceByIssue.set(issue, prEvidence);
347
435
  }
348
436
  }
349
437
  }
350
438
  }
439
+ if (deliveryErrors.length > 0) {
440
+ errors.push(...deliveryErrors);
441
+ }
351
442
  if (storyTokens.length === 0 && closingIssues.size === 0) {
352
443
  errors.push("empty cohort: pass --pr <numbers> and/or --stories <ids|paths>.");
353
444
  }
@@ -393,8 +484,33 @@ export function finalizeCohort(args) {
393
484
  const completedBrief = completedBriefReferencesIssue(projectRoot, issue);
394
485
  const issueClosed = completedBrief || fetchIssueClosed(issue, repo, runGh);
395
486
  if (completedBrief || issueClosed) {
487
+ let dispositionNote = "";
488
+ if (completedBrief) {
489
+ try {
490
+ const completedDir = resolve(projectRoot, "xbrief", "completed");
491
+ // Best-effort surface of legacy delivery disposition for completed briefs (#3041).
492
+ if (existsSync(completedDir)) {
493
+ for (const name of readdirSync(completedDir)) {
494
+ if (!name.endsWith(".json"))
495
+ continue;
496
+ const raw = JSON.parse(readFileSync(resolve(completedDir, name), "utf8"));
497
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
498
+ continue;
499
+ const plan = raw.plan;
500
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
501
+ continue;
502
+ const disposition = classifyStoredDeliveryDisposition(plan);
503
+ dispositionNote = ` deliveryDisposition=${disposition}`;
504
+ break;
505
+ }
506
+ }
507
+ }
508
+ catch {
509
+ /* best-effort disposition surfacing for legacy completed records */
510
+ }
511
+ }
396
512
  const reason = completedBrief
397
- ? "a completed brief already exists"
513
+ ? `a completed brief already exists${dispositionNote}`
398
514
  : "the issue is already closed";
399
515
  warnings.push(`#${issue}: no active story references this closing issue; skipped (${reason}).`);
400
516
  }
@@ -417,17 +533,78 @@ export function finalizeCohort(args) {
417
533
  commitSha: null,
418
534
  branch: null,
419
535
  prUrl: null,
536
+ deliveryBranch,
537
+ deliveryErrors,
420
538
  errors,
421
539
  warnings,
422
540
  ok: cleanNoop,
423
541
  emitJson: args.emitJson ?? false,
424
542
  exitCode: cleanNoop
425
543
  ? EXIT_OK
426
- : errors.some((e) => e.includes("not merged"))
544
+ : errors.some((e) => e.includes("not merged") || e.includes("delivery"))
427
545
  ? EXIT_GATE_FAILED
428
546
  : EXIT_CONFIG_ERROR,
429
547
  });
430
548
  }
549
+ // When --pr was supplied, every PR must pass delivery validation before sweep (#3041).
550
+ if (prNumbers.length > 0 && validatedPrs.length !== prNumbers.length && errors.length > 0) {
551
+ return buildResponse({
552
+ projectRoot,
553
+ dryRun,
554
+ noCommit,
555
+ prNumbers,
556
+ storyPaths,
557
+ closingIssues: [...closingIssues],
558
+ sweep: null,
559
+ commitSha: null,
560
+ branch: null,
561
+ prUrl: null,
562
+ deliveryBranch,
563
+ deliveryErrors,
564
+ errors,
565
+ warnings,
566
+ ok: false,
567
+ emitJson: args.emitJson ?? false,
568
+ exitCode: EXIT_GATE_FAILED,
569
+ });
570
+ }
571
+ // Stories without PR-backed delivery evidence still hit the complete gate; if
572
+ // only --stories was supplied, complete-cohort fails closed for code-bearing
573
+ // scopes unless callers pass evidence (finalize requires --pr for delivery).
574
+ if (prNumbers.length === 0 && evidenceByIssue.size === 0) {
575
+ warnings.push("No --pr supplied: code-bearing stories require delivery evidence or " +
576
+ "will fail closed at scope:complete (#3041).");
577
+ }
578
+ // Bind each story path to the evidence of its closing-issue PR (no cohort-wide collapse).
579
+ const evidenceByPath = new Map();
580
+ for (const storyPath of storyPaths) {
581
+ try {
582
+ const raw = JSON.parse(readFileSync(storyPath, "utf8"));
583
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
584
+ continue;
585
+ }
586
+ const plan = raw.plan;
587
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) {
588
+ continue;
589
+ }
590
+ const [, issueNum] = extractIssueRef(plan);
591
+ if (issueNum !== null) {
592
+ const bound = evidenceByIssue.get(issueNum);
593
+ if (bound !== undefined) {
594
+ evidenceByPath.set(resolve(storyPath), bound);
595
+ }
596
+ }
597
+ }
598
+ catch {
599
+ /* unreadable brief — complete gate fails closed later if code-bearing */
600
+ }
601
+ }
602
+ // Single-PR cohorts: every story inherits that PR's evidence when issue binding misses
603
+ // (operator --stories + one --pr is the common finalize path).
604
+ let defaultEvidence = null;
605
+ if (validatedPrs.length === 1 && evidenceByIssue.size > 0) {
606
+ defaultEvidence = evidenceByIssue.values().next().value ?? null;
607
+ }
431
608
  let commitSha = null;
432
609
  let branch = null;
433
610
  let prUrl = null;
@@ -447,11 +624,21 @@ export function finalizeCohort(args) {
447
624
  }
448
625
  }
449
626
  }
627
+ const hasDelivery = evidenceByPath.size > 0 || defaultEvidence !== null;
450
628
  const sweepResult = completeCohort({
451
629
  stories: storyPaths,
452
630
  projectRoot,
453
631
  dryRun,
454
632
  emitJson: false,
633
+ delivery: hasDelivery
634
+ ? {
635
+ evidenceByPath,
636
+ defaultEvidence,
637
+ // Ancestry already verified above; avoid double remote fetch on each story.
638
+ assumeEvidenceValidated: true,
639
+ verifier: "swarm:finalize-cohort",
640
+ }
641
+ : null,
455
642
  });
456
643
  if (sweepResult.exitCode !== 0) {
457
644
  errors.push("cohort completion sweep failed.");
@@ -466,6 +653,8 @@ export function finalizeCohort(args) {
466
653
  commitSha: null,
467
654
  branch: null,
468
655
  prUrl: null,
656
+ deliveryBranch,
657
+ deliveryErrors,
469
658
  errors,
470
659
  warnings,
471
660
  ok: false,
@@ -503,6 +692,8 @@ export function finalizeCohort(args) {
503
692
  commitSha,
504
693
  branch,
505
694
  prUrl,
695
+ deliveryBranch,
696
+ deliveryErrors,
506
697
  errors,
507
698
  warnings,
508
699
  ok,
@@ -522,6 +713,8 @@ function buildResponse(input) {
522
713
  commit_sha: input.commitSha,
523
714
  branch: input.branch,
524
715
  pr_url: input.prUrl,
716
+ delivery_branch: input.deliveryBranch,
717
+ delivery_errors: input.deliveryErrors,
525
718
  errors: input.errors,
526
719
  warnings: input.warnings,
527
720
  ok: input.ok,
@@ -539,6 +732,9 @@ function buildResponse(input) {
539
732
  `(${input.storyPaths.length} stor${input.storyPaths.length === 1 ? "y" : "ies"})`,
540
733
  ` Project root: ${input.projectRoot}`,
541
734
  ];
735
+ if (input.deliveryBranch !== null) {
736
+ lines.push(` Delivery branch: ${input.deliveryBranch}`);
737
+ }
542
738
  if (input.prNumbers.length > 0) {
543
739
  lines.push(` PRs: ${input.prNumbers.map((n) => `#${n}`).join(", ")}`);
544
740
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Safe USER.md Experimental Rules toggle (#46).
3
+ *
4
+ * Pure string helpers that add/remove only the three canonical meta-philosophy
5
+ * reference lines (SOUL / morals / code-field). Personal and Defaults section
6
+ * bodies are never rewritten — only the `## Experimental Rules` region changes.
7
+ *
8
+ * Detection matches lines under `## Experimental Rules` that contain the deposit
9
+ * path (`meta/SOUL.md`, …); enable writes the canonical setup-skill bullet.
10
+ * Custom bullets for other topics in the same section are preserved.
11
+ */
12
+ export type ExperimentalMetaId = "soul" | "morals" | "code-field";
13
+ export interface ExperimentalMetaEntry {
14
+ readonly id: ExperimentalMetaId;
15
+ /** Path fragment matched in USER.md lines (e.g. `meta/SOUL.md`). */
16
+ readonly path: string;
17
+ /** Canonical bullet written when enabling. */
18
+ readonly line: string;
19
+ }
20
+ /** Canonical Experimental Rules entries (setup Phase 1 steps 5a–5c). */
21
+ export declare const EXPERIMENTAL_META_ENTRIES: readonly ExperimentalMetaEntry[];
22
+ export type ExperimentalRulesState = Record<ExperimentalMetaId, boolean>;
23
+ /**
24
+ * Parse on/off state for the three experimental meta entries.
25
+ * A path is ON only when a line under `## Experimental Rules` mentions it —
26
+ * mentions in Personal/Defaults/prose do not count (writes are section-scoped).
27
+ */
28
+ export declare function parseExperimentalRulesState(userMdText: string): ExperimentalRulesState;
29
+ /**
30
+ * Apply desired Experimental Rules on/off state.
31
+ *
32
+ * - Only mutates the `## Experimental Rules` region (or inserts/removes it).
33
+ * - Personal / Defaults content is left byte-identical outside that region.
34
+ * - Enabling uses the canonical setup-skill lines; disabling drops path matches.
35
+ * - Custom non-meta bullets under the section are preserved.
36
+ * - When all three are off and no custom bullets remain, the section is removed.
37
+ */
38
+ export declare function applyExperimentalRulesState(userMdText: string, desired: ExperimentalRulesState): string;
39
+ /**
40
+ * Toggle a single experimental meta entry; leave the other two unchanged.
41
+ */
42
+ export declare function setExperimentalRule(userMdText: string, id: ExperimentalMetaId, enabled: boolean): string;
43
+ //# sourceMappingURL=experimental-rules.d.ts.map