@plurnk/plurnk-service 0.26.0 → 0.27.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 (98) hide show
  1. package/SPEC.md +36 -34
  2. package/dist/Paths.d.ts +0 -1
  3. package/dist/Paths.d.ts.map +1 -1
  4. package/dist/Paths.js +3 -6
  5. package/dist/Paths.js.map +1 -1
  6. package/dist/content/path-mimetype.js +2 -2
  7. package/dist/content/path-mimetype.js.map +1 -1
  8. package/dist/core/ChannelWrite.d.ts +17 -2
  9. package/dist/core/ChannelWrite.d.ts.map +1 -1
  10. package/dist/core/ChannelWrite.js +11 -5
  11. package/dist/core/ChannelWrite.js.map +1 -1
  12. package/dist/core/Engine.d.ts +4 -5
  13. package/dist/core/Engine.d.ts.map +1 -1
  14. package/dist/core/Engine.js +113 -49
  15. package/dist/core/Engine.js.map +1 -1
  16. package/dist/core/ExecutorRegistry.d.ts +3 -0
  17. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  18. package/dist/core/ExecutorRegistry.js +8 -1
  19. package/dist/core/ExecutorRegistry.js.map +1 -1
  20. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  21. package/dist/core/SchemeRegistry.js +39 -15
  22. package/dist/core/SchemeRegistry.js.map +1 -1
  23. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
  24. package/dist/core/caps/DbNotifyCaps.js +4 -1
  25. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  26. package/dist/core/caps/DbSubscriptionCaps.d.ts +1 -1
  27. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
  28. package/dist/core/caps/DbSubscriptionCaps.js +2 -2
  29. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  30. package/dist/core/git-membership.d.ts.map +1 -1
  31. package/dist/core/git-membership.js +51 -25
  32. package/dist/core/git-membership.js.map +1 -1
  33. package/dist/core/packet-wire.js +5 -5
  34. package/dist/core/packet-wire.js.map +1 -1
  35. package/dist/core/plugin-trust.d.ts +4 -0
  36. package/dist/core/plugin-trust.d.ts.map +1 -0
  37. package/dist/core/plugin-trust.js +23 -0
  38. package/dist/core/plugin-trust.js.map +1 -0
  39. package/dist/schemes/Exec.d.ts.map +1 -1
  40. package/dist/schemes/Exec.js +78 -20
  41. package/dist/schemes/Exec.js.map +1 -1
  42. package/dist/schemes/File.d.ts.map +1 -1
  43. package/dist/schemes/File.js +15 -12
  44. package/dist/schemes/File.js.map +1 -1
  45. package/dist/schemes/Log.js +6 -6
  46. package/dist/schemes/Log.js.map +1 -1
  47. package/dist/schemes/Plurnk.js +4 -4
  48. package/dist/schemes/Plurnk.js.map +1 -1
  49. package/dist/schemes/_entry-chunk.d.ts +11 -0
  50. package/dist/schemes/_entry-chunk.d.ts.map +1 -0
  51. package/dist/schemes/_entry-chunk.js +100 -0
  52. package/dist/schemes/_entry-chunk.js.map +1 -0
  53. package/dist/schemes/_entry-find.d.ts.map +1 -1
  54. package/dist/schemes/_entry-find.js +6 -2
  55. package/dist/schemes/_entry-find.js.map +1 -1
  56. package/dist/schemes/_entry-graph.js +1 -1
  57. package/dist/schemes/_entry-graph.js.map +1 -1
  58. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  59. package/dist/schemes/_entry-manifest.js +14 -6
  60. package/dist/schemes/_entry-manifest.js.map +1 -1
  61. package/dist/schemes/_entry-ops.js +2 -2
  62. package/dist/schemes/_entry-ops.js.map +1 -1
  63. package/dist/schemes/_entry-semantic.d.ts +22 -2
  64. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  65. package/dist/schemes/_entry-semantic.js +96 -9
  66. package/dist/schemes/_entry-semantic.js.map +1 -1
  67. package/dist/schemes/exec-env.d.ts +5 -0
  68. package/dist/schemes/exec-env.d.ts.map +1 -0
  69. package/dist/schemes/exec-env.js +29 -0
  70. package/dist/schemes/exec-env.js.map +1 -0
  71. package/dist/server/Daemon.d.ts +1 -1
  72. package/dist/server/Daemon.d.ts.map +1 -1
  73. package/dist/server/Daemon.js +11 -1
  74. package/dist/server/Daemon.js.map +1 -1
  75. package/dist/server/methods/entry_read.d.ts.map +1 -1
  76. package/dist/server/methods/entry_read.js +29 -7
  77. package/dist/server/methods/entry_read.js.map +1 -1
  78. package/dist/server/methods/loop_inject.d.ts +5 -0
  79. package/dist/server/methods/loop_inject.d.ts.map +1 -0
  80. package/dist/server/methods/loop_inject.js +58 -0
  81. package/dist/server/methods/loop_inject.js.map +1 -0
  82. package/dist/server/methods/loop_run.js +2 -2
  83. package/dist/server/methods/loop_run.js.map +1 -1
  84. package/dist/server/methods/op_edit.js +1 -1
  85. package/dist/server/methods/op_edit.js.map +1 -1
  86. package/dist/server/methods/op_find.js +1 -1
  87. package/dist/server/methods/op_find.js.map +1 -1
  88. package/dist/server/methods/run_fork.d.ts +5 -0
  89. package/dist/server/methods/run_fork.d.ts.map +1 -0
  90. package/dist/server/methods/run_fork.js +42 -0
  91. package/dist/server/methods/run_fork.js.map +1 -0
  92. package/dist/server/methods/session_create.d.ts +1 -0
  93. package/dist/server/methods/session_create.d.ts.map +1 -1
  94. package/dist/server/methods/session_create.js +32 -0
  95. package/dist/server/methods/session_create.js.map +1 -1
  96. package/migrations/0000-00-00.01_schema.sql +22 -10
  97. package/package.json +108 -111
  98. package/requirements.md +1 -2
@@ -1,5 +1,5 @@
1
1
  var _a;
2
- import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
2
+ import { PlurnkParser, PlurnkParseError, parsePath } from "@plurnk/plurnk-grammar";
3
3
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
4
4
  import EntryCrud from "../schemes/_entry-crud.js";
5
5
  import EntryManifest from "../schemes/_entry-manifest.js";
@@ -8,6 +8,8 @@ import GitState from "./git-state.js";
8
8
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
9
9
  import { LineMarkerOps, MimetypeBinary, editedSpan } from "../content/index.js";
10
10
  import { readFile } from "node:fs/promises";
11
+ import { fileURLToPath } from "node:url";
12
+ import { setTimeout as delay } from "node:timers/promises";
11
13
  import Paths from "../Paths.js";
12
14
  import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
13
15
  // Shared module imported by both Engine and bin/digest.ts, so wire
@@ -275,9 +277,20 @@ class Engine {
275
277
  // attaches it iff the backend supports it and silently drops it otherwise —
276
278
  // capability is providers' concern, not ours. Pure plumbing grammar→provider.
277
279
  async #grammarConstraint() {
278
- if (process.env.PLURNK_PROVIDERS_GBNF !== "1")
280
+ // PLURNK_PROVIDERS_GBNF SELECTS the GBNF variant to constrain sampling to (#225):
281
+ // a bare name (`plurnk-strict.gbnf` | `plurnk.gbnf`) is a variant shipped by
282
+ // @plurnk/plurnk-grammar; an absolute/relative path is a BYO grammar. Empty or "0"
283
+ // disables — unconstrained generation. (Was a `=== "1"` boolean; the value change
284
+ // would otherwise read as "off", leaving every turn unconstrained.)
285
+ const variant = process.env.PLURNK_PROVIDERS_GBNF;
286
+ if (variant === undefined || variant === "" || variant === "0")
279
287
  return undefined;
280
- this.#gbnfCache ??= await readFile(Paths.grammarGbnf, "utf8");
288
+ if (this.#gbnfCache === null) {
289
+ const path = variant.startsWith("/") || variant.startsWith(".")
290
+ ? variant
291
+ : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
292
+ this.#gbnfCache = await readFile(path, "utf8");
293
+ }
281
294
  return this.#gbnfCache;
282
295
  }
283
296
  // Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
@@ -376,6 +389,16 @@ class Engine {
376
389
  cleanup("forceful", "max_turns");
377
390
  return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
378
391
  }
392
+ // PLURNK_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
393
+ // is still in flight, give it a tunable beat to land in THIS turn's packet
394
+ // before we assemble it. A fixed grace beat, never a wait-for-completion;
395
+ // 0/unset = off. Abortable with the loop signal.
396
+ const execWaitMs = Number(process.env.PLURNK_EXEC_WAIT_MS ?? "0");
397
+ if (execWaitMs > 0) {
398
+ const execHandler = this.#schemes.get("exec");
399
+ if (execHandler?.hasActiveSpawns?.(runId) === true)
400
+ await delay(execWaitMs, undefined, { signal });
401
+ }
379
402
  const turn = await this.runTurn({
380
403
  provider, messages, persona, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
381
404
  turnNumber: turnIds.length + 1, maxTurns,
@@ -462,7 +485,7 @@ class Engine {
462
485
  throw new Error("Engine.runTurn: turn open returned no row");
463
486
  const turnId = openRow.id;
464
487
  // Pre-model writes. Each turn opens with a system-origin EDIT
465
- // against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
488
+ // against `plurnk:///prompt/<loop_id>/<seq>` IF there's a prompt
466
489
  // for THIS turn the model hasn't seen yet:
467
490
  // - Turn 1: loop.prompt is the initial user prompt.
468
491
  // - Turn N>1: only if Engine.inject (or wake-on-completion via
@@ -475,15 +498,15 @@ class Engine {
475
498
  let nextActionIndex = 1;
476
499
  if (seq === 1) {
477
500
  // Operator doc READs (PLURNK_MD_<ALIAS>). The docs were materialized
478
- // as plurnk://<entry> entries by the plurnk run (loop_run, via the
501
+ // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
479
502
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
480
503
  // reads them inline. It sees only the READ — the materializing EDIT
481
504
  // lives in the plurnk run's log, never the model's.
482
505
  for (const doc of Paths.docs()) {
483
506
  const docTarget = {
484
- kind: "url", raw: `plurnk://${doc.entryName}`, scheme: "plurnk",
507
+ kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
485
508
  username: null, password: null, hostname: null, port: null,
486
- pathname: doc.entryName, params: {}, fragment: null,
509
+ pathname: `/${doc.entryName}`, params: {}, fragment: null,
487
510
  };
488
511
  const docRead = {
489
512
  op: "READ", suffix: "", signal: null, target: docTarget,
@@ -498,10 +521,10 @@ class Engine {
498
521
  const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
499
522
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
500
523
  const promptPath = {
501
- kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
524
+ kind: "url", raw: `plurnk:///prompt/${loopId}/${seq}`,
502
525
  scheme: "plurnk", username: null, password: null,
503
526
  hostname: null, port: null,
504
- pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
527
+ pathname: `/prompt/${loopId}/${seq}`, params: {}, fragment: null,
505
528
  };
506
529
  const promptStmt = {
507
530
  op: "EDIT", suffix: "", signal: null,
@@ -515,7 +538,7 @@ class Engine {
515
538
  nextActionIndex++;
516
539
  }
517
540
  }
518
- // plurnk://manifest.json — rewritten EVERY turn (a live view of the
541
+ // plurnk:///manifest.json — rewritten EVERY turn (a live view of the
519
542
  // entry set, which changes each turn). A derived view like the index,
520
543
  // NOT an action — written directly (Engine.inject's path): no log entry,
521
544
  // no sequence slot, not dispatched. The catalog body is built in the
@@ -540,7 +563,7 @@ class Engine {
540
563
  // this turn's packet reflects them.
541
564
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
542
565
  await this.#logFsFictions(sessionId, fsDivergences);
543
- await EntryCrud.writeEntry("manifest.json", {
566
+ await EntryCrud.writeEntry("/manifest.json", {
544
567
  channels: { body: { content: await EntryManifest.buildManifestBody(systemCtx), mimetype: "application/json" } },
545
568
  tags: [],
546
569
  }, systemCtx, "plurnk");
@@ -555,9 +578,9 @@ class Engine {
555
578
  const manifestRead = {
556
579
  op: "READ", suffix: "", signal: null, lineMarker: null,
557
580
  target: {
558
- kind: "url", raw: "plurnk://manifest.json", scheme: "plurnk",
581
+ kind: "url", raw: "plurnk:///manifest.json", scheme: "plurnk",
559
582
  username: null, password: null, hostname: null, port: null,
560
- pathname: "manifest.json", params: {}, fragment: null,
583
+ pathname: "/manifest.json", params: {}, fragment: null,
561
584
  },
562
585
  body: manifestItems < 0 ? null : { dialect: "jsonpath", raw: `$[0:${manifestItems}]` },
563
586
  position: { line: 1, column: 1 },
@@ -641,13 +664,17 @@ class Engine {
641
664
  });
642
665
  }
643
666
  const opsCount = packetAssistant.ops.length;
644
- const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
667
+ // Informational SEND[103] broadcasts (#free-text-capture) are log rows, not
668
+ // actions: excluded from the real-op count so a prose-only turn still strikes
669
+ // as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
670
+ const realOpsCount = packetAssistant.ops.filter((op) => !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
671
+ const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
645
672
  // Rail #41 (revised): the per-turn requirement is "emit at least one
646
673
  // op," not "emit a terminal SEND." SEND is purely a signal verb; many
647
674
  // turns may pass without one. An empty op list is the only strike.
648
675
  const turnStatus = sendOp !== undefined
649
676
  ? sendOp.signal
650
- : opsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
677
+ : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
651
678
  // Close the turn with the final packet, status, and usage stats.
652
679
  const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
653
680
  const { usage, finishReason, model } = callMetadata;
@@ -717,24 +744,45 @@ class Engine {
717
744
  const { assistant } = response;
718
745
  const preParsedOps = assistant.ops;
719
746
  const ops = [];
720
- const textFragments = [];
747
+ // #plan-reasoning: PLAN bodies are the model's in-band reasoning — hoisted
748
+ // out of the op stream into the reasoning field, never dispatched as a log op.
749
+ // Free text becomes informational SEND[103] log ops (#free-text-capture) below.
750
+ const planFragments = [];
751
+ const hoistPlan = (op) => {
752
+ if (op.op !== "PLAN")
753
+ return false;
754
+ const raw = (op.body ?? "").trim();
755
+ if (raw.length > 0)
756
+ planFragments.push(raw);
757
+ return true;
758
+ };
721
759
  // Full PlurnkParseError context (line/column/source) is preserved
722
760
  // here so runTurn can build TelemetryEvent envelopes per the
723
761
  // grammar 0.17.0 protocol — model needs position info to locate
724
762
  // its own offending content on the next turn.
725
763
  const parseErrors = [];
726
764
  if (preParsedOps !== undefined) {
727
- ops.push(...preParsedOps);
765
+ for (const op of preParsedOps)
766
+ if (!hoistPlan(op))
767
+ ops.push(op);
728
768
  }
729
769
  else {
730
770
  const parsed = PlurnkParser.parse(assistant.content);
731
771
  for (const item of parsed.items) {
732
- if (item.kind === "statement")
733
- ops.push(item.statement);
772
+ if (item.kind === "statement") {
773
+ if (!hoistPlan(item.statement))
774
+ ops.push(item.statement);
775
+ }
734
776
  else if (item.kind === "text") {
777
+ // #free-text-capture: interstitial prose → an informational
778
+ // SEND[103] broadcast log op, interleaved in emission order.
735
779
  const trimmed = item.text.trim();
736
780
  if (trimmed.length > 0)
737
- textFragments.push(trimmed);
781
+ ops.push({
782
+ op: "SEND", suffix: "", signal: 103, target: null,
783
+ lineMarker: null, body: { raw: trimmed, json: null },
784
+ position: item.position ?? { line: 1, column: 1 },
785
+ });
738
786
  }
739
787
  else if (item.kind === "error") {
740
788
  const err = item.error;
@@ -759,8 +807,8 @@ class Engine {
759
807
  }
760
808
  }
761
809
  const wireReasoning = assistant.reasoning ?? "";
762
- const scrapedReasoning = textFragments.join("\n");
763
- const reasoningParts = [wireReasoning, scrapedReasoning].filter((s) => s.length > 0);
810
+ const planReasoning = planFragments.join("\n\n");
811
+ const reasoningParts = [wireReasoning, planReasoning].filter((s) => s.length > 0);
764
812
  const reasoning = reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null;
765
813
  return {
766
814
  packetAssistant: { content: assistant.content, ops, reasoning },
@@ -776,7 +824,7 @@ class Engine {
776
824
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
777
825
  const system_definition = byRole("system");
778
826
  // user.prompt sources from the loop's most recent prompt entry first
779
- // (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
827
+ // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
780
828
  // This is what inject + the turn-1 foist write into. Falls back to
781
829
  // the runLoop caller's messages.user for tests that bypass the
782
830
  // foist mechanism entirely.
@@ -861,12 +909,26 @@ class Engine {
861
909
  // The # Plurnk System Tools capability sheet (SPEC §tools). A hook: each
862
910
  // enabled capability contributes one line, rendered above Requirements so
863
911
  // the model sees what it can do before the rules. PLAN is the first
864
- // contributor (gated by PLURNK_PLAN); wired executor tags inject their own
865
- // lines here later (the ExecutorRegistry surface), retiring the blind EXEC.
912
+ // contributor (gated by PLURNK_PLAN); each available executor tag then
913
+ // contributes its self-documenting example (plurnk-execs#7), retiring the
914
+ // blind EXEC.
866
915
  #collectTools() {
867
916
  const tools = [];
868
917
  if (process.env.PLURNK_PLAN === "1") {
869
- tools.push("- `<<PLAN:...:PLAN` think or plan in-band before acting; the body is your reasoning, not an op.");
918
+ tools.push("* Begin every response with <<PLAN:{planning and reasoning}:PLAN");
919
+ }
920
+ // Each available runtime tag contributes its self-documenting example —
921
+ // the example carries syntax + purpose, so there's no prose line. Tags
922
+ // with no example (sh/node, covered by the core prompt) contribute
923
+ // nothing; available-only, so the model never sees an unusable tag. `* `
924
+ // bullets + bare op forms match the packet's list/op rendering (no `- `,
925
+ // no backticks — see packet-wire.ts).
926
+ if (this.#executors !== undefined) {
927
+ for (const tag of this.#executors.availableRuntimes()) {
928
+ const example = this.#executors.entry(tag)?.example;
929
+ if (example)
930
+ tools.push(`* ${example}`);
931
+ }
870
932
  }
871
933
  return tools;
872
934
  }
@@ -965,7 +1027,7 @@ class Engine {
965
1027
  // SPEC §packet packet.system.log — chronological action-entries for the loop.
966
1028
  // Snapshot is taken at packet build (pre-dispatch this turn), so it
967
1029
  // reflects "what has happened before this turn." Each row carries a
968
- // log://<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
1030
+ // log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
969
1031
  async #buildLog(runId) {
970
1032
  // SPEC §packet-terms: runs own log entries — log is the run's history,
971
1033
  // not the loop's. Span all loops in the run so the model sees
@@ -1272,7 +1334,7 @@ class Engine {
1272
1334
  return (row?.n ?? 0) > 0;
1273
1335
  }
1274
1336
  // Inject a prompt into the run's currently-executing loop. Writes a
1275
- // plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
1337
+ // plurnk:///prompt/<loop_id>/<next-turn> entry whose body becomes
1276
1338
  // packet.user.prompt at the next turn boundary. Last-wins: if two
1277
1339
  // injects target the same next-turn slot, the second overwrites the
1278
1340
  // first.
@@ -1401,7 +1463,8 @@ class Engine {
1401
1463
  return this.#denyIfDisallowed("exec", origin);
1402
1464
  }
1403
1465
  if (statement.op === "COPY" || statement.op === "MOVE") {
1404
- const dstScheme = this.#schemeNameOf(statement.body);
1466
+ const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
1467
+ const dstScheme = this.#schemeNameOf(dst);
1405
1468
  const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
1406
1469
  if (dstDenial !== null)
1407
1470
  return dstDenial;
@@ -1454,7 +1517,7 @@ class Engine {
1454
1517
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1455
1518
  };
1456
1519
  if (statement.op === "COPY" || statement.op === "MOVE") {
1457
- return check(statement.target) ?? check(statement.body);
1520
+ return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
1458
1521
  }
1459
1522
  return check(statement.target);
1460
1523
  }
@@ -1462,11 +1525,14 @@ class Engine {
1462
1525
  if (statement.op !== "COPY")
1463
1526
  throw new Error("unreachable");
1464
1527
  const srcPath = statement.target;
1465
- const dstPath = statement.body;
1528
+ // COPY's body is an opaque raw string (grammar §COPY: a dest path OR a run-fork
1529
+ // prompt); parse it to the dest path. Non-path bodies (run:// fork prompts) are
1530
+ // not yet handled and surface as a 400.
1531
+ const dstPath = statement.body === null ? null : parsePath(statement.body);
1466
1532
  if (srcPath === null)
1467
1533
  return { status: 400, error: "COPY requires source path" };
1468
1534
  if (dstPath === null)
1469
- return { status: 400, error: "COPY requires destination path (in body slot)" };
1535
+ return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
1470
1536
  return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
1471
1537
  }
1472
1538
  async #handleMove(statement, ctx) {
@@ -1505,12 +1571,12 @@ class Engine {
1505
1571
  }
1506
1572
  // KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
1507
1573
  // permanently deletes the entry: the canonical delete now, MOVE→/dev/null
1508
- // retired from the model's vocabulary. Process-KILL (exec://) aborts the
1574
+ // retired from the model's vocabulary. Process-KILL (exec:///) aborts the
1509
1575
  // running spawn's controller (the same teardown loop.cancel rides), addressed
1510
1576
  // by coordinate pathname (#203). The KILL body is an opaque
1511
1577
  // annotation with no runtime meaning; it survives into the log row's tx for
1512
1578
  // free via the statement serialization. Status: 200 killed · 404 unknown ·
1513
- // 405 log:// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
1579
+ // 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
1514
1580
  // MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
1515
1581
  async #handleKill(statement, ctx) {
1516
1582
  if (statement.op !== "KILL")
@@ -1522,7 +1588,7 @@ class Engine {
1522
1588
  if (schemeName === null)
1523
1589
  return { status: 400, error: "KILL target must be a URL path with a scheme" };
1524
1590
  if (schemeName === "log")
1525
- return { status: 405, error: "log:// is append-only; KILL must bounce" };
1591
+ return { status: 405, error: "log:/// is append-only; KILL must bounce" };
1526
1592
  if (schemeName === "exec") {
1527
1593
  const execHandler = this.#schemes.get("exec");
1528
1594
  if (execHandler === undefined || typeof execHandler.kill !== "function")
@@ -1535,14 +1601,12 @@ class Engine {
1535
1601
  const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
1536
1602
  return { status: delResult.status };
1537
1603
  }
1538
- // PLAN — undocumented reasoning op (plurnk-grammar 0.30.0, the 11th op). A pure
1539
- // no-op: the body is the model's in-content reasoning, which lands in the log
1540
- // row's tx for free via statement serialization, no runtime effect (PLAN ∉
1541
- // MUTATING_OPS). NOT taught in plurnk.md by design reserved for a future model
1542
- // that must reason in the content field (no separate reasoning channel). gemma
1543
- // reasons on its own channel via the relaxed root, is never shown PLAN, and never
1544
- // emits it; this handler exists only so the op resolves cleanly if we ever
1545
- // deliberately reach for it (which would also require a plurnk.md override).
1604
+ // PLAN — the model's in-band reasoning op (plurnk-grammar 0.30.0, the 11th op).
1605
+ // Production HOISTS PLAN bodies into the turn's reasoning field in #parseAssistant
1606
+ // (#plan-reasoning) before dispatch runs, so PLAN normally never reaches here. This
1607
+ // handler is the op's direct-dispatch semantics (exercised by the dispatch unit
1608
+ // test): a pure no-op (PLAN MUTATING_OPS) whose body would serialize into the log
1609
+ // row's tx, no runtime effect.
1546
1610
  #handlePlan(statement) {
1547
1611
  if (statement.op !== "PLAN")
1548
1612
  throw new Error("unreachable");
@@ -1657,7 +1721,7 @@ class Engine {
1657
1721
  }
1658
1722
  // Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
1659
1723
  // "Bare paths (no scheme) default to local relative project file paths."
1660
- // file:// remains an optional explicit form for absolute paths.
1724
+ // file:/// remains an optional explicit form for absolute paths.
1661
1725
  #schemeNameOf(path) {
1662
1726
  if (path === null)
1663
1727
  return null;
@@ -1682,11 +1746,11 @@ class Engine {
1682
1746
  ? { ...result.attrs }
1683
1747
  : {};
1684
1748
  // EXEC pathname is executor-domain + coordinate: the stream entry
1685
- // lives at exec://<runtime>/<loop_seq>/<turn_seq>/<sequence> (e.g.
1686
- // exec://sh/1/1/2). The runtime leads — domain-aware, the executor
1749
+ // lives at exec:///<runtime>/<loop_seq>/<turn_seq>/<sequence> (e.g.
1750
+ // exec:///sh/1/1/2). The runtime leads — domain-aware, the executor
1687
1751
  // as authority — and the coordinate that follows is already unique
1688
1752
  // per statement, so no slug is injected. The log row's target points
1689
- // at this same address; its log:// coordinate shares the trailing
1753
+ // at this same address; its log:/// coordinate shares the trailing
1690
1754
  // <loop>/<turn>/<seq>, so the model correlates op to stream output.
1691
1755
  // Runtime comes from statement.signal (EXEC's runtime slot) so it's
1692
1756
  // resolvable for failed execs too; empty/absent = the default shell.
@@ -1697,7 +1761,7 @@ class Engine {
1697
1761
  if (seqs === undefined)
1698
1762
  throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
1699
1763
  const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
1700
- const coordPathname = `${runtime}/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
1764
+ const coordPathname = `/${runtime}/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
1701
1765
  target.scheme = "exec";
1702
1766
  target.pathname = coordPathname;
1703
1767
  attrsObj.pathname = coordPathname;
@@ -1748,7 +1812,7 @@ class Engine {
1748
1812
  }
1749
1813
  // Normalize a parsed path for storage. The `file` scheme is a routing
1750
1814
  // internal — never stored, never rendered to the model. Both bare paths
1751
- // and `file://...` inputs collapse to scheme=null at this boundary, so
1815
+ // and `file:///...` inputs collapse to scheme=null at this boundary, so
1752
1816
  // entries.scheme / log_entries.scheme never carry the string "file".
1753
1817
  #extractTarget(path) {
1754
1818
  if (path === null)