opencode-herdr-orchestration 0.2.0 → 0.3.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.
package/src/state.js CHANGED
@@ -13,12 +13,39 @@ export const ARTIFACT_TYPES = Object.freeze({
13
13
 
14
14
  const ARTIFACT_TYPE_VALUES = new Set(Object.values(ARTIFACT_TYPES));
15
15
 
16
- const STATE_DIR = "herdr";
16
+ const STATE_DIR = "flocky";
17
+ // The legacy "herdr" root is a compatibility source only: it is reconciled
18
+ // into the canonical Flocky root before every plan/execution operation, but
19
+ // it is never auto-deleted and never treated as a second canonical authority.
20
+ const LEGACY_STATE_DIR = "herdr";
17
21
  const ARTIFACT_DIRECTORIES = Object.freeze({
18
22
  [ARTIFACT_TYPES.PLAN]: "plans",
19
23
  [ARTIFACT_TYPES.EXECUTION]: "executions",
20
24
  });
21
25
 
26
+ const MIGRATION_TEMP_SUFFIX = ".migrating";
27
+ const MARKDOWN_SUFFIX = ".md";
28
+
29
+ // Retired M1 coordination files. Earlier revisions created a shared migration
30
+ // lock and a shared write-ahead journal under the canonical root; the
31
+ // lock-free protocol below never creates them, and reconciliation removes
32
+ // leftovers best-effort. They are service-owned coordination state, never
33
+ // user artifacts, so removing them never touches legacy or canonical data.
34
+ const RETIRED_MIGRATION_FILES = Object.freeze([".migration-lock", ".migration-journal"]);
35
+
36
+ // Staging temps left behind by a crashed promotion are inert: promotion only
37
+ // ever happens from freshly validated legacy bytes through an exclusive
38
+ // create, so a later call safely sweeps another call's stale temp while never
39
+ // touching a live contender's fresh temp.
40
+ const ORPHAN_TEMP_STALE_MS = 30_000;
41
+
42
+ // Per-artifact repair guards serialize corrupt-canonical repair: only the
43
+ // contender holding the guard may remove the corrupt target, so a repair can
44
+ // never unlink a concurrent winner. A stale guard (holder crashed) is taken
45
+ // over by modification time; a live guard makes contenders fail closed.
46
+ const REPAIR_GUARD_SUFFIX = ".repairing";
47
+ const REPAIR_GUARD_STALE_MS = 30_000;
48
+
22
49
  const SCHEMA_VERSION = 1;
23
50
  const FRONTMATTER_DELIMITER = "---";
24
51
 
@@ -40,8 +67,77 @@ const RESERVED_METADATA_KEYS = Object.freeze([
40
67
  "updatedAt",
41
68
  ]);
42
69
 
43
- function error(code, message, retryable = false) {
44
- return { ok: false, error: { code, message, retryable } };
70
+ export const STEERING_SCHEMA_VERSION = 1;
71
+ export const MAX_STEERING_BYTES = 8192;
72
+ const STEERING_DIR = "steering";
73
+ const STEERING_ENTRIES_DIR = "entries";
74
+ const STEERING_CHECKPOINT_FILE = "checkpoint.json";
75
+ const STEERING_LOCK_FILE = "queue.lock";
76
+ const STEERING_JOURNAL_FILE = "queue.journal";
77
+ const STEERING_LOCK_STALE_MS = 30_000;
78
+ const STEERING_LOCK_RETRIES = 200;
79
+ const STEERING_LOCK_RETRY_MS = 10;
80
+ const STEERING_ID_PREFIX = "st_";
81
+
82
+ // --- Shepherd ownership and lifecycle synchronization (M3) ------------------
83
+ // Validated target lifecycle records per active Plan ID. All text fields are
84
+ // bounded semantic summaries; reasoning transcripts and terminal scrollback
85
+ // are never stored (SENSITIVE_CONTENT_EXCLUDED). Closed vocabularies below
86
+ // are the only accepted values for their fields.
87
+ export const OWNERSHIP_SCHEMA_VERSION = 1;
88
+ export const OWNER_PHASES = Object.freeze({ PLANNING: "planning", GOVERNANCE: "governance" });
89
+ export const LIFECYCLE_STATES = Object.freeze({
90
+ PLANNING: "planning",
91
+ EXECUTING: "executing",
92
+ RESULT_EVALUATION: "result-evaluation",
93
+ CONSEQUENTIAL_PREPARATION: "consequential-preparation",
94
+ FINALIZED: "finalized",
95
+ });
96
+ export const SYNC_POINTS = Object.freeze({
97
+ PLANNING_START: "planning-start",
98
+ PRE_PLAN: "pre-plan",
99
+ PRE_ASSIGNMENT: "pre-assignment",
100
+ MILESTONE_EXECUTING: "milestone-executing",
101
+ RESULT_RECEIVED: "result-received",
102
+ CONTINUE: "continue",
103
+ FINALIZE: "finalize",
104
+ CONSEQUENTIAL_PREPARATION: "consequential-preparation",
105
+ });
106
+ export const SYNC_DISPOSITIONS = Object.freeze({
107
+ INTEGRATED: "integrated",
108
+ CORRECTED: "corrected",
109
+ ESCALATED: "escalated",
110
+ DEFERRED: "deferred",
111
+ });
112
+ export const SNAPSHOT_STAGES = Object.freeze({
113
+ PLANNING: "planning",
114
+ EXECUTING: "executing",
115
+ RESULT_EVALUATION: "result-evaluation",
116
+ CONSEQUENTIAL_PREPARATION: "consequential-preparation",
117
+ });
118
+ // Steering never authorizes consequential actions. This closed list plus any
119
+ // other consequential action is always denied; existing approvals still apply.
120
+ export const CONSEQUENTIAL_DENIED_ACTIONS = Object.freeze(["push", "tag", "publish", "deploy", "merge"]);
121
+ const OWNERSHIP_DIR = "ownership";
122
+ const OWNERSHIP_RECORD_FILE = "record.json";
123
+ const OWNERSHIP_SYNC_FILE = "sync.json";
124
+ const OWNERSHIP_SNAPSHOTS_DIR = "snapshots";
125
+ const OWNERSHIP_LOCK_FILE = "queue.lock";
126
+ const OWNERSHIP_LOCK_STALE_MS = 30_000;
127
+ const OWNERSHIP_LOCK_RETRIES = 200;
128
+ const OWNERSHIP_LOCK_RETRY_MS = 10;
129
+ const SESSION_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
130
+ const MAX_MILESTONE_CHARS = 256;
131
+ const MAX_OBJECTIVE_CHARS = 2048;
132
+ const MAX_ACTION_CHARS = 2048;
133
+ const MAX_SHEEPDOG_TARGET_CHARS = 128;
134
+ const MAX_REVISION_CHARS = 128;
135
+ const MAX_PENDING_CONSEQUENTIAL_CHARS = 1024;
136
+ const MAX_CORRECTION_CHARS = 2048;
137
+ const SENSITIVE_CONTENT_PATTERN = /(transcript|scrollback)/i;
138
+
139
+ function error(code, message, retryable = false, details) {
140
+ return { ok: false, error: { code, message, retryable, ...(details ?? {}) } };
45
141
  }
46
142
 
47
143
  function processErrorDetail(cause) {
@@ -215,6 +311,9 @@ export function createStateService(options = {}) {
215
311
  const location = artifactPath(layout, type, planId);
216
312
  if (location.error) return location;
217
313
 
314
+ const reconciled = await reconcileLegacyState(layout);
315
+ if (reconciled.error) return reconciled;
316
+
218
317
  const timestamp = resolveNow(now).toISOString();
219
318
  let existingMetadata;
220
319
  const existing = readStoredArtifact(location.target);
@@ -273,6 +372,9 @@ export function createStateService(options = {}) {
273
372
  const location = artifactPath(layout, type, planId);
274
373
  if (location.error) return location;
275
374
 
375
+ const reconciled = await reconcileLegacyState(layout);
376
+ if (reconciled.error) return reconciled;
377
+
276
378
  const stored = readStoredArtifact(location.target);
277
379
  if (stored.error) return stored;
278
380
  const { metadata } = stored.artifact;
@@ -324,13 +426,2152 @@ export function createStateService(options = {}) {
324
426
  };
325
427
  }
326
428
 
327
- return {
328
- layout: resolveRepositoryLayout,
329
- writeArtifact,
330
- readArtifact,
331
- writePlan: (input) => writeArtifact(ARTIFACT_TYPES.PLAN, input),
332
- readPlan: (planId) => readArtifact(ARTIFACT_TYPES.PLAN, planId),
333
- writeExecution: (input) => writeArtifact(ARTIFACT_TYPES.EXECUTION, input),
334
- readExecution: (planId) => readArtifact(ARTIFACT_TYPES.EXECUTION, planId),
429
+ // --- Legacy herdr -> canonical Flocky reconciliation -----------------------
430
+ //
431
+ // Before every plan/execution operation the legacy `<git-common-dir>/herdr`
432
+ // root is reconciled into the canonical `<git-common-dir>/flocky` root.
433
+ // Reconciliation is lock-free and idempotent per artifact:
434
+ //
435
+ // - legacy-only artifacts are validated, staged to a unique temp, and
436
+ // installed through an atomic exclusive create (`wx`), which stays the
437
+ // single linearization point: a lost create race re-reads the winner
438
+ // and byte-compares instead of overwriting, so concurrent contenders
439
+ // can never produce a divergent promotion;
440
+ // - corrupt canonical repair is serialized by a per-artifact repair guard:
441
+ // only the guard holder may remove the corrupt target, and any contender
442
+ // that cannot claim the guard — or observes an unexpected state inside
443
+ // it — fails closed with a structured conflict instead of overwriting;
444
+ // - identical bytes on both sides are accepted without modification;
445
+ // - divergent valid bytes fail closed with a structured MIGRATION_CONFLICT
446
+ // (no silent selection and no silent replacement);
447
+ // - the legacy root is never auto-deleted and never acts as a second
448
+ // canonical authority, so an active legacy write is surfaced as a
449
+ // conflict instead of silently losing to the canonical copy.
450
+ //
451
+ // Recovery needs no journal: an interrupted promotion leaves at most an
452
+ // inert uniquely-named staging temp, and any later call revalidates the
453
+ // legacy source and completes the install idempotently.
454
+
455
+ function migrationPaths(layout) {
456
+ const canonicalRoot = path.join(layout.identity, STATE_DIR);
457
+ const legacyRoot = path.join(layout.identity, LEGACY_STATE_DIR);
458
+ return {
459
+ canonicalRoot,
460
+ legacyRoot,
461
+ canonicalDirectory(type) {
462
+ return path.join(canonicalRoot, ARTIFACT_DIRECTORIES[type]);
463
+ },
464
+ legacyDirectory(type) {
465
+ return path.join(legacyRoot, ARTIFACT_DIRECTORIES[type]);
466
+ },
467
+ };
468
+ }
469
+
470
+ function readBytesOptional(target) {
471
+ let bytes;
472
+ try {
473
+ bytes = fs.readFileSync(target);
474
+ } catch (cause) {
475
+ if (cause?.code === "ENOENT") return { ok: true, present: false };
476
+ return error("READ_FAILED", `Unable to read ${JSON.stringify(target)}: ${processErrorDetail(cause)}`, true);
477
+ }
478
+ return { ok: true, present: true, bytes };
479
+ }
480
+
481
+ function listLegacyArtifactNames(directory) {
482
+ let names;
483
+ try {
484
+ names = fs.readdirSync(directory);
485
+ } catch (cause) {
486
+ if (cause?.code === "ENOENT") return { ok: true, names: [] };
487
+ return error("READ_FAILED", `Unable to list legacy artifacts in ${JSON.stringify(directory)}: ${processErrorDetail(cause)}`, true);
488
+ }
489
+ return { ok: true, names: names.filter((name) => name.endsWith(MARKDOWN_SUFFIX)) };
490
+ }
491
+
492
+ function removeRetiredCoordinationFiles(paths) {
493
+ for (const name of RETIRED_MIGRATION_FILES) {
494
+ try {
495
+ fs.unlinkSync(path.join(paths.canonicalRoot, name));
496
+ } catch {
497
+ // Absent or unreadable coordination files need no action.
498
+ }
499
+ }
500
+ }
501
+
502
+ function sweepStaleStagingTemps(paths) {
503
+ const cutoff = Date.now() - ORPHAN_TEMP_STALE_MS;
504
+ for (const type of Object.values(ARTIFACT_TYPES)) {
505
+ const directory = paths.canonicalDirectory(type);
506
+ let names;
507
+ try {
508
+ names = fs.readdirSync(directory);
509
+ } catch {
510
+ continue;
511
+ }
512
+ for (const name of names) {
513
+ if (!name.endsWith(MIGRATION_TEMP_SUFFIX)) continue;
514
+ const target = path.join(directory, name);
515
+ let mtimeMs;
516
+ try {
517
+ mtimeMs = fs.statSync(target).mtimeMs;
518
+ } catch {
519
+ continue;
520
+ }
521
+ // Only stale temps are swept: a live contender's fresh temp is never
522
+ // touched, and staging temps are inert until promoted from freshly
523
+ // validated legacy bytes through an exclusive create.
524
+ if (!Number.isFinite(mtimeMs) || mtimeMs > cutoff) continue;
525
+ try {
526
+ fs.unlinkSync(target);
527
+ } catch {
528
+ // Best-effort sweep; the temp stays inert either way.
529
+ }
530
+ }
531
+ }
532
+ }
533
+
534
+ function installExclusive(target, bytes) {
535
+ try {
536
+ fs.writeFileSync(target, bytes, { flag: "wx" });
537
+ return { ok: true, installed: true };
538
+ } catch (cause) {
539
+ if (cause?.code === "EEXIST") return { ok: true, installed: false };
540
+ return error("MIGRATION_STAGE_FAILED", `Unable to install canonical artifact ${JSON.stringify(target)}: ${processErrorDetail(cause)}`, true);
541
+ }
542
+ }
543
+
544
+ // Settles one validated promotion against the canonical target without ever
545
+ // overwriting it: the exclusive create is the single linearization point,
546
+ // and a lost race re-reads the winner and byte-compares instead. Returns
547
+ // `{ ok, migrated }`, a retryable error result, or `{ ok: false, conflict }`
548
+ // for the caller to collect into a fail-closed MIGRATION_CONFLICT.
549
+ function settlePromotionTarget({ type, planId, legacyTarget, target, bytes }) {
550
+ const installed = installExclusive(target, bytes);
551
+ if (installed.error) return installed;
552
+ if (installed.installed) return { ok: true, migrated: true };
553
+ // Another contender installed first: byte-compare, never overwrite.
554
+ const current = readBytesOptional(target);
555
+ if (current.error) return current;
556
+ if (!current.present) {
557
+ return error("MIGRATION_PROMOTE_FAILED", `Canonical artifact ${JSON.stringify(target)} changed during promotion; retry.`, true);
558
+ }
559
+ if (current.bytes.equals(bytes)) {
560
+ return { ok: true, migrated: false }; // Idempotent: the canonical copy already carries these bytes.
561
+ }
562
+ if (parseArtifact(current.bytes.toString("utf8")).error) {
563
+ return repairCorruptTarget({ type, planId, legacyTarget, target });
564
+ }
565
+ return {
566
+ ok: false,
567
+ conflict: {
568
+ artifactType: type,
569
+ planId,
570
+ reason: "DIVERGENT_BYTES",
571
+ detail: "Another contender promoted different valid bytes first; no side was selected or replaced.",
572
+ legacyPath: legacyTarget,
573
+ canonicalPath: target,
574
+ },
575
+ };
576
+ }
577
+
578
+ // Claims the per-artifact repair guard through an atomic exclusive create.
579
+ // Returns `{ ok: true }` for the single holder, a retryable error result
580
+ // when coordination itself fails, or `{ ok: false, conflict }` when another
581
+ // contender holds a live guard — which fails closed instead of risking a
582
+ // divergent promotion. A stale guard (holder crashed) is removed and the
583
+ // claim retried once.
584
+ function acquireRepairGuard(guard) {
585
+ const claim = `${process.pid}.${randomBytes(6).toString("hex")}\n`;
586
+ const concurrentConflict = () => ({
587
+ ok: false,
588
+ conflict: {
589
+ reason: "CONCURRENT_REPAIR",
590
+ detail: "Another contender is repairing this artifact; failing closed instead of risking a divergent promotion. Retry once it settles.",
591
+ },
592
+ });
593
+ for (let attempt = 0; attempt < 2; attempt += 1) {
594
+ try {
595
+ fs.writeFileSync(guard, claim, { flag: "wx" });
596
+ return { ok: true };
597
+ } catch (cause) {
598
+ if (cause?.code !== "EEXIST") {
599
+ return error("MIGRATION_PROMOTE_FAILED", `Unable to coordinate corrupt repair for ${JSON.stringify(guard)}: ${processErrorDetail(cause)}`, true);
600
+ }
601
+ }
602
+ let mtimeMs;
603
+ try {
604
+ mtimeMs = fs.statSync(guard).mtimeMs;
605
+ } catch {
606
+ continue; // The guard vanished; retry the exclusive claim once.
607
+ }
608
+ if (Number.isFinite(mtimeMs) && Date.now() - mtimeMs > REPAIR_GUARD_STALE_MS) {
609
+ try {
610
+ fs.unlinkSync(guard);
611
+ } catch {
612
+ // Another contender removed or replaced it; retry the claim once.
613
+ }
614
+ continue;
615
+ }
616
+ return concurrentConflict();
617
+ }
618
+ return concurrentConflict();
619
+ }
620
+
621
+ // Repairs a corrupt canonical target from the legacy source without ever
622
+ // unlinking a concurrent winner: a per-artifact repair guard serializes the
623
+ // unlink window, both sides are re-read under the guard, and anything other
624
+ // than the expected corrupt state settles by byte-compare instead of
625
+ // overwriting. The exclusive install stays the single linearization point,
626
+ // so two contenders can never both report migrated with divergent bytes:
627
+ // at most one guard holder repairs, and every other path byte-compares.
628
+ function repairCorruptTarget({ type, planId, layout, legacyTarget, target }) {
629
+ const guard = `${target}${REPAIR_GUARD_SUFFIX}`;
630
+ const claim = acquireRepairGuard(guard);
631
+ if (claim.error) return claim;
632
+ if (claim.conflict) {
633
+ return {
634
+ ok: false,
635
+ conflict: {
636
+ artifactType: type,
637
+ planId,
638
+ reason: claim.conflict.reason,
639
+ detail: claim.conflict.detail,
640
+ legacyPath: legacyTarget,
641
+ canonicalPath: target,
642
+ },
643
+ };
644
+ }
645
+ try {
646
+ const current = readBytesOptional(target);
647
+ if (current.error) return current;
648
+ const fresh = readBytesOptional(legacyTarget);
649
+ if (fresh.error) return fresh;
650
+ if (!fresh.present) {
651
+ return error("MIGRATION_PROMOTE_FAILED", `Legacy artifact ${JSON.stringify(legacyTarget)} disappeared during repair; retry.`, true);
652
+ }
653
+ const check = inspectLegacyArtifact(type, layout, planId, fresh.bytes);
654
+ if (!check.ok) {
655
+ return {
656
+ ok: false,
657
+ conflict: {
658
+ artifactType: type,
659
+ planId,
660
+ reason: check.reason,
661
+ detail: check.detail,
662
+ legacyPath: legacyTarget,
663
+ canonicalPath: target,
664
+ },
665
+ };
666
+ }
667
+ if (current.present && !parseArtifact(current.bytes.toString("utf8")).error) {
668
+ // Settled while the guard was claimed: byte-compare, never overwrite.
669
+ if (current.bytes.equals(fresh.bytes)) return { ok: true, migrated: false };
670
+ return {
671
+ ok: false,
672
+ conflict: {
673
+ artifactType: type,
674
+ planId,
675
+ reason: "DIVERGENT_BYTES",
676
+ detail: "The canonical artifact settled while repair was coordinated; no side was selected or replaced.",
677
+ legacyPath: legacyTarget,
678
+ canonicalPath: target,
679
+ },
680
+ };
681
+ }
682
+ if (current.present) {
683
+ try {
684
+ fs.unlinkSync(target);
685
+ } catch (cause) {
686
+ if (cause?.code !== "ENOENT") {
687
+ return error("MIGRATION_PROMOTE_FAILED", `Unable to remove the corrupt canonical artifact ${JSON.stringify(target)}: ${processErrorDetail(cause)}`, true);
688
+ }
689
+ return error("MIGRATION_PROMOTE_FAILED", `Canonical artifact ${JSON.stringify(target)} changed during repair; retry.`, true);
690
+ }
691
+ }
692
+ const installed = installExclusive(target, fresh.bytes);
693
+ if (installed.error) return installed;
694
+ if (!installed.installed) {
695
+ const raced = readBytesOptional(target);
696
+ if (raced.error) return raced;
697
+ if (!raced.present) {
698
+ return error("MIGRATION_PROMOTE_FAILED", `Canonical artifact ${JSON.stringify(target)} changed during repair; retry.`, true);
699
+ }
700
+ if (raced.bytes.equals(fresh.bytes)) return { ok: true, migrated: false };
701
+ if (parseArtifact(raced.bytes.toString("utf8")).error) {
702
+ return error("MIGRATION_PROMOTE_FAILED", `Canonical artifact ${JSON.stringify(target)} changed during repair; retry.`, true);
703
+ }
704
+ return {
705
+ ok: false,
706
+ conflict: {
707
+ artifactType: type,
708
+ planId,
709
+ reason: "DIVERGENT_BYTES",
710
+ detail: "Another contender promoted different valid bytes during repair; no side was selected or replaced.",
711
+ legacyPath: legacyTarget,
712
+ canonicalPath: target,
713
+ },
714
+ };
715
+ }
716
+ const landed = readBytesOptional(target);
717
+ if (landed.error) return landed;
718
+ if (!landed.present || !landed.bytes.equals(fresh.bytes)) {
719
+ return error("MIGRATION_PROMOTE_FAILED", `Canonical artifact ${JSON.stringify(target)} changed during repair; retry.`, true);
720
+ }
721
+ return { ok: true, migrated: true };
722
+ } finally {
723
+ try {
724
+ fs.unlinkSync(guard);
725
+ } catch {
726
+ // The guard is always released; a crash leaves a stale guard that a
727
+ // later contender takes over by modification time.
728
+ }
729
+ }
730
+ }
731
+
732
+ function inspectLegacyArtifact(type, layout, planId, bytes) {
733
+ const parsed = parseArtifact(bytes.toString("utf8"));
734
+ if (parsed.error) {
735
+ return { ok: false, reason: "CORRUPT_LEGACY_ARTIFACT", detail: parsed.error.message };
736
+ }
737
+ const metadata = parsed.metadata;
738
+ if (metadata.schema !== SCHEMA_VERSION) {
739
+ return {
740
+ ok: false,
741
+ reason: "LEGACY_SCHEMA_MISMATCH",
742
+ detail: `Legacy artifact schema ${JSON.stringify(metadata.schema)} is not ${SCHEMA_VERSION}.`,
743
+ };
744
+ }
745
+ if (metadata.artifactType !== type) {
746
+ return {
747
+ ok: false,
748
+ reason: "LEGACY_ARTIFACT_TYPE_MISMATCH",
749
+ detail: `Legacy artifact records type ${JSON.stringify(metadata.artifactType)}, expected ${JSON.stringify(type)}.`,
750
+ };
751
+ }
752
+ if (metadata.planId !== planId) {
753
+ return {
754
+ ok: false,
755
+ reason: "LEGACY_PLAN_ID_MISMATCH",
756
+ detail: `Legacy artifact records plan ID ${JSON.stringify(metadata.planId)}, expected ${JSON.stringify(planId)}.`,
757
+ };
758
+ }
759
+ if (typeof metadata.identity !== "string" || metadata.identity.length === 0 || metadata.identity !== layout.identity) {
760
+ return {
761
+ ok: false,
762
+ reason: "LEGACY_IDENTITY_MISMATCH",
763
+ detail: `Legacy artifact belongs to repository ${JSON.stringify(metadata.identity)}, not ${JSON.stringify(layout.identity)}.`,
764
+ };
765
+ }
766
+ // Full required metadata validity, mirroring the canonical writer's stamp.
767
+ if (typeof metadata.toplevel !== "string" || metadata.toplevel.length === 0) {
768
+ return {
769
+ ok: false,
770
+ reason: "LEGACY_INVALID_METADATA",
771
+ detail: "Legacy artifact is missing a valid toplevel.",
772
+ };
773
+ }
774
+ for (const key of ["createdAt", "updatedAt"]) {
775
+ const value = metadata[key];
776
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
777
+ return {
778
+ ok: false,
779
+ reason: "LEGACY_INVALID_METADATA",
780
+ detail: `Legacy artifact metadata ${JSON.stringify(key)} is not a valid timestamp.`,
781
+ };
782
+ }
783
+ }
784
+ // The legacy body must satisfy the same constraints as a canonical write:
785
+ // a nonempty Markdown body of at most MAX_MARKDOWN_BYTES UTF-8 bytes.
786
+ const markdownFailure = validateMarkdown(parsed.markdown);
787
+ if (markdownFailure) {
788
+ return {
789
+ ok: false,
790
+ reason: "LEGACY_INVALID_MARKDOWN",
791
+ detail: markdownFailure.error.message,
792
+ };
793
+ }
794
+ return { ok: true };
795
+ }
796
+
797
+ function promoteLegacyArtifact({ type, planId, legacyTarget, canonicalDirectory, target, bytes }) {
798
+ let temp;
799
+ try {
800
+ fs.mkdirSync(canonicalDirectory, { recursive: true });
801
+ temp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}${MIGRATION_TEMP_SUFFIX}`;
802
+ fs.writeFileSync(temp, bytes);
803
+ } catch (cause) {
804
+ if (temp) {
805
+ try {
806
+ fs.unlinkSync(temp);
807
+ } catch {
808
+ // The staging write may never have created the temp file.
809
+ }
810
+ }
811
+ return error("MIGRATION_STAGE_FAILED", `Unable to stage legacy ${type} artifact ${JSON.stringify(planId)}: ${processErrorDetail(cause)}`, true);
812
+ }
813
+ try {
814
+ return settlePromotionTarget({ type, planId, legacyTarget, target, bytes });
815
+ } finally {
816
+ try {
817
+ fs.unlinkSync(temp);
818
+ } catch {
819
+ // Each contender always cleans its own staging temp; a crash leaves
820
+ // an inert orphan that later calls sweep once stale.
821
+ }
822
+ }
823
+ }
824
+
825
+ async function reconcileLegacyState(layout) {
826
+ const paths = migrationPaths(layout);
827
+ removeRetiredCoordinationFiles(paths);
828
+ sweepStaleStagingTemps(paths);
829
+
830
+ const conflicts = [];
831
+ const migrated = [];
832
+ for (const type of [ARTIFACT_TYPES.PLAN, ARTIFACT_TYPES.EXECUTION]) {
833
+ const legacyDirectory = paths.legacyDirectory(type);
834
+ const canonicalDirectory = paths.canonicalDirectory(type);
835
+ const listed = listLegacyArtifactNames(legacyDirectory);
836
+ if (listed.error) return listed;
837
+ for (const name of listed.names) {
838
+ const planId = name.slice(0, -MARKDOWN_SUFFIX.length);
839
+ const legacyTarget = path.join(legacyDirectory, name);
840
+ const canonicalTarget = path.join(canonicalDirectory, `${planId}${MARKDOWN_SUFFIX}`);
841
+ if (!PLAN_ID_PATTERN.test(planId)) {
842
+ conflicts.push({
843
+ artifactType: type,
844
+ planId,
845
+ reason: "INVALID_PLAN_ID",
846
+ detail: "Legacy artifact file name is not a valid plan ID.",
847
+ legacyPath: legacyTarget,
848
+ canonicalPath: null,
849
+ });
850
+ continue;
851
+ }
852
+ const legacyBytes = readBytesOptional(legacyTarget);
853
+ if (legacyBytes.error) return legacyBytes;
854
+ if (!legacyBytes.present) continue; // Vanished mid-scan; nothing to reconcile.
855
+ const canonicalBytes = readBytesOptional(canonicalTarget);
856
+ if (canonicalBytes.error) return canonicalBytes;
857
+
858
+ if (canonicalBytes.present && canonicalBytes.bytes.equals(legacyBytes.bytes)) {
859
+ continue; // Identical bytes: the canonical copy already carries the legacy state.
860
+ }
861
+
862
+ if (canonicalBytes.present && parseArtifact(canonicalBytes.bytes.toString("utf8")).error) {
863
+ // The canonical copy carries no parsable state, so the legacy
864
+ // source repairs it through fresh revalidation plus unlink and an
865
+ // exclusive create — never a blind overwrite.
866
+ const repaired = repairCorruptTarget({ type, planId, layout, legacyTarget, target: canonicalTarget });
867
+ if (repaired.error) return repaired;
868
+ if (repaired.conflict) {
869
+ conflicts.push(repaired.conflict);
870
+ continue;
871
+ }
872
+ if (repaired.migrated) migrated.push({ artifactType: type, planId, path: canonicalTarget });
873
+ continue;
874
+ }
875
+
876
+ const legacyCheck = inspectLegacyArtifact(type, layout, planId, legacyBytes.bytes);
877
+ if (!legacyCheck.ok) {
878
+ conflicts.push({
879
+ artifactType: type,
880
+ planId,
881
+ reason: legacyCheck.reason,
882
+ detail: legacyCheck.detail,
883
+ legacyPath: legacyTarget,
884
+ canonicalPath: canonicalTarget,
885
+ });
886
+ continue;
887
+ }
888
+ if (canonicalBytes.present) {
889
+ conflicts.push({
890
+ artifactType: type,
891
+ planId,
892
+ reason: "DIVERGENT_BYTES",
893
+ detail: "Legacy and canonical artifacts are both valid but differ; no side was selected or replaced.",
894
+ legacyPath: legacyTarget,
895
+ canonicalPath: canonicalTarget,
896
+ });
897
+ continue;
898
+ }
899
+ const promotion = promoteLegacyArtifact({ type, planId, legacyTarget, canonicalDirectory, target: canonicalTarget, bytes: legacyBytes.bytes });
900
+ if (promotion.error) return promotion;
901
+ if (promotion.conflict) {
902
+ conflicts.push(promotion.conflict);
903
+ continue;
904
+ }
905
+ if (promotion.migrated) migrated.push({ artifactType: type, planId, path: canonicalTarget });
906
+ }
907
+ }
908
+
909
+ if (conflicts.length > 0) {
910
+ return error(
911
+ "MIGRATION_CONFLICT",
912
+ `Legacy state reconciliation found ${conflicts.length} unresolvable conflict(s) between ${JSON.stringify(paths.legacyRoot)} and ${JSON.stringify(paths.canonicalRoot)}; no artifact was selected or replaced. Resolve the named files and retry.`,
913
+ false,
914
+ { conflicts },
915
+ );
916
+ }
917
+ return { ok: true, migrated };
918
+ }
919
+
920
+ // --- Developer steering primitives (M2, Option A) ---------------------------
921
+ //
922
+ // Trusted Developer steering only. The sole submitter is the explicit
923
+ // non-flock `developer` context enforced in src/index.js; this service
924
+ // never infers Developer from session mode, directory, environment text,
925
+ // or prompt content. Provenance recorded here is integration-asserted,
926
+ // never an authenticated human (see README).
927
+ //
928
+ // Storage is per Plan ID target scoped under
929
+ // `<git-common-dir>/flocky/steering/<planId>/`:
930
+ // - `entries/<seq10>-<id>.json` immutable publications, service-assigned
931
+ // sequence (1, 2, ...) plus opaque steering id plus timestamp plus
932
+ // trusted provenance plus target identity plus bounded content plus
933
+ // schema version;
934
+ // - `checkpoint.json` holding highest contiguous consumed sequence plus
935
+ // consumed ids;
936
+ // - `queue.lock` scoped per-target lock via exclusive create with stale
937
+ // takeover;
938
+ // - `queue.journal` write-ahead journal via atomic rename for recoverable
939
+ // interruption.
940
+ //
941
+ // Target resolution uses the explicit planId or infers only when exactly
942
+ // one active steering target exists, else fails closed AMBIGUOUS_TARGET
943
+ // with no repository-wide steering. Check shows unread without loading
944
+ // bodies; read returns ordered exact unread with no mutation; consume
945
+ // advances only after durable checkpoint disposition and is idempotent.
946
+
947
+ function validateSteeringContent(content) {
948
+ if (typeof content !== "string" || content.length === 0) {
949
+ return error("INVALID_STEERING_CONTENT", "Steering content must be a non-empty string.");
950
+ }
951
+ if (Buffer.byteLength(content, "utf8") > MAX_STEERING_BYTES) {
952
+ return error(
953
+ "INVALID_STEERING_CONTENT",
954
+ `Steering content must not exceed ${MAX_STEERING_BYTES} UTF-8 bytes.`,
955
+ );
956
+ }
957
+ return null;
958
+ }
959
+
960
+ function validateSteeringIds(ids) {
961
+ if (!Array.isArray(ids) || ids.length === 0) {
962
+ return error("INVALID_STEERING_IDS", "Steering consume requires a non-empty array of steering ids.");
963
+ }
964
+ if (ids.length > 1000) {
965
+ return error("INVALID_STEERING_IDS", "Steering consume accepts at most 1000 ids per call.");
966
+ }
967
+ for (const id of ids) {
968
+ if (typeof id !== "string" || id.length === 0 || id.length > 128) {
969
+ return error("INVALID_STEERING_IDS", "Each steering id must be a non-empty string of at most 128 characters.");
970
+ }
971
+ }
972
+ return null;
973
+ }
974
+
975
+ // --- M3 ownership validation ------------------------------------------------
976
+ const OWNER_PHASE_VALUES = new Set(Object.values(OWNER_PHASES));
977
+ const LIFECYCLE_STATE_VALUES = new Set(Object.values(LIFECYCLE_STATES));
978
+ const SYNC_POINT_VALUES = new Set(Object.values(SYNC_POINTS));
979
+ const SYNC_DISPOSITION_VALUES = new Set(Object.values(SYNC_DISPOSITIONS));
980
+ const SNAPSHOT_STAGE_VALUES = new Set(Object.values(SNAPSHOT_STAGES));
981
+
982
+ function sensitiveExcluded(value) {
983
+ return typeof value === "string" && SENSITIVE_CONTENT_PATTERN.test(value);
984
+ }
985
+
986
+ function validateOwnerPhase(phase) {
987
+ if (typeof phase !== "string" || !OWNER_PHASE_VALUES.has(phase)) {
988
+ return error(
989
+ "INVALID_OWNER_PHASE",
990
+ `Owner phase must be one of: ${[...OWNER_PHASE_VALUES].join(", ")}.`,
991
+ );
992
+ }
993
+ return null;
994
+ }
995
+
996
+ function validateSession(session) {
997
+ if (typeof session !== "string" || !SESSION_PATTERN.test(session)) {
998
+ return error(
999
+ "INVALID_SESSION",
1000
+ "Authoritative session must be 1-128 characters of letters, digits, colon, underscore, or hyphen.",
1001
+ );
1002
+ }
1003
+ return null;
1004
+ }
1005
+
1006
+ function validateGeneration(generation) {
1007
+ if (!Number.isSafeInteger(generation) || generation < 1) {
1008
+ return error("INVALID_GENERATION", "Generation must be a safe integer of at least 1.");
1009
+ }
1010
+ return null;
1011
+ }
1012
+
1013
+ function validateMilestone(milestone) {
1014
+ if (typeof milestone !== "string" || milestone.length === 0 || milestone.length > MAX_MILESTONE_CHARS) {
1015
+ return error(
1016
+ "INVALID_MILESTONE",
1017
+ `Milestone must be a non-empty string of at most ${MAX_MILESTONE_CHARS} characters.`,
1018
+ );
1019
+ }
1020
+ if (sensitiveExcluded(milestone)) {
1021
+ return error(
1022
+ "SENSITIVE_CONTENT_EXCLUDED",
1023
+ "Milestone must not contain reasoning transcript or scrollback content; store only bounded semantic summaries.",
1024
+ );
1025
+ }
1026
+ return null;
1027
+ }
1028
+
1029
+ function validateLifecycleState(value) {
1030
+ if (typeof value !== "string" || !LIFECYCLE_STATE_VALUES.has(value)) {
1031
+ return error(
1032
+ "INVALID_LIFECYCLE_STATE",
1033
+ `Lifecycle state must be one of: ${[...LIFECYCLE_STATE_VALUES].join(", ")}.`,
1034
+ );
1035
+ }
1036
+ return null;
1037
+ }
1038
+
1039
+ function validateBoundedSemantic(field, value, max, { allowEmpty = true } = {}) {
1040
+ if (typeof value !== "string") {
1041
+ return error("INVALID_LIFECYCLE_FIELD", `${field} must be a string.`);
1042
+ }
1043
+ if ((!allowEmpty && value.length === 0) || value.length > max) {
1044
+ return error(
1045
+ "INVALID_LIFECYCLE_FIELD",
1046
+ `${field} must be ${allowEmpty ? "0" : "1"}-${max} characters.`,
1047
+ );
1048
+ }
1049
+ if (sensitiveExcluded(value)) {
1050
+ return error(
1051
+ "SENSITIVE_CONTENT_EXCLUDED",
1052
+ `${field} must not contain reasoning transcript or scrollback content; store only bounded semantic summaries.`,
1053
+ );
1054
+ }
1055
+ return null;
1056
+ }
1057
+
1058
+ function validateSyncPoint(value) {
1059
+ if (typeof value !== "string" || !SYNC_POINT_VALUES.has(value)) {
1060
+ return error("INVALID_SYNC_POINT", `Sync point must be one of: ${[...SYNC_POINT_VALUES].join(", ")}.`);
1061
+ }
1062
+ return null;
1063
+ }
1064
+
1065
+ function validateDisposition(value) {
1066
+ if (typeof value !== "string" || !SYNC_DISPOSITION_VALUES.has(value)) {
1067
+ return error(
1068
+ "INVALID_DISPOSITION",
1069
+ `Disposition must be one of: ${[...SYNC_DISPOSITION_VALUES].join(", ")}.`,
1070
+ );
1071
+ }
1072
+ return null;
1073
+ }
1074
+
1075
+ function validateSnapshotStage(value) {
1076
+ if (typeof value !== "string" || !SNAPSHOT_STAGE_VALUES.has(value)) {
1077
+ return error(
1078
+ "INVALID_SNAPSHOT_STAGE",
1079
+ `Snapshot stage must be one of: ${[...SNAPSHOT_STAGE_VALUES].join(", ")}.`,
1080
+ );
1081
+ }
1082
+ return null;
1083
+ }
1084
+
1085
+ function consequentialDenial() {
1086
+ return {
1087
+ push: false,
1088
+ tag: false,
1089
+ publish: false,
1090
+ deploy: false,
1091
+ merge: false,
1092
+ anyConsequential: false,
1093
+ approvalsStillRequired: true,
1094
+ note: "Steering never authorizes push, tag, publish, deploy, merge, or any consequential action; existing approvals still required.",
1095
+ };
1096
+ }
1097
+
1098
+ function validateCorrectionText(correction) {
1099
+ if (typeof correction !== "string" || correction.length === 0 || correction.length > MAX_CORRECTION_CHARS) {
1100
+ return error(
1101
+ "INVALID_CORRECTION",
1102
+ `Correction must be a non-empty string of at most ${MAX_CORRECTION_CHARS} characters.`,
1103
+ );
1104
+ }
1105
+ if (sensitiveExcluded(correction)) {
1106
+ return error(
1107
+ "SENSITIVE_CONTENT_EXCLUDED",
1108
+ "Correction must not contain reasoning transcript or scrollback content; send only bounded semantic instructions.",
1109
+ );
1110
+ }
1111
+ // Raw steering records are JSON dumps with sequence plus opaque id, or
1112
+ // checkpoint shapes. Shepherd sends normal corrective instructions, never
1113
+ // raw records.
1114
+ if (
1115
+ (/"sequence"\s*:\s*\d+/.test(correction) && /st_[0-9a-f]{16}/.test(correction)) ||
1116
+ /"consumedIds"/.test(correction) ||
1117
+ (/"checkpoint"\s*:/.test(correction) && /"highestContiguous"/.test(correction))
1118
+ ) {
1119
+ return error(
1120
+ "RAW_RECORD_REJECTED",
1121
+ "Correction must be normal semantic instructions for sheepdog, never raw steering records or checkpoint dumps.",
1122
+ );
1123
+ }
1124
+ return null;
1125
+ }
1126
+
1127
+ function steeringRoot(layout) {
1128
+ return path.join(layout.identity, STATE_DIR, STEERING_DIR);
1129
+ }
1130
+
1131
+ function steeringTargetDir(layout, planId) {
1132
+ return path.join(steeringRoot(layout), planId);
1133
+ }
1134
+
1135
+ function steeringEntriesDir(layout, planId) {
1136
+ return path.join(steeringTargetDir(layout, planId), STEERING_ENTRIES_DIR);
1137
+ }
1138
+
1139
+ function steeringCheckpointPath(layout, planId) {
1140
+ return path.join(steeringTargetDir(layout, planId), STEERING_CHECKPOINT_FILE);
1141
+ }
1142
+
1143
+ function steeringLockPath(layout, planId) {
1144
+ return path.join(steeringTargetDir(layout, planId), STEERING_LOCK_FILE);
1145
+ }
1146
+
1147
+ function steeringJournalPath(layout, planId) {
1148
+ return path.join(steeringTargetDir(layout, planId), STEERING_JOURNAL_FILE);
1149
+ }
1150
+
1151
+ function parseSteeringFileName(name) {
1152
+ const match = /^(\d{10})-(st_[0-9a-f]{16})\.json$/.exec(name);
1153
+ if (!match) return null;
1154
+ return { sequence: Number.parseInt(match[1], 10), id: match[2] };
1155
+ }
1156
+
1157
+ function steeringEntryFileName(sequence, id) {
1158
+ return `${String(sequence).padStart(10, "0")}-${id}.json`;
1159
+ }
1160
+
1161
+ function createSteeringId() {
1162
+ return `${STEERING_ID_PREFIX}${randomBytes(8).toString("hex")}`;
1163
+ }
1164
+
1165
+ function normalizeSteeringTargetInput(input) {
1166
+ if (typeof input === "string") return { planId: input };
1167
+ if (input === undefined) return { planId: undefined };
1168
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1169
+ return { invalid: true };
1170
+ }
1171
+ const keys = Object.keys(input);
1172
+ if (keys.length === 1 && keys[0] === "planId") return { planId: input.planId };
1173
+ if (keys.length === 0) return { planId: undefined };
1174
+ return { invalid: true };
1175
+ }
1176
+
1177
+ function listSteeringTargets(layout) {
1178
+ const root = steeringRoot(layout);
1179
+ let names;
1180
+ try {
1181
+ names = fs.readdirSync(root);
1182
+ } catch (cause) {
1183
+ if (cause?.code === "ENOENT") return { ok: true, targets: [] };
1184
+ return error("READ_FAILED", `Unable to list steering targets: ${processErrorDetail(cause)}`, true);
1185
+ }
1186
+ const targets = [];
1187
+ for (const name of names) {
1188
+ if (!PLAN_ID_PATTERN.test(name)) continue;
1189
+ const candidate = path.join(root, name);
1190
+ let stat;
1191
+ try {
1192
+ stat = fs.statSync(candidate);
1193
+ } catch {
1194
+ continue;
1195
+ }
1196
+ if (!stat.isDirectory()) continue;
1197
+ targets.push(name);
1198
+ }
1199
+ targets.sort();
1200
+ return { ok: true, targets };
1201
+ }
1202
+
1203
+ async function resolveSteeringTarget(layout, planId) {
1204
+ if (planId === undefined) {
1205
+ const listed = listSteeringTargets(layout);
1206
+ if (listed.error) return listed;
1207
+ if (listed.targets.length !== 1) {
1208
+ return error(
1209
+ "AMBIGUOUS_TARGET",
1210
+ listed.targets.length === 0
1211
+ ? "No steering target was given and no active steering target exists; provide an explicit planId. Repository-wide steering is not permitted."
1212
+ : `No steering target was given and ${listed.targets.length} active steering targets exist; provide an explicit planId. Repository-wide steering is not permitted.`,
1213
+ );
1214
+ }
1215
+ return { ok: true, planId: listed.targets[0] };
1216
+ }
1217
+ const failure = validatePlanId(planId);
1218
+ if (failure) return failure;
1219
+ return { ok: true, planId };
1220
+ }
1221
+
1222
+ function readSteeringCheckpointFile(checkpointPath, layout, planId) {
1223
+ let text;
1224
+ try {
1225
+ text = fs.readFileSync(checkpointPath, "utf8");
1226
+ } catch (cause) {
1227
+ if (cause?.code === "ENOENT") {
1228
+ return {
1229
+ ok: true,
1230
+ checkpoint: { schema: STEERING_SCHEMA_VERSION, planId, identity: layout.identity, highestContiguous: 0, consumedIds: [] },
1231
+ present: false,
1232
+ };
1233
+ }
1234
+ return error("READ_FAILED", `Unable to read steering checkpoint: ${processErrorDetail(cause)}`, true);
1235
+ }
1236
+ let parsed;
1237
+ try {
1238
+ parsed = JSON.parse(text);
1239
+ } catch (cause) {
1240
+ return error("CORRUPT_CHECKPOINT", `Steering checkpoint is not valid JSON: ${processErrorDetail(cause)}`);
1241
+ }
1242
+ if (parsed?.schema !== STEERING_SCHEMA_VERSION) {
1243
+ return error("CORRUPT_CHECKPOINT", `Steering checkpoint schema ${JSON.stringify(parsed?.schema)} is not ${STEERING_SCHEMA_VERSION}.`);
1244
+ }
1245
+ if (parsed?.planId !== planId) {
1246
+ return error("CORRUPT_CHECKPOINT", "Steering checkpoint records a different plan ID.");
1247
+ }
1248
+ if (parsed?.identity !== layout.identity) {
1249
+ return error(
1250
+ "IDENTITY_MISMATCH",
1251
+ `Steering checkpoint belongs to repository ${parsed?.identity}, current repository identity is ${layout.identity}.`,
1252
+ );
1253
+ }
1254
+ if (!Number.isSafeInteger(parsed?.highestContiguous) || parsed.highestContiguous < 0) {
1255
+ return error("CORRUPT_CHECKPOINT", "Steering checkpoint highestContiguous is not a valid sequence.");
1256
+ }
1257
+ if (!Array.isArray(parsed?.consumedIds) || parsed.consumedIds.some((id) => typeof id !== "string")) {
1258
+ return error("CORRUPT_CHECKPOINT", "Steering checkpoint consumedIds must be an array of strings.");
1259
+ }
1260
+ return {
1261
+ ok: true,
1262
+ checkpoint: {
1263
+ schema: STEERING_SCHEMA_VERSION,
1264
+ planId,
1265
+ identity: layout.identity,
1266
+ highestContiguous: parsed.highestContiguous,
1267
+ consumedIds: [...parsed.consumedIds],
1268
+ },
1269
+ present: true,
1270
+ };
1271
+ }
1272
+
1273
+ function listSteeringEntryNames(entriesDir) {
1274
+ let names;
1275
+ try {
1276
+ names = fs.readdirSync(entriesDir);
1277
+ } catch (cause) {
1278
+ if (cause?.code === "ENOENT") return { ok: true, names: [] };
1279
+ return error("READ_FAILED", `Unable to list steering entries: ${processErrorDetail(cause)}`, true);
1280
+ }
1281
+ return { ok: true, names: names.filter((name) => name.endsWith(".json")).sort() };
1282
+ }
1283
+
1284
+ function computeHighestContiguous(sequenceToId, consumedSet) {
1285
+ let next = 1;
1286
+ for (;;) {
1287
+ const id = sequenceToId.get(next);
1288
+ if (!id) break;
1289
+ if (!consumedSet.has(id)) break;
1290
+ next += 1;
1291
+ }
1292
+ return next - 1;
1293
+ }
1294
+
1295
+ function writeAtomicFile(target, data) {
1296
+ const temp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1297
+ try {
1298
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1299
+ fs.writeFileSync(temp, data);
1300
+ fs.renameSync(temp, target);
1301
+ } catch (cause) {
1302
+ try {
1303
+ fs.unlinkSync(temp);
1304
+ } catch {
1305
+ // Temp may not exist when the write itself failed.
1306
+ }
1307
+ return error("WRITE_FAILED", `Unable to atomically write ${JSON.stringify(target)}: ${processErrorDetail(cause)}`, true);
1308
+ }
1309
+ return { ok: true };
1310
+ }
1311
+
1312
+ async function acquireSteeringLock(lockPath) {
1313
+ const claim = `${process.pid}.${randomBytes(6).toString("hex")}\n`;
1314
+ for (let attempt = 0; attempt < STEERING_LOCK_RETRIES; attempt += 1) {
1315
+ try {
1316
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
1317
+ fs.writeFileSync(lockPath, claim, { flag: "wx" });
1318
+ return { ok: true, claim };
1319
+ } catch (cause) {
1320
+ if (cause?.code !== "EEXIST") {
1321
+ return error("WRITE_FAILED", `Unable to acquire steering lock: ${processErrorDetail(cause)}`, true);
1322
+ }
1323
+ }
1324
+ let mtimeMs;
1325
+ try {
1326
+ mtimeMs = fs.statSync(lockPath).mtimeMs;
1327
+ } catch {
1328
+ continue;
1329
+ }
1330
+ if (Number.isFinite(mtimeMs) && Date.now() - mtimeMs > STEERING_LOCK_STALE_MS) {
1331
+ try {
1332
+ fs.unlinkSync(lockPath);
1333
+ } catch {
1334
+ // Another contender removed or replaced it; retry.
1335
+ }
1336
+ continue;
1337
+ }
1338
+ await new Promise((resolve) => setTimeout(resolve, STEERING_LOCK_RETRY_MS));
1339
+ }
1340
+ return error("STEERING_BUSY", "Another steering mutation holds the per-target lock; retry.", true);
1341
+ }
1342
+
1343
+ function releaseSteeringLock(lockPath, claim) {
1344
+ try {
1345
+ const current = fs.readFileSync(lockPath, "utf8");
1346
+ if (current !== claim) return;
1347
+ } catch {
1348
+ return;
1349
+ }
1350
+ try {
1351
+ fs.unlinkSync(lockPath);
1352
+ } catch {
1353
+ // Best-effort release; a stale lock is taken over by mtime.
1354
+ }
1355
+ }
1356
+
1357
+ function readSteeringJournal(journalPath) {
1358
+ let text;
1359
+ try {
1360
+ text = fs.readFileSync(journalPath, "utf8");
1361
+ } catch (cause) {
1362
+ if (cause?.code === "ENOENT") return { ok: true, present: false };
1363
+ return error("READ_FAILED", `Unable to read steering journal: ${processErrorDetail(cause)}`, true);
1364
+ }
1365
+ let parsed;
1366
+ try {
1367
+ parsed = JSON.parse(text);
1368
+ } catch {
1369
+ // A torn journal write is recoverable: discard the torn bytes and let
1370
+ // the caller retry; committed state was never touched.
1371
+ try {
1372
+ fs.unlinkSync(journalPath);
1373
+ } catch {}
1374
+ return { ok: true, present: false, recovered: "torn-journal-discarded" };
1375
+ }
1376
+ return { ok: true, present: true, journal: parsed };
1377
+ }
1378
+
1379
+ function recoverSteeringJournal(layout, planId) {
1380
+ const journalPath = steeringJournalPath(layout, planId);
1381
+ const checkpointPath = steeringCheckpointPath(layout, planId);
1382
+ const entriesDir = steeringEntriesDir(layout, planId);
1383
+ const read = readSteeringJournal(journalPath);
1384
+ if (read.error) return read;
1385
+ if (!read.present) return { ok: true, recovered: false };
1386
+ const journal = read.journal;
1387
+ if (!journal || typeof journal !== "object" || journal.planId !== planId) {
1388
+ try {
1389
+ fs.unlinkSync(journalPath);
1390
+ } catch {}
1391
+ return { ok: true, recovered: "invalid-journal-discarded" };
1392
+ }
1393
+ if (journal.op === "submit" && typeof journal.sequence === "number" && typeof journal.id === "string") {
1394
+ const fileName = steeringEntryFileName(journal.sequence, journal.id);
1395
+ const target = path.join(entriesDir, fileName);
1396
+ let exists = false;
1397
+ try {
1398
+ fs.statSync(target);
1399
+ exists = true;
1400
+ } catch (cause) {
1401
+ if (cause?.code !== "ENOENT") return error("READ_FAILED", `Unable to inspect journaled steering entry: ${processErrorDetail(cause)}`, true);
1402
+ }
1403
+ if (exists) {
1404
+ try {
1405
+ fs.unlinkSync(journalPath);
1406
+ } catch {}
1407
+ return { ok: true, recovered: "submit-already-durable" };
1408
+ }
1409
+ // Replay the journaled submit idempotently when the full entry is
1410
+ // present; otherwise discard (the submitter retries with a new id).
1411
+ if (typeof journal.content === "string" && typeof journal.createdAt === "string" && journal.entry && typeof journal.entry === "object") {
1412
+ try {
1413
+ fs.mkdirSync(entriesDir, { recursive: true });
1414
+ fs.writeFileSync(target, JSON.stringify(journal.entry, null, 2), { flag: "wx" });
1415
+ } catch (cause) {
1416
+ if (cause?.code !== "EEXIST") {
1417
+ return error("WRITE_FAILED", `Unable to replay journaled steering submit: ${processErrorDetail(cause)}`, true);
1418
+ }
1419
+ }
1420
+ }
1421
+ try {
1422
+ fs.unlinkSync(journalPath);
1423
+ } catch {}
1424
+ return { ok: true, recovered: "submit-replayed" };
1425
+ }
1426
+ if (journal.op === "consume" && Array.isArray(journal.consumedIds)) {
1427
+ const checkpoint = readSteeringCheckpointFile(checkpointPath, layout, planId);
1428
+ if (checkpoint.error) return checkpoint;
1429
+ const current = new Set(checkpoint.checkpoint.consumedIds);
1430
+ const wanted = new Set(journal.consumedIds.filter((id) => typeof id === "string"));
1431
+ let covered = true;
1432
+ for (const id of wanted) {
1433
+ if (!current.has(id)) {
1434
+ covered = false;
1435
+ break;
1436
+ }
1437
+ }
1438
+ if (covered) {
1439
+ try {
1440
+ fs.unlinkSync(journalPath);
1441
+ } catch {}
1442
+ return { ok: true, recovered: "consume-already-durable" };
1443
+ }
1444
+ // The journaled consume was not durably recorded: merge it with the
1445
+ // current checkpoint and install atomically, then clear the journal.
1446
+ const merged = new Set([...current, ...wanted]);
1447
+ const listed = listSteeringEntryNames(entriesDir);
1448
+ if (listed.error) return listed;
1449
+ const sequenceToId = new Map();
1450
+ for (const name of listed.names) {
1451
+ const parsed = parseSteeringFileName(name);
1452
+ if (parsed) sequenceToId.set(parsed.sequence, parsed.id);
1453
+ }
1454
+ // Also include journaled submit entries that may not yet be listed?
1455
+ // Consume only references existing entries, so the map is complete.
1456
+ const highest = computeHighestContiguous(sequenceToId, merged);
1457
+ const nextCheckpoint = {
1458
+ schema: STEERING_SCHEMA_VERSION,
1459
+ planId,
1460
+ identity: layout.identity,
1461
+ highestContiguous: highest,
1462
+ consumedIds: [...merged].sort(),
1463
+ };
1464
+ const written = writeAtomicFile(checkpointPath, JSON.stringify(nextCheckpoint, null, 2));
1465
+ if (written.error) return written;
1466
+ try {
1467
+ fs.unlinkSync(journalPath);
1468
+ } catch {}
1469
+ return { ok: true, recovered: "consume-replayed" };
1470
+ }
1471
+ try {
1472
+ fs.unlinkSync(journalPath);
1473
+ } catch {}
1474
+ return { ok: true, recovered: "unknown-journal-discarded" };
1475
+ }
1476
+
1477
+ // --- M3 ownership storage ---------------------------------------------------
1478
+ //
1479
+ // Lifecycle records live per active Plan ID under
1480
+ // `<git-common-dir>/flocky/ownership/<planId>/record.json` with a scoped
1481
+ // per-target lock. Sync dispositions live in `sync.json` as a map from
1482
+ // closed sync-point vocabulary to disposition plus fencing. Snapshots live
1483
+ // in `snapshots/<stage>.json` for the four snapshot stages. All writes are
1484
+ // atomic via temp plus rename under the per-target lock, so concurrent
1485
+ // planning and governance contenders elect exactly one winner and losers
1486
+ // fail closed with STALE_GENERATION instead of diverging.
1487
+ function ownershipPlanDir(layout, planId) {
1488
+ return path.join(layout.identity, STATE_DIR, OWNERSHIP_DIR, planId);
1489
+ }
1490
+
1491
+ function ownershipRecordPath(layout, planId) {
1492
+ return path.join(ownershipPlanDir(layout, planId), OWNERSHIP_RECORD_FILE);
1493
+ }
1494
+
1495
+ function ownershipSyncPath(layout, planId) {
1496
+ return path.join(ownershipPlanDir(layout, planId), OWNERSHIP_SYNC_FILE);
1497
+ }
1498
+
1499
+ function ownershipSnapshotPath(layout, planId, stage) {
1500
+ return path.join(ownershipPlanDir(layout, planId), OWNERSHIP_SNAPSHOTS_DIR, `${stage}.json`);
1501
+ }
1502
+
1503
+ function ownershipLockPath(layout, planId) {
1504
+ return path.join(ownershipPlanDir(layout, planId), OWNERSHIP_LOCK_FILE);
1505
+ }
1506
+
1507
+ async function acquireOwnershipLock(lockPath) {
1508
+ const claim = `${process.pid}.${randomBytes(6).toString("hex")}\n`;
1509
+ for (let attempt = 0; attempt < OWNERSHIP_LOCK_RETRIES; attempt += 1) {
1510
+ try {
1511
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
1512
+ fs.writeFileSync(lockPath, claim, { flag: "wx" });
1513
+ return { ok: true, claim };
1514
+ } catch (cause) {
1515
+ if (cause?.code !== "EEXIST") {
1516
+ return error("WRITE_FAILED", `Unable to acquire ownership lock: ${processErrorDetail(cause)}`, true);
1517
+ }
1518
+ }
1519
+ let mtimeMs;
1520
+ try {
1521
+ mtimeMs = fs.statSync(lockPath).mtimeMs;
1522
+ } catch {
1523
+ continue;
1524
+ }
1525
+ if (Number.isFinite(mtimeMs) && Date.now() - mtimeMs > OWNERSHIP_LOCK_STALE_MS) {
1526
+ try {
1527
+ fs.unlinkSync(lockPath);
1528
+ } catch {
1529
+ // Another contender removed or replaced it; retry.
1530
+ }
1531
+ continue;
1532
+ }
1533
+ await new Promise((resolve) => setTimeout(resolve, OWNERSHIP_LOCK_RETRY_MS));
1534
+ }
1535
+ return error("OWNERSHIP_BUSY", "Another ownership mutation holds the per-target lock; retry.", true);
1536
+ }
1537
+
1538
+ function releaseOwnershipLock(lockPath, claim) {
1539
+ try {
1540
+ const current = fs.readFileSync(lockPath, "utf8");
1541
+ if (current !== claim) return;
1542
+ } catch {
1543
+ return;
1544
+ }
1545
+ try {
1546
+ fs.unlinkSync(lockPath);
1547
+ } catch {
1548
+ // Best-effort release; a stale lock is taken over by mtime.
1549
+ }
1550
+ }
1551
+
1552
+ function readOwnershipRecordFile(recordPath, layout, planId) {
1553
+ let text;
1554
+ try {
1555
+ text = fs.readFileSync(recordPath, "utf8");
1556
+ } catch (cause) {
1557
+ if (cause?.code === "ENOENT") return { ok: true, present: false };
1558
+ return error("READ_FAILED", `Unable to read ownership record: ${processErrorDetail(cause)}`, true);
1559
+ }
1560
+ let parsed;
1561
+ try {
1562
+ parsed = JSON.parse(text);
1563
+ } catch (cause) {
1564
+ return error("CORRUPT_OWNERSHIP", `Ownership record is not valid JSON: ${processErrorDetail(cause)}`);
1565
+ }
1566
+ if (parsed?.schema !== OWNERSHIP_SCHEMA_VERSION) {
1567
+ return error("CORRUPT_OWNERSHIP", `Ownership schema ${JSON.stringify(parsed?.schema)} is not ${OWNERSHIP_SCHEMA_VERSION}.`);
1568
+ }
1569
+ if (parsed?.planId !== planId) {
1570
+ return error("CORRUPT_OWNERSHIP", "Ownership record carries a different plan ID.");
1571
+ }
1572
+ if (parsed?.identity !== layout.identity) {
1573
+ return error(
1574
+ "IDENTITY_MISMATCH",
1575
+ `Ownership record belongs to repository ${parsed?.identity}, current repository identity is ${layout.identity}.`,
1576
+ );
1577
+ }
1578
+ return { ok: true, present: true, record: parsed };
1579
+ }
1580
+
1581
+ function validateOwnershipRecordShape(record) {
1582
+ const phaseFailure = validateOwnerPhase(record?.phase);
1583
+ if (phaseFailure) return phaseFailure;
1584
+ const sessionFailure = validateSession(record?.session);
1585
+ if (sessionFailure) return sessionFailure;
1586
+ const generationFailure = validateGeneration(record?.generation);
1587
+ if (generationFailure) return generationFailure;
1588
+ const milestoneFailure = validateMilestone(record?.milestone);
1589
+ if (milestoneFailure) return milestoneFailure;
1590
+ const stateFailure = validateLifecycleState(record?.lifecycleState);
1591
+ if (stateFailure) return stateFailure;
1592
+ for (const [field, max] of [
1593
+ ["currentObjective", MAX_OBJECTIVE_CHARS],
1594
+ ["currentAction", MAX_ACTION_CHARS],
1595
+ ]) {
1596
+ const failure = validateBoundedSemantic(field, record?.[field], max, { allowEmpty: true });
1597
+ if (failure) return failure;
1598
+ }
1599
+ for (const [field, max] of [
1600
+ ["activeSheepdogTarget", MAX_SHEEPDOG_TARGET_CHARS],
1601
+ ["relevantRevision", MAX_REVISION_CHARS],
1602
+ ["pendingConsequentialAction", MAX_PENDING_CONSEQUENTIAL_CHARS],
1603
+ ]) {
1604
+ const failure = validateBoundedSemantic(field, record?.[field], max, { allowEmpty: true });
1605
+ if (failure) return failure;
1606
+ }
1607
+ if (typeof record?.updatedAt !== "string" || !Number.isFinite(Date.parse(record.updatedAt))) {
1608
+ return error("CORRUPT_OWNERSHIP", "Ownership record updatedAt is not a valid timestamp.");
1609
+ }
1610
+ return null;
1611
+ }
1612
+
1613
+ function checkOwnerFencing(record, phase, session, generation) {
1614
+ if (record.phase !== phase || record.session !== session) {
1615
+ return error(
1616
+ "NOT_AUTHORITATIVE_PHASE",
1617
+ `NOT AUTHORITATIVE PHASE: plan ${JSON.stringify(record.planId)} is owned by phase ${JSON.stringify(record.phase)} session ${JSON.stringify(record.session)} generation ${record.generation}; caller ${JSON.stringify(phase)} session ${JSON.stringify(session)} is not authoritative.`,
1618
+ );
1619
+ }
1620
+ if (generation !== undefined && record.generation !== generation) {
1621
+ return error(
1622
+ "STALE_GENERATION",
1623
+ `Generation ${JSON.stringify(generation)} does not match authoritative generation ${record.generation} for plan ${JSON.stringify(record.planId)}; refetch ownership before acting.`,
1624
+ );
1625
+ }
1626
+ return null;
1627
+ }
1628
+
1629
+ function readOwnershipSyncFile(syncPath, layout, planId) {
1630
+ let text;
1631
+ try {
1632
+ text = fs.readFileSync(syncPath, "utf8");
1633
+ } catch (cause) {
1634
+ if (cause?.code === "ENOENT") {
1635
+ return {
1636
+ ok: true,
1637
+ present: false,
1638
+ sync: { schema: OWNERSHIP_SCHEMA_VERSION, planId, identity: layout.identity, points: {} },
1639
+ };
1640
+ }
1641
+ return error("READ_FAILED", `Unable to read ownership sync: ${processErrorDetail(cause)}`, true);
1642
+ }
1643
+ let parsed;
1644
+ try {
1645
+ parsed = JSON.parse(text);
1646
+ } catch (cause) {
1647
+ return error("CORRUPT_OWNERSHIP", `Ownership sync is not valid JSON: ${processErrorDetail(cause)}`);
1648
+ }
1649
+ if (parsed?.schema !== OWNERSHIP_SCHEMA_VERSION || parsed?.planId !== planId || parsed?.identity !== layout.identity) {
1650
+ return error("CORRUPT_OWNERSHIP", "Ownership sync identity or schema mismatch.");
1651
+ }
1652
+ if (!parsed?.points || typeof parsed.points !== "object" || Array.isArray(parsed.points)) {
1653
+ return error("CORRUPT_OWNERSHIP", "Ownership sync points must be an object.");
1654
+ }
1655
+ return { ok: true, present: true, sync: parsed };
1656
+ }
1657
+
1658
+ function validateClaimInput(input) {
1659
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1660
+ return error("INVALID_REQUEST", "Ownership claim requires an object with planId, phase, session, generation, milestone, lifecycleState, and bounded semantic fields.");
1661
+ }
1662
+ const allowed = new Set([
1663
+ "planId",
1664
+ "phase",
1665
+ "session",
1666
+ "generation",
1667
+ "milestone",
1668
+ "lifecycleState",
1669
+ "currentObjective",
1670
+ "currentAction",
1671
+ "activeSheepdogTarget",
1672
+ "relevantRevision",
1673
+ "pendingConsequentialAction",
1674
+ ]);
1675
+ for (const key of Object.keys(input)) {
1676
+ if (!allowed.has(key)) return error("INVALID_REQUEST", `Ownership claim accepts only ${[...allowed].join(", ")}.`);
1677
+ }
1678
+ for (const key of ["planId", "phase", "session", "generation", "milestone", "lifecycleState"]) {
1679
+ if (!(key in input)) return error("INVALID_REQUEST", `Ownership claim requires ${key}.`);
1680
+ }
1681
+ // Optional semantic fields default to empty string when omitted.
1682
+ const normalized = {
1683
+ currentObjective: "",
1684
+ currentAction: "",
1685
+ activeSheepdogTarget: "",
1686
+ relevantRevision: "",
1687
+ pendingConsequentialAction: "",
1688
+ ...input,
1689
+ };
1690
+ const planIdFailure = validatePlanId(normalized.planId);
1691
+ if (planIdFailure) return planIdFailure;
1692
+ const phaseFailure = validateOwnerPhase(normalized.phase);
1693
+ if (phaseFailure) return phaseFailure;
1694
+ const sessionFailure = validateSession(normalized.session);
1695
+ if (sessionFailure) return sessionFailure;
1696
+ const generationFailure = validateGeneration(normalized.generation);
1697
+ if (generationFailure) return generationFailure;
1698
+ const milestoneFailure = validateMilestone(normalized.milestone);
1699
+ if (milestoneFailure) return milestoneFailure;
1700
+ const stateFailure = validateLifecycleState(normalized.lifecycleState);
1701
+ if (stateFailure) return stateFailure;
1702
+ for (const [field, max] of [
1703
+ ["currentObjective", MAX_OBJECTIVE_CHARS],
1704
+ ["currentAction", MAX_ACTION_CHARS],
1705
+ ["activeSheepdogTarget", MAX_SHEEPDOG_TARGET_CHARS],
1706
+ ["relevantRevision", MAX_REVISION_CHARS],
1707
+ ["pendingConsequentialAction", MAX_PENDING_CONSEQUENTIAL_CHARS],
1708
+ ]) {
1709
+ const failure = validateBoundedSemantic(field, normalized[field], max, { allowEmpty: true });
1710
+ if (failure) return failure;
1711
+ }
1712
+ return { ok: true, normalized };
1713
+ }
1714
+
1715
+ async function claimOwnership(input) {
1716
+ const validated = validateClaimInput(input);
1717
+ if (validated?.error) return validated;
1718
+ const normalized = validated.normalized;
1719
+ const layout = await resolveRepositoryLayout();
1720
+ if (layout.error) return layout;
1721
+ const lockPath = ownershipLockPath(layout, normalized.planId);
1722
+ const acquired = await acquireOwnershipLock(lockPath);
1723
+ if (acquired.error) return acquired;
1724
+ try {
1725
+ const recordPath = ownershipRecordPath(layout, normalized.planId);
1726
+ const existing = readOwnershipRecordFile(recordPath, layout, normalized.planId);
1727
+ if (existing.error) return existing;
1728
+ if (!existing.present) {
1729
+ if (normalized.generation !== 1) {
1730
+ return error(
1731
+ "STALE_GENERATION",
1732
+ `First ownership claim for plan ${JSON.stringify(normalized.planId)} must use generation 1.`,
1733
+ );
1734
+ }
1735
+ } else {
1736
+ const shapeFailure = validateOwnershipRecordShape(existing.record);
1737
+ if (shapeFailure) return shapeFailure;
1738
+ if (normalized.generation <= existing.record.generation) {
1739
+ return error(
1740
+ "STALE_GENERATION",
1741
+ `Claim generation ${normalized.generation} is not newer than authoritative generation ${existing.record.generation} for plan ${JSON.stringify(normalized.planId)}; both phases cannot race on the same generation.`,
1742
+ );
1743
+ }
1744
+ }
1745
+ const timestamp = resolveNow(now).toISOString();
1746
+ const record = {
1747
+ schema: OWNERSHIP_SCHEMA_VERSION,
1748
+ planId: normalized.planId,
1749
+ identity: layout.identity,
1750
+ toplevel: layout.toplevel,
1751
+ phase: normalized.phase,
1752
+ session: normalized.session,
1753
+ generation: normalized.generation,
1754
+ milestone: normalized.milestone,
1755
+ lifecycleState: normalized.lifecycleState,
1756
+ currentObjective: normalized.currentObjective,
1757
+ currentAction: normalized.currentAction,
1758
+ activeSheepdogTarget: normalized.activeSheepdogTarget,
1759
+ relevantRevision: normalized.relevantRevision,
1760
+ pendingConsequentialAction: normalized.pendingConsequentialAction,
1761
+ updatedAt: timestamp,
1762
+ };
1763
+ const written = writeAtomicFile(recordPath, JSON.stringify(record, null, 2));
1764
+ if (written.error) return written;
1765
+ return { ok: true, ownership: record };
1766
+ } finally {
1767
+ releaseOwnershipLock(lockPath, acquired.claim);
1768
+ }
1769
+ }
1770
+
1771
+ async function readOwnership(input) {
1772
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1773
+ return error("INVALID_REQUEST", "Ownership read requires an object with planId, phase, and session.");
1774
+ }
1775
+ const keys = Object.keys(input);
1776
+ if (keys.some((key) => key !== "planId" && key !== "phase" && key !== "session")) {
1777
+ return error("INVALID_REQUEST", "Ownership read accepts only planId, phase, and session.");
1778
+ }
1779
+ if (input.planId === undefined || input.phase === undefined || input.session === undefined) {
1780
+ return error("INVALID_REQUEST", "Ownership read requires planId, phase, and session.");
1781
+ }
1782
+ const planIdFailure = validatePlanId(input.planId);
1783
+ if (planIdFailure) return planIdFailure;
1784
+ const phaseFailure = validateOwnerPhase(input.phase);
1785
+ if (phaseFailure) return phaseFailure;
1786
+ const sessionFailure = validateSession(input.session);
1787
+ if (sessionFailure) return sessionFailure;
1788
+ const layout = await resolveRepositoryLayout();
1789
+ if (layout.error) return layout;
1790
+ const recordPath = ownershipRecordPath(layout, input.planId);
1791
+ const stored = readOwnershipRecordFile(recordPath, layout, input.planId);
1792
+ if (stored.error) return stored;
1793
+ if (!stored.present) return error("OWNERSHIP_NOT_FOUND", `No ownership record exists for plan ${JSON.stringify(input.planId)}.`);
1794
+ const shapeFailure = validateOwnershipRecordShape(stored.record);
1795
+ if (shapeFailure) return shapeFailure;
1796
+ const fencing = checkOwnerFencing(stored.record, input.phase, input.session, undefined);
1797
+ if (fencing) return fencing;
1798
+ return { ok: true, ownership: stored.record };
1799
+ }
1800
+
1801
+ async function recordSync(input) {
1802
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1803
+ return error("INVALID_REQUEST", "Ownership sync requires planId, phase, session, generation, syncPoint, and disposition.");
1804
+ }
1805
+ const allowed = new Set(["planId", "phase", "session", "generation", "syncPoint", "disposition", "note"]);
1806
+ for (const key of Object.keys(input)) {
1807
+ if (!allowed.has(key)) return error("INVALID_REQUEST", "Ownership sync accepts only planId, phase, session, generation, syncPoint, disposition, and optional note.");
1808
+ }
1809
+ for (const key of ["planId", "phase", "session", "generation", "syncPoint", "disposition"]) {
1810
+ if (!(key in input)) return error("INVALID_REQUEST", `Ownership sync requires ${key}.`);
1811
+ }
1812
+ const planIdFailure = validatePlanId(input.planId);
1813
+ if (planIdFailure) return planIdFailure;
1814
+ const phaseFailure = validateOwnerPhase(input.phase);
1815
+ if (phaseFailure) return phaseFailure;
1816
+ const sessionFailure = validateSession(input.session);
1817
+ if (sessionFailure) return sessionFailure;
1818
+ const generationFailure = validateGeneration(input.generation);
1819
+ if (generationFailure) return generationFailure;
1820
+ const pointFailure = validateSyncPoint(input.syncPoint);
1821
+ if (pointFailure) return pointFailure;
1822
+ const dispositionFailure = validateDisposition(input.disposition);
1823
+ if (dispositionFailure) return dispositionFailure;
1824
+ if (input.note !== undefined) {
1825
+ const noteFailure = validateBoundedSemantic("note", input.note, MAX_ACTION_CHARS, { allowEmpty: true });
1826
+ if (noteFailure) return noteFailure;
1827
+ }
1828
+ const layout = await resolveRepositoryLayout();
1829
+ if (layout.error) return layout;
1830
+ const lockPath = ownershipLockPath(layout, input.planId);
1831
+ const acquired = await acquireOwnershipLock(lockPath);
1832
+ if (acquired.error) return acquired;
1833
+ try {
1834
+ const recordPath = ownershipRecordPath(layout, input.planId);
1835
+ const stored = readOwnershipRecordFile(recordPath, layout, input.planId);
1836
+ if (stored.error) return stored;
1837
+ if (!stored.present) return error("OWNERSHIP_NOT_FOUND", `No ownership record exists for plan ${JSON.stringify(input.planId)}; claim ownership before sync.`);
1838
+ const shapeFailure = validateOwnershipRecordShape(stored.record);
1839
+ if (shapeFailure) return shapeFailure;
1840
+ const fencing = checkOwnerFencing(stored.record, input.phase, input.session, input.generation);
1841
+ if (fencing) return fencing;
1842
+ // Pending consequential action must be recorded before its mandatory
1843
+ // consequential-preparation check: the lifecycle record must already
1844
+ // carry a non-empty pending action.
1845
+ if (input.syncPoint === SYNC_POINTS.CONSEQUENTIAL_PREPARATION && stored.record.pendingConsequentialAction.length === 0) {
1846
+ return error(
1847
+ "PENDING_CONSEQUENTIAL_REQUIRED",
1848
+ "Pending consequential action must be recorded in the lifecycle record before the consequential-preparation sync point.",
1849
+ );
1850
+ }
1851
+ const syncPath = ownershipSyncPath(layout, input.planId);
1852
+ const current = readOwnershipSyncFile(syncPath, layout, input.planId);
1853
+ if (current.error) return current;
1854
+ const timestamp = resolveNow(now).toISOString();
1855
+ const entry = {
1856
+ syncPoint: input.syncPoint,
1857
+ disposition: input.disposition,
1858
+ phase: input.phase,
1859
+ session: input.session,
1860
+ generation: input.generation,
1861
+ note: input.note ?? "",
1862
+ timestamp,
1863
+ consequentialAuthorization: consequentialDenial(),
1864
+ };
1865
+ const next = {
1866
+ schema: OWNERSHIP_SCHEMA_VERSION,
1867
+ planId: input.planId,
1868
+ identity: layout.identity,
1869
+ points: { ...current.sync.points, [input.syncPoint]: entry },
1870
+ };
1871
+ const written = writeAtomicFile(syncPath, JSON.stringify(next, null, 2));
1872
+ if (written.error) return written;
1873
+ return { ok: true, planId: input.planId, sync: entry, points: next.points };
1874
+ } finally {
1875
+ releaseOwnershipLock(lockPath, acquired.claim);
1876
+ }
1877
+ }
1878
+
1879
+ async function readSync(input) {
1880
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1881
+ return error("INVALID_REQUEST", "Ownership sync read requires planId, phase, and session.");
1882
+ }
1883
+ const keys = Object.keys(input);
1884
+ if (keys.some((key) => key !== "planId" && key !== "phase" && key !== "session")) {
1885
+ return error("INVALID_REQUEST", "Ownership sync read accepts only planId, phase, and session.");
1886
+ }
1887
+ if (input.planId === undefined || input.phase === undefined || input.session === undefined) {
1888
+ return error("INVALID_REQUEST", "Ownership sync read requires planId, phase, and session.");
1889
+ }
1890
+ const planIdFailure = validatePlanId(input.planId);
1891
+ if (planIdFailure) return planIdFailure;
1892
+ const phaseFailure = validateOwnerPhase(input.phase);
1893
+ if (phaseFailure) return phaseFailure;
1894
+ const sessionFailure = validateSession(input.session);
1895
+ if (sessionFailure) return sessionFailure;
1896
+ const layout = await resolveRepositoryLayout();
1897
+ if (layout.error) return layout;
1898
+ const recordPath = ownershipRecordPath(layout, input.planId);
1899
+ const stored = readOwnershipRecordFile(recordPath, layout, input.planId);
1900
+ if (stored.error) return stored;
1901
+ if (!stored.present) return error("OWNERSHIP_NOT_FOUND", `No ownership record exists for plan ${JSON.stringify(input.planId)}.`);
1902
+ const fencing = checkOwnerFencing(stored.record, input.phase, input.session, undefined);
1903
+ if (fencing) return fencing;
1904
+ const syncPath = ownershipSyncPath(layout, input.planId);
1905
+ const current = readOwnershipSyncFile(syncPath, layout, input.planId);
1906
+ if (current.error) return current;
1907
+ return { ok: true, planId: input.planId, points: current.sync.points, ownership: stored.record };
1908
+ }
1909
+
1910
+ async function recordSnapshot(input) {
1911
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1912
+ return error("INVALID_REQUEST", "Ownership snapshot requires planId, phase, session, generation, and stage.");
1913
+ }
1914
+ const allowed = new Set([
1915
+ "planId",
1916
+ "phase",
1917
+ "session",
1918
+ "generation",
1919
+ "stage",
1920
+ "milestone",
1921
+ "lifecycleState",
1922
+ "currentObjective",
1923
+ "currentAction",
1924
+ "activeSheepdogTarget",
1925
+ "relevantRevision",
1926
+ "pendingConsequentialAction",
1927
+ ]);
1928
+ for (const key of Object.keys(input)) {
1929
+ if (!allowed.has(key)) return error("INVALID_REQUEST", "Ownership snapshot accepts only lifecycle fields plus stage.");
1930
+ }
1931
+ for (const key of ["planId", "phase", "session", "generation", "stage"]) {
1932
+ if (!(key in input)) return error("INVALID_REQUEST", `Ownership snapshot requires ${key}.`);
1933
+ }
1934
+ const planIdFailure = validatePlanId(input.planId);
1935
+ if (planIdFailure) return planIdFailure;
1936
+ const phaseFailure = validateOwnerPhase(input.phase);
1937
+ if (phaseFailure) return phaseFailure;
1938
+ const sessionFailure = validateSession(input.session);
1939
+ if (sessionFailure) return sessionFailure;
1940
+ const generationFailure = validateGeneration(input.generation);
1941
+ if (generationFailure) return generationFailure;
1942
+ const stageFailure = validateSnapshotStage(input.stage);
1943
+ if (stageFailure) return stageFailure;
1944
+ for (const [field, max] of [
1945
+ ["milestone", MAX_MILESTONE_CHARS],
1946
+ ["currentObjective", MAX_OBJECTIVE_CHARS],
1947
+ ["currentAction", MAX_ACTION_CHARS],
1948
+ ["activeSheepdogTarget", MAX_SHEEPDOG_TARGET_CHARS],
1949
+ ["relevantRevision", MAX_REVISION_CHARS],
1950
+ ["pendingConsequentialAction", MAX_PENDING_CONSEQUENTIAL_CHARS],
1951
+ ]) {
1952
+ if (input[field] !== undefined) {
1953
+ const failure = field === "milestone"
1954
+ ? (input[field].length === 0 ? error("INVALID_MILESTONE", "Milestone must be non-empty when provided.") : validateBoundedSemantic(field, input[field], max, { allowEmpty: false }))
1955
+ : validateBoundedSemantic(field, input[field], max, { allowEmpty: true });
1956
+ if (failure) return failure;
1957
+ if (sensitiveExcluded(input[field])) {
1958
+ return error("SENSITIVE_CONTENT_EXCLUDED", `${field} must not contain reasoning transcript or scrollback content.`);
1959
+ }
1960
+ }
1961
+ }
1962
+ if (input.lifecycleState !== undefined) {
1963
+ const stateFailure = validateLifecycleState(input.lifecycleState);
1964
+ if (stateFailure) return stateFailure;
1965
+ }
1966
+ const layout = await resolveRepositoryLayout();
1967
+ if (layout.error) return layout;
1968
+ const lockPath = ownershipLockPath(layout, input.planId);
1969
+ const acquired = await acquireOwnershipLock(lockPath);
1970
+ if (acquired.error) return acquired;
1971
+ try {
1972
+ const recordPath = ownershipRecordPath(layout, input.planId);
1973
+ const stored = readOwnershipRecordFile(recordPath, layout, input.planId);
1974
+ if (stored.error) return stored;
1975
+ if (!stored.present) return error("OWNERSHIP_NOT_FOUND", `No ownership record exists for plan ${JSON.stringify(input.planId)}; claim ownership before snapshots.`);
1976
+ const shapeFailure = validateOwnershipRecordShape(stored.record);
1977
+ if (shapeFailure) return shapeFailure;
1978
+ const fencing = checkOwnerFencing(stored.record, input.phase, input.session, input.generation);
1979
+ if (fencing) return fencing;
1980
+ const pending = input.pendingConsequentialAction ?? stored.record.pendingConsequentialAction;
1981
+ if (input.stage === SNAPSHOT_STAGES.CONSEQUENTIAL_PREPARATION && pending.length === 0) {
1982
+ return error(
1983
+ "PENDING_CONSEQUENTIAL_REQUIRED",
1984
+ "Pending consequential action must be recorded before the consequential-preparation snapshot.",
1985
+ );
1986
+ }
1987
+ const timestamp = resolveNow(now).toISOString();
1988
+ const snapshot = {
1989
+ schema: OWNERSHIP_SCHEMA_VERSION,
1990
+ planId: input.planId,
1991
+ identity: layout.identity,
1992
+ stage: input.stage,
1993
+ phase: input.phase,
1994
+ session: input.session,
1995
+ generation: input.generation,
1996
+ milestone: input.milestone ?? stored.record.milestone,
1997
+ lifecycleState: input.lifecycleState ?? stored.record.lifecycleState,
1998
+ currentObjective: input.currentObjective ?? stored.record.currentObjective,
1999
+ currentAction: input.currentAction ?? stored.record.currentAction,
2000
+ activeSheepdogTarget: input.activeSheepdogTarget ?? stored.record.activeSheepdogTarget,
2001
+ relevantRevision: input.relevantRevision ?? stored.record.relevantRevision,
2002
+ pendingConsequentialAction: pending,
2003
+ timestamp,
2004
+ consequentialAuthorization: consequentialDenial(),
2005
+ };
2006
+ const target = ownershipSnapshotPath(layout, input.planId, input.stage);
2007
+ const written = writeAtomicFile(target, JSON.stringify(snapshot, null, 2));
2008
+ if (written.error) return written;
2009
+ return { ok: true, snapshot };
2010
+ } finally {
2011
+ releaseOwnershipLock(lockPath, acquired.claim);
2012
+ }
2013
+ }
2014
+
2015
+ async function routeCorrection(input) {
2016
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
2017
+ return error("INVALID_REQUEST", "Correction routing requires planId, phase, session, generation, and correction.");
2018
+ }
2019
+ const allowed = new Set(["planId", "phase", "session", "generation", "correction", "syncPoint"]);
2020
+ for (const key of Object.keys(input)) {
2021
+ if (!allowed.has(key)) return error("INVALID_REQUEST", "Correction routing accepts only planId, phase, session, generation, correction, and optional syncPoint.");
2022
+ }
2023
+ for (const key of ["planId", "phase", "session", "generation", "correction"]) {
2024
+ if (!(key in input)) return error("INVALID_REQUEST", `Correction routing requires ${key}.`);
2025
+ }
2026
+ const planIdFailure = validatePlanId(input.planId);
2027
+ if (planIdFailure) return planIdFailure;
2028
+ const phaseFailure = validateOwnerPhase(input.phase);
2029
+ if (phaseFailure) return phaseFailure;
2030
+ const sessionFailure = validateSession(input.session);
2031
+ if (sessionFailure) return sessionFailure;
2032
+ const generationFailure = validateGeneration(input.generation);
2033
+ if (generationFailure) return generationFailure;
2034
+ const correctionFailure = validateCorrectionText(input.correction);
2035
+ if (correctionFailure) return correctionFailure;
2036
+ if (input.syncPoint !== undefined) {
2037
+ const pointFailure = validateSyncPoint(input.syncPoint);
2038
+ if (pointFailure) return pointFailure;
2039
+ }
2040
+ const layout = await resolveRepositoryLayout();
2041
+ if (layout.error) return layout;
2042
+ const recordPath = ownershipRecordPath(layout, input.planId);
2043
+ const stored = readOwnershipRecordFile(recordPath, layout, input.planId);
2044
+ if (stored.error) return stored;
2045
+ if (!stored.present) return error("OWNERSHIP_NOT_FOUND", `No ownership record exists for plan ${JSON.stringify(input.planId)}; claim ownership before corrections.`);
2046
+ const fencing = checkOwnerFencing(stored.record, input.phase, input.session, input.generation);
2047
+ if (fencing) return fencing;
2048
+ const timestamp = resolveNow(now).toISOString();
2049
+ // Semantic correction: normal instructions for sheepdog, never raw
2050
+ // records. Steering never authorizes consequential actions.
2051
+ return {
2052
+ ok: true,
2053
+ planId: input.planId,
2054
+ correction: {
2055
+ instruction: input.correction,
2056
+ syncPoint: input.syncPoint ?? null,
2057
+ target: "sheepdog",
2058
+ channel: "normal-corrective-instructions",
2059
+ timestamp,
2060
+ generation: input.generation,
2061
+ },
2062
+ consequentialAuthorization: consequentialDenial(),
2063
+ };
2064
+ }
2065
+
2066
+ // Ownership-gated steering proof: when no ownership record exists the raw
2067
+ // M2 behavior applies (explicit planId or single-target inference). When a
2068
+ // record exists, the caller must prove authoritative phase plus session
2069
+ // plus generation; otherwise NOT AUTHORITATIVE PHASE with no bodies loaded.
2070
+ function parseOwnershipProof(input) {
2071
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return { present: false };
2072
+ const hasProof = "phase" in input || "session" in input || "generation" in input;
2073
+ if (!hasProof) return { present: false };
2074
+ if (!("phase" in input && "session" in input && "generation" in input)) {
2075
+ return { invalid: true };
2076
+ }
2077
+ const phaseFailure = validateOwnerPhase(input.phase);
2078
+ if (phaseFailure) return { invalid: true, failure: phaseFailure };
2079
+ const sessionFailure = validateSession(input.session);
2080
+ if (sessionFailure) return { invalid: true, failure: sessionFailure };
2081
+ const generationFailure = validateGeneration(input.generation);
2082
+ if (generationFailure) return { invalid: true, failure: generationFailure };
2083
+ return { present: true, phase: input.phase, session: input.session, generation: input.generation };
2084
+ }
2085
+
2086
+ async function enforceOwnershipForSteering(layout, planId) {
2087
+ const recordPath = ownershipRecordPath(layout, planId);
2088
+ const stored = readOwnershipRecordFile(recordPath, layout, planId);
2089
+ if (stored.error) return stored;
2090
+ if (!stored.present) return { ok: true, owned: false };
2091
+ const shapeFailure = validateOwnershipRecordShape(stored.record);
2092
+ if (shapeFailure) return shapeFailure;
2093
+ return { ok: true, owned: true, record: stored.record };
2094
+ }
2095
+
2096
+ async function submitSteering(input) {
2097
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
2098
+ return error("INVALID_REQUEST", "Steering submit requires an object with content and optional planId.");
2099
+ }
2100
+ const keys = Object.keys(input);
2101
+ if (!keys.includes("content") || keys.some((key) => key !== "content" && key !== "planId")) {
2102
+ return error("INVALID_REQUEST", "Steering submit accepts only content plus optional explicit planId.");
2103
+ }
2104
+ const contentFailure = validateSteeringContent(input.content);
2105
+ if (contentFailure) return contentFailure;
2106
+ if (input.planId !== undefined) {
2107
+ const planIdFailure = validatePlanId(input.planId);
2108
+ if (planIdFailure) return planIdFailure;
2109
+ }
2110
+
2111
+ const layout = await resolveRepositoryLayout();
2112
+ if (layout.error) return layout;
2113
+
2114
+ const resolved = await resolveSteeringTarget(layout, input.planId);
2115
+ if (resolved.error) return resolved;
2116
+ const planId = resolved.planId;
2117
+
2118
+ const entriesDir = steeringEntriesDir(layout, planId);
2119
+ const checkpointPath = steeringCheckpointPath(layout, planId);
2120
+ const lockPath = steeringLockPath(layout, planId);
2121
+ const journalPath = steeringJournalPath(layout, planId);
2122
+
2123
+ const acquired = await acquireSteeringLock(lockPath);
2124
+ if (acquired.error) return acquired;
2125
+ try {
2126
+ const recovered = recoverSteeringJournal(layout, planId);
2127
+ if (recovered.error) return recovered;
2128
+
2129
+ const listed = listSteeringEntryNames(entriesDir);
2130
+ if (listed.error) return listed;
2131
+ let maxSequence = 0;
2132
+ for (const name of listed.names) {
2133
+ const parsed = parseSteeringFileName(name);
2134
+ if (parsed && parsed.sequence > maxSequence) maxSequence = parsed.sequence;
2135
+ }
2136
+ const sequence = maxSequence + 1;
2137
+ const id = createSteeringId();
2138
+ const createdAt = resolveNow(now).toISOString();
2139
+ const entry = {
2140
+ schema: STEERING_SCHEMA_VERSION,
2141
+ id,
2142
+ sequence,
2143
+ planId,
2144
+ identity: layout.identity,
2145
+ toplevel: layout.toplevel,
2146
+ createdAt,
2147
+ provenance: {
2148
+ submitter: "developer",
2149
+ integration: "integration-asserted Developer context; not an authenticated human",
2150
+ },
2151
+ content: input.content,
2152
+ };
2153
+ const fileName = steeringEntryFileName(sequence, id);
2154
+ const target = path.join(entriesDir, fileName);
2155
+
2156
+ // Write-ahead journal via atomic rename, then immutable install via
2157
+ // exclusive create, then clear the journal. A crash leaves either an
2158
+ // inert temp, a journal that replays idempotently, or a durable entry.
2159
+ const journalPayload = { op: "submit", planId, sequence, id, content: input.content, createdAt, entry };
2160
+ const journaled = writeAtomicFile(journalPath, JSON.stringify(journalPayload, null, 2));
2161
+ if (journaled.error) return journaled;
2162
+ try {
2163
+ fs.mkdirSync(entriesDir, { recursive: true });
2164
+ fs.writeFileSync(target, JSON.stringify(entry, null, 2), { flag: "wx" });
2165
+ } catch (cause) {
2166
+ if (cause?.code === "EEXIST") {
2167
+ return error("WRITE_FAILED", "Steering sequence raced; retry the submission.", true);
2168
+ }
2169
+ return error("WRITE_FAILED", `Unable to publish immutable steering entry: ${processErrorDetail(cause)}`, true);
2170
+ }
2171
+ try {
2172
+ fs.unlinkSync(journalPath);
2173
+ } catch {
2174
+ // The entry is already durable via exclusive create; journal cleanup
2175
+ // is best-effort and recovered on the next mutation.
2176
+ }
2177
+ return { ok: true, entry };
2178
+ } finally {
2179
+ releaseSteeringLock(lockPath, acquired.claim);
2180
+ }
2181
+ }
2182
+
2183
+ async function checkSteering(input) {
2184
+ // M3 ownership fencing: when a lifecycle record exists for the target,
2185
+ // only the recorded owner phase plus session plus generation may check.
2186
+ // M2 callers without ownership keep the original explicit-or-singleton
2187
+ // behavior with no filesystem side effects beyond reads.
2188
+ let planIdInput;
2189
+ let proof = { present: false };
2190
+ if (input !== null && typeof input === "object" && !Array.isArray(input) && ("phase" in input || "session" in input || "generation" in input)) {
2191
+ const parsed = parseOwnershipProof(input);
2192
+ if (parsed.invalid) {
2193
+ return parsed.failure ?? error("INVALID_REQUEST", "Steering check ownership proof requires phase, session, and generation together.");
2194
+ }
2195
+ proof = parsed;
2196
+ if (!("planId" in input)) {
2197
+ return error("INVALID_REQUEST", "Steering check with ownership proof requires an explicit planId.");
2198
+ }
2199
+ const extra = Object.keys(input).filter((key) => key !== "planId" && key !== "phase" && key !== "session" && key !== "generation");
2200
+ if (extra.length > 0) {
2201
+ return error("INVALID_REQUEST", "Steering check accepts only planId plus optional ownership proof (phase, session, generation).");
2202
+ }
2203
+ planIdInput = input.planId;
2204
+ } else {
2205
+ const normalized = normalizeSteeringTargetInput(input);
2206
+ if (normalized.invalid) {
2207
+ return error("INVALID_REQUEST", "Steering check accepts only an explicit planId string or an object with optional planId.");
2208
+ }
2209
+ planIdInput = normalized.planId;
2210
+ }
2211
+ const layout = await resolveRepositoryLayout();
2212
+ if (layout.error) return layout;
2213
+ const resolved = await resolveSteeringTarget(layout, planIdInput);
2214
+ if (resolved.error) return resolved;
2215
+ const planId = resolved.planId;
2216
+ const ownership = await enforceOwnershipForSteering(layout, planId);
2217
+ if (ownership.error) return ownership;
2218
+ if (ownership.owned) {
2219
+ if (!proof.present) {
2220
+ return error(
2221
+ "NOT_AUTHORITATIVE_PHASE",
2222
+ `NOT AUTHORITATIVE PHASE: plan ${JSON.stringify(planId)} is owned by phase ${JSON.stringify(ownership.record.phase)} session ${JSON.stringify(ownership.record.session)}; check requires the authoritative phase, session, and generation.`,
2223
+ );
2224
+ }
2225
+ const fencing = checkOwnerFencing(ownership.record, proof.phase, proof.session, proof.generation);
2226
+ if (fencing) {
2227
+ if (fencing.error.code === "STALE_GENERATION") {
2228
+ return error(
2229
+ "NOT_AUTHORITATIVE_PHASE",
2230
+ `NOT AUTHORITATIVE PHASE: stale generation ${JSON.stringify(proof.generation)} for plan ${JSON.stringify(planId)}; authoritative generation is ${ownership.record.generation}.`,
2231
+ );
2232
+ }
2233
+ return fencing;
2234
+ }
2235
+ }
2236
+
2237
+ // Lightweight: list entry names plus checkpoint only; never load bodies.
2238
+ const entriesDir = steeringEntriesDir(layout, planId);
2239
+ const checkpointPath = steeringCheckpointPath(layout, planId);
2240
+ const listed = listSteeringEntryNames(entriesDir);
2241
+ if (listed.error) return listed;
2242
+ const checkpoint = readSteeringCheckpointFile(checkpointPath, layout, planId);
2243
+ if (checkpoint.error) return checkpoint;
2244
+
2245
+ const parsedEntries = [];
2246
+ for (const name of listed.names) {
2247
+ const parsed = parseSteeringFileName(name);
2248
+ if (parsed) parsedEntries.push(parsed);
2249
+ }
2250
+ parsedEntries.sort((a, b) => a.sequence - b.sequence);
2251
+ const consumed = new Set(checkpoint.checkpoint.consumedIds);
2252
+ let unread = 0;
2253
+ let maxSequence = 0;
2254
+ for (const entry of parsedEntries) {
2255
+ if (entry.sequence > maxSequence) maxSequence = entry.sequence;
2256
+ if (!consumed.has(entry.id)) unread += 1;
2257
+ }
2258
+ return {
2259
+ ok: true,
2260
+ planId,
2261
+ total: parsedEntries.length,
2262
+ unread,
2263
+ nextSequence: maxSequence + 1,
2264
+ highestContiguous: checkpoint.checkpoint.highestContiguous,
2265
+ };
2266
+ }
2267
+
2268
+ async function readSteering(input) {
2269
+ let planIdInput;
2270
+ let proof = { present: false };
2271
+ if (input !== null && typeof input === "object" && !Array.isArray(input) && ("phase" in input || "session" in input || "generation" in input)) {
2272
+ const parsed = parseOwnershipProof(input);
2273
+ if (parsed.invalid) {
2274
+ return parsed.failure ?? error("INVALID_REQUEST", "Steering read ownership proof requires phase, session, and generation together.");
2275
+ }
2276
+ proof = parsed;
2277
+ if (!("planId" in input)) {
2278
+ return error("INVALID_REQUEST", "Steering read with ownership proof requires an explicit planId.");
2279
+ }
2280
+ const extra = Object.keys(input).filter((key) => key !== "planId" && key !== "phase" && key !== "session" && key !== "generation");
2281
+ if (extra.length > 0) {
2282
+ return error("INVALID_REQUEST", "Steering read accepts only planId plus optional ownership proof (phase, session, generation).");
2283
+ }
2284
+ planIdInput = input.planId;
2285
+ } else {
2286
+ const normalized = normalizeSteeringTargetInput(input);
2287
+ if (normalized.invalid) {
2288
+ return error("INVALID_REQUEST", "Steering read accepts only an explicit planId string or an object with optional planId.");
2289
+ }
2290
+ planIdInput = normalized.planId;
2291
+ }
2292
+ const layout = await resolveRepositoryLayout();
2293
+ if (layout.error) return layout;
2294
+ const resolved = await resolveSteeringTarget(layout, planIdInput);
2295
+ if (resolved.error) return resolved;
2296
+ const planId = resolved.planId;
2297
+ const ownership = await enforceOwnershipForSteering(layout, planId);
2298
+ if (ownership.error) return ownership;
2299
+ if (ownership.owned) {
2300
+ if (!proof.present) {
2301
+ return error(
2302
+ "NOT_AUTHORITATIVE_PHASE",
2303
+ `NOT AUTHORITATIVE PHASE: plan ${JSON.stringify(planId)} is owned by phase ${JSON.stringify(ownership.record.phase)} session ${JSON.stringify(ownership.record.session)}; read requires the authoritative phase, session, and generation.`,
2304
+ );
2305
+ }
2306
+ const fencing = checkOwnerFencing(ownership.record, proof.phase, proof.session, proof.generation);
2307
+ if (fencing) {
2308
+ if (fencing.error.code === "STALE_GENERATION") {
2309
+ return error(
2310
+ "NOT_AUTHORITATIVE_PHASE",
2311
+ `NOT AUTHORITATIVE PHASE: stale generation ${JSON.stringify(proof.generation)} for plan ${JSON.stringify(planId)}; authoritative generation is ${ownership.record.generation}.`,
2312
+ );
2313
+ }
2314
+ return fencing;
2315
+ }
2316
+ }
2317
+
2318
+ // Ordered exact unread with no mutation: no lock, no journal, no
2319
+ // checkpoint write; only reads.
2320
+ const entriesDir = steeringEntriesDir(layout, planId);
2321
+ const checkpointPath = steeringCheckpointPath(layout, planId);
2322
+ const checkpoint = readSteeringCheckpointFile(checkpointPath, layout, planId);
2323
+ if (checkpoint.error) return checkpoint;
2324
+ const listed = listSteeringEntryNames(entriesDir);
2325
+ if (listed.error) return listed;
2326
+
2327
+ const consumed = new Set(checkpoint.checkpoint.consumedIds);
2328
+ const unread = [];
2329
+ for (const name of listed.names) {
2330
+ const parsed = parseSteeringFileName(name);
2331
+ if (!parsed) continue;
2332
+ if (consumed.has(parsed.id)) continue;
2333
+ let text;
2334
+ try {
2335
+ text = fs.readFileSync(path.join(entriesDir, name), "utf8");
2336
+ } catch (cause) {
2337
+ if (cause?.code === "ENOENT") continue;
2338
+ return error("READ_FAILED", `Unable to read steering entry: ${processErrorDetail(cause)}`, true);
2339
+ }
2340
+ let entry;
2341
+ try {
2342
+ entry = JSON.parse(text);
2343
+ } catch (cause) {
2344
+ return error("CORRUPT_CHECKPOINT", `Steering entry ${name} is not valid JSON: ${processErrorDetail(cause)}`);
2345
+ }
2346
+ if (
2347
+ entry?.schema !== STEERING_SCHEMA_VERSION ||
2348
+ entry?.id !== parsed.id ||
2349
+ entry?.sequence !== parsed.sequence ||
2350
+ entry?.planId !== planId ||
2351
+ entry?.identity !== layout.identity ||
2352
+ typeof entry?.content !== "string" ||
2353
+ typeof entry?.createdAt !== "string"
2354
+ ) {
2355
+ return error("CORRUPT_CHECKPOINT", `Steering entry ${name} failed validation.`);
2356
+ }
2357
+ unread.push(entry);
2358
+ }
2359
+ unread.sort((a, b) => a.sequence - b.sequence);
2360
+ return {
2361
+ ok: true,
2362
+ planId,
2363
+ entries: unread,
2364
+ checkpoint: {
2365
+ highestContiguous: checkpoint.checkpoint.highestContiguous,
2366
+ consumedIds: [...checkpoint.checkpoint.consumedIds].sort(),
2367
+ },
2368
+ };
2369
+ }
2370
+
2371
+ async function consumeSteering(input) {
2372
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
2373
+ return error("INVALID_REQUEST", "Steering consume requires an object with planId and ids.");
2374
+ }
2375
+ // M3 gated form adds ownership proof plus mandatory sync disposition:
2376
+ // planId, ids, phase, session, generation, syncPoint, disposition.
2377
+ // M2 form (planId plus ids only) is preserved for targets without an
2378
+ // ownership record.
2379
+ const gatedKeys = new Set(["planId", "ids", "phase", "session", "generation", "syncPoint", "disposition"]);
2380
+ const keys = Object.keys(input);
2381
+ if (keys.some((key) => !gatedKeys.has(key))) {
2382
+ return error("INVALID_REQUEST", "Steering consume accepts only planId, ids, and optional ownership proof plus sync disposition (phase, session, generation, syncPoint, disposition).");
2383
+ }
2384
+ const idsFailure = validateSteeringIds(input.ids);
2385
+ if (idsFailure) return idsFailure;
2386
+ if (input.planId !== undefined) {
2387
+ const planIdFailure = validatePlanId(input.planId);
2388
+ if (planIdFailure) return planIdFailure;
2389
+ }
2390
+ const hasProof = "phase" in input || "session" in input || "generation" in input || "syncPoint" in input || "disposition" in input;
2391
+ let proof = { present: false };
2392
+ let syncClaim = null;
2393
+ if (hasProof) {
2394
+ const parsed = parseOwnershipProof(input);
2395
+ if (parsed.invalid) {
2396
+ return parsed.failure ?? error("INVALID_REQUEST", "Steering consume ownership proof requires phase, session, and generation together.");
2397
+ }
2398
+ proof = parsed;
2399
+ if (!proof.present) {
2400
+ return error("INVALID_REQUEST", "Steering consume with sync disposition requires phase, session, and generation together.");
2401
+ }
2402
+ if (!("syncPoint" in input && "disposition" in input)) {
2403
+ return error("INVALID_REQUEST", "Steering consume with ownership proof requires syncPoint and disposition; disposition is recorded before consume.");
2404
+ }
2405
+ const pointFailure = validateSyncPoint(input.syncPoint);
2406
+ if (pointFailure) return pointFailure;
2407
+ const dispositionFailure = validateDisposition(input.disposition);
2408
+ if (dispositionFailure) return dispositionFailure;
2409
+ syncClaim = { syncPoint: input.syncPoint, disposition: input.disposition };
2410
+ if (input.planId === undefined) {
2411
+ return error("INVALID_REQUEST", "Steering consume with ownership proof requires an explicit planId.");
2412
+ }
2413
+ }
2414
+
2415
+ const layout = await resolveRepositoryLayout();
2416
+ if (layout.error) return layout;
2417
+ const resolved = await resolveSteeringTarget(layout, input.planId);
2418
+ if (resolved.error) return resolved;
2419
+ const planId = resolved.planId;
2420
+ const ownership = await enforceOwnershipForSteering(layout, planId);
2421
+ if (ownership.error) return ownership;
2422
+ if (ownership.owned) {
2423
+ if (!proof.present || !syncClaim) {
2424
+ return error(
2425
+ "NOT_AUTHORITATIVE_PHASE",
2426
+ `NOT AUTHORITATIVE PHASE: plan ${JSON.stringify(planId)} is owned by phase ${JSON.stringify(ownership.record.phase)} session ${JSON.stringify(ownership.record.session)}; consume requires the authoritative phase, session, generation, plus recorded sync disposition before consume.`,
2427
+ );
2428
+ }
2429
+ const fencing = checkOwnerFencing(ownership.record, proof.phase, proof.session, proof.generation);
2430
+ if (fencing) {
2431
+ if (fencing.error.code === "STALE_GENERATION") {
2432
+ return error(
2433
+ "NOT_AUTHORITATIVE_PHASE",
2434
+ `NOT AUTHORITATIVE PHASE: stale generation ${JSON.stringify(proof.generation)} for plan ${JSON.stringify(planId)}; authoritative generation is ${ownership.record.generation}.`,
2435
+ );
2436
+ }
2437
+ return fencing;
2438
+ }
2439
+ // Disposition must already be recorded for this sync point and
2440
+ // generation before consume advances the checkpoint (idempotent).
2441
+ const syncPath = ownershipSyncPath(layout, planId);
2442
+ const currentSync = readOwnershipSyncFile(syncPath, layout, planId);
2443
+ if (currentSync.error) return currentSync;
2444
+ const recorded = currentSync.sync.points[syncClaim.syncPoint];
2445
+ if (
2446
+ !recorded ||
2447
+ recorded.generation !== proof.generation ||
2448
+ recorded.disposition !== syncClaim.disposition ||
2449
+ recorded.phase !== proof.phase ||
2450
+ recorded.session !== proof.session
2451
+ ) {
2452
+ return error(
2453
+ "SYNC_REQUIRED",
2454
+ `Disposition ${JSON.stringify(syncClaim.disposition)} for sync point ${JSON.stringify(syncClaim.syncPoint)} must be recorded by the authoritative owner before consume; call ownership sync first.`,
2455
+ );
2456
+ }
2457
+ } else if (syncClaim) {
2458
+ return error("INVALID_REQUEST", "Steering consume sync disposition requires an ownership record; claim ownership first.");
2459
+ }
2460
+
2461
+ const entriesDir = steeringEntriesDir(layout, planId);
2462
+ const checkpointPath = steeringCheckpointPath(layout, planId);
2463
+ const lockPath = steeringLockPath(layout, planId);
2464
+ const journalPath = steeringJournalPath(layout, planId);
2465
+
2466
+ const acquired = await acquireSteeringLock(lockPath);
2467
+ if (acquired.error) return acquired;
2468
+ try {
2469
+ const recovered = recoverSteeringJournal(layout, planId);
2470
+ if (recovered.error) return recovered;
2471
+
2472
+ const listed = listSteeringEntryNames(entriesDir);
2473
+ if (listed.error) return listed;
2474
+ const sequenceToId = new Map();
2475
+ const idToSequence = new Map();
2476
+ for (const name of listed.names) {
2477
+ const parsed = parseSteeringFileName(name);
2478
+ if (parsed) {
2479
+ sequenceToId.set(parsed.sequence, parsed.id);
2480
+ idToSequence.set(parsed.id, parsed.sequence);
2481
+ }
2482
+ }
2483
+ for (const id of input.ids) {
2484
+ if (!idToSequence.has(id)) {
2485
+ return error("STEERING_NOT_FOUND", `Steering entry ${JSON.stringify(id)} does not exist for plan ${JSON.stringify(planId)}.`);
2486
+ }
2487
+ }
2488
+
2489
+ const checkpoint = readSteeringCheckpointFile(checkpointPath, layout, planId);
2490
+ if (checkpoint.error) return checkpoint;
2491
+ const merged = new Set(checkpoint.checkpoint.consumedIds);
2492
+ for (const id of input.ids) merged.add(id);
2493
+ const highest = computeHighestContiguous(sequenceToId, merged);
2494
+ const nextCheckpoint = {
2495
+ schema: STEERING_SCHEMA_VERSION,
2496
+ planId,
2497
+ identity: layout.identity,
2498
+ highestContiguous: highest,
2499
+ consumedIds: [...merged].sort(),
2500
+ };
2501
+
2502
+ // Durable disposition first: journal via atomic rename, checkpoint via
2503
+ // atomic rename, then clear journal. Advancement is idempotent.
2504
+ const journaled = writeAtomicFile(journalPath, JSON.stringify({ op: "consume", planId, consumedIds: [...merged].sort() }, null, 2));
2505
+ if (journaled.error) return journaled;
2506
+ const written = writeAtomicFile(checkpointPath, JSON.stringify(nextCheckpoint, null, 2));
2507
+ if (written.error) return written;
2508
+ try {
2509
+ fs.unlinkSync(journalPath);
2510
+ } catch {
2511
+ // Checkpoint is already durable; journal cleanup is recovered next time.
2512
+ }
2513
+ const unread = sequenceToId.size - merged.size;
2514
+ // Steering never authorizes consequential actions; existing approvals
2515
+ // still required. This denial rides every consume, owned or not.
2516
+ return {
2517
+ ok: true,
2518
+ planId,
2519
+ checkpoint: { highestContiguous: highest, consumedIds: [...merged].sort() },
2520
+ unread,
2521
+ consequentialAuthorization: consequentialDenial(),
2522
+ };
2523
+ } finally {
2524
+ releaseSteeringLock(lockPath, acquired.claim);
2525
+ }
2526
+ }
2527
+
2528
+ return {
2529
+ layout: resolveRepositoryLayout,
2530
+ writeArtifact,
2531
+ readArtifact,
2532
+ writePlan: (input) => writeArtifact(ARTIFACT_TYPES.PLAN, input),
2533
+ readPlan: (planId) => readArtifact(ARTIFACT_TYPES.PLAN, planId),
2534
+ writeExecution: (input) => writeArtifact(ARTIFACT_TYPES.EXECUTION, input),
2535
+ readExecution: (planId) => readArtifact(ARTIFACT_TYPES.EXECUTION, planId),
2536
+ submitSteering,
2537
+ checkSteering,
2538
+ readSteering,
2539
+ consumeSteering,
2540
+ claimOwnership,
2541
+ readOwnership,
2542
+ recordSync,
2543
+ readSync,
2544
+ recordSnapshot,
2545
+ routeCorrection,
2546
+ consequentialPolicy: () => ({ ...consequentialDenial(), deniedActions: [...CONSEQUENTIAL_DENIED_ACTIONS] }),
2547
+ listSteeringTargets: async () => {
2548
+ const layout = await resolveRepositoryLayout();
2549
+ if (layout.error) return layout;
2550
+ return listSteeringTargets(layout);
2551
+ },
2552
+ listOwnershipTargets: async () => {
2553
+ const layout = await resolveRepositoryLayout();
2554
+ if (layout.error) return layout;
2555
+ const root = path.join(layout.identity, STATE_DIR, OWNERSHIP_DIR);
2556
+ let names;
2557
+ try {
2558
+ names = fs.readdirSync(root);
2559
+ } catch (cause) {
2560
+ if (cause?.code === "ENOENT") return { ok: true, targets: [] };
2561
+ return error("READ_FAILED", `Unable to list ownership targets: ${processErrorDetail(cause)}`, true);
2562
+ }
2563
+ const targets = [];
2564
+ for (const name of names) {
2565
+ if (!PLAN_ID_PATTERN.test(name)) continue;
2566
+ try {
2567
+ if (!fs.statSync(path.join(root, name)).isDirectory()) continue;
2568
+ } catch {
2569
+ continue;
2570
+ }
2571
+ targets.push(name);
2572
+ }
2573
+ targets.sort();
2574
+ return { ok: true, targets };
2575
+ },
335
2576
  };
336
2577
  }