@plurnk/plurnk-service 0.44.0 → 0.45.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 (116) hide show
  1. package/.env.example +27 -23
  2. package/README.md +37 -18
  3. package/SPEC.md +31 -7
  4. package/dist/core/ChannelWrite.d.ts +4 -0
  5. package/dist/core/ChannelWrite.d.ts.map +1 -1
  6. package/dist/core/ChannelWrite.js +9 -0
  7. package/dist/core/ChannelWrite.js.map +1 -1
  8. package/dist/core/ChannelWrite.sql +69 -0
  9. package/dist/core/Engine.d.ts.map +1 -1
  10. package/dist/core/Engine.js +108 -71
  11. package/dist/core/Engine.js.map +1 -1
  12. package/dist/core/Engine.sql +291 -0
  13. package/dist/core/ExecutorRegistry.d.ts +4 -2
  14. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  15. package/dist/core/ExecutorRegistry.js +14 -4
  16. package/dist/core/ExecutorRegistry.js.map +1 -1
  17. package/dist/core/SchemeRegistry.d.ts +1 -0
  18. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  19. package/dist/core/SchemeRegistry.js +6 -0
  20. package/dist/core/SchemeRegistry.js.map +1 -1
  21. package/dist/core/fork.d.ts.map +1 -1
  22. package/dist/core/fork.js +8 -1
  23. package/dist/core/fork.js.map +1 -1
  24. package/dist/core/fork.sql +50 -0
  25. package/dist/core/plugin-attribution.d.ts +5 -0
  26. package/dist/core/plugin-attribution.d.ts.map +1 -0
  27. package/dist/core/plugin-attribution.js +39 -0
  28. package/dist/core/plugin-attribution.js.map +1 -0
  29. package/dist/core/run-ops.sql +16 -0
  30. package/dist/core/session-settings.d.ts +1 -0
  31. package/dist/core/session-settings.d.ts.map +1 -1
  32. package/dist/core/session-settings.js +2 -1
  33. package/dist/core/session-settings.js.map +1 -1
  34. package/dist/schemes/Exec.d.ts +1 -0
  35. package/dist/schemes/Exec.d.ts.map +1 -1
  36. package/dist/schemes/Exec.js +17 -5
  37. package/dist/schemes/Exec.js.map +1 -1
  38. package/dist/schemes/File.d.ts.map +1 -1
  39. package/dist/schemes/File.js +27 -3
  40. package/dist/schemes/File.js.map +1 -1
  41. package/dist/schemes/Log.sql +37 -0
  42. package/dist/schemes/_entry-crud.sql +88 -0
  43. package/dist/schemes/_entry-find.sql +31 -0
  44. package/dist/schemes/_entry-graph.sql +60 -0
  45. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  46. package/dist/schemes/_entry-manifest.js +4 -1
  47. package/dist/schemes/_entry-manifest.js.map +1 -1
  48. package/dist/schemes/_entry-ops.sql +20 -0
  49. package/dist/schemes/_entry-semantic.sql +64 -0
  50. package/dist/schemes/exec-env.js +1 -1
  51. package/dist/schemes/exec-env.js.map +1 -1
  52. package/dist/server/Daemon.d.ts.map +1 -1
  53. package/dist/server/Daemon.js +103 -37
  54. package/dist/server/Daemon.js.map +1 -1
  55. package/dist/server/clientTurn.sql +10 -0
  56. package/dist/server/drain.sql +82 -0
  57. package/dist/server/dsl.d.ts.map +1 -1
  58. package/dist/server/dsl.js +11 -4
  59. package/dist/server/dsl.js.map +1 -1
  60. package/dist/server/envelope.sql +75 -0
  61. package/dist/server/logEntry.sql +10 -0
  62. package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -1
  63. package/dist/server/methods/_dispatchAsClient.js +11 -6
  64. package/dist/server/methods/_dispatchAsClient.js.map +1 -1
  65. package/dist/server/methods/entry_read.sql +21 -0
  66. package/dist/server/methods/log_read.sql +11 -0
  67. package/dist/server/methods/loop_run.sql +9 -0
  68. package/dist/server/methods/session_create.d.ts.map +1 -1
  69. package/dist/server/methods/session_create.js +10 -3
  70. package/dist/server/methods/session_create.js.map +1 -1
  71. package/dist/service.d.ts +6 -0
  72. package/dist/service.d.ts.map +1 -0
  73. package/dist/service.js +221 -0
  74. package/dist/service.js.map +1 -0
  75. package/package.json +28 -14
  76. package/bin/plurnk-service.ts +0 -176
  77. package/dist/core/ProviderRegistry.d.ts +0 -42
  78. package/dist/core/ProviderRegistry.d.ts.map +0 -1
  79. package/dist/core/ProviderRegistry.js +0 -72
  80. package/dist/core/ProviderRegistry.js.map +0 -1
  81. package/dist/core/line-marker.d.ts +0 -23
  82. package/dist/core/line-marker.d.ts.map +0 -1
  83. package/dist/core/line-marker.js +0 -321
  84. package/dist/core/line-marker.js.map +0 -1
  85. package/dist/core/matcher.d.ts +0 -12
  86. package/dist/core/matcher.d.ts.map +0 -1
  87. package/dist/core/matcher.js +0 -72
  88. package/dist/core/matcher.js.map +0 -1
  89. package/dist/core/mimetype-binary.d.ts +0 -6
  90. package/dist/core/mimetype-binary.d.ts.map +0 -1
  91. package/dist/core/mimetype-binary.js +0 -82
  92. package/dist/core/mimetype-binary.js.map +0 -1
  93. package/dist/core/path-mimetype.d.ts +0 -3
  94. package/dist/core/path-mimetype.d.ts.map +0 -1
  95. package/dist/core/path-mimetype.js +0 -47
  96. package/dist/core/path-mimetype.js.map +0 -1
  97. package/dist/core/plugin-trust.d.ts +0 -4
  98. package/dist/core/plugin-trust.d.ts.map +0 -1
  99. package/dist/core/plugin-trust.js +0 -23
  100. package/dist/core/plugin-trust.js.map +0 -1
  101. package/dist/providers/Mock.d.ts +0 -43
  102. package/dist/providers/Mock.d.ts.map +0 -1
  103. package/dist/providers/Mock.js +0 -36
  104. package/dist/providers/Mock.js.map +0 -1
  105. package/dist/server/methods/op_hide.d.ts +0 -5
  106. package/dist/server/methods/op_hide.d.ts.map +0 -1
  107. package/dist/server/methods/op_hide.js +0 -24
  108. package/dist/server/methods/op_hide.js.map +0 -1
  109. package/dist/server/methods/op_show.d.ts +0 -5
  110. package/dist/server/methods/op_show.d.ts.map +0 -1
  111. package/dist/server/methods/op_show.js +0 -24
  112. package/dist/server/methods/op_show.js.map +0 -1
  113. package/dist/server/methods/session_set_persona.d.ts +0 -5
  114. package/dist/server/methods/session_set_persona.d.ts.map +0 -1
  115. package/dist/server/methods/session_set_persona.js +0 -29
  116. package/dist/server/methods/session_set_persona.js.map +0 -1
@@ -71,6 +71,7 @@ const readManifestItems = () => {
71
71
  return null;
72
72
  return normalizeManifestItems(Number.parseInt(raw, 10));
73
73
  };
74
+ import { ProviderError } from "@plurnk/plurnk-providers";
74
75
  // Resolution timeout — proposed entries auto-cancel if nothing arrives
75
76
  // within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
76
77
  const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
@@ -599,7 +600,7 @@ class Engine {
599
600
  // it, not a 404; same plurnk-origin foist as the operator docs.
600
601
  if (seq === 1) {
601
602
  // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
602
- const { manifestItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
603
+ const { manifestItems: sessionMI, autoReadAgents } = await SessionSettings.read(this.#db, sessionId);
603
604
  const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
604
605
  if (manifestItems !== null) {
605
606
  const manifestRead = {
@@ -618,6 +619,30 @@ class Engine {
618
619
  });
619
620
  nextActionIndex++;
620
621
  }
622
+ // #250 — auto-READ the project's AGENTS.md scratchpad into THIS first model turn
623
+ // when the session opted in AND it's a member. The client picks it (gitignored by
624
+ // convention → not a git member); the engine READs it here so its body is part of
625
+ // turn-1's log — a normal file:/// member READ (the model sees only the READ; it
626
+ // stays read-write, so the model edits the scratchpad back as it evolves).
627
+ if (autoReadAgents === true) {
628
+ const agentsMember = await this.#db.crud_get_member_sig.get({ session_id: sessionId, scheme: null, pathname: "/AGENTS.md" });
629
+ if (agentsMember !== undefined) {
630
+ const agentsRead = {
631
+ op: "READ", suffix: "", signal: null, lineMarker: null,
632
+ target: {
633
+ kind: "url", raw: "file:///AGENTS.md", scheme: "file",
634
+ username: null, password: null, hostname: null, port: null,
635
+ pathname: "/AGENTS.md", params: {}, fragment: null,
636
+ },
637
+ body: null, position: { line: 1, column: 1 },
638
+ };
639
+ await this.dispatch({
640
+ statement: agentsRead, sessionId, runId, loopId, turnId,
641
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
642
+ });
643
+ nextActionIndex++;
644
+ }
645
+ }
621
646
  }
622
647
  // §env-delta — pre-seed environment deltas (changes since this run last
623
648
  // reconciled) as system EDIT rows, before the packet composes; advance
@@ -661,7 +686,31 @@ class Engine {
661
686
  // decode at the free window so a runaway can't reach the context wall.
662
687
  const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
663
688
  const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.tokens);
664
- const response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired
689
+ let response;
690
+ // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
691
+ // active-plugin set (placeholder); real per-turn grounding is deferred.
692
+ const attributions = [...new Set([...this.#schemes.attributions(), ...(this.#executors?.attributions() ?? [])])].toSorted();
693
+ try {
694
+ response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens, attributions: attributions.length > 0 ? attributions : undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved
695
+ }
696
+ catch (err) {
697
+ // #256 — grammar_unenforced is the one provider error the MODEL can recover from:
698
+ // the backend didn't constrain the GBNF, so this turn's output was rejected, but a
699
+ // conforming emission next turn is accepted. Surface it as telemetry (the model's
700
+ // next packet shows it) and fall through as an empty no-op turn, so the strike rail
701
+ // gives it maxStrikes chances before terminating — unlike infra errors (rate_limit,
702
+ // network_failure, unauthorized), which propagate and end the loop.
703
+ if (err instanceof ProviderError && err.kind === "grammar_unenforced") {
704
+ this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: "grammar_unenforced", message: err.message });
705
+ response = {
706
+ assistant: { content: "", reasoning: null, usage: { prompt: requestPacket.tokens, completion: 0, reasoning: 0, cached: 0, total: requestPacket.tokens }, finishReason: null, model: provider.model },
707
+ assistantRaw: null,
708
+ };
709
+ }
710
+ else {
711
+ throw err;
712
+ }
713
+ }
665
714
  // Engine splits wire-level response: emission (content, reasoning,
666
715
  // parsed ops) → packet.assistant per Packet.json §assistant;
667
716
  // call-metadata (usage, finishReason, model) → Turn columns per
@@ -691,10 +740,10 @@ class Engine {
691
740
  });
692
741
  }
693
742
  const opsCount = packetAssistant.ops.length;
694
- // Informational SEND[103] broadcasts (#free-text-capture) are log rows, not
695
- // actions: excluded from the real-op count so a prose-only turn still strikes
743
+ // PLAN (reasoning) and informational SEND[103] are no-ops, not actions: both are
744
+ // excluded from the real-op count so a PLAN-only or prose-only turn still strikes
696
745
  // as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
697
- const realOpsCount = packetAssistant.ops.filter((op) => !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
746
+ const realOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
698
747
  const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
699
748
  // Rail #41 (revised): the per-turn requirement is "emit at least one
700
749
  // op," not "emit a terminal SEND." SEND is purely a signal verb; many
@@ -728,7 +777,14 @@ class Engine {
728
777
  // its emission was truncated.
729
778
  // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
730
779
  const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
731
- const opsToDispatch = packetAssistant.ops.slice(0, maxCommands);
780
+ // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
781
+ // actions — they always dispatch and never count against the cap. maxCommands
782
+ // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
783
+ // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
784
+ let realCommands = 0;
785
+ const opsToDispatch = packetAssistant.ops.filter((op) => op.op === "PLAN"
786
+ || (op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200)
787
+ || realCommands++ < maxCommands);
732
788
  const droppedCount = opsCount - opsToDispatch.length;
733
789
  const statuses = [];
734
790
  for (const [i, statement] of opsToDispatch.entries()) {
@@ -772,46 +828,26 @@ class Engine {
772
828
  const { assistant } = response;
773
829
  const preParsedOps = assistant.ops;
774
830
  const ops = [];
775
- // #plan-reasoning: PLAN bodies are the model's in-band reasoning hoisted
776
- // out of the op stream into the reasoning field, never dispatched as a log op.
777
- // Free text becomes informational SEND[103] log ops (#free-text-capture) below.
778
- const planFragments = [];
779
- const hoistPlan = (op) => {
780
- if (op.op !== "PLAN")
781
- return false;
782
- const raw = (op.body ?? "").trim();
783
- if (raw.length > 0)
784
- planFragments.push(raw);
785
- return true;
786
- };
831
+ // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
832
+ // client as a log entry. No special hoisting into the reasoning field (that
833
+ // legacy paradigm is abandoned). Interstitial free text is DROPPED — the prior
834
+ // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
835
+ // (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
787
836
  // Full PlurnkParseError context (line/column/source) is preserved
788
837
  // here so runTurn can build TelemetryEvent envelopes per the
789
838
  // grammar 0.17.0 protocol — model needs position info to locate
790
839
  // its own offending content on the next turn.
791
840
  const parseErrors = [];
792
841
  if (preParsedOps !== undefined) {
793
- for (const op of preParsedOps)
794
- if (!hoistPlan(op))
795
- ops.push(op);
842
+ ops.push(...preParsedOps);
796
843
  }
797
844
  else {
798
845
  const parsed = PlurnkParser.parse(assistant.content);
799
846
  for (const item of parsed.items) {
800
847
  if (item.kind === "statement") {
801
- if (!hoistPlan(item.statement))
802
- ops.push(item.statement);
803
- }
804
- else if (item.kind === "text") {
805
- // #free-text-capture: interstitial prose → an informational
806
- // SEND[103] broadcast log op, interleaved in emission order.
807
- const trimmed = item.text.trim();
808
- if (trimmed.length > 0)
809
- ops.push({
810
- op: "SEND", suffix: "", signal: 103, target: null,
811
- lineMarker: null, body: { raw: trimmed, json: null },
812
- position: item.position ?? { line: 1, column: 1 },
813
- });
848
+ ops.push(item.statement);
814
849
  }
850
+ // Free text (kind "text") is dropped — #free-text-capture retired (above).
815
851
  else if (item.kind === "error") {
816
852
  const err = item.error;
817
853
  if (err instanceof PlurnkParseError) {
@@ -834,10 +870,7 @@ class Engine {
834
870
  parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
835
871
  }
836
872
  }
837
- const wireReasoning = assistant.reasoning ?? "";
838
- const planReasoning = planFragments.join("\n\n");
839
- const reasoningParts = [wireReasoning, planReasoning].filter((s) => s.length > 0);
840
- const reasoning = reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null;
873
+ const reasoning = assistant.reasoning ?? null;
841
874
  return {
842
875
  packetAssistant: { content: assistant.content, ops, reasoning },
843
876
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
@@ -850,9 +883,10 @@ class Engine {
850
883
  // the stored packet and the wire payload share one source of truth.
851
884
  async #buildRequestPacket({ initialMessages, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
852
885
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
853
- // plurnk.md (grammar/dialects onlygrammar 0.49+ is scheme-agnostic). The
854
- // scheme catalogue is now its OWN section (`schemes`, below tools), so the
855
- // definition is just the operator's system prompt tools sits right below it.
886
+ // plurnk.md (grammar/dialects) ONLYthe definition is the hot-path grammar.
887
+ // The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
888
+ // NOT appended here: grammar 0.49+ is scheme-agnostic, so the service advertises
889
+ // the scheme set at packet-time (grammar#239 item 7) via SchemeRegistry.teach().
856
890
  const system_definition = byRole("system");
857
891
  // the prompt section sources from the loop's most recent prompt entry first
858
892
  // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
@@ -869,11 +903,11 @@ class Engine {
869
903
  // requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
870
904
  // requirements.md) fresh each build so edits take effect; a non-empty param wins.
871
905
  const baseRequirements = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
872
- // The op syntax leads the requirements; when PLURNK_PLAN=1 the plan directive joins the
873
- // HARD requirements list (dynamically added/removed with the flag) rather than softly
874
- // appearing in the optional # Plurnk System Tools sheet. §requirements-plan-gated
875
- const planDirective = process.env.PLURNK_PLAN === "1" ? "YOU MUST begin every response with <<PLAN:...:PLAN\n" : "";
876
- const requirementsText = `Syntax: <<OPsuffix[signal]?(target)?<Line/Result>?:body?:OPsuffix\n\n${planDirective}${baseRequirements}`;
906
+ // The op syntax leads the requirements. PLAN is mandated unconditionally by
907
+ // plurnk.md §Imperatives (grammar 0.70 requires every turn to lead with PLAN),
908
+ // so the service injects no separate plan directive here — the former PLURNK_PLAN
909
+ // gating is retired (PLURNK_PLAN is no longer a flag).
910
+ const requirementsText = `Syntax: <<OPsuffix[signal]?(target)?<Line/Result>?:body?:OPsuffix\n\n${baseRequirements}`;
877
911
  const log = await this.#buildLog(runId);
878
912
  const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
879
913
  const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
@@ -889,8 +923,8 @@ class Engine {
889
923
  const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
890
924
  // The default packet: an ordered list of sections, each addressable state
891
925
  // (§packet-construction). `slot` is the prompt-cache boundary; the STATIC
892
- // sections (definition, tools, schemes) lead the system slot so they form the
893
- // cached prefix, with the dynamic log after. In the user slot, requirements renders
926
+ // sections (definition, tools) lead the system slot so they form the cached
927
+ // prefix, with the dynamic log after. In the user slot, requirements renders
894
928
  // last (the contract closest to the assistant turn); budget/errors/git are
895
929
  // peer sections (unbundled). The budget section carries its {{tokensFree}}
896
930
  // placeholders here; they resolve below once the assembled total is known.
@@ -961,9 +995,7 @@ class Engine {
961
995
  // The # Plurnk System Tools capability sheet (SPEC §tools). A hook: each enabled
962
996
  // capability contributes one line, rendered above Requirements so the model sees what
963
997
  // it can do before the rules. Each available executor tag contributes its self-documenting
964
- // example (plurnk-execs#7), retiring the blind EXEC. The plan directive is NOT a tool — it
965
- // is a hard requirement gated by PLURNK_PLAN (§requirements-plan-gated), so it joins the
966
- // rules list, not this optional sheet.
998
+ // example (plurnk-execs#7), retiring the blind EXEC.
967
999
  // The capability sheet — the live tool surface (wired executor tags). §tools-capability-sheet
968
1000
  #collectTools() {
969
1001
  const tools = [];
@@ -1269,13 +1301,13 @@ class Engine {
1269
1301
  const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
1270
1302
  onDispatch?.(logEntryId);
1271
1303
  // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
1272
- // scheme returns status 202, the entry is written as state='proposed';
1273
- // dispatch then PAUSES on a per-entry waiter until resolution
1274
- // arrives via Engine.resolveProposal (from the loop/resolve RPC,
1275
- // YOLO listener, or timeout). The post-resolution status replaces
1276
- // 202 in the result the caller sees, so runTurn never branches on
1277
- // a pending state.
1278
- if (result.status === 202) {
1304
+ // side-effecting op returns status 202 (a broadcast SEND[202] park is model
1305
+ // speech, not a proposal #isProposal, #255), the entry is written
1306
+ // state='proposed'; dispatch then PAUSES on a per-entry waiter until
1307
+ // resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
1308
+ // YOLO listener, or timeout). The post-resolution status replaces 202 in the
1309
+ // result the caller sees, so runTurn never branches on a pending state.
1310
+ if (_a.#isProposal(statement, result)) {
1279
1311
  // Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
1280
1312
  // no human gate, no loop/proposal notification. Accept + apply
1281
1313
  // in-process; the model sees the outcome directly, never a review.
@@ -1749,12 +1781,9 @@ class Engine {
1749
1781
  const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
1750
1782
  return { status: delResult.status };
1751
1783
  }
1752
- // PLAN — the model's in-band reasoning op (plurnk-grammar 0.30.0, the 11th op).
1753
- // Production HOISTS PLAN bodies into the turn's reasoning field in #parseAssistant
1754
- // (#plan-reasoning) before dispatch runs, so PLAN normally never reaches here. This
1755
- // handler is the op's direct-dispatch semantics (exercised by the dispatch unit
1756
- // test): a pure no-op (PLAN ∉ MUTATING_OPS) whose body would serialize into the log
1757
- // row's tx, no runtime effect.
1784
+ // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
1785
+ // other, logged, and broadcast to the client as a log entry — but a pure no-op for
1786
+ // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
1758
1787
  #handlePlan(statement) {
1759
1788
  if (statement.op !== "PLAN")
1760
1789
  throw new Error("unreachable");
@@ -1882,18 +1911,26 @@ class Engine {
1882
1911
  return path.scheme === "https" ? "http" : path.scheme;
1883
1912
  return "file"; // local (bare) → file
1884
1913
  }
1914
+ // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
1915
+ // directed write — paused for client resolution) UNLESS it is a broadcast SEND.
1916
+ // A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
1917
+ // plurnk.md), never a side-effect — #255: gating the propose/await path on the
1918
+ // bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
1919
+ // is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
1920
+ static #isProposal(statement, result) {
1921
+ return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
1922
+ }
1885
1923
  async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
1886
1924
  const target = this.#extractTarget(statement.target);
1887
1925
  const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
1888
1926
  ? JSON.stringify(statement.lineMarker)
1889
1927
  : null;
1890
- // Status 202 from a scheme means the action is proposed written to
1891
- // the log in state='proposed' until the proposal lifecycle resolves
1892
- // it. attrs holds the scheme-supplied payload (file diff, exec
1893
- // command, etc.) that the client renders for review and the scheme
1894
- // consumes on accept. All other statuses are terminal state =
1895
- // 'resolved' for the common case.
1896
- const isProposed = result.status === 202;
1928
+ // A proposal (status 202 from a side-effecting op) is written to the log in
1929
+ // state='proposed' until the proposal lifecycle resolves it; attrs holds the
1930
+ // scheme-supplied payload (file diff, exec command, etc.) the client renders
1931
+ // for review and the scheme consumes on accept. A broadcast SEND[202] is a
1932
+ // parked-terminal, NOT a proposal (#isProposal / #255) state='resolved'.
1933
+ const isProposed = _a.#isProposal(statement, result);
1897
1934
  let attrsObj = (result.attrs !== undefined && result.attrs !== null)
1898
1935
  ? { ...result.attrs }
1899
1936
  : {};