brainclaw 1.5.5 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli.js +124 -7
  3. package/dist/commands/bootstrap-loop.js +206 -0
  4. package/dist/commands/loop.js +156 -0
  5. package/dist/commands/loops-handlers.js +110 -55
  6. package/dist/commands/mcp-read-handlers.js +37 -0
  7. package/dist/commands/mcp.js +621 -202
  8. package/dist/commands/questions.js +180 -0
  9. package/dist/commands/reply.js +190 -0
  10. package/dist/commands/session-end.js +105 -3
  11. package/dist/commands/session-start.js +32 -53
  12. package/dist/commands/switch.js +17 -1
  13. package/dist/core/agentrun-reconciler.js +65 -0
  14. package/dist/core/claims.js +29 -0
  15. package/dist/core/dispatch-status.js +219 -0
  16. package/dist/core/entity-operations.js +128 -9
  17. package/dist/core/execution-adapters.js +38 -2
  18. package/dist/core/facade-schema.js +55 -0
  19. package/dist/core/federation-cloud.js +27 -12
  20. package/dist/core/federation-materialize.js +57 -0
  21. package/dist/core/instruction-templates.js +2 -0
  22. package/dist/core/loops/bootstrap-acquire.js +195 -0
  23. package/dist/core/loops/facade-schema.js +68 -1
  24. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  25. package/dist/core/loops/hooks/notify-operator.js +148 -0
  26. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  27. package/dist/core/loops/index.js +8 -2
  28. package/dist/core/loops/next-expected.js +63 -0
  29. package/dist/core/loops/presets/bootstrap.js +75 -0
  30. package/dist/core/loops/presets/index.js +16 -0
  31. package/dist/core/loops/store.js +224 -4
  32. package/dist/core/loops/types.js +346 -1
  33. package/dist/core/loops/verbs.js +739 -6
  34. package/dist/core/schema.js +28 -2
  35. package/dist/core/state.js +62 -0
  36. package/dist/facts.js +7 -5
  37. package/dist/facts.json +6 -4
  38. package/docs/concepts/dispatch-lifecycle.md +228 -0
  39. package/docs/concepts/loop-engine.md +55 -0
  40. package/docs/concepts/multi-agent-workflows.md +167 -166
  41. package/docs/concepts/troubleshooting.md +10 -2
  42. package/docs/integrations/overview.md +14 -12
  43. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { nowISO } from '../ids.js';
3
- import { appendEvent, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
4
- import { LoopArtifactSchema, } from './types.js';
3
+ import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
4
+ import { appendEvent, closeLoop, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
5
+ import { LoopArtifactSchema, PAUSE_REASONS, } from './types.js';
5
6
  import { decideNextPhase, } from './iteration-engine.js';
6
7
  function nextSeq(loopId, cwd) {
7
8
  const events = listLoopEvents(loopId, cwd);
@@ -35,6 +36,8 @@ export function evaluateStopCondition(thread, condition) {
35
36
  return thread.artifacts.some(isVerdictAccepted);
36
37
  case 'max_iterations':
37
38
  return thread.iteration_count >= condition.n;
39
+ case 'min_iterations':
40
+ return thread.iteration_count >= condition.n;
38
41
  case 'artifact_produced':
39
42
  return thread.artifacts.some((artifact) => artifact.phase === condition.phase && artifact.type === condition.type);
40
43
  case 'min_artifacts_by_type': {
@@ -69,6 +72,11 @@ export function evaluateStopCondition(thread, condition) {
69
72
  });
70
73
  return matches.length >= condition.n;
71
74
  }
75
+ case 'no_open_questions':
76
+ // pln#511 step 1 — bootstrap preset clarify-phase primitive. Mirrors
77
+ // the persisted `thread.open_questions` set (kept in sync by
78
+ // request_input / provide_input — see reconcileOpenQuestions).
79
+ return thread.open_questions.length === 0;
72
80
  case 'manual':
73
81
  return false;
74
82
  case 'any':
@@ -124,14 +132,26 @@ function describeUnmetGate(thread, gate) {
124
132
  return 'reviewer_green unmet: no accepted verdict artifact yet';
125
133
  case 'max_iterations':
126
134
  return `max_iterations unmet: iteration_count=${thread.iteration_count} < n=${gate.n}`;
135
+ case 'min_iterations':
136
+ return `min_iterations unmet: iteration_count (${thread.iteration_count}) < required (${gate.n})`;
127
137
  case 'artifact_produced':
128
138
  return `artifact_produced unmet: no artifact of type "${gate.type}" in phase "${gate.phase}"`;
139
+ case 'no_open_questions':
140
+ return `no_open_questions unmet: ${thread.open_questions.length} questions still open`;
129
141
  case 'manual':
130
142
  return 'manual gate: caller did not signal advance';
131
143
  case 'any':
132
144
  return `any-of unmet: none of ${gate.conditions.length} sub-conditions held`;
133
- case 'all':
145
+ case 'all': {
146
+ // pln#516 step 2 — `all` composes other gates; the generic "at least
147
+ // one of N failed" tells the operator nothing actionable. Find the
148
+ // first sub-condition that evaluates false and report its reason so
149
+ // the journal records what is actually blocking the advance.
150
+ const failing = gate.conditions.find((c) => !evaluateStopCondition(thread, c));
151
+ if (failing)
152
+ return describeUnmetGate(thread, failing);
134
153
  return `all-of unmet: at least one of ${gate.conditions.length} sub-conditions failed`;
154
+ }
135
155
  default: {
136
156
  const exhaustive = gate;
137
157
  void exhaustive;
@@ -185,6 +205,20 @@ export function advance(input, cwd) {
185
205
  throw new Error(`advance: phase_advance_blocked on "${current.current_phase}" — ${gateOutcome.gate_reason}`);
186
206
  }
187
207
  }
208
+ // pln#514 post-validation fix (can_27ebf1a0) — evaluate stop_condition
209
+ // BEFORE decideNextPhase. The iteration engine throws "already at last
210
+ // phase" when current_phase has no successor, which would shadow the
211
+ // pre-advance auto-close branch below. For a loop whose stop_condition is
212
+ // satisfied AT the last phase (the bootstrap preset's converge case after
213
+ // project_md_final lands), the right behavior is auto-close, not throw.
214
+ // Field-observed during pln#514 v1.1 validation (run_79f8443a).
215
+ if (input.to_phase === undefined && evaluateStopCondition(current, current.stop_condition)) {
216
+ const finalStatus = stopHitsMaxIterations(current, current.stop_condition)
217
+ ? 'blocked'
218
+ : 'completed';
219
+ const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd);
220
+ return { loop: closed, auto_closed: true };
221
+ }
188
222
  // Decide the next state. If the caller specified a to_phase, honour it
189
223
  // verbatim (used by `force`-style overrides and explicit jumps). Otherwise
190
224
  // consult the iteration engine, which knows about the cycle, exit_when,
@@ -290,6 +324,23 @@ function stopHitsMaxIterations(thread, condition) {
290
324
  return false;
291
325
  }
292
326
  function commitClosedTransition(thread, final_status, actor, reason, cwd) {
327
+ // pln#514 post-validation fix (can_27ebf1a0, run_79f8443a) — when the
328
+ // auto-close path completes a bootstrap-preset loop, delegate to
329
+ // closeLoop() so its writeProjectMdSafe pre-hook runs. Without this
330
+ // delegation, the FSM auto-close via `advance` would bypass PROJECT.md
331
+ // materialization entirely, defeating pln#512 step 2. The pre-hook
332
+ // re-reads the thread from disk; the caller (advance) has already
333
+ // persisted the latest state via writeThreadFile before reaching here
334
+ // in both the pre- and post-advance auto-close branches.
335
+ //
336
+ // Non-bootstrap loops and non-completed closes (cancelled / blocked /
337
+ // timeout-driven) fall through to the streamlined commit below — the
338
+ // pre-hook is gated on final_status='completed' && preset='bootstrap'
339
+ // in closeLoop, so delegating other cases would be a no-op anyway, but
340
+ // delegation also costs a disk re-read. Avoid it when not needed.
341
+ if (final_status === 'completed' && thread.protocol?.preset === 'bootstrap') {
342
+ return closeLoop({ id: thread.id, final_status, reason, actor }, cwd);
343
+ }
293
344
  const now = nowISO();
294
345
  const mutation_id = generateMutationId();
295
346
  const version = thread.version + 1;
@@ -301,6 +352,10 @@ function commitClosedTransition(thread, final_status, actor, reason, cwd) {
301
352
  status: final_status,
302
353
  updated_at: now,
303
354
  closed_at: now,
355
+ // Schema invariant: pause_reason / pending_file_apply only legal in
356
+ // status='paused'. Closing from a paused state must clear both.
357
+ pause_reason: undefined,
358
+ pending_file_apply: undefined,
304
359
  };
305
360
  appendEvent(thread.id, {
306
361
  event_id: crypto.randomUUID(),
@@ -490,21 +545,474 @@ export function add_artifact(input, cwd) {
490
545
  writeThreadFile(next, cwd);
491
546
  return next;
492
547
  }
548
+ /**
549
+ * pln#508 step 3 (Phase 0 spec §5, INVARIANT 2) — coerce a freeform `reason`
550
+ * string into a structured PauseReason when it matches the known enum.
551
+ * Returns undefined for non-matching values so legacy callers still work
552
+ * without populating `thread.pause_reason`.
553
+ */
554
+ function coercePauseReason(reason) {
555
+ if (reason === undefined)
556
+ return undefined;
557
+ return PAUSE_REASONS.includes(reason)
558
+ ? reason
559
+ : undefined;
560
+ }
493
561
  export function pause(input, cwd) {
494
562
  const current = loadLoopOrThrow(input.id, cwd);
495
563
  if (current.status !== 'open') {
496
564
  throw new Error(`pause: loop ${current.id} is ${current.status}, not open`);
497
565
  }
498
- return commitSimpleStatus(current, 'paused', 'paused', input.actor, input.reason, cwd);
566
+ // Resolve effective pause_reason: explicit param wins, else coerce from
567
+ // the freeform reason string when it matches PAUSE_REASONS, else leave
568
+ // undefined for backward compatibility.
569
+ const effectiveReason = input.pause_reason ?? coercePauseReason(input.reason);
570
+ return commitSimpleStatus(current, 'paused', 'paused', input.actor, input.reason, cwd, effectiveReason);
499
571
  }
500
572
  export function resume(input, cwd) {
501
573
  const current = loadLoopOrThrow(input.id, cwd);
502
574
  if (current.status !== 'paused') {
503
575
  throw new Error(`resume: loop ${current.id} is ${current.status}, not paused`);
504
576
  }
505
- return commitSimpleStatus(current, 'open', 'resumed', input.actor, input.reason, cwd);
577
+ // Clear pause_reason on resume schema enforces "pause_reason requires
578
+ // status=paused", so leaving it set would fail LoopThreadSchema validation.
579
+ return commitSimpleStatus(current, 'open', 'resumed', input.actor, input.reason, cwd, null);
506
580
  }
507
- function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd) {
581
+ /* ============= pln#508 step 3 open_questions reconciliation ============== */
582
+ /**
583
+ * Phase 0 spec §5 INVARIANT 3 — compute the canonical set of open question
584
+ * ids from `thread.artifacts`. A question is "open" when it has an
585
+ * operator_question artifact and NO matching operator_answer artifact (matched
586
+ * by `body.replies_to === question.question_id`).
587
+ *
588
+ * The persisted `thread.open_questions` SHOULD always equal this computed set
589
+ * after request_input / provide_input. The debug assert in
590
+ * `assertOpenQuestionsInvariant` verifies the equality when
591
+ * `BRAINCLAW_FSM_ASSERTS=1`; in production we log a warning rather than
592
+ * throw so a single drift doesn't take down the engine.
593
+ */
594
+ export function reconcileOpenQuestions(thread) {
595
+ const questionIds = new Set();
596
+ const answeredIds = new Set();
597
+ for (const artifact of thread.artifacts) {
598
+ if (!artifact.body)
599
+ continue;
600
+ if (artifact.type === 'operator_question') {
601
+ try {
602
+ const parsed = JSON.parse(artifact.body);
603
+ if (typeof parsed.question_id === 'string') {
604
+ questionIds.add(parsed.question_id);
605
+ }
606
+ }
607
+ catch { /* malformed body — skip; LoopArtifactSchema rejects new ones */ }
608
+ }
609
+ else if (artifact.type === 'operator_answer') {
610
+ try {
611
+ const parsed = JSON.parse(artifact.body);
612
+ if (typeof parsed.replies_to === 'string') {
613
+ answeredIds.add(parsed.replies_to);
614
+ }
615
+ }
616
+ catch { /* malformed body — skip */ }
617
+ }
618
+ }
619
+ const open = [];
620
+ for (const id of questionIds) {
621
+ if (!answeredIds.has(id))
622
+ open.push(id);
623
+ }
624
+ return open;
625
+ }
626
+ function fsmAssertsEnabled() {
627
+ return process.env.BRAINCLAW_FSM_ASSERTS === '1';
628
+ }
629
+ function assertOpenQuestionsInvariant(thread, intent) {
630
+ const canonical = new Set(reconcileOpenQuestions(thread));
631
+ const persisted = new Set(thread.open_questions);
632
+ if (canonical.size !== persisted.size || [...canonical].some((id) => !persisted.has(id))) {
633
+ const message = `[brainclaw/loops/fsm] ${intent}: open_questions drift on loop ${thread.id} ` +
634
+ `— persisted=${JSON.stringify([...persisted])} canonical=${JSON.stringify([...canonical])}`;
635
+ // Log in prod so drift is visible without taking down the engine.
636
+ // eslint-disable-next-line no-console
637
+ console.warn(message);
638
+ if (fsmAssertsEnabled()) {
639
+ throw new Error(message);
640
+ }
641
+ }
642
+ }
643
+ /**
644
+ * Atomic operator-question primitive (Phase 0 spec §3).
645
+ *
646
+ * Validates the question body against `OperatorQuestionBodySchema` (via
647
+ * `LoopArtifactSchema.parse` in `add_artifact`-style construction), enforces
648
+ * the protocol's `max_operator_questions` cap, appends the question to the
649
+ * loop's `open_questions`, and pauses either the asking slot
650
+ * (`pause_scope='slot'` → slot.status=waiting_input) or the whole loop
651
+ * (`pause_scope='loop'` → loop.status=paused, pause_reason='awaiting_operator').
652
+ *
653
+ * Refuses on terminal status or when status !== 'open' (no compounding
654
+ * pauses — see Phase 0 spec §5, INVARIANT 1/2).
655
+ */
656
+ export function requestInput(input, cwd) {
657
+ const current = loadLoopOrThrow(input.loop_id, cwd);
658
+ assertMutable(current, 'request_input');
659
+ if (current.status !== 'open') {
660
+ throw new Error(`request_input: loop ${current.id} is "${current.status}", cannot accept new questions ` +
661
+ `(no compounding pauses — resolve current open_questions first)`);
662
+ }
663
+ if (current.open_questions.length > 0) {
664
+ throw new Error(`request_input: loop ${current.id} already has open_questions=${current.open_questions.length}; ` +
665
+ `resolve existing operator question(s) before requesting another`);
666
+ }
667
+ const max = current.protocol?.max_operator_questions;
668
+ if (max !== undefined) {
669
+ const existing = current.artifacts.filter((a) => a.type === 'operator_question').length;
670
+ if (existing >= max) {
671
+ throw new Error(`request_input: loop ${current.id} has reached max_operator_questions=${max}; ` +
672
+ `champion must derive remaining answers autonomously`);
673
+ }
674
+ }
675
+ const slot = current.slots.find((s) => s.slot_id === input.slot_id);
676
+ if (!slot) {
677
+ throw new Error(`request_input: slot ${input.slot_id} not found on loop ${current.id}`);
678
+ }
679
+ if (slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled') {
680
+ throw new Error(`request_input: slot ${input.slot_id} is terminal (${slot.status})`);
681
+ }
682
+ const question_id = `qst_${crypto.randomBytes(6).toString('hex')}`;
683
+ const questionBody = {
684
+ question_id,
685
+ question_text: input.question_text,
686
+ evidence: input.evidence,
687
+ suggested_default: input.suggested_default,
688
+ options: input.options,
689
+ pause_scope: input.pause_scope,
690
+ on_timeout: input.on_timeout,
691
+ timeout_at: input.timeout_at,
692
+ by_slot_id: input.slot_id,
693
+ };
694
+ const now = nowISO();
695
+ const mutation_id = generateMutationId();
696
+ const version = current.version + 1;
697
+ // LoopArtifactSchema.parse runs the body schema validation for
698
+ // type='operator_question' via KNOWN_ARTIFACT_BODY_SCHEMAS — so any
699
+ // invariant violation (empty evidence, options size, on_timeout vs
700
+ // suggested_default) surfaces here.
701
+ const newArtifact = LoopArtifactSchema.parse({
702
+ artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
703
+ phase: input.phase,
704
+ type: 'operator_question',
705
+ body: JSON.stringify(questionBody),
706
+ produced_by: slot.agent_id ?? slot.agent ?? input.actor,
707
+ produced_at: now,
708
+ iteration: current.iteration_count,
709
+ });
710
+ let nextStatus = current.status;
711
+ let nextPauseReason = current.pause_reason;
712
+ let nextSlots = current.slots;
713
+ const fromSlotStatus = slot.status;
714
+ if (input.pause_scope === 'loop') {
715
+ nextStatus = 'paused';
716
+ nextPauseReason = 'awaiting_operator';
717
+ }
718
+ else {
719
+ nextSlots = current.slots.map((s) => s.slot_id === input.slot_id ? { ...s, status: 'waiting_input' } : s);
720
+ }
721
+ const next = {
722
+ ...current,
723
+ version,
724
+ mutation_id,
725
+ artifacts: [...current.artifacts, newArtifact],
726
+ open_questions: [...current.open_questions, question_id],
727
+ status: nextStatus,
728
+ pause_reason: nextPauseReason,
729
+ slots: nextSlots,
730
+ updated_at: now,
731
+ };
732
+ let seq = nextSeq(current.id, cwd);
733
+ appendEvent(current.id, {
734
+ event_id: crypto.randomUUID(),
735
+ loop_id: current.id,
736
+ seq,
737
+ at: now,
738
+ by: input.actor,
739
+ mutation_id,
740
+ kind: 'input_requested',
741
+ question_id,
742
+ pause_scope: input.pause_scope,
743
+ by_slot_id: input.slot_id,
744
+ }, cwd,
745
+ // pln#513 phase 4 codex review fix — pass the next thread so the
746
+ // notification hook sees the freshly-added operator_question rather
747
+ // than re-reading the previous on-disk snapshot.
748
+ next);
749
+ if (input.pause_scope === 'slot') {
750
+ seq += 1;
751
+ appendEvent(current.id, {
752
+ event_id: crypto.randomUUID(),
753
+ loop_id: current.id,
754
+ seq,
755
+ at: now,
756
+ by: input.actor,
757
+ mutation_id,
758
+ kind: 'slot_status_changed',
759
+ slot_id: input.slot_id,
760
+ from_status: fromSlotStatus,
761
+ to_status: 'waiting_input',
762
+ }, cwd);
763
+ }
764
+ writeThreadFile(next, cwd);
765
+ assertOpenQuestionsInvariant(next, 'request_input');
766
+ return { thread: next, question_id, artifact_id: newArtifact.artifact_id };
767
+ }
768
+ /**
769
+ * Resolves an open operator_question with an operator (or synthetic
770
+ * timeout-default) answer. Phase 0 spec §3 atomic operation:
771
+ * 1. Resolve `replies_to`:
772
+ * - In `open_questions` → proceed.
773
+ * - Else, find existing operator_answer with same `replies_to` →
774
+ * return the existing artifact (idempotent replay).
775
+ * - Else → throw `unknown_question`.
776
+ * 2. Materialize `answer_text` / `chosen_option_id` from the source
777
+ * question's `suggested_default` when `resolved_via='skip'` or
778
+ * `'timeout_default'` and the caller didn't pass them.
779
+ * 3. Append the operator_answer artifact (validated body).
780
+ * 4. Remove the question_id from `open_questions`.
781
+ * 5. Resume: if source had `pause_scope='slot'`, transition the asking
782
+ * slot back to 'working'. If `pause_scope='loop'` AND open_questions
783
+ * now empty AND pause_reason='awaiting_operator', resume the loop.
784
+ * 6. Emit `input_provided` (+ `slot_status_changed` for slot scope).
785
+ */
786
+ export function provideInput(input, cwd) {
787
+ const current = loadLoopOrThrow(input.loop_id, cwd);
788
+ // assertMutable allows paused loops (we need to resume them); only
789
+ // refuse terminal status.
790
+ if (current.status === 'completed' || current.status === 'cancelled' || current.status === 'blocked') {
791
+ throw new Error(`provide_input: loop ${current.id} is already ${current.status}`);
792
+ }
793
+ const isOpen = current.open_questions.includes(input.replies_to);
794
+ if (!isOpen) {
795
+ // Idempotent-replay path: look for an existing operator_answer with
796
+ // matching replies_to in the artifact list.
797
+ const existing = current.artifacts.find((a) => {
798
+ if (a.type !== 'operator_answer' || !a.body)
799
+ return false;
800
+ try {
801
+ const parsed = JSON.parse(a.body);
802
+ return parsed.replies_to === input.replies_to;
803
+ }
804
+ catch {
805
+ return false;
806
+ }
807
+ });
808
+ if (existing) {
809
+ return { thread: current, artifact_id: existing.artifact_id, duplicate: true };
810
+ }
811
+ throw new Error(`provide_input: unknown_question — replies_to "${input.replies_to}" is not in ` +
812
+ `open_questions and no existing operator_answer artifact references it`);
813
+ }
814
+ // Locate the source question to determine pause_scope and by_slot_id.
815
+ const sourceQuestion = current.artifacts.find((a) => {
816
+ if (a.type !== 'operator_question' || !a.body)
817
+ return false;
818
+ try {
819
+ const parsed = JSON.parse(a.body);
820
+ return parsed.question_id === input.replies_to;
821
+ }
822
+ catch {
823
+ return false;
824
+ }
825
+ });
826
+ if (!sourceQuestion || !sourceQuestion.body) {
827
+ throw new Error(`provide_input: question ${input.replies_to} is in open_questions but its artifact ` +
828
+ `was not found on the loop — state corruption`);
829
+ }
830
+ const sourceBody = JSON.parse(sourceQuestion.body);
831
+ // Materialize default values for skip / timeout_default resolutions.
832
+ let answerText = input.answer_text;
833
+ let chosenOptionId = input.chosen_option_id;
834
+ if ((input.resolved_via === 'skip' || input.resolved_via === 'timeout_default') &&
835
+ answerText === undefined &&
836
+ chosenOptionId === undefined) {
837
+ if (sourceBody.suggested_default === undefined) {
838
+ throw new Error(`provide_input: resolved_via="${input.resolved_via}" without an explicit ` +
839
+ `answer requires the source question to have suggested_default set`);
840
+ }
841
+ if (sourceBody.options) {
842
+ chosenOptionId = sourceBody.suggested_default;
843
+ }
844
+ else {
845
+ answerText = sourceBody.suggested_default;
846
+ }
847
+ }
848
+ const by = input.by ?? 'operator';
849
+ const synthetic = by === 'system';
850
+ const answerBody = {
851
+ replies_to: input.replies_to,
852
+ resolved_via: input.resolved_via,
853
+ answer_text: answerText,
854
+ chosen_option_id: chosenOptionId,
855
+ by,
856
+ synthetic: synthetic ? true : undefined,
857
+ };
858
+ const now = nowISO();
859
+ const mutation_id = generateMutationId();
860
+ const version = current.version + 1;
861
+ const newArtifact = LoopArtifactSchema.parse({
862
+ artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
863
+ phase: sourceQuestion.phase,
864
+ type: 'operator_answer',
865
+ body: JSON.stringify(answerBody),
866
+ produced_by: by === 'system' ? 'engine' : input.actor,
867
+ produced_at: now,
868
+ iteration: current.iteration_count,
869
+ });
870
+ const nextOpenQuestions = current.open_questions.filter((q) => q !== input.replies_to);
871
+ // Widen to LoopStatus — the assertMutable check above narrowed current.status
872
+ // to 'open' | 'paused', but the file_apply post-hook below can transition
873
+ // directly to 'completed' (pln#512 step 2).
874
+ let nextStatus = current.status;
875
+ let nextPauseReason = current.pause_reason;
876
+ let nextSlots = current.slots;
877
+ let nextPendingFileApply = current.pending_file_apply;
878
+ let nextClosedAt = current.closed_at;
879
+ let resumedSlotId;
880
+ let resumedSlotFromStatus;
881
+ // pln#512 step 2 — file_overwrite_approval resolution path. When the loop
882
+ // is paused on a file_apply question (set by the closeLoop bootstrap
883
+ // pre-hook), the answer carries the operator's approve/reject decision.
884
+ // Approve → write PROJECT.md atomically; reject → leave target untouched.
885
+ // Either way, the loop transitions directly to status='completed' so the
886
+ // caller's original `closeLoop(final_status='completed')` intent is
887
+ // honoured without requiring a second close round-trip.
888
+ let fileApplyResolution;
889
+ if (sourceBody.pause_scope === 'loop' &&
890
+ nextOpenQuestions.length === 0 &&
891
+ current.pause_reason === 'awaiting_file_apply' &&
892
+ current.pending_file_apply !== undefined) {
893
+ const approved = chosenOptionId === 'approve';
894
+ if (approved) {
895
+ // Idempotent on the source artifact: writeProjectMdSafe re-reads
896
+ // project_md_final and atomic-writes the target. Failure here propagates
897
+ // — we don't want to mark the loop completed if the write failed.
898
+ writeProjectMdSafe(current, cwd, { approved: true });
899
+ }
900
+ fileApplyResolution = {
901
+ approved,
902
+ artifact_id: current.pending_file_apply.artifact_id,
903
+ };
904
+ nextStatus = 'completed';
905
+ nextPauseReason = undefined;
906
+ nextPendingFileApply = undefined;
907
+ nextClosedAt = now;
908
+ }
909
+ else if (sourceBody.pause_scope === 'slot') {
910
+ const slotId = sourceBody.by_slot_id;
911
+ if (slotId) {
912
+ const slot = current.slots.find((s) => s.slot_id === slotId);
913
+ if (slot && slot.status === 'waiting_input') {
914
+ resumedSlotId = slotId;
915
+ resumedSlotFromStatus = slot.status;
916
+ nextSlots = current.slots.map((s) => s.slot_id === slotId ? { ...s, status: 'working' } : s);
917
+ }
918
+ }
919
+ }
920
+ else if (sourceBody.pause_scope === 'loop' &&
921
+ nextOpenQuestions.length === 0 &&
922
+ current.pause_reason === 'awaiting_operator') {
923
+ nextStatus = 'open';
924
+ nextPauseReason = undefined;
925
+ }
926
+ const next = {
927
+ ...current,
928
+ version,
929
+ mutation_id,
930
+ artifacts: [...current.artifacts, newArtifact],
931
+ open_questions: nextOpenQuestions,
932
+ status: nextStatus,
933
+ pause_reason: nextPauseReason,
934
+ pending_file_apply: nextPendingFileApply,
935
+ closed_at: nextClosedAt,
936
+ slots: nextSlots,
937
+ updated_at: now,
938
+ };
939
+ let seq = nextSeq(current.id, cwd);
940
+ appendEvent(current.id, {
941
+ event_id: crypto.randomUUID(),
942
+ loop_id: current.id,
943
+ seq,
944
+ at: now,
945
+ by: input.actor,
946
+ mutation_id,
947
+ kind: 'input_provided',
948
+ question_id: input.replies_to,
949
+ resolved_via: input.resolved_via,
950
+ answered_by: by,
951
+ synthetic,
952
+ }, cwd);
953
+ if (resumedSlotId && resumedSlotFromStatus) {
954
+ seq += 1;
955
+ appendEvent(current.id, {
956
+ event_id: crypto.randomUUID(),
957
+ loop_id: current.id,
958
+ seq,
959
+ at: now,
960
+ by: input.actor,
961
+ mutation_id,
962
+ kind: 'slot_status_changed',
963
+ slot_id: resumedSlotId,
964
+ from_status: resumedSlotFromStatus,
965
+ to_status: 'working',
966
+ }, cwd);
967
+ }
968
+ // pln#512 step 2 — file_apply close-out events. The file_apply_resolved
969
+ // event must precede the closed event so the journal reads in causal
970
+ // order (resolve → close), mirroring the absent/empty branch in
971
+ // closeLoop.
972
+ if (fileApplyResolution !== undefined) {
973
+ seq += 1;
974
+ appendEvent(current.id, {
975
+ event_id: crypto.randomUUID(),
976
+ loop_id: current.id,
977
+ seq,
978
+ at: now,
979
+ by: input.actor,
980
+ mutation_id,
981
+ kind: 'file_apply_resolved',
982
+ artifact_id: fileApplyResolution.artifact_id,
983
+ approved: fileApplyResolution.approved,
984
+ }, cwd);
985
+ seq += 1;
986
+ appendEvent(current.id, {
987
+ event_id: crypto.randomUUID(),
988
+ loop_id: current.id,
989
+ seq,
990
+ at: now,
991
+ by: input.actor,
992
+ mutation_id,
993
+ kind: 'closed',
994
+ final_status: 'completed',
995
+ reason: fileApplyResolution.approved
996
+ ? 'file_overwrite_approved'
997
+ : 'file_overwrite_rejected',
998
+ }, cwd);
999
+ }
1000
+ writeThreadFile(next, cwd);
1001
+ assertOpenQuestionsInvariant(next, 'provide_input');
1002
+ return { thread: next, artifact_id: newArtifact.artifact_id, duplicate: false };
1003
+ }
1004
+ /* =========================== /pln#508 step 2 ============================== */
1005
+ /**
1006
+ * Commit a simple open ↔ paused transition.
1007
+ *
1008
+ * `pauseReasonOverride` semantics (pln#508 step 3):
1009
+ * - undefined → leave `thread.pause_reason` as-is (pass-through via spread).
1010
+ * - PauseReason value → write that value to `thread.pause_reason`.
1011
+ * - null → explicitly clear `thread.pause_reason` (used by resume() to
1012
+ * satisfy the LoopThreadSchema invariant "pause_reason requires
1013
+ * status=paused").
1014
+ */
1015
+ function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd, pauseReasonOverride) {
508
1016
  const now = nowISO();
509
1017
  const mutation_id = generateMutationId();
510
1018
  const version = current.version + 1;
@@ -515,6 +1023,9 @@ function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd) {
515
1023
  mutation_id,
516
1024
  status: newStatus,
517
1025
  updated_at: now,
1026
+ pause_reason: pauseReasonOverride === null
1027
+ ? undefined
1028
+ : pauseReasonOverride ?? current.pause_reason,
518
1029
  };
519
1030
  const base = {
520
1031
  event_id: crypto.randomUUID(),
@@ -531,4 +1042,226 @@ function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd) {
531
1042
  writeThreadFile(next, cwd);
532
1043
  return next;
533
1044
  }
1045
+ export function sweepPauseTimeouts(loop_id, now, cwd) {
1046
+ const nowMs = (now ?? new Date()).getTime();
1047
+ let current = getLoop(loop_id, cwd);
1048
+ if (!current) {
1049
+ return { loop_id, fired: [] };
1050
+ }
1051
+ // Terminal loops never get swept (Phase 0 spec §6).
1052
+ if (current.status === 'completed' ||
1053
+ current.status === 'cancelled' ||
1054
+ current.status === 'blocked') {
1055
+ return { loop_id, fired: [] };
1056
+ }
1057
+ if (current.open_questions.length === 0) {
1058
+ return { loop_id, fired: [] };
1059
+ }
1060
+ const fired = [];
1061
+ // Snapshot question_ids: we mutate state inside the loop and want a stable
1062
+ // iteration set even if e.g. provideInput's cascade clears multiple.
1063
+ const candidates = [...current.open_questions];
1064
+ for (const question_id of candidates) {
1065
+ // Reload between iterations — provideInput / continue_incomplete /
1066
+ // commitClosedTransition all rewrite the thread, and a previous iteration
1067
+ // may have already taken the loop terminal.
1068
+ const reloaded = getLoop(loop_id, cwd);
1069
+ if (!reloaded)
1070
+ break;
1071
+ current = reloaded;
1072
+ if (current.status === 'completed' ||
1073
+ current.status === 'cancelled' ||
1074
+ current.status === 'blocked') {
1075
+ break;
1076
+ }
1077
+ if (!current.open_questions.includes(question_id))
1078
+ continue;
1079
+ const source = findOperatorQuestion(current, question_id);
1080
+ if (!source)
1081
+ continue;
1082
+ const deadlineMs = resolveDeadlineMs(current, source);
1083
+ if (deadlineMs === undefined)
1084
+ continue;
1085
+ if (nowMs <= deadlineMs)
1086
+ continue;
1087
+ const policy = source.body.on_timeout;
1088
+ if (policy === 'use_default') {
1089
+ try {
1090
+ provideInput({
1091
+ loop_id,
1092
+ replies_to: question_id,
1093
+ resolved_via: 'timeout_default',
1094
+ by: 'system',
1095
+ actor: 'engine',
1096
+ }, cwd);
1097
+ }
1098
+ catch {
1099
+ // If provideInput rejects (e.g. missing suggested_default that should
1100
+ // have been caught at request_input time), fall through so we still
1101
+ // record an audit event marking the attempted sweep.
1102
+ continue;
1103
+ }
1104
+ emitPauseTimeoutEvent(loop_id, question_id, 'use_default', cwd);
1105
+ fired.push({ question_id, action_taken: 'use_default' });
1106
+ }
1107
+ else if (policy === 'cancel_loop') {
1108
+ // Re-read after we know we're firing — cancel transitions the loop
1109
+ // to terminal and persists.
1110
+ const beforeCancel = getLoop(loop_id, cwd);
1111
+ if (!beforeCancel)
1112
+ break;
1113
+ commitClosedTransition(beforeCancel, 'cancelled', 'engine', 'operator_timeout', cwd);
1114
+ emitPauseTimeoutEvent(loop_id, question_id, 'cancel_loop', cwd);
1115
+ fired.push({ question_id, action_taken: 'cancel_loop' });
1116
+ // Subsequent candidates can't fire on a terminal loop.
1117
+ break;
1118
+ }
1119
+ else if (policy === 'continue_incomplete') {
1120
+ applyContinueIncomplete(current, question_id, source.body, cwd);
1121
+ emitPauseTimeoutEvent(loop_id, question_id, 'continue_incomplete', cwd);
1122
+ fired.push({ question_id, action_taken: 'continue_incomplete' });
1123
+ }
1124
+ }
1125
+ return { loop_id, fired };
1126
+ }
1127
+ function emitPauseTimeoutEvent(loop_id, question_id, action_taken, cwd) {
1128
+ appendEvent(loop_id, {
1129
+ event_id: crypto.randomUUID(),
1130
+ loop_id,
1131
+ seq: nextSeq(loop_id, cwd),
1132
+ at: nowISO(),
1133
+ by: 'engine',
1134
+ mutation_id: generateMutationId(),
1135
+ kind: 'pause_timeout',
1136
+ question_id,
1137
+ action_taken,
1138
+ }, cwd);
1139
+ }
1140
+ function findOperatorQuestion(thread, question_id) {
1141
+ for (const artifact of thread.artifacts) {
1142
+ if (artifact.type !== 'operator_question' || !artifact.body)
1143
+ continue;
1144
+ try {
1145
+ const body = JSON.parse(artifact.body);
1146
+ if (body.question_id === question_id) {
1147
+ return { artifact, body };
1148
+ }
1149
+ }
1150
+ catch { /* skip malformed */ }
1151
+ }
1152
+ return undefined;
1153
+ }
1154
+ /**
1155
+ * Compute the deadline (ms since epoch) for a question. Prefers the
1156
+ * artifact's explicit `timeout_at`. Falls back to `produced_at +
1157
+ * protocol.max_pause_duration` (ISO-8601 duration). Returns undefined when
1158
+ * neither source yields a usable value.
1159
+ */
1160
+ function resolveDeadlineMs(thread, source) {
1161
+ if (source.body.timeout_at) {
1162
+ const parsed = Date.parse(source.body.timeout_at);
1163
+ if (!Number.isNaN(parsed))
1164
+ return parsed;
1165
+ }
1166
+ const max = thread.protocol?.max_pause_duration;
1167
+ if (!max)
1168
+ return undefined;
1169
+ const startMs = Date.parse(source.artifact.produced_at);
1170
+ if (Number.isNaN(startMs))
1171
+ return undefined;
1172
+ const durationMs = parseIso8601DurationMs(max);
1173
+ if (durationMs === undefined)
1174
+ return undefined;
1175
+ return startMs + durationMs;
1176
+ }
1177
+ /**
1178
+ * Minimal ISO-8601 duration parser. Supports the subset needed by the
1179
+ * bootstrap preset: P[n]D[ T[n]H[n]M[n]S ]. Returns the total duration in
1180
+ * milliseconds, or undefined when the input is not a recognised duration.
1181
+ * Note: months/years are intentionally NOT supported — they have no
1182
+ * fixed millisecond length and the preset only needs days/hours/minutes.
1183
+ *
1184
+ * Bound (can_45f6a7fb): output is rejected when ms is non-finite, ≤0,
1185
+ * or exceeds MAX_PAUSE_DURATION_MS (365 days). Without this bound, inputs
1186
+ * like `P99999999999999D` parse to `Infinity` or unsafe integers, and
1187
+ * `now > timeout_at` evaluates falsy ⇒ the deadline never fires ⇒
1188
+ * paused loops hang forever with no recovery path.
1189
+ */
1190
+ const MAX_PAUSE_DURATION_MS = 365 * 24 * 60 * 60 * 1000;
1191
+ function parseIso8601DurationMs(input) {
1192
+ const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(input);
1193
+ if (!match)
1194
+ return undefined;
1195
+ const [, daysStr, hoursStr, minutesStr, secondsStr] = match;
1196
+ // If everything is missing the regex still matches "P" — guard against
1197
+ // returning 0 for a meaningless string.
1198
+ if (!daysStr && !hoursStr && !minutesStr && !secondsStr)
1199
+ return undefined;
1200
+ const days = daysStr ? Number(daysStr) : 0;
1201
+ const hours = hoursStr ? Number(hoursStr) : 0;
1202
+ const minutes = minutesStr ? Number(minutesStr) : 0;
1203
+ const seconds = secondsStr ? Number(secondsStr) : 0;
1204
+ const ms = (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
1205
+ if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_PAUSE_DURATION_MS) {
1206
+ return undefined;
1207
+ }
1208
+ return ms;
1209
+ }
1210
+ /**
1211
+ * Apply the `continue_incomplete` timeout policy: drop the question_id from
1212
+ * `open_questions` without creating an answer artifact, and resume the slot
1213
+ * or loop per the source `pause_scope`. Atomic: one version bump, no
1214
+ * `input_provided` event (continue_incomplete is explicitly "no answer").
1215
+ */
1216
+ function applyContinueIncomplete(current, question_id, body, cwd) {
1217
+ const now = nowISO();
1218
+ const mutation_id = generateMutationId();
1219
+ const version = current.version + 1;
1220
+ const nextOpenQuestions = current.open_questions.filter((q) => q !== question_id);
1221
+ let nextStatus = current.status;
1222
+ let nextPauseReason = current.pause_reason;
1223
+ let nextSlots = current.slots;
1224
+ let resumedSlotId;
1225
+ let resumedSlotFromStatus;
1226
+ if (body.pause_scope === 'slot' && body.by_slot_id) {
1227
+ const slot = current.slots.find((s) => s.slot_id === body.by_slot_id);
1228
+ if (slot && slot.status === 'waiting_input') {
1229
+ resumedSlotId = slot.slot_id;
1230
+ resumedSlotFromStatus = slot.status;
1231
+ nextSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? { ...s, status: 'working' } : s);
1232
+ }
1233
+ }
1234
+ else if (body.pause_scope === 'loop' &&
1235
+ nextOpenQuestions.length === 0 &&
1236
+ current.pause_reason === 'awaiting_operator') {
1237
+ nextStatus = 'open';
1238
+ nextPauseReason = undefined;
1239
+ }
1240
+ const next = {
1241
+ ...current,
1242
+ version,
1243
+ mutation_id,
1244
+ open_questions: nextOpenQuestions,
1245
+ status: nextStatus,
1246
+ pause_reason: nextPauseReason,
1247
+ slots: nextSlots,
1248
+ updated_at: now,
1249
+ };
1250
+ if (resumedSlotId && resumedSlotFromStatus) {
1251
+ appendEvent(current.id, {
1252
+ event_id: crypto.randomUUID(),
1253
+ loop_id: current.id,
1254
+ seq: nextSeq(current.id, cwd),
1255
+ at: now,
1256
+ by: 'engine',
1257
+ mutation_id,
1258
+ kind: 'slot_status_changed',
1259
+ slot_id: resumedSlotId,
1260
+ from_status: resumedSlotFromStatus,
1261
+ to_status: 'working',
1262
+ }, cwd);
1263
+ }
1264
+ writeThreadFile(next, cwd);
1265
+ return next;
1266
+ }
534
1267
  //# sourceMappingURL=verbs.js.map