@plurnk/plurnk-service 0.29.0 → 0.34.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 (139) hide show
  1. package/SPEC.md +19 -20
  2. package/dist/Paths.d.ts +0 -1
  3. package/dist/Paths.d.ts.map +1 -1
  4. package/dist/Paths.js +2 -18
  5. package/dist/Paths.js.map +1 -1
  6. package/dist/content/line-marker.d.ts +4 -0
  7. package/dist/content/line-marker.d.ts.map +1 -1
  8. package/dist/content/line-marker.js +7 -1
  9. package/dist/content/line-marker.js.map +1 -1
  10. package/dist/content/matcher.js +1 -1
  11. package/dist/content/matcher.js.map +1 -1
  12. package/dist/content/mimetype-binary.js +1 -1
  13. package/dist/content/mimetype-binary.js.map +1 -1
  14. package/dist/content/path-mimetype.js +1 -1
  15. package/dist/content/path-mimetype.js.map +1 -1
  16. package/dist/content/read-resolve.js +1 -1
  17. package/dist/content/read-resolve.js.map +1 -1
  18. package/dist/core/ChannelWrite.d.ts +8 -0
  19. package/dist/core/ChannelWrite.d.ts.map +1 -1
  20. package/dist/core/ChannelWrite.js +9 -1
  21. package/dist/core/ChannelWrite.js.map +1 -1
  22. package/dist/core/Engine.d.ts +5 -6
  23. package/dist/core/Engine.d.ts.map +1 -1
  24. package/dist/core/Engine.js +107 -52
  25. package/dist/core/Engine.js.map +1 -1
  26. package/dist/core/ProviderInstantiate.js +1 -1
  27. package/dist/core/ProviderInstantiate.js.map +1 -1
  28. package/dist/core/SchemeRegistry.d.ts +1 -0
  29. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  30. package/dist/core/SchemeRegistry.js +22 -0
  31. package/dist/core/SchemeRegistry.js.map +1 -1
  32. package/dist/core/fork.js +2 -2
  33. package/dist/core/fork.js.map +1 -1
  34. package/dist/core/git-membership.js +6 -6
  35. package/dist/core/git-membership.js.map +1 -1
  36. package/dist/core/packet-wire.d.ts +0 -1
  37. package/dist/core/packet-wire.d.ts.map +1 -1
  38. package/dist/core/packet-wire.js +6 -9
  39. package/dist/core/packet-wire.js.map +1 -1
  40. package/dist/core/scheme-types.d.ts +2 -1
  41. package/dist/core/scheme-types.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/schemes/Exec.d.ts +1 -0
  45. package/dist/schemes/Exec.d.ts.map +1 -1
  46. package/dist/schemes/Exec.js +4 -3
  47. package/dist/schemes/Exec.js.map +1 -1
  48. package/dist/schemes/File.d.ts +1 -0
  49. package/dist/schemes/File.d.ts.map +1 -1
  50. package/dist/schemes/File.js +3 -1
  51. package/dist/schemes/File.js.map +1 -1
  52. package/dist/schemes/Known.d.ts +1 -0
  53. package/dist/schemes/Known.d.ts.map +1 -1
  54. package/dist/schemes/Known.js +2 -0
  55. package/dist/schemes/Known.js.map +1 -1
  56. package/dist/schemes/Log.d.ts +1 -0
  57. package/dist/schemes/Log.d.ts.map +1 -1
  58. package/dist/schemes/Log.js +4 -1
  59. package/dist/schemes/Log.js.map +1 -1
  60. package/dist/schemes/Plurnk.d.ts +1 -0
  61. package/dist/schemes/Plurnk.d.ts.map +1 -1
  62. package/dist/schemes/Plurnk.js +1 -0
  63. package/dist/schemes/Plurnk.js.map +1 -1
  64. package/dist/schemes/Run.d.ts +17 -0
  65. package/dist/schemes/Run.d.ts.map +1 -0
  66. package/dist/schemes/Run.js +72 -0
  67. package/dist/schemes/Run.js.map +1 -0
  68. package/dist/schemes/Unknown.d.ts +1 -0
  69. package/dist/schemes/Unknown.d.ts.map +1 -1
  70. package/dist/schemes/Unknown.js +1 -0
  71. package/dist/schemes/Unknown.js.map +1 -1
  72. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  73. package/dist/schemes/_entry-crud.js +2 -2
  74. package/dist/schemes/_entry-crud.js.map +1 -1
  75. package/dist/schemes/_entry-find.d.ts.map +1 -1
  76. package/dist/schemes/_entry-find.js +22 -6
  77. package/dist/schemes/_entry-find.js.map +1 -1
  78. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  79. package/dist/schemes/_entry-manifest.js +47 -30
  80. package/dist/schemes/_entry-manifest.js.map +1 -1
  81. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  82. package/dist/schemes/_entry-ops.js +10 -9
  83. package/dist/schemes/_entry-ops.js.map +1 -1
  84. package/dist/schemes/_entry-semantic.d.ts +1 -1
  85. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  86. package/dist/schemes/_entry-semantic.js +5 -2
  87. package/dist/schemes/_entry-semantic.js.map +1 -1
  88. package/dist/schemes/_entry-send.js +1 -1
  89. package/dist/schemes/_entry-send.js.map +1 -1
  90. package/dist/server/ClientConnection.d.ts.map +1 -1
  91. package/dist/server/ClientConnection.js +2 -3
  92. package/dist/server/ClientConnection.js.map +1 -1
  93. package/dist/server/Daemon.d.ts +1 -3
  94. package/dist/server/Daemon.d.ts.map +1 -1
  95. package/dist/server/Daemon.js +25 -14
  96. package/dist/server/Daemon.js.map +1 -1
  97. package/dist/server/MethodRegistry.d.ts +0 -2
  98. package/dist/server/MethodRegistry.d.ts.map +1 -1
  99. package/dist/server/clientTurn.js +1 -1
  100. package/dist/server/clientTurn.js.map +1 -1
  101. package/dist/server/dsl.d.ts.map +1 -1
  102. package/dist/server/dsl.js +6 -4
  103. package/dist/server/dsl.js.map +1 -1
  104. package/dist/server/envelope.d.ts +0 -6
  105. package/dist/server/envelope.d.ts.map +1 -1
  106. package/dist/server/envelope.js +12 -24
  107. package/dist/server/envelope.js.map +1 -1
  108. package/dist/server/logEntry.d.ts.map +1 -1
  109. package/dist/server/logEntry.js.map +1 -1
  110. package/dist/server/methods/_dispatchAsPlurnk.js +1 -1
  111. package/dist/server/methods/_dispatchAsPlurnk.js.map +1 -1
  112. package/dist/server/methods/discover.d.ts.map +1 -1
  113. package/dist/server/methods/discover.js +1 -0
  114. package/dist/server/methods/discover.js.map +1 -1
  115. package/dist/server/methods/log_read.js +1 -1
  116. package/dist/server/methods/log_read.js.map +1 -1
  117. package/dist/server/methods/loop_cancel.js +1 -1
  118. package/dist/server/methods/loop_cancel.js.map +1 -1
  119. package/dist/server/methods/loop_inject.d.ts.map +1 -1
  120. package/dist/server/methods/loop_inject.js +1 -2
  121. package/dist/server/methods/loop_inject.js.map +1 -1
  122. package/dist/server/methods/loop_run.d.ts.map +1 -1
  123. package/dist/server/methods/loop_run.js +4 -10
  124. package/dist/server/methods/loop_run.js.map +1 -1
  125. package/dist/server/methods/session_attach.d.ts.map +1 -1
  126. package/dist/server/methods/session_attach.js +3 -9
  127. package/dist/server/methods/session_attach.js.map +1 -1
  128. package/dist/server/methods/session_constraints.js +1 -1
  129. package/dist/server/methods/session_constraints.js.map +1 -1
  130. package/dist/server/methods/session_create.d.ts.map +1 -1
  131. package/dist/server/methods/session_create.js +5 -10
  132. package/dist/server/methods/session_create.js.map +1 -1
  133. package/dist/server/yolo.d.ts.map +1 -1
  134. package/dist/server/yolo.js +1 -0
  135. package/dist/server/yolo.js.map +1 -1
  136. package/migrations/0000-00-00.01_schema.sql +16 -10
  137. package/package.json +9 -10
  138. package/requirements.md +2 -2
  139. package/persona.md +0 -1
@@ -5,6 +5,7 @@ import EntryCrud from "../schemes/_entry-crud.js";
5
5
  import EntryManifest from "../schemes/_entry-manifest.js";
6
6
  import GitMembership from "./git-membership.js";
7
7
  import GitState from "./git-state.js";
8
+ import Fork from "./fork.js";
8
9
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
9
10
  import { LineMarkerOps, MimetypeBinary, editedSpan } from "../content/index.js";
10
11
  import { readFile } from "node:fs/promises";
@@ -90,7 +91,7 @@ const pathnameFromPath = (path) => {
90
91
  // loop terminal). No strike, no telemetry.
91
92
  const TURN_STATUS_IMPLICIT_CONTINUE = 102;
92
93
  // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
93
- // action routes through telemetry.errors[] (§telemetry).
94
+ // action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
94
95
  const TURN_STATUS_NO_OPS = 422;
95
96
  // Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
96
97
  // to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
@@ -127,7 +128,7 @@ const fingerprintOp = (stmt) => {
127
128
  }
128
129
  const lm = stmt.lineMarker;
129
130
  if (lm !== null && lm !== undefined)
130
- parts.push(`L:${lm.first},${lm.last ?? ""}`);
131
+ parts.push(`L:${lm.marks.join(",")}`);
131
132
  return parts.length > 0 ? `|${parts.join("|")}` : "";
132
133
  };
133
134
  if (path === null) {
@@ -239,6 +240,7 @@ class Engine {
239
240
  #loopAborts = new Map();
240
241
  #streamEventNotify;
241
242
  #wakeRunNotify;
243
+ #injectRun;
242
244
  // Telemetry event fan-out: every TelemetryEvent pushed to the loop's
243
245
  // buffer is also broadcast live to the connected client(s) on the
244
246
  // session. Without this, the client sees `loop/terminated` with a
@@ -247,11 +249,12 @@ class Engine {
247
249
  #telemetryEventNotify;
248
250
  // Cached plurnk GBNF — read once on the first constrained generate (#189).
249
251
  #gbnfCache = null;
250
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, telemetryEventNotify, tokenize }) {
252
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, telemetryEventNotify, tokenize }) {
251
253
  this.#db = db;
252
254
  this.#schemes = schemes;
253
255
  this.#streamEventNotify = streamEventNotify;
254
256
  this.#wakeRunNotify = wakeRunNotify;
257
+ this.#injectRun = injectRun;
255
258
  this.#telemetryEventNotify = telemetryEventNotify;
256
259
  // Default to empty discovery — standalone Engine construction (in
257
260
  // tests) gets no handlers, and content flows through the framework's
@@ -315,6 +318,7 @@ class Engine {
315
318
  // both sides per the grammar 0.17.0 TelemetryEvent protocol.
316
319
  this.#telemetryEventNotify?.(sessionId, { loopId, event });
317
320
  }
321
+ // Telemetry drains as it's read into the packet — each event surfaces once. §telemetry-drain-on-read
318
322
  #drainTelemetry(loopId) {
319
323
  const buf = this.#telemetryBuffer.get(loopId);
320
324
  if (buf === undefined)
@@ -343,7 +347,7 @@ class Engine {
343
347
  }
344
348
  return slice.join("\n");
345
349
  }
346
- async runLoop({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
350
+ async runLoop({ provider, messages, requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
347
351
  const turnIds = [];
348
352
  const suddenDeathThreshold = maxTurns - maxStrikes;
349
353
  // Per-loop AbortController for scheme-side cancellation propagation.
@@ -400,7 +404,7 @@ class Engine {
400
404
  await delay(execWaitMs, undefined, { signal });
401
405
  }
402
406
  const turn = await this.runTurn({
403
- provider, messages, persona, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
407
+ provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
404
408
  turnNumber: turnIds.length + 1, maxTurns,
405
409
  });
406
410
  turnIds.push(turn.turnId);
@@ -425,7 +429,7 @@ class Engine {
425
429
  state.turnErrors++;
426
430
  // SPEC §grinder: a non-soft grinder fire counts toward the strike streak.
427
431
  if (turn.budgetStruck)
428
- state.turnErrors++;
432
+ state.turnErrors++; // a grinder fire bumps the strike streak — §grinder-strike-coupling
429
433
  this.#strikeState.set(loopId, state);
430
434
  // Rail #38: strike accounting. Three sources strike a turn:
431
435
  // 1. recordedFailed — any action-entry at hard failure status
@@ -465,7 +469,7 @@ class Engine {
465
469
  }
466
470
  }
467
471
  }
468
- async runTurn({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
472
+ async runTurn({ provider, messages, requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
469
473
  // === Turn-as-container model ===
470
474
  //
471
475
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -497,7 +501,7 @@ class Engine {
497
501
  // otherwise.
498
502
  let nextActionIndex = 1;
499
503
  if (seq === 1) {
500
- // Operator doc READs (PLURNK_MD_<ALIAS>). The docs were materialized
504
+ // Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
501
505
  // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
502
506
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
503
507
  // reads them inline. It sees only the READ — the materializing EDIT
@@ -531,10 +535,17 @@ class Engine {
531
535
  target: promptPath, lineMarker: null,
532
536
  body: promptRow.prompt, position: { line: 1, column: 1 },
533
537
  };
538
+ let promptLogId;
534
539
  await this.dispatch({
535
540
  statement: promptStmt, sessionId, runId, loopId, turnId,
536
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
541
+ sequence: nextActionIndex, origin: "plurnk",
542
+ onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
537
543
  });
544
+ // §prompt-fold (User Note 6): the prompt EDIT duplicates
545
+ // packet.user.prompt (its own section), so fold it — logged for
546
+ // forensics, collapsed in the model's log, re-OPENable.
547
+ if (promptLogId !== undefined)
548
+ await this.#db.engine_fold_log_entry.run({ id: promptLogId });
538
549
  nextActionIndex++;
539
550
  }
540
551
  }
@@ -604,14 +615,14 @@ class Engine {
604
615
  // queries log_entries scoped to the run — the prompt entry just
605
616
  // written (if turn 1) is part of that query result.
606
617
  let requestPacket = await this.#buildRequestPacket({
607
- initialMessages: messages, persona, requirements, runId, loopId,
618
+ initialMessages: messages, requirements, runId, loopId,
608
619
  currentTurnSeq: seq, provider, gitStatus,
609
620
  });
610
621
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
611
622
  const enforced = await this.#enforceBudget({
612
623
  packet: requestPacket, provider, runId, loopId, turnId, sessionId, turnNumber,
613
624
  rebuild: (telemetryErrors) => this.#buildRequestPacket({
614
- initialMessages: messages, persona, requirements, runId, loopId,
625
+ initialMessages: messages, requirements, runId, loopId,
615
626
  currentTurnSeq: seq, provider, telemetryErrors, gitStatus,
616
627
  }),
617
628
  });
@@ -632,14 +643,14 @@ class Engine {
632
643
  // The 0.28.0 EOS-forcing root terminates the turn at the status SEND, but a
633
644
  // grammar can't bound degeneration *inside* a statement body — this caps the
634
645
  // decode at the free window so a runaway can't reach the context wall.
635
- const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
646
+ const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
636
647
  const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.system.tokens - requestPacket.user.tokens);
637
- const response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens });
648
+ 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
638
649
  // Engine splits wire-level response: emission (content, reasoning,
639
650
  // parsed ops) → packet.assistant per Packet.json §assistant;
640
651
  // call-metadata (usage, finishReason, model) → Turn columns per
641
652
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
642
- const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
653
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
643
654
  // Surface parse errors to the model's NEXT packet so it can self-
644
655
  // correct. Without this, malformed emissions (e.g. a READ matcher
645
656
  // body starting with `//` being interpreted as xpath) silently
@@ -685,7 +696,7 @@ class Engine {
685
696
  usage_prompt: usage.prompt,
686
697
  usage_completion: usage.completion,
687
698
  usage_cached: usage.cached,
688
- usage_cost_pico: provider.costFor(usage),
699
+ usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
689
700
  finish_reason: finishReason,
690
701
  model,
691
702
  });
@@ -820,9 +831,12 @@ class Engine {
820
831
  // and §user) BEFORE the provider call. The same packet object is then
821
832
  // completed with assistant + assistantRaw after the model responds, so
822
833
  // the stored packet and the wire payload share one source of truth.
823
- async #buildRequestPacket({ initialMessages, persona: defaultPersona, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
834
+ async #buildRequestPacket({ initialMessages, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
824
835
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
825
- const system_definition = byRole("system");
836
+ // plurnk.md (grammar/dialects) THEN the scheme catalogue: grammar 0.49+ is
837
+ // scheme-agnostic, so the service teaches what schemes exist + what they do
838
+ // at packet-time (grammar#239 item 7). SchemeRegistry.teach() assembles it.
839
+ const system_definition = `${byRole("system")}\n\n${this.#schemes.teach()}`;
826
840
  // user.prompt sources from the loop's most recent prompt entry first
827
841
  // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
828
842
  // This is what inject + the turn-1 foist write into. Falls back to
@@ -832,13 +846,8 @@ class Engine {
832
846
  const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
833
847
  ? latestPromptRow.content
834
848
  : byRole("user");
835
- // Resolve persona cascade: loops.persona > runs.persona >
836
- // sessions.persona > caller-supplied default. SQL coalesces in one
837
- // query; null result means no DB override exists, use the default.
838
- const row = await this.#db.engine_resolve_persona.get({ loop_id: loopId });
839
- const persona = (row?.persona !== undefined && row?.persona !== null) ? row.persona : defaultPersona;
840
849
  // Requirements is engine-sourced, NOT threaded from callers — that threading is
841
- // exactly how it went missing (loop_run/Daemon read sysprompt + persona but never
850
+ // exactly how it went missing (callers read the sysprompt but never the
842
851
  // requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
843
852
  // requirements.md) fresh each build so edits take effect; a non-empty param wins.
844
853
  const requirementsText = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
@@ -849,7 +858,7 @@ class Engine {
849
858
  // form — wire-payload tokens may differ slightly because chat-
850
859
  // template scaffolding adds bytes, but the subtotal tracks "what
851
860
  // the model has to process" closely enough for budget diagnostics.
852
- const countTokens = (t) => provider.countTokens(t);
861
+ const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
853
862
  // Budget readout (SPEC.md §tokenomics). Two-pass: measure the wire-rendered
854
863
  // index/log sections (budget-independent), install the readout with a
855
864
  // tokensFree placeholder, measure the assembled total, resolve free,
@@ -859,21 +868,21 @@ class Engine {
859
868
  // headline omitted, section lines still shown).
860
869
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
861
870
  const scratch = {
862
- system: { system_definition, persona, log },
871
+ system: { system_definition, log },
863
872
  user: { prompt, telemetry: { budget: "", errors: telemetryErrors, git: gitStatus }, tools: this.#collectTools(), system_requirements: requirementsText },
864
873
  };
865
874
  const sections = PacketWire.measureBudgetSections(scratch, countTokens);
866
875
  scratch.user.telemetry.budget = this.#renderBudget(sections, ceiling);
867
876
  const total = countTokens(PacketWire.renderSystemContent(scratch.system)) + countTokens(PacketWire.renderUserContent(scratch.user));
868
- const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total);
869
- const percent = ceiling === null ? null : Math.round((total / ceiling) * 100);
877
+ const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
878
+ const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
870
879
  const budget = tokensFree === null
871
880
  ? scratch.user.telemetry.budget
872
881
  : scratch.user.telemetry.budget
873
882
  .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
874
883
  .replace(TOKEN_PERCENT_PLACEHOLDER, String(percent))
875
884
  .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
876
- const system = { tokens: 0, system_definition, persona, log };
885
+ const system = { tokens: 0, system_definition, log };
877
886
  const user = { tokens: 0, prompt, telemetry: { budget, errors: telemetryErrors, git: gitStatus }, tools: scratch.user.tools, system_requirements: requirementsText };
878
887
  system.tokens = countTokens(PacketWire.renderSystemContent(system));
879
888
  user.tokens = countTokens(PacketWire.renderUserContent(user));
@@ -912,10 +921,11 @@ class Engine {
912
921
  // contributor (gated by PLURNK_PLAN); each available executor tag then
913
922
  // contributes its self-documenting example (plurnk-execs#7), retiring the
914
923
  // blind EXEC.
924
+ // The capability sheet — the live tool surface (PLAN + wired executor tags). §tools-capability-sheet
915
925
  #collectTools() {
916
926
  const tools = [];
917
- if (process.env.PLURNK_PLAN === "1") {
918
- tools.push("* Begin every response with <<PLAN:planning and reasoning:PLAN");
927
+ if (process.env.PLURNK_PLAN === "1") { // <<PLAN advertised only when PLAN is enabled — §tools-plan-gated
928
+ tools.push("* Begin every response with <<PLAN:...:PLAN");
919
929
  }
920
930
  // Each available runtime tag contributes its self-documenting example —
921
931
  // the example carries syntax + purpose, so there's no prose line. Tags
@@ -937,6 +947,7 @@ class Engine {
937
947
  // passes, re-measuring between. Folds (never deletes) — the prior turn's logs,
938
948
  // then the catalog except the manifest lifeline. The strike it raises and the
939
949
  // hard-stop it can signal are returned to runLoop, which owns abandonment.
950
+ // §grinder-overflow-only — fires only on actual overflow, never speculatively
940
951
  async #enforceBudget({ packet, provider, runId, loopId, turnId, sessionId, turnNumber, rebuild }) {
941
952
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
942
953
  const measure = (p) => p.system.tokens + p.user.tokens;
@@ -946,7 +957,7 @@ class Engine {
946
957
  const note = (scheme) => { folded.set(scheme, (folded.get(scheme) ?? 0) + 1); };
947
958
  // Pass 1 — prior-turn rollback: fold the latest emissions (the ones that
948
959
  // pushed it over). No prior turn (turn 1, env overflow) → no-op → pass 2.
949
- const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId });
960
+ const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId }); // prior-turn rollback folds the latest emissions — §grinder-layer1-rollback
950
961
  for (const le of priorLogs)
951
962
  note(le.scheme ?? "log");
952
963
  if (priorLogs.length > 0)
@@ -955,17 +966,18 @@ class Engine {
955
966
  let current = priorLogs.length > 0 ? await rebuild(errors) : packet;
956
967
  if (measure(current) <= ceiling) {
957
968
  this.#emitBudgetOverflow(sessionId, loopId, folded);
958
- return { packet: current, fit: true, struck: turnNumber > 1 };
969
+ return { packet: current, fit: true, struck: turnNumber > 1 }; // turn 0/1 overflow is the environment, never a strike — §grinder-soft-turn-0-1
959
970
  }
960
971
  // Prior-turn rollback is the only budget lever now: entries don't render
961
972
  // (no index), so there is no catalog to collapse. If pass 1 didn't fit,
962
- // the packet is over and the caller hard-413s.
973
+ // the packet is over and the caller hard-413s. §grinder-hard-413-abort
963
974
  this.#emitBudgetOverflow(sessionId, loopId, folded);
964
975
  return { packet: current, fit: measure(current) <= ceiling, struck: turnNumber > 1 };
965
976
  }
966
977
  // The model-facing budget event (SPEC §grinder, §telemetry): which entries left the
967
978
  // window, by scheme — the model's own terms, no mechanism vocabulary. The
968
979
  // strike this overflow triggers stays engine-internal (gamification policy).
980
+ // §grinder-event-model-terms — model-facing terms only; the strike stays engine-internal
969
981
  #emitBudgetOverflow(sessionId, loopId, folded) {
970
982
  if (folded.size === 0)
971
983
  return;
@@ -1061,7 +1073,7 @@ class Engine {
1061
1073
  source: r.source,
1062
1074
  }));
1063
1075
  }
1064
- // §env-delta — at pre-turn build, surface what changed in the shared world since this
1076
+ // §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
1065
1077
  // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
1066
1078
  // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1067
1079
  // entries since this run's prior turn — real cross-run edits and the plurnk run's
@@ -1092,11 +1104,12 @@ class Engine {
1092
1104
  // world changed," so the fiction keeps its perspective aligned with what its tooling
1093
1105
  // would show. The fiction lives in the plurnk run's log; every other run pulls it
1094
1106
  // through the one delta path, exactly like a sibling's real edit.
1107
+ // §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
1095
1108
  async #logFsFictions(sessionId, divergences) {
1096
1109
  if (divergences.length === 0)
1097
1110
  return;
1098
1111
  const run = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name: "plurnk" })
1099
- ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", persona: null, origin: "plurnk" });
1112
+ ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
1100
1113
  if (run === undefined)
1101
1114
  throw new Error("logFsFictions: plurnk run resolution returned no row");
1102
1115
  const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
@@ -1129,6 +1142,7 @@ class Engine {
1129
1142
  signal: this.#loopAborts.get(loopId)?.signal,
1130
1143
  streamEventNotify: this.#streamEventNotify,
1131
1144
  wakeRunNotify: this.#wakeRunNotify,
1145
+ injectRun: this.#injectRun,
1132
1146
  mimetypes: this.#mimetypes,
1133
1147
  tokenize: this.#tokenize,
1134
1148
  pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
@@ -1171,10 +1185,10 @@ class Engine {
1171
1185
  result = await this.#run("exec", statement, schemeCtx);
1172
1186
  }
1173
1187
  else {
1174
- result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
1188
+ result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
1175
1189
  }
1176
1190
  }
1177
- catch (err) {
1191
+ catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
1178
1192
  result = {
1179
1193
  status: 500,
1180
1194
  error: err instanceof Error ? err.message : String(err),
@@ -1183,7 +1197,7 @@ class Engine {
1183
1197
  }
1184
1198
  const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
1185
1199
  onDispatch?.(logEntryId);
1186
- // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve). When a
1200
+ // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
1187
1201
  // scheme returns status 202, the entry is written as state='proposed';
1188
1202
  // dispatch then PAUSES on a per-entry waiter until resolution
1189
1203
  // arrives via Engine.resolveProposal (from the loop/resolve RPC,
@@ -1208,7 +1222,7 @@ class Engine {
1208
1222
  // YOLO listener auto-resolves) BEFORE awaiting — they may
1209
1223
  // resolve synchronously inside their handlers.
1210
1224
  const target = this.#extractTarget(statement.target);
1211
- const flags = await this.#loadLoopFlags(loopId);
1225
+ const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
1212
1226
  const event = {
1213
1227
  logEntryId, sessionId, runId, loopId, turnId,
1214
1228
  op: statement.op,
@@ -1245,6 +1259,7 @@ class Engine {
1245
1259
  }
1246
1260
  return result;
1247
1261
  }
1262
+ // On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
1248
1263
  async #runApplyResolution(statement, originalResult, resolution, ids) {
1249
1264
  const { sessionId, runId, loopId, turnId } = ids;
1250
1265
  if (resolution.decision !== "accept")
@@ -1406,7 +1421,7 @@ class Engine {
1406
1421
  // transitions to cancelled with outcome='timeout'.
1407
1422
  if (this.#pendingProposals.has(logEntryId)) {
1408
1423
  this.#pendingProposals.delete(logEntryId);
1409
- resolve({ decision: "cancel", outcome: "timeout" });
1424
+ resolve({ decision: "cancel", outcome: "timeout" }); // §proposal-timeout-cancels
1410
1425
  }
1411
1426
  }, timeoutMs);
1412
1427
  this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
@@ -1415,8 +1430,8 @@ class Engine {
1415
1430
  async #applyResolution(logEntryId, resolution) {
1416
1431
  // Map decision → terminal state + HTTP-aligned status:
1417
1432
  // accept → state='resolved', status=200
1418
- // reject → state='failed', status=400, outcome='rejected' (default)
1419
- // cancel → state='cancelled',status=499, outcome='loop_aborted' (default)
1433
+ // reject → state='failed', status=400, outcome='rejected' (default) §proposal-reject-fails
1434
+ // cancel → state='cancelled',status=499, outcome='loop_aborted' (default) §proposal-cancel-aborts
1420
1435
  // resolution.outcome wins over the default when supplied; this is how
1421
1436
  // veto filters (Phase E.2 proposal.accepting) can specify a more
1422
1437
  // precise outcome string like 'policy_veto' or 'timeout'.
@@ -1462,6 +1477,11 @@ class Engine {
1462
1477
  if (statement.op === "EXEC") {
1463
1478
  return this.#denyIfDisallowed("exec", origin);
1464
1479
  }
1480
+ // A run-fork (COPY src=run://) is gated by run://'s writableBy — its body
1481
+ // is a fork prompt, not a dst path, so the entry-COPY dst-parse below
1482
+ // doesn't apply. §machine-processes
1483
+ if (this.#isRunFork(statement))
1484
+ return this.#denyIfDisallowed("run", origin);
1465
1485
  if (statement.op === "COPY" || statement.op === "MOVE") {
1466
1486
  const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
1467
1487
  const dstScheme = this.#schemeNameOf(dst);
@@ -1492,7 +1512,7 @@ class Engine {
1492
1512
  return null;
1493
1513
  if (manifest.writableBy.includes(origin))
1494
1514
  return null;
1495
- return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
1515
+ return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
1496
1516
  }
1497
1517
  // Per-loop flag gating. Schemes self-declare their flag affinity in
1498
1518
  // their manifest (excludedInAsk / requiresWeb /
@@ -1516,14 +1536,47 @@ class Engine {
1516
1536
  return null;
1517
1537
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1518
1538
  };
1539
+ if (this.#isRunFork(statement))
1540
+ return check(statement.target); // body is a fork prompt, not a dst path
1519
1541
  if (statement.op === "COPY" || statement.op === "MOVE") {
1520
1542
  return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
1521
1543
  }
1522
1544
  return check(statement.target);
1523
1545
  }
1546
+ // A COPY whose SOURCE is run:// is a run-fork, not an entry-copy — its body
1547
+ // is the fork's seed prompt, not a destination path. The COPY gates and
1548
+ // #handleCopy branch on this so they never parse the prompt as a dst path.
1549
+ #isRunFork(statement) {
1550
+ return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1551
+ }
1552
+ // COPY(run:///<src>):prompt — fork: deep-copy the source run's log into a new
1553
+ // run (Fork), then start it with the prompt (ctx.injectRun). Source "."/"" =
1554
+ // self (ctx.runId); a name resolves within the session (404 if absent).
1555
+ // §machine-processes-fork-copies-the-log
1556
+ async #handleRunFork(statement, ctx) {
1557
+ const target = statement.target;
1558
+ if (target === null)
1559
+ return { status: 400, error: "run:// fork requires a source run" };
1560
+ const name = pathnameFromPath(target).replace(/^\/+/, "");
1561
+ let srcRunId = ctx.runId;
1562
+ if (name !== "" && name !== ".") {
1563
+ const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1564
+ if (row === undefined)
1565
+ return { status: 404, error: `run:///${name} not found in this session` };
1566
+ srcRunId = row.id;
1567
+ }
1568
+ if (ctx.injectRun === undefined)
1569
+ throw new Error("run fork: injectRun capability absent");
1570
+ const branchRunId = await Fork.fork(this.#db, srcRunId);
1571
+ const branch = await this.#db.fork_get_run.get({ id: branchRunId });
1572
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt: typeof statement.body === "string" ? statement.body : "" });
1573
+ return { status: 200, body: branch?.name ?? "" };
1574
+ }
1524
1575
  async #handleCopy(statement, ctx) {
1525
1576
  if (statement.op !== "COPY")
1526
1577
  throw new Error("unreachable");
1578
+ if (this.#isRunFork(statement))
1579
+ return await this.#handleRunFork(statement, ctx);
1527
1580
  const srcPath = statement.target;
1528
1581
  // COPY's body is an opaque raw string (grammar §COPY: a dest path OR a run-fork
1529
1582
  // prompt); parse it to the dest path. Non-path bodies (run:// fork prompts) are
@@ -1542,17 +1595,17 @@ class Engine {
1542
1595
  const dstPath = statement.body;
1543
1596
  if (srcPath === null)
1544
1597
  return { status: 400, error: "MOVE requires source path" };
1545
- // MOVE is relocation only — deletion is KILL's job (§move). The /dev/null
1598
+ // MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
1546
1599
  // and null-body delete-by-MOVE back-compat is retired: no silent debt.
1547
1600
  if (dstPath === null)
1548
- return { status: 400, error: "MOVE requires a destination; use KILL to delete" };
1601
+ return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
1549
1602
  const srcSchemeName = this.#schemeNameOf(srcPath);
1550
1603
  if (srcSchemeName === null)
1551
1604
  return { status: 400, error: "MOVE source must be a URL path with a scheme" };
1552
1605
  const srcHandler = this.#schemes.get(srcSchemeName);
1553
1606
  if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
1554
1607
  return { status: 501 };
1555
- // Relocation: COPY then DELETE source.
1608
+ // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
1556
1609
  const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
1557
1610
  if (copyResult.status >= 400)
1558
1611
  return copyResult;
@@ -1612,6 +1665,7 @@ class Engine {
1612
1665
  throw new Error("unreachable");
1613
1666
  return { status: 200 };
1614
1667
  }
1668
+ // Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
1615
1669
  async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
1616
1670
  const srcSchemeName = this.#schemeNameOf(srcPath);
1617
1671
  const dstSchemeName = this.#schemeNameOf(dstPath);
@@ -1627,7 +1681,7 @@ class Engine {
1627
1681
  const dstPathname = pathnameFromPath(dstPath);
1628
1682
  const srcResult = await srcHandler.readEntry(srcPathname, ctx);
1629
1683
  if (srcResult.status !== 200 || srcResult.entry === null)
1630
- return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` };
1684
+ return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
1631
1685
  const entry = srcResult.entry;
1632
1686
  // Destination read — the conflict/no-op verdict is deferred until the
1633
1687
  // to-be-written content is known (after <L> slice + tag resolution below),
@@ -1641,7 +1695,7 @@ class Engine {
1641
1695
  for (const [channelName, channelData] of Object.entries(entry.channels)) {
1642
1696
  const expectedMimetype = dstChannels[channelName];
1643
1697
  if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
1644
- return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
1698
+ return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` }; // cross-mimetype COPY/MOVE → 415, never coerce — §channel-mimetype-cross-mimetype-415
1645
1699
  }
1646
1700
  }
1647
1701
  // `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
@@ -1663,7 +1717,7 @@ class Engine {
1663
1717
  }
1664
1718
  channels = sliced;
1665
1719
  }
1666
- // Tag resolution: signal = replace; absent/empty = carry from source
1720
+ // Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
1667
1721
  const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
1668
1722
  ? statement.signal
1669
1723
  : entry.tags;
@@ -1679,8 +1733,8 @@ class Engine {
1679
1733
  && writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
1680
1734
  const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
1681
1735
  if (sameContent && sameTags)
1682
- return { status: 304 };
1683
- return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` };
1736
+ return { status: 304 }; // identical → §copy-noop-304
1737
+ return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
1684
1738
  }
1685
1739
  const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
1686
1740
  // A file dest returns 202 (disk write → §membership review): propagate the
@@ -1817,7 +1871,8 @@ class Engine {
1817
1871
  #extractTarget(path) {
1818
1872
  if (path === null)
1819
1873
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
1820
- if (path.kind === "local")
1874
+ // `local` (bare path) and `regex` (grammar 0.46 `#pattern#flags` target) carry no URL parts — store the raw text as the pathname for the log record, scheme=null.
1875
+ if (path.kind === "local" || path.kind === "regex")
1821
1876
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
1822
1877
  const scheme = path.scheme === "file" ? null : path.scheme;
1823
1878
  return {