@plurnk/plurnk-service 3.0.0 → 3.1.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 (156) hide show
  1. package/.env.defaults +16 -4
  2. package/INSTALL.md +18 -0
  3. package/README.md +3 -0
  4. package/SPEC.md +355 -187
  5. package/dist/build-info.json +1 -1
  6. package/dist/completions/_plurnk-service +13 -0
  7. package/dist/completions/plurnk-service.bash +19 -0
  8. package/dist/completions/plurnk-service.fish +93 -0
  9. package/dist/core/AdmittedTurnExecutor.d.ts +2 -2
  10. package/dist/core/AdmittedTurnExecutor.d.ts.map +1 -1
  11. package/dist/core/AdmittedTurnExecutor.js +16 -24
  12. package/dist/core/AdmittedTurnExecutor.js.map +1 -1
  13. package/dist/core/BudgetReadout.d.ts +7 -2
  14. package/dist/core/BudgetReadout.d.ts.map +1 -1
  15. package/dist/core/BudgetReadout.js +36 -5
  16. package/dist/core/BudgetReadout.js.map +1 -1
  17. package/dist/core/Dispatcher.d.ts +2 -2
  18. package/dist/core/Dispatcher.d.ts.map +1 -1
  19. package/dist/core/Dispatcher.js +43 -39
  20. package/dist/core/Dispatcher.js.map +1 -1
  21. package/dist/core/Engine.d.ts.map +1 -1
  22. package/dist/core/Engine.js +1 -4
  23. package/dist/core/Engine.js.map +1 -1
  24. package/dist/core/EnvFlags.js +1 -1
  25. package/dist/core/EnvFlags.js.map +1 -1
  26. package/dist/core/FabricatedLog.d.ts +1 -0
  27. package/dist/core/FabricatedLog.d.ts.map +1 -1
  28. package/dist/core/FabricatedLog.js +8 -0
  29. package/dist/core/FabricatedLog.js.map +1 -1
  30. package/dist/core/KillHandler.d.ts.map +1 -1
  31. package/dist/core/KillHandler.js +2 -0
  32. package/dist/core/KillHandler.js.map +1 -1
  33. package/dist/core/LiveSubscription.d.ts.map +1 -1
  34. package/dist/core/LiveSubscription.js +14 -2
  35. package/dist/core/LiveSubscription.js.map +1 -1
  36. package/dist/core/LiveSubscriptions.d.ts +3 -1
  37. package/dist/core/LiveSubscriptions.d.ts.map +1 -1
  38. package/dist/core/LiveSubscriptions.js +16 -1
  39. package/dist/core/LiveSubscriptions.js.map +1 -1
  40. package/dist/core/LogBody.d.ts.map +1 -1
  41. package/dist/core/LogBody.js +3 -1
  42. package/dist/core/LogBody.js.map +1 -1
  43. package/dist/core/LoopDriver.d.ts.map +1 -1
  44. package/dist/core/LoopDriver.js +3 -0
  45. package/dist/core/LoopDriver.js.map +1 -1
  46. package/dist/core/LoopLifecycle.sql +1 -0
  47. package/dist/core/MessageResources.sql +31 -0
  48. package/dist/core/PacketBuilder.d.ts +2 -2
  49. package/dist/core/PacketBuilder.d.ts.map +1 -1
  50. package/dist/core/PacketBuilder.js +123 -51
  51. package/dist/core/PacketBuilder.js.map +1 -1
  52. package/dist/core/PacketBuilder.sql +13 -14
  53. package/dist/core/ReplyMetadata.d.ts +10 -0
  54. package/dist/core/ReplyMetadata.d.ts.map +1 -0
  55. package/dist/core/ReplyMetadata.js +20 -0
  56. package/dist/core/ReplyMetadata.js.map +1 -0
  57. package/dist/core/ServiceHelp.d.ts +6 -0
  58. package/dist/core/ServiceHelp.d.ts.map +1 -0
  59. package/dist/core/ServiceHelp.js +35 -0
  60. package/dist/core/ServiceHelp.js.map +1 -0
  61. package/dist/core/StoredPacket.d.ts +1 -0
  62. package/dist/core/StoredPacket.d.ts.map +1 -1
  63. package/dist/core/StoredPacket.js +4 -1
  64. package/dist/core/StoredPacket.js.map +1 -1
  65. package/dist/core/StrikeRail.d.ts.map +1 -1
  66. package/dist/core/StrikeRail.js +2 -2
  67. package/dist/core/StrikeRail.js.map +1 -1
  68. package/dist/core/TokenCalibration.d.ts +2 -2
  69. package/dist/core/TokenCalibration.d.ts.map +1 -1
  70. package/dist/core/TokenCalibration.js +12 -16
  71. package/dist/core/TokenCalibration.js.map +1 -1
  72. package/dist/core/TokenCalibration.sql +29 -6
  73. package/dist/core/TurnDispositionHandler.d.ts +1 -2
  74. package/dist/core/TurnDispositionHandler.d.ts.map +1 -1
  75. package/dist/core/TurnDispositionHandler.js +30 -26
  76. package/dist/core/TurnDispositionHandler.js.map +1 -1
  77. package/dist/core/TurnMaterialization.d.ts.map +1 -1
  78. package/dist/core/TurnMaterialization.js +11 -1
  79. package/dist/core/TurnMaterialization.js.map +1 -1
  80. package/dist/core/TurnOps.d.ts +5 -1
  81. package/dist/core/TurnOps.d.ts.map +1 -1
  82. package/dist/core/TurnOps.js +29 -2
  83. package/dist/core/TurnOps.js.map +1 -1
  84. package/dist/core/TurnRunner.d.ts.map +1 -1
  85. package/dist/core/TurnRunner.js +119 -55
  86. package/dist/core/TurnRunner.js.map +1 -1
  87. package/dist/core/TurnSources.sql +2 -2
  88. package/dist/core/packet-wire.d.ts +7 -1
  89. package/dist/core/packet-wire.d.ts.map +1 -1
  90. package/dist/core/packet-wire.js +87 -27
  91. package/dist/core/packet-wire.js.map +1 -1
  92. package/dist/core/recap-lines.d.ts +4 -0
  93. package/dist/core/recap-lines.d.ts.map +1 -0
  94. package/dist/core/recap-lines.js +11 -0
  95. package/dist/core/recap-lines.js.map +1 -0
  96. package/dist/core/sql-functions.d.ts +2 -0
  97. package/dist/core/sql-functions.d.ts.map +1 -0
  98. package/dist/core/sql-functions.js +5 -0
  99. package/dist/core/sql-functions.js.map +1 -0
  100. package/dist/core/turn-scheduler.d.ts.map +1 -1
  101. package/dist/core/turn-scheduler.js +1 -1
  102. package/dist/core/turn-scheduler.js.map +1 -1
  103. package/dist/evidence/EvidenceReader.d.ts.map +1 -1
  104. package/dist/evidence/EvidenceReader.js +15 -2
  105. package/dist/evidence/EvidenceReader.js.map +1 -1
  106. package/dist/evidence/digest.sql +1 -0
  107. package/dist/man/plurnk-service.1 +488 -0
  108. package/dist/schemes/Env.d.ts +26 -0
  109. package/dist/schemes/Env.d.ts.map +1 -0
  110. package/dist/schemes/Env.js +133 -0
  111. package/dist/schemes/Env.js.map +1 -0
  112. package/dist/schemes/Exec.d.ts.map +1 -1
  113. package/dist/schemes/Exec.js +15 -15
  114. package/dist/schemes/Exec.js.map +1 -1
  115. package/dist/schemes/ExecOutputScheme.d.ts +3 -2
  116. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  117. package/dist/schemes/ExecOutputScheme.js +9 -0
  118. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  119. package/dist/schemes/Log.d.ts.map +1 -1
  120. package/dist/schemes/Log.js +33 -10
  121. package/dist/schemes/Log.js.map +1 -1
  122. package/dist/schemes/Log.sql +9 -0
  123. package/dist/schemes/_entry-find.d.ts +1 -1
  124. package/dist/schemes/_entry-find.d.ts.map +1 -1
  125. package/dist/schemes/_entry-find.js +16 -9
  126. package/dist/schemes/_entry-find.js.map +1 -1
  127. package/dist/schemes/_entry-manifest.js +3 -3
  128. package/dist/schemes/_entry-manifest.js.map +1 -1
  129. package/dist/schemes/exec-abort.js +2 -2
  130. package/dist/schemes/exec-abort.js.map +1 -1
  131. package/dist/server/Daemon.d.ts +1 -0
  132. package/dist/server/Daemon.d.ts.map +1 -1
  133. package/dist/server/Daemon.js +6 -1
  134. package/dist/server/Daemon.js.map +1 -1
  135. package/dist/server/DrainSupervisor.d.ts +1 -0
  136. package/dist/server/DrainSupervisor.d.ts.map +1 -1
  137. package/dist/server/DrainSupervisor.js +4 -1
  138. package/dist/server/DrainSupervisor.js.map +1 -1
  139. package/dist/server/EnvFunctionality.d.ts +4 -2
  140. package/dist/server/EnvFunctionality.d.ts.map +1 -1
  141. package/dist/server/EnvFunctionality.js +4 -1
  142. package/dist/server/EnvFunctionality.js.map +1 -1
  143. package/dist/server/client-input.d.ts.map +1 -1
  144. package/dist/server/client-input.js +12 -3
  145. package/dist/server/client-input.js.map +1 -1
  146. package/dist/server/drain.sql +10 -9
  147. package/dist/service.d.ts.map +1 -1
  148. package/dist/service.js +5 -31
  149. package/dist/service.js.map +1 -1
  150. package/docs/env.md +22 -0
  151. package/migrations/018_worker_owners.sql +1 -0
  152. package/migrations/019_interactive_owners.sql +1 -0
  153. package/migrations/020_log_projection.sql +1 -0
  154. package/migrations/021_packet_roles.sql +86 -0
  155. package/migrations/022_message_completion.sql +24 -0
  156. package/package.json +16 -14
@@ -1,7 +1,7 @@
1
1
  var _a;
2
2
  import NativeContent from "./NativeContent.js";
3
- import { PlurnkParser } from "@plurnk/plurnk-parser";
4
- import { PathSyntax, PlurnkParseError, TurnDisposition, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
3
+ import { PlurnkParser, parsePath } from "@plurnk/plurnk-parser";
4
+ import { PathSyntax, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
5
5
  import WorkerOwners from "./WorkerOwners.js";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
  import { aggregateProviderAccounting } from "@plurnk/plurnk-providers";
@@ -12,20 +12,16 @@ import GitMembership from "./git-membership.js";
12
12
  import { acceptedKinds } from "./attachments.js";
13
13
  import GitState from "./git-state.js";
14
14
  import WorkspaceSettings from "./workspace-settings.js";
15
- import { contentWeight } from "./content-weight.js";
16
15
  import { generatedPathname } from "./plurnk-uri.js";
16
+ import { contentWeight } from "./content-weight.js";
17
17
  import { readFile } from "node:fs/promises";
18
18
  import { resolve as resolvePath } from "node:path";
19
19
  import { homedir } from "node:os";
20
- // {§operator-grammar} — an operator's own GBNF is a file path: absolute, `~`-relative, or
21
- // relative to the daemon's working directory. The service ships no grammar profile, so a bare
22
- // name (no separator) is refused by name rather than resolved against anything.
20
+ // {§operator-grammar}
23
21
  export const resolveOperatorGrammarPath = (value) => {
24
22
  if (value === "~" || value.startsWith("~/"))
25
23
  return resolvePath(homedir(), value.slice(2));
26
- if (value.startsWith("/") || value.startsWith(".") || value.includes("/"))
27
- return resolvePath(value);
28
- throw new Error(`PLURNK_PROVIDERS_GBNF=${value} names a bundled grammar profile; the service ships none (#588). Give the path of a grammar file you wrote.`);
24
+ return resolvePath(value);
29
25
  };
30
26
  // Shared module imported by both Engine and the digest, so wire
31
27
  // projection and digest projection are structurally one function — no
@@ -101,6 +97,24 @@ const windowOverflowFailure = (overflow) => Results.failure("engine:context", "w
101
97
  // {§context-over-budget-row} — the row is the mandate; the gauge is the one home for the numbers
102
98
  // ({§context-gauge}), which the row's own charge would otherwise put out of agreement.
103
99
  const OVER_BUDGET_DETAIL = "Context exceeds budget. YOU MUST ONLY KILL, MOVE or NOTE this turn.";
100
+ // {§worker-initialization-entry} — the one NOTE that leads the initialization surveys.
101
+ const INITIALIZATION_NOTE = {
102
+ op: "NOTE", aside: null, metadata: null, target: null, lineMarker: null,
103
+ body: "Surveyed tooling, documentation, extended context, and project root.", position: UNKNOWN_POSITION,
104
+ };
105
+ // {§continued-without-note} — said once, in the next packet, of a continuing turn that carried no NOTE.
106
+ const CONTINUED_WITHOUT_NOTE = "The turn continued without a NOTE.";
107
+ // Trial (#1073): a continuing turn that carried NOTEs and no other operation is told so under the same
108
+ // per-turn rule; it is a notice, never a strike.
109
+ const NOTED_WITHOUT_OPERATION = "The turn carried NOTEs and no other operation.";
110
+ // {§notice-directive} — what each notice of the model's own output asks of it: MUST where the card says MUST
111
+ // or the output was refused, SHOULD where a near-miss was accepted.
112
+ const DIRECTIVE = Object.freeze({
113
+ note: "YOU MUST emit at least one NOTE operation with a complete status summary and at least one other operation per turn.",
114
+ emission: "YOU MUST ONLY emit fenced operations; with the parameters, pattern, and terse aside on the fenced Operation line.",
115
+ advisory: "YOU SHOULD write the canonical form.",
116
+ output: "YOU SHOULD emit fewer or shorter operations per turn.",
117
+ });
104
118
  const overBudgetFailure = () => Results.failure("engine:context", "packet-exceeds-budget", 413, OVER_BUDGET_DETAIL);
105
119
  const INVALID_EMISSION_RECOVERY_MESSAGE = "Response rejected before dispatch; no operations were performed.";
106
120
  // {§output-allowance-notice} — THE single derivation of a ceiling cut: a
@@ -109,7 +123,7 @@ const INVALID_EMISSION_RECOVERY_MESSAGE = "Response rejected before dispatch; no
109
123
  // path. Null means the emission was not cut by the allowance.
110
124
  const allowanceCutMessage = (finishReason, grant) => finishReason !== "length"
111
125
  ? null
112
- : `emission truncated at the output allowance${grant === null ? "" : ` (${grant} tokens)`}`;
126
+ : `Emission truncated at the output allowance${grant === null ? "" : ` (${grant} tokens)`}`;
113
127
  const readEmissionAttempts = () => Knob.integer("PLURNK_SERVICE_EMISSION_ATTEMPTS", 1);
114
128
  // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
115
129
  export const LOOP_TIMEOUT_REASON = "loop_timeout";
@@ -488,9 +502,14 @@ class TurnRunner {
488
502
  await Turn.complete(this.#db, initializationTurn.id, 200);
489
503
  return;
490
504
  }
491
- const program = TurnOps.renderInternal(admittedInitializationStatements);
505
+ // {§worker-initialization-entry} — the example turn keeps the NOTE contract the card teaches:
506
+ // when the surveys run, one NOTE leads them.
507
+ const programStatements = admittedInitializationStatements.some((statement) => statement.op === "FIND")
508
+ ? [INITIALIZATION_NOTE, ...admittedInitializationStatements]
509
+ : admittedInitializationStatements;
510
+ const program = TurnOps.renderInternal(programStatements);
492
511
  const admitted = PlurnkParser.parseStatements(program).items.flatMap((item) => item.kind === "statement" ? [item.statement] : []);
493
- if (admitted.length !== admittedInitializationStatements.length) {
512
+ if (admitted.length !== programStatements.length) {
494
513
  throw new Error("initialization program did not preserve every authored operation");
495
514
  }
496
515
  await Turn.recordSource(this.#db, initializationTurn.id, "ops", program);
@@ -505,7 +524,7 @@ class TurnRunner {
505
524
  turnId: initializationTurn.id,
506
525
  fromSequence: 1,
507
526
  failOnOperationError: true,
508
- fit: this.#fitFor(args, { turnId: initializationTurn.id, seq: initializationTurn.sequence, gitStatus, notices: [], transientOpenLogEntryId: container.transientOpenLogEntryId, projection: { bodiless: new Set(), omitPreviousEmission: false } }),
527
+ fit: this.#fitFor(args, { turnId: initializationTurn.id, seq: initializationTurn.sequence, gitStatus, notices: [], transientOpenLogEntryId: container.transientOpenLogEntryId, projection: { bodiless: new Set(), omitEmissionHistory: false } }),
509
528
  signal: this.#loopSignal(loopId),
510
529
  onDispatch,
511
530
  onSettled,
@@ -517,11 +536,18 @@ class TurnRunner {
517
536
  // {§actor-boundary-catalog-preview} — the opening surveys, each admitted only when
518
537
  // its scheme is registered for the workspace. A positive filesItems caps the
519
538
  // project rows.
520
- async #catalogSurveys({ workspaceId }, { workerName, initializationPolicies }, filesItems) {
539
+ async #catalogSurveys({ workspaceId }, { workerName, initializationPolicies, loopSequence }, filesItems) {
521
540
  const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
522
541
  const fileItems = catalogSchemes.find(({ scheme }) => scheme === "file")?.shallow_items ?? 0;
523
542
  const fileCap = filesItems > 0 && fileItems > 0 ? Math.min(filesItems, fileItems) : null;
524
543
  const surveys = [
544
+ {
545
+ // Trial (#1073): the survey reads its own program, so the log carries one program verbatim
546
+ // and shows that every ops:// turn is readable.
547
+ op: "READ", aside: "this program",
548
+ target: { kind: "url", raw: `ops://${workerName}/${loopSequence}/1`, scheme: "ops", username: null, password: null, hostname: workerName, port: null, pathname: `/${loopSequence}/1`, query: null, fragment: null },
549
+ metadata: null, lineMarker: null, matcher: null, body: null, position: UNKNOWN_POSITION,
550
+ },
525
551
  {
526
552
  op: "FIND", aside: null,
527
553
  target: { kind: "url", raw: "skill://*/SKILL.md", scheme: "skill", username: null, password: null, hostname: "*", port: null, pathname: "/SKILL.md", query: null, fragment: null },
@@ -631,7 +657,7 @@ class TurnRunner {
631
657
  const systemCtx = this.#schemeContext(args, turnId);
632
658
  // {§context-fit} — one measure for everything this turn lands: the budget less the packet as it
633
659
  // would render now. The drained notices join the same facts below.
634
- const facts = { turnId, seq, gitStatus, notices: [], transientOpenLogEntryId: container.transientOpenLogEntryId, projection: { bodiless: new Set(), omitPreviousEmission: false } };
660
+ const facts = { turnId, seq, gitStatus, notices: [], transientOpenLogEntryId: container.transientOpenLogEntryId, projection: { bodiless: new Set(), omitEmissionHistory: false } };
635
661
  const fit = this.#fitFor(args, facts);
636
662
  const messages = await this.#publishMessages(args, turnId, fit);
637
663
  let nextActionIndex = await this.#readOpenPaths(args, turnId, messages.openPaths, messages.nextActionIndex, fit);
@@ -692,20 +718,20 @@ class TurnRunner {
692
718
  }
693
719
  return { nextActionIndex, openPaths };
694
720
  }
695
- // {§methods-loop-run-open-paths}: selected workspace paths belong to the message.
721
+ // {§methods-loop-run-open-paths}: selected resources belong to the message.
696
722
  // Publish it, then dispatch ordinary core READs in that same turn;
697
723
  // missing/non-member paths retain their normal 4xx. Returns the next action index.
698
724
  async #readOpenPaths({ workspaceId, workerId, loopId, onDispatch, onSettled }, turnId, openPaths, fromSequence, fit) {
699
725
  let nextActionIndex = fromSequence;
700
726
  for (const [index, raw] of openPaths.entries()) {
701
- const fileRead = {
727
+ const resourceRead = {
702
728
  op: "READ", aside: null, lineMarker: null, matcher: null,
703
- target: { kind: "local", raw },
729
+ target: parsePath(PathSyntax.escapeTarget(raw)),
704
730
  metadata: null,
705
731
  body: null, position: UNKNOWN_POSITION,
706
732
  };
707
733
  await this.#dispatch({
708
- statement: fileRead, workspaceId, workerId, loopId, turnId,
734
+ statement: resourceRead, workspaceId, workerId, loopId, turnId,
709
735
  sequence: nextActionIndex, origin: "_plurnk", onDispatch, onSettled,
710
736
  fit: reserved(fit, openPaths.length - index - 1),
711
737
  });
@@ -728,12 +754,12 @@ class TurnRunner {
728
754
  transientOpenLogEntryId: facts.transientOpenLogEntryId,
729
755
  turnId: facts.turnId,
730
756
  bodiless: facts.projection.bodiless,
731
- omitPreviousEmission: facts.projection.omitPreviousEmission,
757
+ omitEmissionHistory: facts.projection.omitEmissionHistory,
732
758
  });
733
- // {§previous-emission}: all builds, including result-admission probes, shed the optional
759
+ // {§emission-history}: all builds, including result-admission probes, shed the optional
734
760
  // replay before taking result bodies. Share the decision across this request's rebuilds.
735
- if (PacketWire.sectionContent(packet, "previous-emission").length > 0 && this.#packets.windowOverflow(packet, provider) !== null) {
736
- facts.projection.omitPreviousEmission = true;
761
+ if (PacketWire.sectionContent(packet, "emission-history").length > 0 && this.#packets.windowOverflow(packet, provider) !== null) {
762
+ facts.projection.omitEmissionHistory = true;
737
763
  return this.#buildPacket(args, facts);
738
764
  }
739
765
  return packet;
@@ -796,12 +822,12 @@ class TurnRunner {
796
822
  }
797
823
  }
798
824
  // {§context-own-rows-fit} — one shedding step for whichever measure found the request over the wall: the
799
- // previous program goes whole first ({§previous-emission}), then the newest rows still carrying a body,
825
+ // previous program goes whole first ({§emission-history}), then the newest rows still carrying a body,
800
826
  // enough of them by their rendered tokens to shed the excess; every body stays stored. Null when nothing
801
827
  // is left to take.
802
828
  async #shed(args, request, overflow) {
803
- if (!request.projection.omitPreviousEmission && PacketWire.sectionContent(request.packet, "previous-emission").length > 0) {
804
- request.projection.omitPreviousEmission = true;
829
+ if (!request.projection.omitEmissionHistory && PacketWire.sectionContent(request.packet, "emission-history").length > 0) {
830
+ request.projection.omitEmissionHistory = true;
805
831
  }
806
832
  else {
807
833
  let shed = 0;
@@ -1260,7 +1286,8 @@ class TurnRunner {
1260
1286
  source: "engine:capacity",
1261
1287
  kind: "output_truncated",
1262
1288
  level: "error",
1263
- message: `${cut}; no operations were performed`,
1289
+ directive: DIRECTIVE.output,
1290
+ message: `${cut}; no operations were performed.`,
1264
1291
  });
1265
1292
  }
1266
1293
  else {
@@ -1268,6 +1295,7 @@ class TurnRunner {
1268
1295
  source: "engine:grammar",
1269
1296
  kind: "invalid_emission",
1270
1297
  level: "error",
1298
+ directive: DIRECTIVE.emission,
1271
1299
  message: diagnostic === undefined
1272
1300
  ? INVALID_EMISSION_RECOVERY_MESSAGE
1273
1301
  : `${INVALID_EMISSION_RECOVERY_MESSAGE} Parser: ${diagnostic.message}`,
@@ -1303,7 +1331,8 @@ class TurnRunner {
1303
1331
  source: "engine:turn",
1304
1332
  kind: "output_truncated",
1305
1333
  level: "warn",
1306
- message: `${allowanceCut}; no authored operations were performed`,
1334
+ directive: DIRECTIVE.output,
1335
+ message: `${allowanceCut}; no authored operations were performed.`,
1307
1336
  });
1308
1337
  }
1309
1338
  // Non-fatal provider transport notices on an accepted turn. Forward each
@@ -1317,6 +1346,7 @@ class TurnRunner {
1317
1346
  this.#notices.push(workspaceId, workerId, loopId, {
1318
1347
  source: notice.source,
1319
1348
  kind: notice.kind,
1349
+ ...(notice.directive === undefined ? {} : { directive: notice.directive }),
1320
1350
  message: notice.message ?? "",
1321
1351
  level: notice.level,
1322
1352
  ...(located !== null
@@ -1328,27 +1358,19 @@ class TurnRunner {
1328
1358
  this.#notices.push(workspaceId, workerId, loopId, {
1329
1359
  source: "engine:capacity",
1330
1360
  kind: "output_truncated",
1331
- message: allowanceCut,
1361
+ directive: DIRECTIVE.output,
1362
+ message: `${allowanceCut}.`,
1332
1363
  level: "warn",
1333
1364
  });
1334
1365
  }
1335
- // {§outside-text} — text outside the operations is the turn's own source, never a log row
1336
- // or a packet field; the model reads only its weight, the client reads its text once.
1366
+ // {§outside-text} — a turn that fenced no operation keeps its text as its outside source: the
1367
+ // evidence behind the empty turn's strike and a terminal's citation; no row, notice or NOTE.
1337
1368
  if (split.outside !== null) {
1338
- await Turn.recordSource(this.#db, request.turnId, "outside", split.outside.text, { modelCallId: emission.modelCallId });
1339
- this.#notices.push(workspaceId, workerId, loopId, {
1340
- source: "engine:turn",
1341
- kind: "outside_text",
1342
- level: "warn",
1343
- message: `${split.outside.tokens} tokens emitted outside OPs. Discarded.`,
1344
- });
1369
+ await Turn.recordSource(this.#db, request.turnId, "outside", split.outside, { modelCallId: emission.modelCallId });
1345
1370
  this.#notify.outsideEventNotify?.(workspaceId, {
1346
- workerId,
1347
- loopId,
1348
- turnId: request.turnId,
1371
+ workerId, loopId, turnId: request.turnId,
1349
1372
  coordinate: `${request.workerName}-${request.loopSeq}-${request.seq}`,
1350
- text: split.outside.text,
1351
- tokens: split.outside.tokens,
1373
+ text: split.outside, tokens: contentWeight(split.outside),
1352
1374
  });
1353
1375
  }
1354
1376
  // {§operator-grammar} — transport evidence only: the turn records whether the operator's
@@ -1404,7 +1426,7 @@ class TurnRunner {
1404
1426
  fit: request.fit,
1405
1427
  recoverableParseErrors: split.recoverableParseErrors,
1406
1428
  emptyTurn: split.emptyTurn,
1407
- finalResponse: split.finalResponse,
1429
+ completionAllowed: split.completionAllowed,
1408
1430
  bare: {
1409
1431
  provider: childProvider,
1410
1432
  loopSequence: request.loopSeq,
@@ -1415,6 +1437,22 @@ class TurnRunner {
1415
1437
  onDispatch,
1416
1438
  onSettled,
1417
1439
  });
1440
+ // {§continued-without-note} — a turn the loop continues past or parks on, having carried no NOTE
1441
+ // forward in its program or from its reasoning, is told so in the next packet. An empty turn has
1442
+ // its own no-operation row instead.
1443
+ const ops = split.packetAssistant.ops;
1444
+ if ((executed.status === 102 || executed.status === 202) && ops.length > 0
1445
+ && !ops.some((statement) => "op" in statement && statement.op === "NOTE")) {
1446
+ this.#notices.push(workspaceId, workerId, loopId, {
1447
+ source: "engine:turn", kind: "continued_without_note", level: "warn", directive: DIRECTIVE.note, message: CONTINUED_WITHOUT_NOTE,
1448
+ });
1449
+ }
1450
+ const annotationOnly = ops.length > 0 && ops.every((statement) => "op" in statement && statement.op === "NOTE");
1451
+ if ((executed.status === 102 || executed.status === 202) && annotationOnly) {
1452
+ this.#notices.push(workspaceId, workerId, loopId, {
1453
+ source: "engine:turn", kind: "noted_without_operation", level: "warn", directive: DIRECTIVE.note, message: NOTED_WITHOUT_OPERATION,
1454
+ });
1455
+ }
1418
1456
  return turnResult(request, executed.status, {
1419
1457
  outcomes: executed.outcomes,
1420
1458
  progressed: executed.progressed,
@@ -1477,10 +1515,9 @@ class TurnRunner {
1477
1515
  const parseErrors = [];
1478
1516
  let contentStatementCount = 0;
1479
1517
  let hasUnparsedTail = false;
1518
+ let operationOmitted = false;
1480
1519
  const parseNotices = [];
1481
1520
  const fabrications = [];
1482
- // {§outside-text}: every span outside an operation, verbatim and in source order; never an
1483
- // operation of its own, so a repetitive or length-cut response stays one source.
1484
1521
  const outside = [];
1485
1522
  if (preParsedOps !== undefined) {
1486
1523
  ops.push(...preParsedOps);
@@ -1496,13 +1533,33 @@ class TurnRunner {
1496
1533
  span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
1497
1534
  return result;
1498
1535
  });
1536
+ // {§outside-text}: beside a fenced operation, the text between two operations is one NOTE at its
1537
+ // position, annotation the model wrote without its fence; a turn that fenced no operation keeps
1538
+ // its text as its outside source and stays an empty turn ({§empty-turn}). Echoed emission
1539
+ // headings are announcements, not annotation, and are dropped ({§fabricated-log-entry}).
1540
+ const fenced = parsed.items.filter((item) => item.kind === "statement").length;
1541
+ let span = null;
1542
+ const flushSpan = () => {
1543
+ if (span === null)
1544
+ return;
1545
+ const body = span.body.join("\n\n");
1546
+ if (body.length > 0) {
1547
+ if (fenced > 0) {
1548
+ ops.push({ op: "NOTE", aside: null, metadata: null, target: null, lineMarker: null, body, position: span.position });
1549
+ contentStatementCount += 1;
1550
+ }
1551
+ else
1552
+ outside.push(body);
1553
+ }
1554
+ span = null;
1555
+ };
1499
1556
  for (const item of parsed.items) {
1557
+ if (item.kind !== "text")
1558
+ flushSpan();
1500
1559
  if (item.kind === "statement") {
1501
1560
  ops.push(item.statement);
1502
1561
  contentStatementCount += 1;
1503
1562
  }
1504
- // Operation boundaries stay the parser's responsibility; what falls outside them is
1505
- // stored as the turn's outside source and counted as nothing.
1506
1563
  else if (item.kind === "text") {
1507
1564
  // {§fabricated-log-entry}: a log-entry heading in outside text rejects the attempt.
1508
1565
  const fabricated = FabricatedLog.find(item.content);
@@ -1510,16 +1567,22 @@ class TurnRunner {
1510
1567
  fabrications.push({ message: FabricatedLog.message(fabricated.heading), line: item.position.line + fabricated.line, column: 1, source: "harness" });
1511
1568
  continue;
1512
1569
  }
1513
- outside.push(item.content.trim());
1570
+ const body = FabricatedLog.stripEchoes(item.content);
1571
+ if (body.length === 0)
1572
+ continue;
1573
+ span ??= { body: [], position: item.position };
1574
+ span.body.push(body);
1514
1575
  }
1515
1576
  else if (item.kind === "error") {
1516
1577
  const err = item.error;
1517
1578
  if (err instanceof PlurnkParseError) {
1579
+ operationOmitted ||= err.operationOmitted;
1518
1580
  if (err.severity === "warning") {
1519
1581
  parseNotices.push({
1520
1582
  source: "grammar",
1521
1583
  kind: "parse_advisory",
1522
1584
  level: "warn",
1585
+ directive: DIRECTIVE.advisory,
1523
1586
  message: err.message,
1524
1587
  position: {
1525
1588
  type: "content-offset",
@@ -1539,6 +1602,7 @@ class TurnRunner {
1539
1602
  }
1540
1603
  }
1541
1604
  }
1605
+ flushSpan();
1542
1606
  // {§unparsed-tail-boundary} — the lexer's one boundary fact: from `unparsedTail.from` on,
1543
1607
  // nothing was read. The statements that closed before it are ordinary facts and run; the
1544
1608
  // loss itself is one more hard diagnostic, a failed row the model sees. An emission that
@@ -1555,6 +1619,7 @@ class TurnRunner {
1555
1619
  source: "grammar:reasoning",
1556
1620
  kind: "parse_advisory",
1557
1621
  level: "warn",
1622
+ directive: DIRECTIVE.advisory,
1558
1623
  message: warning.message,
1559
1624
  parserSource: warning.source,
1560
1625
  });
@@ -1571,9 +1636,9 @@ class TurnRunner {
1571
1636
  parseErrors.unshift(...fabrications.slice(0, 1));
1572
1637
  const emissionStatements = preParsedOps === undefined && contentStatementCount > 0 ? [...ops] : null;
1573
1638
  ops.unshift(...reasoningOps);
1574
- const finalResponse = TurnDisposition.requestsCompletion(ops)
1575
- && !hasUnparsedTail && parseErrors.length === 0
1576
- && assistant.finishReason !== "length";
1639
+ const completionAllowed = !hasUnparsedTail && !operationOmitted && !assistant.nativeToolCalls
1640
+ && parseErrors.every((error) => error.message === PlurnkParser.NO_VALID_OPERATION)
1641
+ && !["length", "content_filter", "tool_calls"].includes(assistant.finishReason ?? "");
1577
1642
  const emptyTurn = preParsedOps === undefined && operationCount === 0 && !hasUnparsedTail;
1578
1643
  // {§unparsed-tail-boundary}: outside text is not operational work.
1579
1644
  const emissionValid = fabrications.length === 0 && (preParsedOps !== undefined
@@ -1582,7 +1647,6 @@ class TurnRunner {
1582
1647
  const recoverableParseErrors = parseErrors
1583
1648
  .filter((error) => error.message !== PlurnkParser.NO_VALID_OPERATION)
1584
1649
  .toSorted(comparePosition);
1585
- const outsideText = outside.join("\n\n");
1586
1650
  return {
1587
1651
  packetAssistant: { content: assistant.content, ops, reasoning },
1588
1652
  sourceBacked: preParsedOps === undefined,
@@ -1590,10 +1654,10 @@ class TurnRunner {
1590
1654
  parseErrors,
1591
1655
  recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
1592
1656
  emptyTurn,
1593
- finalResponse,
1657
+ completionAllowed,
1594
1658
  parseNotices,
1595
1659
  emissionValid,
1596
- outside: outside.length === 0 ? null : { text: outsideText, tokens: contentWeight(outsideText) },
1660
+ outside: outside.length === 0 ? null : outside.join("\n\n"),
1597
1661
  emissionStatements,
1598
1662
  };
1599
1663
  }