@deftai/directive-core 0.91.0 → 0.92.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 (46) 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/main.js +37 -0
  6. package/dist/hooks/dispatcher.d.ts +6 -1
  7. package/dist/hooks/dispatcher.js +30 -0
  8. package/dist/init-deposit/gitignore.js +3 -0
  9. package/dist/init-deposit/hygiene.d.ts +43 -1
  10. package/dist/init-deposit/hygiene.js +112 -10
  11. package/dist/init-deposit/scaffold.js +3 -1
  12. package/dist/orchestration/probe-session.d.ts +5 -1
  13. package/dist/orchestration/probe-session.js +24 -13
  14. package/dist/platform/agents-md.js +1 -1
  15. package/dist/policy/deft-directive-disable.d.ts +86 -0
  16. package/dist/policy/deft-directive-disable.js +167 -0
  17. package/dist/policy/delivery-branch.d.ts +33 -0
  18. package/dist/policy/delivery-branch.js +124 -0
  19. package/dist/policy/index.d.ts +2 -0
  20. package/dist/policy/index.js +17 -1
  21. package/dist/scope/brief-io.d.ts +3 -1
  22. package/dist/scope/brief-io.js +5 -3
  23. package/dist/scope/delivery-evidence.d.ts +112 -0
  24. package/dist/scope/delivery-evidence.js +419 -0
  25. package/dist/scope/index.d.ts +1 -0
  26. package/dist/scope/index.js +1 -0
  27. package/dist/scope/main.d.ts +5 -0
  28. package/dist/scope/main.js +98 -3
  29. package/dist/scope/registry-artifact-sync.js +4 -1
  30. package/dist/scope/transition.d.ts +11 -1
  31. package/dist/scope/transition.js +30 -4
  32. package/dist/session/ritual-sentinel.js +21 -12
  33. package/dist/session/session-start-hook.d.ts +3 -0
  34. package/dist/session/session-start-hook.js +21 -0
  35. package/dist/session/session-start.js +31 -0
  36. package/dist/swarm/complete-cohort.d.ts +20 -1
  37. package/dist/swarm/complete-cohort.js +56 -9
  38. package/dist/swarm/finalize-cohort-cli.js +9 -0
  39. package/dist/swarm/finalize-cohort.d.ts +8 -0
  40. package/dist/swarm/finalize-cohort.js +217 -21
  41. package/dist/user-config/experimental-rules.d.ts +43 -0
  42. package/dist/user-config/experimental-rules.js +162 -0
  43. package/dist/user-config/index.d.ts +1 -0
  44. package/dist/user-config/index.js +1 -0
  45. package/dist/verify-source/contained-writes.js +1 -1
  46. package/package.json +3 -3
@@ -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
@@ -0,0 +1,162 @@
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
+ /** Canonical Experimental Rules entries (setup Phase 1 steps 5a–5c). */
13
+ export const EXPERIMENTAL_META_ENTRIES = [
14
+ {
15
+ id: "soul",
16
+ path: "meta/SOUL.md",
17
+ line: "- ! Use meta/SOUL.md for strategic context and purpose-driven guidance",
18
+ },
19
+ {
20
+ id: "morals",
21
+ path: "meta/morals.md",
22
+ line: "- ! Use meta/morals.md for ethical AI development principles",
23
+ },
24
+ {
25
+ id: "code-field",
26
+ path: "meta/code-field.md",
27
+ line: "- ~ Use meta/code-field.md for advanced architecture patterns",
28
+ },
29
+ ];
30
+ const SECTION_HEADING = "## Experimental Rules";
31
+ function detectNewline(text) {
32
+ return text.includes("\r\n") ? "\r\n" : "\n";
33
+ }
34
+ function lineMentionsPath(line, path) {
35
+ return line.includes(path);
36
+ }
37
+ function findExperimentalSection(text) {
38
+ const re = /^## Experimental Rules[ \t]*\r?\n?/m;
39
+ const match = re.exec(text);
40
+ if (!match || match.index === undefined) {
41
+ return null;
42
+ }
43
+ const start = match.index;
44
+ const afterHeading = start + match[0].length;
45
+ const rest = text.slice(afterHeading);
46
+ // Next H2 or a horizontal rule at line start ends the section.
47
+ const next = /^(## |---[ \t]*$)/m.exec(rest);
48
+ const end = next && next.index !== undefined ? afterHeading + next.index : text.length;
49
+ return {
50
+ start,
51
+ end,
52
+ full: text.slice(start, end),
53
+ body: text.slice(afterHeading, end),
54
+ };
55
+ }
56
+ /**
57
+ * Parse on/off state for the three experimental meta entries.
58
+ * A path is ON only when a line under `## Experimental Rules` mentions it —
59
+ * mentions in Personal/Defaults/prose do not count (writes are section-scoped).
60
+ */
61
+ export function parseExperimentalRulesState(userMdText) {
62
+ const state = {
63
+ soul: false,
64
+ morals: false,
65
+ "code-field": false,
66
+ };
67
+ const section = findExperimentalSection(userMdText);
68
+ if (!section) {
69
+ return state;
70
+ }
71
+ for (const line of section.body.split(/\r?\n/)) {
72
+ for (const entry of EXPERIMENTAL_META_ENTRIES) {
73
+ if (lineMentionsPath(line, entry.path)) {
74
+ state[entry.id] = true;
75
+ }
76
+ }
77
+ }
78
+ return state;
79
+ }
80
+ /**
81
+ * Build section body lines: preserve non-meta custom bullets; apply desired
82
+ * on/off for the three canonical paths; stable order soul → morals → code-field.
83
+ */
84
+ function buildSectionBody(existingBody, desired, nl) {
85
+ const rawLines = existingBody.split(/\r?\n/);
86
+ const custom = [];
87
+ for (const line of rawLines) {
88
+ if (line.trim() === "") {
89
+ continue;
90
+ }
91
+ const isMeta = EXPERIMENTAL_META_ENTRIES.some((e) => lineMentionsPath(line, e.path));
92
+ if (!isMeta) {
93
+ custom.push(line);
94
+ }
95
+ }
96
+ const metaLines = EXPERIMENTAL_META_ENTRIES.filter((e) => desired[e.id]).map((e) => e.line);
97
+ const bullets = [...metaLines, ...custom];
98
+ if (bullets.length === 0) {
99
+ return "";
100
+ }
101
+ // Heading + blank line + bullets + trailing blank line for clean separation.
102
+ return `${SECTION_HEADING}${nl}${nl}${bullets.join(nl)}${nl}`;
103
+ }
104
+ /**
105
+ * Insert a new Experimental Rules section before the trailing `---` / Note
106
+ * block when present; otherwise append after the last non-empty content.
107
+ */
108
+ function insertSection(text, section, nl) {
109
+ // Prefer before the trailing horizontal rule that precedes the USER.md Note
110
+ // block (allow blank lines between `---` and `**Note**`).
111
+ const noteRe = /\r?\n---[ \t]*\r?\n(?:[ \t]*\r?\n)*[ \t]*\*\*Note\*\*/;
112
+ const noteMatch = noteRe.exec(text);
113
+ if (noteMatch && noteMatch.index !== undefined) {
114
+ const before = text.slice(0, noteMatch.index).replace(/[ \t]+$/u, "");
115
+ const after = text.slice(noteMatch.index);
116
+ const sep = before.endsWith(nl) ? nl : `${nl}${nl}`;
117
+ return `${before}${sep}${section.replace(/\s+$/u, "")}${after}`;
118
+ }
119
+ const trimmed = text.replace(/\s+$/u, "");
120
+ return `${trimmed}${nl}${nl}${section.replace(/\s+$/u, "")}${nl}`;
121
+ }
122
+ /**
123
+ * Apply desired Experimental Rules on/off state.
124
+ *
125
+ * - Only mutates the `## Experimental Rules` region (or inserts/removes it).
126
+ * - Personal / Defaults content is left byte-identical outside that region.
127
+ * - Enabling uses the canonical setup-skill lines; disabling drops path matches.
128
+ * - Custom non-meta bullets under the section are preserved.
129
+ * - When all three are off and no custom bullets remain, the section is removed.
130
+ */
131
+ export function applyExperimentalRulesState(userMdText, desired) {
132
+ const nl = detectNewline(userMdText);
133
+ const sectionText = buildSectionBody(findExperimentalSection(userMdText)?.body ?? "", desired, nl);
134
+ const existing = findExperimentalSection(userMdText);
135
+ if (existing) {
136
+ if (!sectionText) {
137
+ // Remove section; tidy surrounding blank lines.
138
+ const before = userMdText.slice(0, existing.start).replace(/[ \t]+$/u, "");
139
+ let after = userMdText.slice(existing.end);
140
+ // Collapse to at most two newlines at the join.
141
+ const beforeCore = before.replace(/(\r?\n){2,}$/u, nl);
142
+ after = after.replace(/^(\r?\n)+/u, nl);
143
+ if (after.startsWith("---") || after.startsWith("## ")) {
144
+ return `${beforeCore.replace(/(\r?\n)+$/u, nl)}${nl}${after}`;
145
+ }
146
+ return `${beforeCore}${after}`;
147
+ }
148
+ return userMdText.slice(0, existing.start) + sectionText + userMdText.slice(existing.end);
149
+ }
150
+ if (!sectionText) {
151
+ return userMdText;
152
+ }
153
+ return insertSection(userMdText, sectionText, nl);
154
+ }
155
+ /**
156
+ * Toggle a single experimental meta entry; leave the other two unchanged.
157
+ */
158
+ export function setExperimentalRule(userMdText, id, enabled) {
159
+ const current = parseExperimentalRulesState(userMdText);
160
+ return applyExperimentalRulesState(userMdText, { ...current, [id]: enabled });
161
+ }
162
+ //# sourceMappingURL=experimental-rules.js.map
@@ -1,2 +1,3 @@
1
+ export * from "./experimental-rules.js";
1
2
  export * from "./resolve-user-md.js";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,3 @@
1
+ export * from "./experimental-rules.js";
1
2
  export * from "./resolve-user-md.js";
2
3
  //# sourceMappingURL=index.js.map
@@ -59,7 +59,7 @@ export const CONTAINED_WRITES_ALLOWLIST = [
59
59
  "packages/core/src/intake/issue-ingest.ts",
60
60
  "packages/core/src/intake/reconcile-issues.ts",
61
61
  "packages/core/src/issue-sync/sync-from-xbrief.ts",
62
- "packages/core/src/orchestration/probe-session.ts",
62
+ // probe-session.ts removed from allowlist after #3042 contained writeSession.
63
63
  "packages/core/src/orchestration/verify-judgment-gates.ts",
64
64
  "packages/core/src/platform/changelog-cli.ts",
65
65
  "packages/core/src/release/gh.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.91.0",
3
+ "version": "0.92.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -334,8 +334,8 @@
334
334
  "provenance": true
335
335
  },
336
336
  "dependencies": {
337
- "@deftai/directive-content": "^0.91.0",
338
- "@deftai/directive-types": "^0.91.0",
337
+ "@deftai/directive-content": "^0.92.0",
338
+ "@deftai/directive-types": "^0.92.0",
339
339
  "archiver": "^8.0.0"
340
340
  },
341
341
  "scripts": {