immune-brain 5.1.0 → 5.1.2

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 (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
@@ -14,7 +14,14 @@ export type BatchRunState =
14
14
  | "completed"
15
15
  | "budget_stopped"
16
16
  | "failed"
17
- | "rejected";
17
+ | "rejected"
18
+ /**
19
+ * Retired by an explicit literal-user disposition after the batch plan moved
20
+ * past it. Terminal, and not a failure of the batch's own: the record, its
21
+ * children, its commits and its branch stay as evidence, and the retired
22
+ * authorization is never reused for the revised plan.
23
+ */
24
+ | "superseded";
18
25
 
19
26
  export type BatchChildRunState =
20
27
  | "pending"
@@ -88,6 +95,8 @@ export interface BatchLaneBinding {
88
95
  base_head: string;
89
96
  /** Commit created in the Lane after the Lane Kernel settled the child. */
90
97
  lane_commit: string | null;
98
+ /** Kernel run observed at enrollment; absent historical bindings cannot resume a parked child. */
99
+ run_id?: string | null;
91
100
  }
92
101
 
93
102
  export interface BatchLaneChildRun {
@@ -100,6 +109,28 @@ export interface BatchLaneChildRun {
100
109
  commit: string | null;
101
110
  lane: BatchLaneBinding | null;
102
111
  qa_failures: number;
112
+ /**
113
+ * The Child Issue was closed by the coordinator after this child's commit was
114
+ * integrated. Absent until then; a Lane settlement never closes it.
115
+ */
116
+ tracker_closed?: boolean;
117
+ /** Coordinator corrections already spent on this child (at most two). */
118
+ corrections?: number;
119
+ /**
120
+ * Set when the child exhausted its rework budget and waits for one
121
+ * design-level coordinator correction instead of parking; cleared when the
122
+ * correction is recorded.
123
+ */
124
+ correction_due?: string;
125
+ }
126
+
127
+ export interface LaneIntervention {
128
+ at: string;
129
+ task_id: string;
130
+ /** `instruction` is an ordinary correction; `correction` is a design-level correction after an exhausted budget. */
131
+ kind: "instruction" | "correction";
132
+ text: string;
133
+ text_digest: string;
103
134
  }
104
135
 
105
136
  /**
@@ -124,6 +155,30 @@ export interface BatchLaneRunStateRecord {
124
155
  /** Batch-branch commits produced by integration, in integration order. */
125
156
  commits: string[];
126
157
  adopted_heads?: Array<{ from: string; to: string }>;
158
+ /**
159
+ * Lane writes that leaked into the coordinator checkout and were restored on
160
+ * re-entry, each with the backup that undoes it. Absent until one happens.
161
+ */
162
+ restores?: import("./batch_leak_restore").LaneLeakRestore[];
163
+ /**
164
+ * Every text instruction the coordinator sent a Lane session, recorded before
165
+ * it was sent, so coordinator intervention is told apart from the Executor's
166
+ * own behavior afterwards.
167
+ */
168
+ interventions?: LaneIntervention[];
169
+ /** Commands run on the batch branch once every child is integrated; fixed when the batch starts. */
170
+ final_verification?: string[];
171
+ /**
172
+ * ADR 0018: the literal user's grant, in this batch's confirmation, to let the
173
+ * coordinator approve in-envelope Lane Intent revisions. Absent: not granted.
174
+ */
175
+ revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
176
+ /** Every revision applied under that grant. */
177
+ delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
178
+ /** Each child's TaskIntent identity behind `plan_digest`; resealed only for a delegated revision. */
179
+ intent_identities?: Record<string, { intent_path: string; intent_revision: number; intent_content_hash: string }>;
180
+ /** Each `plan_digest` reseal at integration, with the digest before and after. */
181
+ reseals?: Array<{ at: string; task_id: string; from_digest: string; to_digest: string; intent_revision: number; intent_content_hash: string }>;
127
182
  created_at: string;
128
183
  updated_at: string;
129
184
  }
@@ -145,7 +200,7 @@ const CHILD_RUN_STATES: ReadonlySet<string> = new Set([
145
200
  "skipped_blocked",
146
201
  ]);
147
202
 
148
- const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
203
+ export const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
149
204
  "prepared",
150
205
  "running",
151
206
  "needs_human",
@@ -153,6 +208,7 @@ const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
153
208
  "budget_stopped",
154
209
  "failed",
155
210
  "rejected",
211
+ "superseded",
156
212
  ]);
157
213
 
158
214
  const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
@@ -286,7 +342,8 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
286
342
  if (
287
343
  (record.batch_state === "budget_stopped" ||
288
344
  record.batch_state === "failed" ||
289
- record.batch_state === "rejected") &&
345
+ record.batch_state === "rejected" ||
346
+ record.batch_state === "superseded") &&
290
347
  record.children.some((child) => child.state === "enrolled" || child.state === "settled")
291
348
  )
292
349
  throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
@@ -398,6 +455,25 @@ export function replaceBatchRunState(root: string, expected: Buffer, next: Batch
398
455
  });
399
456
  }
400
457
 
458
+ /** Replace a record of either version only after a locked expected-byte check. */
459
+ export function replaceAnyBatchRunState(
460
+ root: string,
461
+ expected: Buffer,
462
+ next: AnyBatchRunStateRecord,
463
+ validate: () => void,
464
+ ): AnyBatchRunStateRecord {
465
+ const path = statePath(next.batch_id);
466
+ if (isLaneBatchRecord(next)) validateLaneRecordShape(next, next.batch_id);
467
+ else validateRecordShape(next, next.batch_id);
468
+ return withKernelStoreLock(root, () => {
469
+ if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
470
+ validate();
471
+ const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
472
+ writeFileAtomically(root, path, canonicalBytes(stored));
473
+ return stored;
474
+ });
475
+ }
476
+
401
477
  export function writeBatchRunState(
402
478
  root: string,
403
479
  record: BatchRunStateRecord,
@@ -493,6 +569,66 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
493
569
  ))
494
570
  )
495
571
  throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
572
+ if (
573
+ record.restores !== undefined &&
574
+ (!Array.isArray(record.restores) ||
575
+ record.restores.some(
576
+ (r: unknown) =>
577
+ typeof r !== "object" || r === null ||
578
+ typeof (r as { backup?: unknown }).backup !== "string" ||
579
+ !Array.isArray((r as { paths?: unknown }).paths),
580
+ ))
581
+ )
582
+ throw new Error(`batch run state ${batchId} has an invalid restores list`);
583
+ if (
584
+ record.final_verification !== undefined &&
585
+ (!Array.isArray(record.final_verification) ||
586
+ record.final_verification.length === 0 ||
587
+ record.final_verification.some((c: unknown) => typeof c !== "string" || !c))
588
+ )
589
+ throw new Error(`batch run state ${batchId} has an invalid final_verification list`);
590
+ if (
591
+ record.interventions !== undefined &&
592
+ (!Array.isArray(record.interventions) ||
593
+ record.interventions.some(
594
+ (i: unknown) =>
595
+ typeof i !== "object" || i === null ||
596
+ typeof (i as LaneIntervention).task_id !== "string" ||
597
+ ((i as LaneIntervention).kind !== "instruction" && (i as LaneIntervention).kind !== "correction") ||
598
+ typeof (i as LaneIntervention).text !== "string" ||
599
+ typeof (i as LaneIntervention).text_digest !== "string",
600
+ ))
601
+ )
602
+ throw new Error(`batch run state ${batchId} has an invalid interventions list`);
603
+ // ADR 0018 fields carry authority effects, so a malformed one fails closed.
604
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
605
+ const isHash = (v: unknown) => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v);
606
+ const isRevision = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
607
+ if (record.revision_delegation !== undefined &&
608
+ (!isObject(record.revision_delegation) || Object.keys(record.revision_delegation).join() !== "confirmation_time" ||
609
+ !isCanonicalTimestamp(record.revision_delegation.confirmation_time)))
610
+ throw new Error(`batch run state ${batchId} has an invalid revision_delegation`);
611
+ if (record.delegated_revisions !== undefined &&
612
+ (!Array.isArray(record.delegated_revisions) || record.delegated_revisions.some((r: unknown) =>
613
+ !isObject(r) || typeof r.task_id !== "string" || !isRevision(r.from_revision) || !isRevision(r.to_revision) ||
614
+ (r.to_revision as number) <= (r.from_revision as number) || !isHash(r.intent_content_hash) ||
615
+ typeof r.confirmation_ref !== "string" || !r.confirmation_ref.startsWith(`delegated-batch:${batchId}@`) ||
616
+ !isCanonicalTimestamp(r.at))))
617
+ throw new Error(`batch run state ${batchId} has an invalid delegated_revisions list`);
618
+ if (record.delegated_revisions?.length && !record.revision_delegation)
619
+ throw new Error(`batch run state ${batchId} records delegated revisions without a delegation grant`);
620
+ if (record.intent_identities !== undefined &&
621
+ (!isObject(record.intent_identities) || Object.values(record.intent_identities).some((i: unknown) =>
622
+ !isObject(i) || typeof i.intent_path !== "string" || !i.intent_path || !isRevision(i.intent_revision) || typeof i.intent_content_hash !== "string" || !i.intent_content_hash)))
623
+ throw new Error(`batch run state ${batchId} has an invalid intent_identities map`);
624
+ if (record.reseals !== undefined &&
625
+ (!Array.isArray(record.reseals) || record.reseals.some((r: unknown) =>
626
+ !isObject(r) || typeof r.task_id !== "string" || typeof r.from_digest !== "string" || typeof r.to_digest !== "string" ||
627
+ !isRevision(r.intent_revision) || !isHash(r.intent_content_hash) ||
628
+ !(record.delegated_revisions as Array<{ task_id: string; intent_content_hash: string }> | undefined)?.some((d) => d.task_id === r.task_id && d.intent_content_hash === r.intent_content_hash))))
629
+ throw new Error(`batch run state ${batchId} has an invalid reseals list`);
630
+ if (record.reseals?.length && record.reseals[record.reseals.length - 1].to_digest !== record.plan_digest)
631
+ throw new Error(`batch run state ${batchId} plan_digest does not match its last reseal`);
496
632
  const seenTaskIds = new Set<string>();
497
633
  for (const child of record.children) {
498
634
  if (
@@ -515,6 +651,14 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
515
651
  throw new Error(`batch run state ${batchId} child ${child.task_id} has invalid terminal fields`);
516
652
  if (typeof child.qa_failures !== "number" || !Number.isInteger(child.qa_failures) || child.qa_failures < 0)
517
653
  throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid qa_failures`);
654
+ if (child.corrections !== undefined && (!Number.isInteger(child.corrections) || child.corrections < 0 || child.corrections > 2))
655
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid corrections count`);
656
+ if (child.correction_due !== undefined && (typeof child.correction_due !== "string" || !child.correction_due || child.state !== "enrolled"))
657
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid correction_due`);
658
+ if (child.tracker_closed !== undefined && child.tracker_closed !== true)
659
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid tracker_closed`);
660
+ if (child.tracker_closed === true && child.state !== "integrated" && child.state !== "released")
661
+ throw new Error(`batch run state ${batchId} child ${child.task_id} closed its tracker Issue before integration`);
518
662
  const lane = child.lane as BatchLaneBinding | null | undefined;
519
663
  if (lane !== null) {
520
664
  if (
@@ -525,7 +669,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
525
669
  !lane.branch ||
526
670
  typeof lane.base_head !== "string" ||
527
671
  !lane.base_head ||
528
- (lane.lane_commit !== null && typeof lane.lane_commit !== "string")
672
+ (lane.lane_commit !== null && typeof lane.lane_commit !== "string") ||
673
+ (lane.run_id !== undefined && lane.run_id !== null && (typeof lane.run_id !== "string" || !lane.run_id))
529
674
  )
530
675
  throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid lane`);
531
676
  }
@@ -558,7 +703,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
558
703
  child.state === "settled" ||
559
704
  child.state === "lane_committed";
560
705
  if (
561
- (record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected") &&
706
+ (record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected" ||
707
+ record.batch_state === "superseded") &&
562
708
  record.children.some(inFlight)
563
709
  )
564
710
  throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
@@ -580,6 +726,8 @@ export function prepareBatchLaneRunState(input: {
580
726
  budget: { max_children: number; qa_failure_limit: number };
581
727
  max_parallel: number;
582
728
  now: string;
729
+ final_verification?: string[];
730
+ revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
583
731
  }): BatchLaneRunStateRecord {
584
732
  validateBatchId(input.batch_id);
585
733
  return {
@@ -604,6 +752,13 @@ export function prepareBatchLaneRunState(input: {
604
752
  qa_failures: 0,
605
753
  })),
606
754
  commits: [],
755
+ ...(input.final_verification?.length ? { final_verification: [...input.final_verification] } : {}),
756
+ ...(input.revision_delegation ? { revision_delegation: { ...input.revision_delegation } } : {}),
757
+ intent_identities: Object.fromEntries(
758
+ input.children
759
+ .filter((child) => child.intent_path !== null && child.intent_revision !== null && child.intent_content_hash !== null)
760
+ .map((child) => [child.task_id, { intent_path: child.intent_path!, intent_revision: child.intent_revision!, intent_content_hash: child.intent_content_hash! }]),
761
+ ),
607
762
  created_at: input.now,
608
763
  updated_at: input.now,
609
764
  };
@@ -693,6 +848,23 @@ export interface BatchLaneRunReport {
693
848
  handoffs: BatchLaneHandoff[];
694
849
  /** Offers refused at admission; a refused offer wrote nothing. */
695
850
  lane_refusals?: Array<{ task_id: string; path: string; reason: string }>;
851
+ /**
852
+ * Child Issue closures this tick attempted after integration. A failure is a
853
+ * tracker observation beside the batch result, retried by the next tick.
854
+ */
855
+ tracker_observations?: Array<{ task_id: string; status: string; message: string }>;
856
+ /** The completion run of the recorded final verification commands; `passed: false` marks the batch not passed. */
857
+ final_verification?: import("./batch_final_verification").FinalVerificationReport;
858
+ /** Coordinator instructions sent to Lane sessions (see BatchLaneRunStateRecord.interventions). */
859
+ interventions?: LaneIntervention[];
860
+ /** This tick's decision on the coordinator's `lane_revision` (ADR 0018). */
861
+ lane_revision?: { task_id: string; accepted: boolean; reason: string | null; fallback?: string };
862
+ delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
863
+ reseals?: BatchLaneRunStateRecord["reseals"];
864
+ /** This tick's decision on the Parent's `lane_instruction`: send the text only when accepted. */
865
+ lane_instruction?: { task_id: string; kind: LaneIntervention["kind"]; accepted: boolean; reason: string | null };
866
+ /** Restored Lane leaks with their backup location (see BatchLaneRunStateRecord.restores). */
867
+ restores?: import("./batch_leak_restore").LaneLeakRestore[];
696
868
  next_action: string;
697
869
  created_at: string;
698
870
  }
@@ -707,6 +879,21 @@ export type BatchLaneHandoff =
707
879
  executor_hosts: readonly string[];
708
880
  }
709
881
  | { role: "lane-steward"; action: "release"; task_id: string; lane_branch: string }
882
+ | {
883
+ /**
884
+ * The child exhausted its rework budget. The Parent reads the findings of
885
+ * every round from the Lane's Kernel state, decides whether they share one
886
+ * root cause, starts a new Lane session and sends one design-level
887
+ * correction as `lane_instruction` kind `correction`.
888
+ */
889
+ role: "coordinator";
890
+ action: "correct";
891
+ task_id: string;
892
+ reason: string;
893
+ corrections_used: number;
894
+ corrections_left: number;
895
+ lane_path: string;
896
+ }
710
897
  | {
711
898
  role: "executor";
712
899
  task_id: string;
@@ -756,5 +943,11 @@ function canonicalReportBytes(report: PersistableReport): string {
756
943
 
757
944
  /** All terminal batch states; needs_human is a park, not terminal. */
758
945
  export function isTerminalBatchState(state: BatchRunState): boolean {
759
- return state === "completed" || state === "budget_stopped" || state === "failed" || state === "rejected";
946
+ return (
947
+ state === "completed" ||
948
+ state === "budget_stopped" ||
949
+ state === "failed" ||
950
+ state === "rejected" ||
951
+ state === "superseded"
952
+ );
760
953
  }
@@ -0,0 +1,58 @@
1
+ // Read-only identification of a Lane worktree from its checked-out branch.
2
+ // A Lane sits on `imm-lane/<initiative-slug>/<task-id>` (ADR 0013), so the
3
+ // settling Host can tell a Lane settlement from a coordinator or single-task
4
+ // settlement without reading any batch state.
5
+ import { spawnSync } from "node:child_process";
6
+ import { realpathSync } from "node:fs";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+
9
+ const LANE_BRANCH = /^imm-lane\/[^/]+\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
10
+
11
+ /** The task id the Lane at `root` was cut for, or null when `root` is not a Lane. */
12
+ export function laneTaskOfWorkspace(root: string): string | null {
13
+ const branch = spawnSync("git", ["-C", root, "symbolic-ref", "--quiet", "--short", "HEAD"], {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ });
17
+ if (branch.status !== 0) return null;
18
+ return LANE_BRANCH.exec((branch.stdout ?? "").trim())?.[1] ?? null;
19
+ }
20
+
21
+ /** True when `root` is the Lane of `taskId`: its tracker close waits for integration. */
22
+ export function isLaneWorkspaceForTask(root: string, taskId: string): boolean {
23
+ return laneTaskOfWorkspace(root) === taskId;
24
+ }
25
+
26
+ function realpathOfNearest(path: string): string {
27
+ let current = path;
28
+ const suffix: string[] = [];
29
+ for (;;) {
30
+ try {
31
+ return join(realpathSync(current), ...suffix.reverse());
32
+ } catch {
33
+ const parent = dirname(current);
34
+ if (parent === current) return path;
35
+ suffix.push(basename(current));
36
+ current = parent;
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Refusal text for an `edit`/`write` a Lane session aims outside its Lane, or
43
+ * null when `cwd` is not a Lane or the target is inside it. The target is
44
+ * resolved against `cwd` and through symlinks of its nearest existing
45
+ * ancestor. Only file-editing tools are checked: a shell command is not
46
+ * intercepted, and the contract says so.
47
+ */
48
+ export function laneWriteRefusal(cwd: string, target: unknown): string | null {
49
+ if (typeof target !== "string" || !target) return null;
50
+ if (laneTaskOfWorkspace(cwd) === null) return null;
51
+ const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
52
+ if (top.status !== 0) return null;
53
+ const laneRoot = realpathOfNearest((top.stdout ?? "").trim());
54
+ const resolved = realpathOfNearest(isAbsolute(target) ? target : resolve(cwd, target));
55
+ const inside = relative(laneRoot, resolved);
56
+ if (inside === "" || (inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside))) return null;
57
+ return `Lane write refused: ${resolved} is outside this Lane. In a batch Lane every edit and write stays under ${laneRoot}; use the same repository-relative path inside the Lane.`;
58
+ }
@@ -32,4 +32,4 @@ worktree, or unmanaged implementation.
32
32
  Every sourced branch closes as resolved, explicitly excluded, explicitly
33
33
  deferred, or dependent-blocked with its blocking node named, including for a
34
34
  clear zero-question request; a confirmed outcome is never conserved as a
35
- smaller deliverable.
35
+ smaller deliverable, and a confirmed user scenario is a `BR-SCN-*` item with Actor, Given, When, Then, written in English, whose Then is a result the actor observes directly; framing without a user-observable change gets no invented scenario.
@@ -16,7 +16,9 @@ grants execution authority — only the native Enrollment gate can. Every Spec d
16
16
  user-owned one with no source is removed or returned for clarification. Before
17
17
  handoff it maps every upstream item and required phase exactly once and reports
18
18
  each Slice result plus the remaining gap; a plan that conserves a smaller
19
- deliverable than the confirmed outcome fails that check instead of passing.
19
+ deliverable than the confirmed outcome fails that check instead of passing, and a
20
+ Spec carries user scenarios mapped one way to acceptance, or records `no
21
+ user-visible change` with a reason.
20
22
 
21
23
  Section routes - load a section's instructions only when its branch applies.
22
24
  Read each linked heading body up to the next heading; nested sections and