@plurnk/plurnk-service 0.29.0 → 0.35.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 (172) hide show
  1. package/SPEC.md +47 -23
  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 +9 -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 +11 -6
  23. package/dist/core/Engine.d.ts.map +1 -1
  24. package/dist/core/Engine.js +199 -73
  25. package/dist/core/Engine.js.map +1 -1
  26. package/dist/core/ExecutorRegistry.d.ts +2 -0
  27. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  28. package/dist/core/ExecutorRegistry.js +1 -0
  29. package/dist/core/ExecutorRegistry.js.map +1 -1
  30. package/dist/core/ProviderInstantiate.js +1 -1
  31. package/dist/core/ProviderInstantiate.js.map +1 -1
  32. package/dist/core/SchemeRegistry.d.ts +5 -0
  33. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  34. package/dist/core/SchemeRegistry.js +39 -0
  35. package/dist/core/SchemeRegistry.js.map +1 -1
  36. package/dist/core/fork.js +2 -2
  37. package/dist/core/fork.js.map +1 -1
  38. package/dist/core/git-membership.d.ts.map +1 -1
  39. package/dist/core/git-membership.js +10 -7
  40. package/dist/core/git-membership.js.map +1 -1
  41. package/dist/core/git-state.d.ts.map +1 -1
  42. package/dist/core/git-state.js +3 -1
  43. package/dist/core/git-state.js.map +1 -1
  44. package/dist/core/packet-wire.d.ts +0 -1
  45. package/dist/core/packet-wire.d.ts.map +1 -1
  46. package/dist/core/packet-wire.js +6 -9
  47. package/dist/core/packet-wire.js.map +1 -1
  48. package/dist/core/path-decode.d.ts +2 -0
  49. package/dist/core/path-decode.d.ts.map +1 -0
  50. package/dist/core/path-decode.js +8 -0
  51. package/dist/core/path-decode.js.map +1 -0
  52. package/dist/core/run-cap.d.ts +8 -0
  53. package/dist/core/run-cap.d.ts.map +1 -0
  54. package/dist/core/run-cap.js +20 -0
  55. package/dist/core/run-cap.js.map +1 -0
  56. package/dist/core/scheme-types.d.ts +2 -1
  57. package/dist/core/scheme-types.d.ts.map +1 -1
  58. package/dist/core/session-settings.d.ts +19 -0
  59. package/dist/core/session-settings.d.ts.map +1 -0
  60. package/dist/core/session-settings.js +44 -0
  61. package/dist/core/session-settings.js.map +1 -0
  62. package/dist/index.js +1 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/schemes/Exec.d.ts +1 -0
  65. package/dist/schemes/Exec.d.ts.map +1 -1
  66. package/dist/schemes/Exec.js +4 -3
  67. package/dist/schemes/Exec.js.map +1 -1
  68. package/dist/schemes/File.d.ts +1 -0
  69. package/dist/schemes/File.d.ts.map +1 -1
  70. package/dist/schemes/File.js +6 -2
  71. package/dist/schemes/File.js.map +1 -1
  72. package/dist/schemes/Known.d.ts +1 -0
  73. package/dist/schemes/Known.d.ts.map +1 -1
  74. package/dist/schemes/Known.js +2 -0
  75. package/dist/schemes/Known.js.map +1 -1
  76. package/dist/schemes/Log.d.ts +1 -0
  77. package/dist/schemes/Log.d.ts.map +1 -1
  78. package/dist/schemes/Log.js +4 -1
  79. package/dist/schemes/Log.js.map +1 -1
  80. package/dist/schemes/Plurnk.d.ts +1 -0
  81. package/dist/schemes/Plurnk.d.ts.map +1 -1
  82. package/dist/schemes/Plurnk.js +1 -0
  83. package/dist/schemes/Plurnk.js.map +1 -1
  84. package/dist/schemes/Run.d.ts +17 -0
  85. package/dist/schemes/Run.d.ts.map +1 -0
  86. package/dist/schemes/Run.js +76 -0
  87. package/dist/schemes/Run.js.map +1 -0
  88. package/dist/schemes/Unknown.d.ts +1 -0
  89. package/dist/schemes/Unknown.d.ts.map +1 -1
  90. package/dist/schemes/Unknown.js +1 -0
  91. package/dist/schemes/Unknown.js.map +1 -1
  92. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  93. package/dist/schemes/_entry-crud.js +2 -2
  94. package/dist/schemes/_entry-crud.js.map +1 -1
  95. package/dist/schemes/_entry-find.d.ts.map +1 -1
  96. package/dist/schemes/_entry-find.js +26 -9
  97. package/dist/schemes/_entry-find.js.map +1 -1
  98. package/dist/schemes/_entry-graph.d.ts +6 -0
  99. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  100. package/dist/schemes/_entry-graph.js +8 -0
  101. package/dist/schemes/_entry-graph.js.map +1 -1
  102. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  103. package/dist/schemes/_entry-manifest.js +60 -30
  104. package/dist/schemes/_entry-manifest.js.map +1 -1
  105. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  106. package/dist/schemes/_entry-ops.js +14 -12
  107. package/dist/schemes/_entry-ops.js.map +1 -1
  108. package/dist/schemes/_entry-semantic.d.ts +1 -1
  109. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  110. package/dist/schemes/_entry-semantic.js +5 -2
  111. package/dist/schemes/_entry-semantic.js.map +1 -1
  112. package/dist/schemes/_entry-send.d.ts.map +1 -1
  113. package/dist/schemes/_entry-send.js +5 -4
  114. package/dist/schemes/_entry-send.js.map +1 -1
  115. package/dist/server/ClientConnection.d.ts.map +1 -1
  116. package/dist/server/ClientConnection.js +2 -3
  117. package/dist/server/ClientConnection.js.map +1 -1
  118. package/dist/server/Daemon.d.ts +1 -3
  119. package/dist/server/Daemon.d.ts.map +1 -1
  120. package/dist/server/Daemon.js +31 -14
  121. package/dist/server/Daemon.js.map +1 -1
  122. package/dist/server/MethodRegistry.d.ts +0 -2
  123. package/dist/server/MethodRegistry.d.ts.map +1 -1
  124. package/dist/server/clientTurn.js +1 -1
  125. package/dist/server/clientTurn.js.map +1 -1
  126. package/dist/server/dsl.d.ts.map +1 -1
  127. package/dist/server/dsl.js +6 -4
  128. package/dist/server/dsl.js.map +1 -1
  129. package/dist/server/envelope.d.ts +2 -6
  130. package/dist/server/envelope.d.ts.map +1 -1
  131. package/dist/server/envelope.js +19 -24
  132. package/dist/server/envelope.js.map +1 -1
  133. package/dist/server/logEntry.d.ts.map +1 -1
  134. package/dist/server/logEntry.js.map +1 -1
  135. package/dist/server/methods/_dispatchAsPlurnk.js +1 -1
  136. package/dist/server/methods/_dispatchAsPlurnk.js.map +1 -1
  137. package/dist/server/methods/discover.d.ts.map +1 -1
  138. package/dist/server/methods/discover.js +6 -2
  139. package/dist/server/methods/discover.js.map +1 -1
  140. package/dist/server/methods/log_read.js +1 -1
  141. package/dist/server/methods/log_read.js.map +1 -1
  142. package/dist/server/methods/loop_cancel.js +1 -1
  143. package/dist/server/methods/loop_cancel.js.map +1 -1
  144. package/dist/server/methods/loop_inject.d.ts.map +1 -1
  145. package/dist/server/methods/loop_inject.js +1 -2
  146. package/dist/server/methods/loop_inject.js.map +1 -1
  147. package/dist/server/methods/loop_run.d.ts.map +1 -1
  148. package/dist/server/methods/loop_run.js +18 -20
  149. package/dist/server/methods/loop_run.js.map +1 -1
  150. package/dist/server/methods/session_attach.d.ts.map +1 -1
  151. package/dist/server/methods/session_attach.js +3 -9
  152. package/dist/server/methods/session_attach.js.map +1 -1
  153. package/dist/server/methods/session_constraints.js +1 -1
  154. package/dist/server/methods/session_constraints.js.map +1 -1
  155. package/dist/server/methods/session_create.d.ts.map +1 -1
  156. package/dist/server/methods/session_create.js +55 -10
  157. package/dist/server/methods/session_create.js.map +1 -1
  158. package/dist/server/methods/session_prompts.d.ts +5 -0
  159. package/dist/server/methods/session_prompts.d.ts.map +1 -0
  160. package/dist/server/methods/session_prompts.js +29 -0
  161. package/dist/server/methods/session_prompts.js.map +1 -0
  162. package/dist/server/version-info.d.ts +14 -0
  163. package/dist/server/version-info.d.ts.map +1 -0
  164. package/dist/server/version-info.js +69 -0
  165. package/dist/server/version-info.js.map +1 -0
  166. package/dist/server/yolo.d.ts.map +1 -1
  167. package/dist/server/yolo.js +9 -0
  168. package/dist/server/yolo.js.map +1 -1
  169. package/migrations/0000-00-00.01_schema.sql +35 -9
  170. package/package.json +9 -10
  171. package/requirements.md +2 -2
  172. package/persona.md +0 -1
@@ -5,6 +5,10 @@ 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";
9
+ import RunCap from "./run-cap.js";
10
+ import SessionSettings from "./session-settings.js";
11
+ import { decodePathParens } from "./path-decode.js";
8
12
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
9
13
  import { LineMarkerOps, MimetypeBinary, editedSpan } from "../content/index.js";
10
14
  import { readFile } from "node:fs/promises";
@@ -59,14 +63,12 @@ const readMaxCommands = () => {
59
63
  };
60
64
  // PLURNK_MANIFEST_ITEMS — the turn-0 manifest preview. null = off (no foist);
61
65
  // -1 = the full manifest; positive N = the first N items. 0 / unset = off.
66
+ const normalizeManifestItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
62
67
  const readManifestItems = () => {
63
68
  const raw = process.env.PLURNK_MANIFEST_ITEMS;
64
69
  if (raw === undefined || raw.length === 0)
65
70
  return null;
66
- const n = Number.parseInt(raw, 10);
67
- if (!Number.isFinite(n) || n === 0)
68
- return null;
69
- return n < 0 ? -1 : n;
71
+ return normalizeManifestItems(Number.parseInt(raw, 10));
70
72
  };
71
73
  // Resolution timeout — proposed entries auto-cancel if nothing arrives
72
74
  // within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
@@ -81,16 +83,16 @@ const readProposalTimeoutMs = () => {
81
83
  return n;
82
84
  };
83
85
  const pathnameFromPath = (path) => {
84
- if (path.kind === "url")
85
- return path.pathname;
86
- return path.raw;
86
+ if (path.kind === "regex")
87
+ return path.raw; // regex source — parens are syntax, never encoded
88
+ return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
87
89
  };
88
90
  // Default turn.status when ops were emitted but no SEND. Model is implicitly
89
91
  // continuing; loop.status stays 102 either way (only SEND broadcast advances
90
92
  // loop terminal). No strike, no telemetry.
91
93
  const TURN_STATUS_IMPLICIT_CONTINUE = 102;
92
94
  // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
93
- // action routes through telemetry.errors[] (§telemetry).
95
+ // action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
94
96
  const TURN_STATUS_NO_OPS = 422;
95
97
  // Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
96
98
  // to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
@@ -127,7 +129,7 @@ const fingerprintOp = (stmt) => {
127
129
  }
128
130
  const lm = stmt.lineMarker;
129
131
  if (lm !== null && lm !== undefined)
130
- parts.push(`L:${lm.first},${lm.last ?? ""}`);
132
+ parts.push(`L:${lm.marks.join(",")}`);
131
133
  return parts.length > 0 ? `|${parts.join("|")}` : "";
132
134
  };
133
135
  if (path === null) {
@@ -239,6 +241,8 @@ class Engine {
239
241
  #loopAborts = new Map();
240
242
  #streamEventNotify;
241
243
  #wakeRunNotify;
244
+ #injectRun;
245
+ #cancelRun;
242
246
  // Telemetry event fan-out: every TelemetryEvent pushed to the loop's
243
247
  // buffer is also broadcast live to the connected client(s) on the
244
248
  // session. Without this, the client sees `loop/terminated` with a
@@ -247,11 +251,13 @@ class Engine {
247
251
  #telemetryEventNotify;
248
252
  // Cached plurnk GBNF — read once on the first constrained generate (#189).
249
253
  #gbnfCache = null;
250
- constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, telemetryEventNotify, tokenize }) {
254
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
251
255
  this.#db = db;
252
256
  this.#schemes = schemes;
253
257
  this.#streamEventNotify = streamEventNotify;
254
258
  this.#wakeRunNotify = wakeRunNotify;
259
+ this.#injectRun = injectRun;
260
+ this.#cancelRun = cancelRun;
255
261
  this.#telemetryEventNotify = telemetryEventNotify;
256
262
  // Default to empty discovery — standalone Engine construction (in
257
263
  // tests) gets no handlers, and content flows through the framework's
@@ -315,6 +321,7 @@ class Engine {
315
321
  // both sides per the grammar 0.17.0 TelemetryEvent protocol.
316
322
  this.#telemetryEventNotify?.(sessionId, { loopId, event });
317
323
  }
324
+ // Telemetry drains as it's read into the packet — each event surfaces once. §telemetry-drain-on-read
318
325
  #drainTelemetry(loopId) {
319
326
  const buf = this.#telemetryBuffer.get(loopId);
320
327
  if (buf === undefined)
@@ -343,7 +350,7 @@ class Engine {
343
350
  }
344
351
  return slice.join("\n");
345
352
  }
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, }) {
353
+ 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
354
  const turnIds = [];
348
355
  const suddenDeathThreshold = maxTurns - maxStrikes;
349
356
  // Per-loop AbortController for scheme-side cancellation propagation.
@@ -385,7 +392,7 @@ class Engine {
385
392
  return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
386
393
  }
387
394
  if (maxTurns >= 0 && turnIds.length >= maxTurns) {
388
- await this.#db.engine_loop_cancel.run({ loop_id: loopId });
395
+ await this.#db.engine_loop_cancel.run({ loop_id: loopId, message: "max_turns" });
389
396
  cleanup("forceful", "max_turns");
390
397
  return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
391
398
  }
@@ -400,13 +407,13 @@ class Engine {
400
407
  await delay(execWaitMs, undefined, { signal });
401
408
  }
402
409
  const turn = await this.runTurn({
403
- provider, messages, persona, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
410
+ provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
404
411
  turnNumber: turnIds.length + 1, maxTurns,
405
412
  });
406
413
  turnIds.push(turn.turnId);
407
414
  // SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
408
415
  if (turn.budgetHardStop) {
409
- await this.#db.engine_loop_cancel.run({ loop_id: loopId });
416
+ await this.#db.engine_loop_cancel.run({ loop_id: loopId, message: "budget_overflow" });
410
417
  cleanup("forceful", "budget_overflow");
411
418
  return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "budget_overflow" };
412
419
  }
@@ -425,7 +432,7 @@ class Engine {
425
432
  state.turnErrors++;
426
433
  // SPEC §grinder: a non-soft grinder fire counts toward the strike streak.
427
434
  if (turn.budgetStruck)
428
- state.turnErrors++;
435
+ state.turnErrors++; // a grinder fire bumps the strike streak — §grinder-strike-coupling
429
436
  this.#strikeState.set(loopId, state);
430
437
  // Rail #38: strike accounting. Three sources strike a turn:
431
438
  // 1. recordedFailed — any action-entry at hard failure status
@@ -447,7 +454,7 @@ class Engine {
447
454
  if (struck) {
448
455
  state.streak++;
449
456
  if (state.streak >= maxStrikes) {
450
- await this.#db.engine_loop_cancel.run({ loop_id: loopId });
457
+ await this.#db.engine_loop_cancel.run({ loop_id: loopId, message: "strike_threshold" });
451
458
  cleanup("forceful", "strike_threshold");
452
459
  return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
453
460
  }
@@ -465,7 +472,7 @@ class Engine {
465
472
  }
466
473
  }
467
474
  }
468
- async runTurn({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
475
+ async runTurn({ provider, messages, requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
469
476
  // === Turn-as-container model ===
470
477
  //
471
478
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -497,12 +504,15 @@ class Engine {
497
504
  // otherwise.
498
505
  let nextActionIndex = 1;
499
506
  if (seq === 1) {
500
- // Operator doc READs (PLURNK_MD_<ALIAS>). The docs were materialized
507
+ // Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
501
508
  // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
502
509
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
503
510
  // reads them inline. It sees only the READ — the materializing EDIT
504
511
  // lives in the plurnk run's log, never the model's.
505
- for (const doc of Paths.docs()) {
512
+ // #231 env docs (PLURNK_MD_*) UNION the session's client docs; foist a READ of
513
+ // each materialized plurnk:///<alias>.md (loop_run materialized the same set).
514
+ const { mdDocs } = await SessionSettings.read(this.#db, sessionId);
515
+ for (const doc of await SessionSettings.resolveDocs(mdDocs)) {
506
516
  const docTarget = {
507
517
  kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
508
518
  username: null, password: null, hostname: null, port: null,
@@ -531,10 +541,17 @@ class Engine {
531
541
  target: promptPath, lineMarker: null,
532
542
  body: promptRow.prompt, position: { line: 1, column: 1 },
533
543
  };
544
+ let promptLogId;
534
545
  await this.dispatch({
535
546
  statement: promptStmt, sessionId, runId, loopId, turnId,
536
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
547
+ sequence: nextActionIndex, origin: "plurnk",
548
+ onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
537
549
  });
550
+ // §prompt-fold (User Note 6): the prompt EDIT duplicates
551
+ // packet.user.prompt (its own section), so fold it — logged for
552
+ // forensics, collapsed in the model's log, re-OPENable.
553
+ if (promptLogId !== undefined)
554
+ await this.#db.engine_fold_log_entry.run({ id: promptLogId });
538
555
  nextActionIndex++;
539
556
  }
540
557
  }
@@ -573,7 +590,9 @@ class Engine {
573
590
  // manifest is JSON); off by default. AFTER the manifest write so the READ hits
574
591
  // it, not a 404; same plurnk-origin foist as the operator docs.
575
592
  if (seq === 1) {
576
- const manifestItems = readManifestItems();
593
+ // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
594
+ const { manifestItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
595
+ const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
577
596
  if (manifestItems !== null) {
578
597
  const manifestRead = {
579
598
  op: "READ", suffix: "", signal: null, lineMarker: null,
@@ -604,14 +623,14 @@ class Engine {
604
623
  // queries log_entries scoped to the run — the prompt entry just
605
624
  // written (if turn 1) is part of that query result.
606
625
  let requestPacket = await this.#buildRequestPacket({
607
- initialMessages: messages, persona, requirements, runId, loopId,
626
+ initialMessages: messages, requirements, runId, loopId,
608
627
  currentTurnSeq: seq, provider, gitStatus,
609
628
  });
610
629
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
611
630
  const enforced = await this.#enforceBudget({
612
631
  packet: requestPacket, provider, runId, loopId, turnId, sessionId, turnNumber,
613
632
  rebuild: (telemetryErrors) => this.#buildRequestPacket({
614
- initialMessages: messages, persona, requirements, runId, loopId,
633
+ initialMessages: messages, requirements, runId, loopId,
615
634
  currentTurnSeq: seq, provider, telemetryErrors, gitStatus,
616
635
  }),
617
636
  });
@@ -632,14 +651,14 @@ class Engine {
632
651
  // The 0.28.0 EOS-forcing root terminates the turn at the status SEND, but a
633
652
  // grammar can't bound degeneration *inside* a statement body — this caps the
634
653
  // decode at the free window so a runaway can't reach the context wall.
635
- const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
654
+ const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
636
655
  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 });
656
+ 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
657
  // Engine splits wire-level response: emission (content, reasoning,
639
658
  // parsed ops) → packet.assistant per Packet.json §assistant;
640
659
  // call-metadata (usage, finishReason, model) → Turn columns per
641
660
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
642
- const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
661
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
643
662
  // Surface parse errors to the model's NEXT packet so it can self-
644
663
  // correct. Without this, malformed emissions (e.g. a READ matcher
645
664
  // body starting with `//` being interpreted as xpath) silently
@@ -685,7 +704,7 @@ class Engine {
685
704
  usage_prompt: usage.prompt,
686
705
  usage_completion: usage.completion,
687
706
  usage_cached: usage.cached,
688
- usage_cost_pico: provider.costFor(usage),
707
+ usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
689
708
  finish_reason: finishReason,
690
709
  model,
691
710
  });
@@ -699,7 +718,8 @@ class Engine {
699
718
  // entries (avoids bloating forensics with hundreds of identical refusals)
700
719
  // and the model gets a single telemetry signal next packet so it knows
701
720
  // its emission was truncated.
702
- const maxCommands = readMaxCommands();
721
+ // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
722
+ const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
703
723
  const opsToDispatch = packetAssistant.ops.slice(0, maxCommands);
704
724
  const droppedCount = opsCount - opsToDispatch.length;
705
725
  const statuses = [];
@@ -820,9 +840,12 @@ class Engine {
820
840
  // and §user) BEFORE the provider call. The same packet object is then
821
841
  // completed with assistant + assistantRaw after the model responds, so
822
842
  // 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, }) {
843
+ async #buildRequestPacket({ initialMessages, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
824
844
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
825
- const system_definition = byRole("system");
845
+ // plurnk.md (grammar/dialects) THEN the scheme catalogue: grammar 0.49+ is
846
+ // scheme-agnostic, so the service teaches what schemes exist + what they do
847
+ // at packet-time (grammar#239 item 7). SchemeRegistry.teach() assembles it.
848
+ const system_definition = `${byRole("system")}\n\n${this.#schemes.teach()}`;
826
849
  // user.prompt sources from the loop's most recent prompt entry first
827
850
  // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
828
851
  // This is what inject + the turn-1 foist write into. Falls back to
@@ -832,13 +855,8 @@ class Engine {
832
855
  const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
833
856
  ? latestPromptRow.content
834
857
  : 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
858
  // 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
859
+ // exactly how it went missing (callers read the sysprompt but never the
842
860
  // requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
843
861
  // requirements.md) fresh each build so edits take effect; a non-empty param wins.
844
862
  const requirementsText = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
@@ -849,7 +867,7 @@ class Engine {
849
867
  // form — wire-payload tokens may differ slightly because chat-
850
868
  // template scaffolding adds bytes, but the subtotal tracks "what
851
869
  // the model has to process" closely enough for budget diagnostics.
852
- const countTokens = (t) => provider.countTokens(t);
870
+ const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
853
871
  // Budget readout (SPEC.md §tokenomics). Two-pass: measure the wire-rendered
854
872
  // index/log sections (budget-independent), install the readout with a
855
873
  // tokensFree placeholder, measure the assembled total, resolve free,
@@ -859,21 +877,21 @@ class Engine {
859
877
  // headline omitted, section lines still shown).
860
878
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
861
879
  const scratch = {
862
- system: { system_definition, persona, log },
880
+ system: { system_definition, log },
863
881
  user: { prompt, telemetry: { budget: "", errors: telemetryErrors, git: gitStatus }, tools: this.#collectTools(), system_requirements: requirementsText },
864
882
  };
865
883
  const sections = PacketWire.measureBudgetSections(scratch, countTokens);
866
884
  scratch.user.telemetry.budget = this.#renderBudget(sections, ceiling);
867
885
  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);
886
+ const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
887
+ const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
870
888
  const budget = tokensFree === null
871
889
  ? scratch.user.telemetry.budget
872
890
  : scratch.user.telemetry.budget
873
891
  .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
874
892
  .replace(TOKEN_PERCENT_PLACEHOLDER, String(percent))
875
893
  .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
876
- const system = { tokens: 0, system_definition, persona, log };
894
+ const system = { tokens: 0, system_definition, log };
877
895
  const user = { tokens: 0, prompt, telemetry: { budget, errors: telemetryErrors, git: gitStatus }, tools: scratch.user.tools, system_requirements: requirementsText };
878
896
  system.tokens = countTokens(PacketWire.renderSystemContent(system));
879
897
  user.tokens = countTokens(PacketWire.renderUserContent(user));
@@ -896,10 +914,12 @@ class Engine {
896
914
  for (const t of sections.log.byTurn)
897
915
  lines.push(`| ${t.turn} | ${t.tokens} |`);
898
916
  }
899
- // The heaviest individual entries — the FOLD targets behind the
900
- // weight (§tokenomics {§tokenomics-largest-entries}).
917
+ // The heaviest individual log items — the FOLD targets behind the weight
918
+ // (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
919
+ // lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
920
+ // is only for entries. Do not attempt to edit log items.").
901
921
  if (sections.log.largest.length > 0) {
902
- lines.push("Heaviest entries:", "| entry | tokens |", "|---|--:|");
922
+ lines.push("Heaviest items:", "| item | tokens |", "|---|--:|");
903
923
  for (const e of sections.log.largest)
904
924
  lines.push(`| ${e.path} | ${e.tokens} |`);
905
925
  }
@@ -912,10 +932,11 @@ class Engine {
912
932
  // contributor (gated by PLURNK_PLAN); each available executor tag then
913
933
  // contributes its self-documenting example (plurnk-execs#7), retiring the
914
934
  // blind EXEC.
935
+ // The capability sheet — the live tool surface (PLAN + wired executor tags). §tools-capability-sheet
915
936
  #collectTools() {
916
937
  const tools = [];
917
- if (process.env.PLURNK_PLAN === "1") {
918
- tools.push("* Begin every response with <<PLAN:planning and reasoning:PLAN");
938
+ if (process.env.PLURNK_PLAN === "1") { // <<PLAN advertised only when PLAN is enabled — §tools-plan-gated
939
+ tools.push("* Begin every response with <<PLAN:...:PLAN");
919
940
  }
920
941
  // Each available runtime tag contributes its self-documenting example —
921
942
  // the example carries syntax + purpose, so there's no prose line. Tags
@@ -925,18 +946,37 @@ class Engine {
925
946
  // no backticks — see packet-wire.ts).
926
947
  if (this.#executors !== undefined) {
927
948
  for (const tag of this.#executors.availableRuntimes()) {
928
- const example = this.#executors.entry(tag)?.example;
929
- if (example)
930
- tools.push(`* ${example}`);
949
+ const entry = this.#executors.entry(tag);
950
+ if (entry?.example)
951
+ tools.push(`* ${entry.example}`);
952
+ // #note12 — link the executor's fuller doc (materialized at plurnk:///docs/<tag>);
953
+ // its token cost rides that manifest entry, so no inline recount here.
954
+ if (entry?.documentation)
955
+ tools.push(`* docs for ${tag}: plurnk:///docs/${tag}`);
931
956
  }
932
957
  }
933
958
  return tools;
934
959
  }
960
+ // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
961
+ // materialized at plurnk:///docs/<name> by loop_run (like operator docs) so the
962
+ // catalogue's doc-links READ and the manifest carries each doc's token cost.
963
+ docEntries() {
964
+ const out = this.#schemes.docs();
965
+ if (this.#executors !== undefined) {
966
+ for (const tag of this.#executors.availableRuntimes()) {
967
+ const doc = this.#executors.entry(tag)?.documentation;
968
+ if (doc !== undefined && doc.length > 0)
969
+ out.push({ name: tag, content: doc });
970
+ }
971
+ }
972
+ return out;
973
+ }
935
974
  // SPEC §grinder — the budget grinder. Runs pre-LLM (in runTurn, after the packet
936
975
  // is built, before provider.generate); fires only on actual overflow. Two
937
976
  // passes, re-measuring between. Folds (never deletes) — the prior turn's logs,
938
977
  // then the catalog except the manifest lifeline. The strike it raises and the
939
978
  // hard-stop it can signal are returned to runLoop, which owns abandonment.
979
+ // §grinder-overflow-only — fires only on actual overflow, never speculatively
940
980
  async #enforceBudget({ packet, provider, runId, loopId, turnId, sessionId, turnNumber, rebuild }) {
941
981
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
942
982
  const measure = (p) => p.system.tokens + p.user.tokens;
@@ -946,7 +986,7 @@ class Engine {
946
986
  const note = (scheme) => { folded.set(scheme, (folded.get(scheme) ?? 0) + 1); };
947
987
  // Pass 1 — prior-turn rollback: fold the latest emissions (the ones that
948
988
  // 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 });
989
+ 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
990
  for (const le of priorLogs)
951
991
  note(le.scheme ?? "log");
952
992
  if (priorLogs.length > 0)
@@ -955,17 +995,18 @@ class Engine {
955
995
  let current = priorLogs.length > 0 ? await rebuild(errors) : packet;
956
996
  if (measure(current) <= ceiling) {
957
997
  this.#emitBudgetOverflow(sessionId, loopId, folded);
958
- return { packet: current, fit: true, struck: turnNumber > 1 };
998
+ return { packet: current, fit: true, struck: turnNumber > 1 }; // turn 0/1 overflow is the environment, never a strike — §grinder-soft-turn-0-1
959
999
  }
960
1000
  // Prior-turn rollback is the only budget lever now: entries don't render
961
1001
  // (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.
1002
+ // the packet is over and the caller hard-413s. §grinder-hard-413-abort
963
1003
  this.#emitBudgetOverflow(sessionId, loopId, folded);
964
1004
  return { packet: current, fit: measure(current) <= ceiling, struck: turnNumber > 1 };
965
1005
  }
966
1006
  // The model-facing budget event (SPEC §grinder, §telemetry): which entries left the
967
1007
  // window, by scheme — the model's own terms, no mechanism vocabulary. The
968
1008
  // strike this overflow triggers stays engine-internal (gamification policy).
1009
+ // §grinder-event-model-terms — model-facing terms only; the strike stays engine-internal
969
1010
  #emitBudgetOverflow(sessionId, loopId, folded) {
970
1011
  if (folded.size === 0)
971
1012
  return;
@@ -1061,7 +1102,7 @@ class Engine {
1061
1102
  source: r.source,
1062
1103
  }));
1063
1104
  }
1064
- // §env-delta — at pre-turn build, surface what changed in the shared world since this
1105
+ // §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
1106
  // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
1066
1107
  // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1067
1108
  // entries since this run's prior turn — real cross-run edits and the plurnk run's
@@ -1084,6 +1125,19 @@ class Engine {
1084
1125
  });
1085
1126
  written++;
1086
1127
  }
1128
+ // §run-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1129
+ // same way an entry-change does, carrying its deliverable (the SEND body) or the
1130
+ // abandonment reason. Folded, attributed to the terminated run.
1131
+ const terms = await this.#db.engine_pull_loop_terminations.all({ session_id: sessionId, run_id: runId, since });
1132
+ for (const t of terms) {
1133
+ await this.#db.engine_insert_loop_termination_delta.run({
1134
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1135
+ source: String(t.run_id), pathname: `/${t.run_name}`,
1136
+ rx: t.terminal_message ?? `loop "${t.prompt}" ended (${t.status})`,
1137
+ status: t.status,
1138
+ });
1139
+ written++;
1140
+ }
1087
1141
  return written;
1088
1142
  }
1089
1143
  // §env-delta — the filesystem as an actor. Ambient disk divergences detected at
@@ -1092,11 +1146,12 @@ class Engine {
1092
1146
  // world changed," so the fiction keeps its perspective aligned with what its tooling
1093
1147
  // would show. The fiction lives in the plurnk run's log; every other run pulls it
1094
1148
  // through the one delta path, exactly like a sibling's real edit.
1149
+ // §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
1095
1150
  async #logFsFictions(sessionId, divergences) {
1096
1151
  if (divergences.length === 0)
1097
1152
  return;
1098
1153
  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" });
1154
+ ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
1100
1155
  if (run === undefined)
1101
1156
  throw new Error("logFsFictions: plurnk run resolution returned no row");
1102
1157
  const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
@@ -1129,6 +1184,7 @@ class Engine {
1129
1184
  signal: this.#loopAborts.get(loopId)?.signal,
1130
1185
  streamEventNotify: this.#streamEventNotify,
1131
1186
  wakeRunNotify: this.#wakeRunNotify,
1187
+ injectRun: this.#injectRun,
1132
1188
  mimetypes: this.#mimetypes,
1133
1189
  tokenize: this.#tokenize,
1134
1190
  pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
@@ -1171,10 +1227,10 @@ class Engine {
1171
1227
  result = await this.#run("exec", statement, schemeCtx);
1172
1228
  }
1173
1229
  else {
1174
- result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
1230
+ result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
1175
1231
  }
1176
1232
  }
1177
- catch (err) {
1233
+ catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
1178
1234
  result = {
1179
1235
  status: 500,
1180
1236
  error: err instanceof Error ? err.message : String(err),
@@ -1183,7 +1239,7 @@ class Engine {
1183
1239
  }
1184
1240
  const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
1185
1241
  onDispatch?.(logEntryId);
1186
- // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve). When a
1242
+ // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
1187
1243
  // scheme returns status 202, the entry is written as state='proposed';
1188
1244
  // dispatch then PAUSES on a per-entry waiter until resolution
1189
1245
  // arrives via Engine.resolveProposal (from the loop/resolve RPC,
@@ -1208,7 +1264,10 @@ class Engine {
1208
1264
  // YOLO listener auto-resolves) BEFORE awaiting — they may
1209
1265
  // resolve synchronously inside their handlers.
1210
1266
  const target = this.#extractTarget(statement.target);
1211
- const flags = await this.#loadLoopFlags(loopId);
1267
+ const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
1268
+ // #note10 — if the target diverged on disk this turn, the model's EDIT is based
1269
+ // on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
1270
+ const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
1212
1271
  const event = {
1213
1272
  logEntryId, sessionId, runId, loopId, turnId,
1214
1273
  op: statement.op,
@@ -1216,6 +1275,7 @@ class Engine {
1216
1275
  body: typeof result.body === "string" ? result.body : "",
1217
1276
  attrs: (result.attrs ?? {}),
1218
1277
  flags,
1278
+ staleClobberRisk: diverged !== undefined,
1219
1279
  };
1220
1280
  for (const listener of this.#proposalPendingListeners) {
1221
1281
  try {
@@ -1245,6 +1305,7 @@ class Engine {
1245
1305
  }
1246
1306
  return result;
1247
1307
  }
1308
+ // On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
1248
1309
  async #runApplyResolution(statement, originalResult, resolution, ids) {
1249
1310
  const { sessionId, runId, loopId, turnId } = ids;
1250
1311
  if (resolution.decision !== "accept")
@@ -1406,7 +1467,7 @@ class Engine {
1406
1467
  // transitions to cancelled with outcome='timeout'.
1407
1468
  if (this.#pendingProposals.has(logEntryId)) {
1408
1469
  this.#pendingProposals.delete(logEntryId);
1409
- resolve({ decision: "cancel", outcome: "timeout" });
1470
+ resolve({ decision: "cancel", outcome: "timeout" }); // §proposal-timeout-cancels
1410
1471
  }
1411
1472
  }, timeoutMs);
1412
1473
  this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
@@ -1415,8 +1476,8 @@ class Engine {
1415
1476
  async #applyResolution(logEntryId, resolution) {
1416
1477
  // Map decision → terminal state + HTTP-aligned status:
1417
1478
  // accept → state='resolved', status=200
1418
- // reject → state='failed', status=400, outcome='rejected' (default)
1419
- // cancel → state='cancelled',status=499, outcome='loop_aborted' (default)
1479
+ // reject → state='failed', status=400, outcome='rejected' (default) §proposal-reject-fails
1480
+ // cancel → state='cancelled',status=499, outcome='loop_aborted' (default) §proposal-cancel-aborts
1420
1481
  // resolution.outcome wins over the default when supplied; this is how
1421
1482
  // veto filters (Phase E.2 proposal.accepting) can specify a more
1422
1483
  // precise outcome string like 'policy_veto' or 'timeout'.
@@ -1462,6 +1523,11 @@ class Engine {
1462
1523
  if (statement.op === "EXEC") {
1463
1524
  return this.#denyIfDisallowed("exec", origin);
1464
1525
  }
1526
+ // A run-fork (COPY src=run://) is gated by run://'s writableBy — its body
1527
+ // is a fork prompt, not a dst path, so the entry-COPY dst-parse below
1528
+ // doesn't apply. §machine-processes
1529
+ if (this.#isRunFork(statement))
1530
+ return this.#denyIfDisallowed("run", origin);
1465
1531
  if (statement.op === "COPY" || statement.op === "MOVE") {
1466
1532
  const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
1467
1533
  const dstScheme = this.#schemeNameOf(dst);
@@ -1492,7 +1558,7 @@ class Engine {
1492
1558
  return null;
1493
1559
  if (manifest.writableBy.includes(origin))
1494
1560
  return null;
1495
- return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
1561
+ return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
1496
1562
  }
1497
1563
  // Per-loop flag gating. Schemes self-declare their flag affinity in
1498
1564
  // their manifest (excludedInAsk / requiresWeb /
@@ -1516,14 +1582,50 @@ class Engine {
1516
1582
  return null;
1517
1583
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1518
1584
  };
1585
+ if (this.#isRunFork(statement))
1586
+ return check(statement.target); // body is a fork prompt, not a dst path
1519
1587
  if (statement.op === "COPY" || statement.op === "MOVE") {
1520
1588
  return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
1521
1589
  }
1522
1590
  return check(statement.target);
1523
1591
  }
1592
+ // A COPY whose SOURCE is run:// is a run-fork, not an entry-copy — its body
1593
+ // is the fork's seed prompt, not a destination path. The COPY gates and
1594
+ // #handleCopy branch on this so they never parse the prompt as a dst path.
1595
+ #isRunFork(statement) {
1596
+ return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1597
+ }
1598
+ // COPY(run:///<src>):prompt — fork: deep-copy the source run's log into a new
1599
+ // run (Fork), then start it with the prompt (ctx.injectRun). Source "."/"" =
1600
+ // self (ctx.runId); a name resolves within the session (404 if absent).
1601
+ // §machine-processes-fork-copies-the-log
1602
+ async #handleRunFork(statement, ctx) {
1603
+ const target = statement.target;
1604
+ if (target === null)
1605
+ return { status: 400, error: "run:// fork requires a source run" };
1606
+ const name = pathnameFromPath(target).replace(/^\/+/, "");
1607
+ let srcRunId = ctx.runId;
1608
+ if (name !== "" && name !== ".") {
1609
+ const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1610
+ if (row === undefined)
1611
+ return { status: 404, error: `run:///${name} not found in this session` };
1612
+ srcRunId = row.id;
1613
+ }
1614
+ if (ctx.injectRun === undefined)
1615
+ throw new Error("run fork: injectRun capability absent");
1616
+ const denied = await RunCap.deny(this.#db, ctx.sessionId);
1617
+ if (denied !== null)
1618
+ return denied;
1619
+ const branchRunId = await Fork.fork(this.#db, srcRunId);
1620
+ const branch = await this.#db.fork_get_run.get({ id: branchRunId });
1621
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt: typeof statement.body === "string" ? statement.body : "" });
1622
+ return { status: 200, body: branch?.name ?? "" };
1623
+ }
1524
1624
  async #handleCopy(statement, ctx) {
1525
1625
  if (statement.op !== "COPY")
1526
1626
  throw new Error("unreachable");
1627
+ if (this.#isRunFork(statement))
1628
+ return await this.#handleRunFork(statement, ctx);
1527
1629
  const srcPath = statement.target;
1528
1630
  // COPY's body is an opaque raw string (grammar §COPY: a dest path OR a run-fork
1529
1631
  // prompt); parse it to the dest path. Non-path bodies (run:// fork prompts) are
@@ -1542,17 +1644,17 @@ class Engine {
1542
1644
  const dstPath = statement.body;
1543
1645
  if (srcPath === null)
1544
1646
  return { status: 400, error: "MOVE requires source path" };
1545
- // MOVE is relocation only — deletion is KILL's job (§move). The /dev/null
1647
+ // MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
1546
1648
  // and null-body delete-by-MOVE back-compat is retired: no silent debt.
1547
1649
  if (dstPath === null)
1548
- return { status: 400, error: "MOVE requires a destination; use KILL to delete" };
1650
+ return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
1549
1651
  const srcSchemeName = this.#schemeNameOf(srcPath);
1550
1652
  if (srcSchemeName === null)
1551
1653
  return { status: 400, error: "MOVE source must be a URL path with a scheme" };
1552
1654
  const srcHandler = this.#schemes.get(srcSchemeName);
1553
1655
  if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
1554
1656
  return { status: 501 };
1555
- // Relocation: COPY then DELETE source.
1657
+ // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
1556
1658
  const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
1557
1659
  if (copyResult.status >= 400)
1558
1660
  return copyResult;
@@ -1595,6 +1697,23 @@ class Engine {
1595
1697
  return { status: 501 };
1596
1698
  return await execHandler.kill(pathnameFromPath(path), ctx);
1597
1699
  }
1700
+ if (schemeName === "run") {
1701
+ // terminate — abort any run by address; whoever holds it may end it.
1702
+ // `.`/"" = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
1703
+ // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
1704
+ const name = pathnameFromPath(path).replace(/^\/+/, "");
1705
+ let runId = ctx.runId;
1706
+ if (name !== "" && name !== ".") {
1707
+ const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1708
+ if (row === undefined)
1709
+ return { status: 404, error: `run:///${name} not found in this session` };
1710
+ runId = row.id;
1711
+ }
1712
+ if (this.#cancelRun === undefined)
1713
+ throw new Error("run kill: cancelRun capability absent");
1714
+ this.#cancelRun(runId);
1715
+ return { status: 200 };
1716
+ }
1598
1717
  const handler = this.#schemes.get(schemeName);
1599
1718
  if (handler === undefined || typeof handler.deleteEntry !== "function")
1600
1719
  return { status: 501 };
@@ -1612,6 +1731,7 @@ class Engine {
1612
1731
  throw new Error("unreachable");
1613
1732
  return { status: 200 };
1614
1733
  }
1734
+ // Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
1615
1735
  async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
1616
1736
  const srcSchemeName = this.#schemeNameOf(srcPath);
1617
1737
  const dstSchemeName = this.#schemeNameOf(dstPath);
@@ -1627,7 +1747,7 @@ class Engine {
1627
1747
  const dstPathname = pathnameFromPath(dstPath);
1628
1748
  const srcResult = await srcHandler.readEntry(srcPathname, ctx);
1629
1749
  if (srcResult.status !== 200 || srcResult.entry === null)
1630
- return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` };
1750
+ return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
1631
1751
  const entry = srcResult.entry;
1632
1752
  // Destination read — the conflict/no-op verdict is deferred until the
1633
1753
  // to-be-written content is known (after <L> slice + tag resolution below),
@@ -1641,7 +1761,7 @@ class Engine {
1641
1761
  for (const [channelName, channelData] of Object.entries(entry.channels)) {
1642
1762
  const expectedMimetype = dstChannels[channelName];
1643
1763
  if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
1644
- return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
1764
+ 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
1765
  }
1646
1766
  }
1647
1767
  // `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
@@ -1663,7 +1783,7 @@ class Engine {
1663
1783
  }
1664
1784
  channels = sliced;
1665
1785
  }
1666
- // Tag resolution: signal = replace; absent/empty = carry from source
1786
+ // Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
1667
1787
  const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
1668
1788
  ? statement.signal
1669
1789
  : entry.tags;
@@ -1679,8 +1799,8 @@ class Engine {
1679
1799
  && writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
1680
1800
  const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
1681
1801
  if (sameContent && sameTags)
1682
- return { status: 304 };
1683
- return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` };
1802
+ return { status: 304 }; // identical → §copy-noop-304
1803
+ return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
1684
1804
  }
1685
1805
  const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
1686
1806
  // A file dest returns 202 (disk write → §membership review): propagate the
@@ -1696,7 +1816,10 @@ class Engine {
1696
1816
  if (status === null)
1697
1817
  return { status: 400 };
1698
1818
  if (status === 200 || status === 499) {
1699
- await this.#db.engine_loop_set_status.run({ status, loop_id: loopId });
1819
+ // the loop's terminal message — its deliverable — rides the termination delta.
1820
+ const body = statement.body;
1821
+ const message = body === null ? null : typeof body === "string" ? body : body.raw;
1822
+ await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
1700
1823
  }
1701
1824
  return { status };
1702
1825
  }
@@ -1817,12 +1940,15 @@ class Engine {
1817
1940
  #extractTarget(path) {
1818
1941
  if (path === null)
1819
1942
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
1943
+ // `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.
1944
+ if (path.kind === "regex")
1945
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
1820
1946
  if (path.kind === "local")
1821
- return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
1947
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
1822
1948
  const scheme = path.scheme === "file" ? null : path.scheme;
1823
1949
  return {
1824
1950
  scheme, username: path.username, password: path.password,
1825
- hostname: path.hostname, port: path.port, pathname: path.pathname,
1951
+ hostname: path.hostname, port: path.port, pathname: decodePathParens(path.pathname), // #239 item 4
1826
1952
  params: JSON.stringify(path.params), fragment: path.fragment,
1827
1953
  };
1828
1954
  }