@ngockhoale/ukit 3.4.49 → 3.4.51

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.
@@ -456,10 +456,118 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
456
456
  return intent;
457
457
  }
458
458
 
459
+ // ─── Gate-continuation provenance ───────────────────────────────────────────
460
+ // When the Stop gate blocks, the harness re-injects the block reason as the next
461
+ // prompt (omp emits it as an agent-attributed `session-stop-continuation`). That
462
+ // text is the gate's OWN voice, not a user order. Reproducible defect shape when
463
+ // such a prompt is routed as a new task: a fresh taskInstanceId is minted, a
464
+ // contract is re-derived from the gate's own wording, and the gate text is
465
+ // stamped as the explicit user prompt — evidence recorded under the original
466
+ // task then fails ownership and the gate can loop on its own feedback.
467
+ //
468
+ // Provenance is NOT content-based: the shape check below only recognizes our own
469
+ // envelope. Trust comes from (a) the host declaring POSITIVE system provenance
470
+ // (ukit_prompt_origin=system — a marker may then be consumed on an exact
471
+ // session+task+reason-hash match within TTL) or (b) native adapter provenance
472
+ // (ukit_harness=omp + origin=system), which resumes with or without a marker.
473
+ // A marker is a CORRELATION binding, never proof of the speaker: without positive
474
+ // provenance the prompt is conservatively treated as the user's own, so a real
475
+ // user paste of the gate text stays a genuine fresh request even with a live
476
+ // marker, and a user-declared origin never consumes a marker at all.
477
+ export const GATE_CONTINUATION_TTL_MS = 30 * 60 * 1000;
478
+ const GATE_CONTINUATION_PREFIX = /^UKit completion gate:/;
479
+ // The exact diagnostics tail evaluateCompletion appends to every gate reason.
480
+ const GATE_CONTINUATION_DIAGNOSTICS = /\s\[task=\S+ contract=\S+ accepted-count=\d+ rejected=[^\]\s]*\]\s*$/;
481
+ const GATE_CONTINUATION_MAX_CHARS = 2000;
482
+
483
+ export function isCompletionGateContinuationText(text) {
484
+ const value = String(text ?? '').trim();
485
+ if (!value || value.length > GATE_CONTINUATION_MAX_CHARS) return false;
486
+ return GATE_CONTINUATION_PREFIX.test(value) && GATE_CONTINUATION_DIAGNOSTICS.test(value);
487
+ }
488
+
489
+ export function completionContinuationTaskId(text) {
490
+ if (!isCompletionGateContinuationText(text)) return null;
491
+ return String(text).trim().match(/\[task=([^\]\s]+) contract=/)?.[1] || null;
492
+ }
493
+
494
+ // Binds a marker to the EXACT reason text the gate emitted (hash only — the
495
+ // marker record never carries prompt text). Prevents a different gate-shaped
496
+ // message about the same task from being mistaken for our own continuation.
497
+ export function completionContinuationReasonHash(text) {
498
+ return crypto.createHash('sha256').update(String(text ?? '').trim(), 'utf8').digest('hex');
499
+ }
500
+
501
+ function completionContinuationPath(projectRoot) {
502
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'completion-continuation.json');
503
+ }
504
+
505
+ // Bounded ring (newest first): session, task id, contract kind and reason hash
506
+ // only — never prompt text. Fail-closed: no task id or no reason text, no
507
+ // marker. Advisory: a failed write degrades to today's behavior (the envelope
508
+ // is then just an unbound user turn), never breaks the gate. Read-modify-write
509
+ // runs under the ledger lock so concurrent recorders/takers serialize —
510
+ // unlocked, two takes could both "win" and parallel records could clobber the
511
+ // ring down to a single entry. A lock failure is conservative on both sides:
512
+ // record nothing, resume nothing.
513
+ export async function recordPendingCompletionContinuation(projectRoot, { sessionId, taskInstanceId, contract, reason }, { now = Date.now() } = {}) {
514
+ if (!taskInstanceId || !String(reason ?? '').trim()) return null;
515
+ const file = completionContinuationPath(projectRoot);
516
+ try {
517
+ const outcome = await withLedgerLock(file, {}, async () => {
518
+ const store = await readJson(file, {}) || {};
519
+ const entries = Array.isArray(store.entries) ? store.entries : [];
520
+ const next = [
521
+ { sessionId: sessionId == null ? null : String(sessionId), taskInstanceId: String(taskInstanceId), contract: contract == null ? null : String(contract), reasonHash: completionContinuationReasonHash(reason), ts: now },
522
+ ...entries.filter((entry) => entry?.taskInstanceId !== taskInstanceId),
523
+ ].slice(0, 4);
524
+ await writeJsonAtomic(file, { entries: next });
525
+ return next[0];
526
+ });
527
+ return outcome?.ok === true ? outcome.value ?? null : null;
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
533
+ // Exact-bound take: the envelope's own task id AND the consuming session AND
534
+ // the reason hash must all match a fresh marker, otherwise nothing is returned
535
+ // and nothing is consumed (an unbound or re-worded quote of the gate text stays
536
+ // a genuine user turn). A call without the reason hash never consumes. The
537
+ // matched entry is removed under the same lock that read it, so of N concurrent
538
+ // takers exactly one wins.
539
+ export async function takePendingCompletionContinuation(projectRoot, { sessionId, taskInstanceId, reasonHash }, { now = Date.now(), consume = true } = {}) {
540
+ if (!taskInstanceId || !reasonHash) return null;
541
+ const file = completionContinuationPath(projectRoot);
542
+ try {
543
+ const outcome = await withLedgerLock(file, {}, async () => {
544
+ const store = await readJson(file, {}) || {};
545
+ const entries = Array.isArray(store.entries) ? store.entries : [];
546
+ const index = entries.findIndex((entry) => entry?.taskInstanceId === String(taskInstanceId)
547
+ && (entry.sessionId ?? null) === (sessionId == null ? null : String(sessionId))
548
+ && typeof entry.reasonHash === 'string' && entry.reasonHash === String(reasonHash)
549
+ && Number.isFinite(entry.ts) && now - entry.ts <= GATE_CONTINUATION_TTL_MS);
550
+ if (index < 0) return null;
551
+ if (consume) {
552
+ const next = entries.filter((_, i) => i !== index);
553
+ await writeJsonAtomic(file, { entries: next });
554
+ }
555
+ return entries[index];
556
+ });
557
+ return outcome?.ok === true ? outcome.value ?? null : null;
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
459
563
  function explicitError(payload = {}) {
460
564
  return [
461
565
  payload.isError,
462
566
  payload.is_error,
567
+ payload.interrupted,
568
+ payload.tool_output?.interrupted,
569
+ payload.tool_result?.interrupted,
570
+ payload.tool_response?.interrupted,
463
571
  payload.tool_output?.isError,
464
572
  payload.tool_output?.is_error,
465
573
  payload.tool_result?.isError,
@@ -530,8 +638,8 @@ function extractExitCode(payload = {}) {
530
638
  return null;
531
639
  }
532
640
 
533
- // The trailing boundary accepts shell terminators (`;`, `&`, `|`, `)`) so
534
- // `yarn release:verify; echo "exit=$?"` — the real-exit-code idiom — still mints a receipt.
641
+ // Recognize runner presence separately from exit proof. A trailing echo of `$?`
642
+ // can report a status in text but cannot make the wrapper's exit authoritative.
535
643
  // Blank out quoted literals so `echo '(jest passed)'` / `git commit -m "run yarn test"` do not
536
644
  // look like a runner invocation. A `bash|sh|zsh -c '<script>'` wrapper really executes its
537
645
  // quoted script, so that script is tested on its own (bounded recursion).
@@ -584,71 +692,94 @@ function isVerificationCommand(command, depth = 0) {
584
692
  ].some((pattern) => pattern.test(text));
585
693
  }
586
694
 
587
- // Split a shell command into pipelines of stages (quote/escape aware). `&&`, `||`, `;` and
588
- // newlines separate pipelines; a lone `|` (or `|&`) separates stages. Redirections such as
589
- // `2>&1` / `&>` are not separators.
590
- function shellPipelineStages(command) {
591
- const text = String(command || '');
592
- const pipelines = [];
593
- let stages = [];
695
+ // Bounded shell exit-proof recognition, not a shell interpreter. Keep separators so a
696
+ // later echo/OR/background job cannot turn a child runner failure into verification PASS.
697
+ function verificationShellUnits(command) {
698
+ const units = [];
594
699
  let buffer = '';
595
700
  let quote = null;
596
701
  let escaped = false;
597
- const closeStage = () => {
598
- if (buffer.trim()) stages.push(buffer.trim());
599
- buffer = '';
600
- };
601
- const closePipeline = () => {
602
- closeStage();
603
- if (stages.length) pipelines.push(stages);
604
- stages = [];
605
- };
606
- for (let i = 0; i < text.length; i += 1) {
607
- const char = text[i];
702
+ const close = (separator) => { units.push({ text: buffer.trim(), separator }); buffer = ''; };
703
+ for (let i = 0; i < command.length; i += 1) {
704
+ const char = command[i];
608
705
  if (escaped) { escaped = false; buffer += char; continue; }
609
- if (char === '\\') { escaped = true; buffer += char; continue; }
706
+ if (char === '\\' && quote !== "'") { escaped = true; buffer += char; continue; }
610
707
  if (quote) { if (char === quote) quote = null; buffer += char; continue; }
611
708
  if (char === "'" || char === '"') { quote = char; buffer += char; continue; }
612
- if (char === '|') {
613
- if (text[i + 1] === '|') { closePipeline(); i += 1; continue; }
614
- closeStage();
615
- if (text[i + 1] === '&') i += 1;
709
+ if (char === '#' && (!buffer || /\s$/.test(buffer))) {
710
+ while (i < command.length && command[i] !== '\n') i += 1;
711
+ if (i < command.length) close(';');
616
712
  continue;
617
713
  }
618
- if (char === '&') {
619
- if (text[i + 1] === '&') { closePipeline(); i += 1; continue; }
620
- buffer += char; // `2>&1`, `&>file`, trailing background `&`
714
+ if (char === '&' && (/[<>]$/.test(buffer) || command[i + 1] === '>')) { buffer += char; continue; }
715
+ if (char === '|' || char === '&') {
716
+ const next = command[i + 1];
717
+ const separator = next === char ? char + next : char === '|' && next === '&' ? '|&' : char;
718
+ close(separator);
719
+ if (separator.length === 2) i += 1;
621
720
  continue;
622
721
  }
623
- if (char === ';' || char === '\n') { closePipeline(); continue; }
722
+ if (char === ';' || char === '\n' || char === '\r') { close(';'); continue; }
624
723
  buffer += char;
625
724
  }
626
- closePipeline();
627
- return pipelines;
725
+ if (quote || escaped) return null;
726
+ if (buffer.trim()) close('');
727
+ return units.filter((unit) => unit.text || unit.separator !== ';');
628
728
  }
629
729
 
630
- // A shell pipeline exits with its LAST stage's status, so `npx vitest run | tail` is exit 0
631
- // even when vitest failed. A verification command in a non-final stage therefore has an
632
- // unproven exit status — unless the command enables `pipefail` or reads `PIPESTATUS`.
633
- // Returns the masked verification stage (string) or null.
634
- function maskedVerificationStage(command) {
635
- const text = String(command || '');
636
- if (/\bpipefail\b|\bPIPESTATUS\b|\bpipestatus\b/.test(text)) return null;
637
- for (const stages of shellPipelineStages(text)) {
638
- for (const stage of stages.slice(0, -1)) {
639
- if (isVerificationCommand(stage)) return stage;
730
+ function maskedVerificationStage(command, depth = 0) {
731
+ const text = String(command || '').trim();
732
+ const units = verificationShellUnits(text);
733
+ if (!units?.length || depth > 2) return text;
734
+ let pipefail = false;
735
+ let sawRunner = false;
736
+ for (let i = 0; i < units.length; i += 1) {
737
+ const unit = units[i];
738
+ const plain = maskQuotedLiterals(unit.text);
739
+ // Unsupported flow may skip a runner or discard an earlier iteration's failure.
740
+ // Parens, braces and `if` are not interpreted here. A sequential consumer must not
741
+ // turn the status of an earlier runner into PASS, even when it prints that status.
742
+ if (sawRunner && units[i - 1]?.separator === ';' && !/^[)}]+$/.test(plain)) return unit.text;
743
+ // Command position only: `vitest -t case` is an argument, not a loop.
744
+ if (/^(?:for|while|until|case|function|done)\b/.test(plain)) return unit.text;
745
+ // A later `exit 0`/`true`/`:` replaces the runner's status; a leading `!` inverts it.
746
+ if (sawRunner && /^(?:exit\s+0|true|:)$/.test(plain)) return unit.text;
747
+ if (/^!\s/.test(plain) && isVerificationCommand(unit.text)) return unit.text;
748
+ if (unit.separator === '||' || unit.separator === '&') return unit.text;
749
+ const enable = /^set\s+-(?:[a-z]*o)\s+pipefail$/.test(plain);
750
+ const disable = /^set\s+\+(?:[a-z]*o)\s+pipefail$/.test(plain);
751
+ if (enable || disable) pipefail = enable;
752
+ let runner = isVerificationCommand(unit.text);
753
+ // Only an entire simple shell wrapper can inherit the inner script's proof.
754
+ const wrapper = unit.text.match(/^(?:bash|sh|zsh|dash|ksh)\s+(?:-o\s+\w+\s+|-[a-z]+\s+)*(-[a-z]*c)\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")$/);
755
+ if (runner && wrapper) {
756
+ if (maskedVerificationStage(wrapper[2] ?? wrapper[3] ?? '', depth + 1)) return unit.text;
757
+ } else if (runner && /(?:^|\s)(?:bash|sh|zsh|dash|ksh)\s/.test(plain)) return unit.text;
758
+ if (runner) sawRunner = true;
759
+ if (unit.separator === '|' || unit.separator === '|&') {
760
+ if (runner && !pipefail) {
761
+ // A single runner's immediate Bash PIPESTATUS read can propagate its status.
762
+ let end = i;
763
+ while (units[end]?.separator === '|' || units[end]?.separator === '|&') end += 1;
764
+ const proof = units[end + 1];
765
+ const expected = `exit ${'${'}PIPESTATUS[${i - (units.slice(0, i).findLastIndex((u) => u.separator !== '|' && u.separator !== '|&') + 1)}]}`;
766
+ if (units[end]?.separator !== ';' || proof?.text !== expected || proof.separator
767
+ || units.slice(i + 1, end + 1).some((u) => isVerificationCommand(u.text))) return unit.text;
768
+ return null;
769
+ }
770
+ continue;
640
771
  }
641
772
  }
642
- return null;
773
+ return sawRunner ? null : text;
643
774
  }
644
775
 
645
776
  // Announce through the in-proc hook emitter only (never fd 1 from library callers).
646
777
  function noteUnprovenExitStatus(stage) {
647
778
  if (!ledgerMessageEmitter) return;
648
779
  const message =
649
- `UKit execution-ledger: \`${stage}\` ran in a pipeline, so the exit status belongs to the last pipe stage `
650
- + '(e.g. tail/grep), not the test runner — it is NOT counted as passing verification. '
651
- + 'Re-run with `set -o pipefail; <cmd> | tail` (or without the pipe) to prove the result.';
780
+ `UKit execution-ledger: runner exit status is unproven for \`${stage}\` — `
781
+ + 'the shell wrapper may mask or skip the runner; it is NOT counted as passing verification. '
782
+ + 'Re-run the bare verification command in the same workspace, without loops, backgrounding or status-swallowing consumers.';
652
783
  try {
653
784
  ledgerMessageEmitter(`${JSON.stringify({ systemMessage: message })}\n`);
654
785
  } catch { /* an emitter failure never loses the ledger event */ }
@@ -4415,6 +4546,17 @@ async function runEvaluateStop() {
4415
4546
  if (result.continue) {
4416
4547
  if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
4417
4548
  else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
4549
+ // Provenance: the harness will re-inject this reason as a prompt. Bind it to this
4550
+ // session+task+exact-reason (hash) so the router can recognise our own continuation
4551
+ // (advisory — a failed write only leaves it unbound).
4552
+ try {
4553
+ await recordPendingCompletionContinuation(projectRoot, {
4554
+ sessionId: explicitSessionId(payload),
4555
+ taskInstanceId: state?.taskInstanceId || null,
4556
+ contract: state?.routeSummary?.executionContract?.taskKind || null,
4557
+ reason: result.reason,
4558
+ });
4559
+ } catch { /* advisory */ }
4418
4560
  process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
4419
4561
  return;
4420
4562
  }
@@ -0,0 +1,262 @@
1
+ // omp-task-dispatch-guard.mjs — block-only preflight for OMP `task` dispatches.
2
+ //
3
+ // Field evidence: an ordinary OMP agent dispatched with a `sourceSession` (or `model`)
4
+ // field is rejected by OMP's embedded validator (18.12.5) even when the value is empty
5
+ // (`''` / `[]` / `null`), and batch-root `sourceSession`/`model` are rejected outright.
6
+ // The model's natural retry after that rejection re-sends the exact same invalid
7
+ // payload, so one bad field can loop a dispatch indefinitely.
8
+ //
9
+ // What this guard does (host PreToolUse return supports BLOCK only — it cannot rewrite
10
+ // the native input, so correction is always the caller's job):
11
+ //
12
+ // - `preflightOmpTaskDispatch()` is the only function a bridge needs to call, before
13
+ // the native validator sees the payload. Valid dispatches are returned as-is with
14
+ // `action: 'allow'` and NEVER touch disk. An invalid dispatch is never rewritten:
15
+ // the guard returns `action: 'block'` carrying `violations`, the exact
16
+ // `removeFields` paths and a `guidance` string, so the model can correct the
17
+ // payload itself; a corrected dispatch is a valid, different shape and is allowed
18
+ // without consuming any budget.
19
+ // - `validateOmpTaskArgs()` checks exactly the confirmed ordinary restriction: batch
20
+ // root `sourceSession`/`model` are invalid, and a `tasks[]` item whose agent is not
21
+ // `seance` must not carry `sourceSession` or `model` at all — presence alone is the
22
+ // violation, any value included. Seance items are NOT validated here (missing
23
+ // `sourceSession`, custom tools, etc. remain the native validator's job); the guard
24
+ // never sanitizes a seance task and never switches an agent to seance. This is
25
+ // deliberately narrower than the full native validator, not a re-implementation.
26
+ // - Repeat blocks are counted per (session, taskInstanceId, dispatch shape). The
27
+ // `taskInstanceId` is REQUIRED for a count: the bridge reads it from
28
+ // `readRouteState(projectRoot, { session_id })` (per-session sidecar, owner
29
+ // verified) and supplies it here. A missing session or task instance id means the
30
+ // budget cannot be scoped, so the dispatch is blocked WITHOUT recording or borrowing
31
+ // any count. Full session ids are hashed (no truncation), so distinct sessions that
32
+ // share a long prefix never collide.
33
+ // - Once attempts reach `OMP_DISPATCH_MAX_RETRIES` identical invalid blocks for one
34
+ // task instance (two, by default), the guidance says STOP: the dispatch shape is
35
+ // provably stuck, and retrying unchanged only burns tokens. The count persists under
36
+ // `.ukit/storage/cache/omp-task-dispatch-guard.json` behind the shared async lock
37
+ // (`withAsyncLock`), so a compact or session reload does NOT reset it. Only hashed
38
+ // keys and counters are stored — never task prose, agent names, or raw session or
39
+ // task-instance ids.
40
+ // - The fingerprint (`ompTaskDispatchFingerprint`) intentionally EXCLUDES task prose,
41
+ // name, read and status — a reworded prompt is the same stuck dispatch and must not
42
+ // reset the count — while preserving agent, the presence AND value of the offending
43
+ // fields, and tools/isolation/isolated, so a genuinely different branch (different
44
+ // agent, tools, isolation, or field value) keeps its own independent count and a new
45
+ // genuine task is never blocked by another task's failures.
46
+ //
47
+ // Failure posture: the guard is fail-CLOSED for invalid input it already understands —
48
+ // a lock timeout or internal I/O error still BLOCKS the known-invalid dispatch
49
+ // (reason: 'lock-unavailable' / 'internal-error') rather than letting a looping
50
+ // payload through; the native validator would reject it anyway. Valid dispatches hit
51
+ // no disk, so they are unaffected by any of this.
52
+
53
+ import fs from 'node:fs';
54
+ import path from 'node:path';
55
+ import crypto from 'node:crypto';
56
+ import { withAsyncLock } from './async-lock.mjs';
57
+
58
+ export const OMP_DISPATCH_MAX_RETRIES = 2;
59
+ const STATE_FILE_NAME = 'omp-task-dispatch-guard.json';
60
+ const MAX_ENTRIES = 200;
61
+ const LOCK_BUDGET_MS = 1_000;
62
+ const ENTRY_TTL_MS = 24 * 60 * 60_000;
63
+ const SEANCE_AGENT = 'seance';
64
+ // Fields the OMP validator rejects when present on an ordinary agent or the batch
65
+ // root. Presence alone (even '' / [] / null) is the violation.
66
+ const ORDINARY_FORBIDDEN_FIELDS = ['sourceSession', 'model'];
67
+
68
+ /**
69
+ * Check the confirmed OMP 18.12.5 ordinary restriction on task dispatch arguments.
70
+ * Pure: never mutates `args`, never talks to disk, and does NOT validate seance tasks
71
+ * (the native validator owns all seance rules).
72
+ * @param {object} args - single task object, or a batch `{ tasks: [...] }`
73
+ * @returns {{ valid: boolean, violations: Array<{code: string, path: string, field: string}> }}
74
+ */
75
+ export function validateOmpTaskArgs(args) {
76
+ const violations = [];
77
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
78
+ return { valid: true, violations };
79
+ }
80
+ const isBatch = Array.isArray(args.tasks);
81
+ // Batch-root fields are only a violation in batch shape; a single task object's own
82
+ // fields are validated as that task below.
83
+ if (isBatch) {
84
+ for (const field of ORDINARY_FORBIDDEN_FIELDS) {
85
+ if (Object.prototype.hasOwnProperty.call(args, field)) {
86
+ violations.push({ code: 'batch-root-field-present', path: field, field });
87
+ }
88
+ }
89
+ }
90
+ const tasks = isBatch ? args.tasks : [args];
91
+ tasks.forEach((task, index) => {
92
+ if (!task || typeof task !== 'object' || Array.isArray(task)) return;
93
+ // The host trims the agent name before dispatch, so the seance comparison must
94
+ // trim too — a padded ' seance ' agent is a seance task, not an ordinary one.
95
+ if (taskAgent(task) === SEANCE_AGENT) return; // native validator owns seance rules
96
+ const label = isBatch ? `tasks[${index}]` : 'task';
97
+ for (const field of ORDINARY_FORBIDDEN_FIELDS) {
98
+ if (Object.prototype.hasOwnProperty.call(task, field)) {
99
+ violations.push({ code: 'ordinary-field-present', path: `${label}.${field}`, field });
100
+ }
101
+ }
102
+ });
103
+ return { valid: violations.length === 0, violations };
104
+ }
105
+
106
+ /**
107
+ * Stable shape fingerprint for repeat counting: includes agent, the presence AND value
108
+ * of the offending fields, tools and isolation; deliberately excludes task prose,
109
+ * name, read and status so a reworded prompt cannot reset the count.
110
+ * @param {object} args
111
+ * @returns {string}
112
+ */
113
+ export function ompTaskDispatchFingerprint(args) {
114
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return 'empty';
115
+ const shape = {};
116
+ for (const field of ORDINARY_FORBIDDEN_FIELDS) {
117
+ if (Object.prototype.hasOwnProperty.call(args, field)) shape[field] = normalizeValue(args[field]);
118
+ }
119
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [args];
120
+ shape.tasks = tasks.map((task) => {
121
+ if (!task || typeof task !== 'object' || Array.isArray(task)) return { agent: String(task) };
122
+ const item = { agent: taskAgent(task) };
123
+ for (const field of ORDINARY_FORBIDDEN_FIELDS) {
124
+ if (Object.prototype.hasOwnProperty.call(task, field)) item[field] = normalizeValue(task[field]);
125
+ }
126
+ if (Array.isArray(task.tools)) item.tools = task.tools.map((t) => normalizeValue(t));
127
+ if (task.isolation !== undefined) item.isolation = normalizeValue(task.isolation);
128
+ if (task.isolated !== undefined) item.isolated = normalizeValue(task.isolated);
129
+ return item;
130
+ });
131
+ return JSON.stringify(shape);
132
+ }
133
+
134
+ // The host trims the agent name before dispatch; mirror that here so a padded agent
135
+ // is classified and fingerprinted as its trimmed value.
136
+ function taskAgent(task) {
137
+ return String(task?.agent ?? '').trim();
138
+ }
139
+
140
+ // Key-order-stable, content-exact serialization: arrays serialize their items (so
141
+ // same-length different-value arrays never collide), objects sort their keys (so
142
+ // insertion order never changes the fingerprint), and only whitespace-only strings
143
+ // collapse to 'empty'. The fingerprint must isolate the ACTUAL offending field value.
144
+ function normalizeValue(value) {
145
+ if (value === null) return ['null'];
146
+ if (Array.isArray(value)) return ['array', value.map(normalizeValue)];
147
+ if (typeof value === 'object') {
148
+ return ['object', Object.keys(value).sort().map((key) => [key, normalizeValue(value[key])])];
149
+ }
150
+ if (typeof value === 'string') return ['string', value.trim() === '' ? '' : value];
151
+ return [typeof value, String(value)];
152
+ }
153
+
154
+ // Full, untruncated session id — slicing (e.g. to 80 chars) would let two distinct
155
+ // long session ids that share a prefix collide into one budget.
156
+ function countKey(sessionId, taskInstanceId, fingerprint) {
157
+ return crypto.createHash('sha1').update(`${sessionId}\u0000${taskInstanceId}\u0000${fingerprint}`).digest('hex').slice(0, 16);
158
+ }
159
+
160
+ function stateFilePath(projectRoot) {
161
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', STATE_FILE_NAME);
162
+ }
163
+
164
+ function readState(file) {
165
+ try {
166
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
167
+ return s && typeof s === 'object' && s.entries && typeof s.entries === 'object' ? s : { entries: {} };
168
+ } catch {
169
+ return { entries: {} };
170
+ }
171
+ }
172
+
173
+ function guidanceFor(violations, exhausted, attempts) {
174
+ const lines = violations.map((v) => {
175
+ if (v.code === 'batch-root-field-present') {
176
+ return `Remove field \`${v.field}\` from the batch root: the OMP task validator rejects it there. Keep the tasks[] items unchanged.`;
177
+ }
178
+ return `Remove field \`${v.field}\` from ${v.path}: an ordinary agent (agent !== 'seance') must not carry it — any value, even '' or null, is rejected. Keep the agent, tools and isolation unchanged; do not switch the agent.`;
179
+ });
180
+ if (exhausted) {
181
+ lines.push(`This exact invalid dispatch was already blocked ${attempts} times for this task. STOP retrying it: apply the removal above ONCE, or record this reviewer as NOT_RUN with the actual reason. Continue independent authorized work; do not invent configuration changes or bypass a denied tool.`);
182
+ }
183
+ return lines.join(' ');
184
+ }
185
+
186
+ /**
187
+ * Block-only preflight for one OMP task dispatch. See the module header for the full
188
+ * contract. Never rewrites `args`; never validates seance tasks; valid calls never
189
+ * touch disk.
190
+ * @param {{
191
+ * projectRoot: string,
192
+ * sessionId?: string,
193
+ * taskInstanceId?: string,
194
+ * args: object,
195
+ * now?: number,
196
+ * }} input
197
+ * @returns {Promise<{
198
+ * action: 'allow' | 'block',
199
+ * args: object,
200
+ * violations?: Array<object>,
201
+ * removeFields?: string[],
202
+ * guidance?: string,
203
+ * attempts?: number,
204
+ * exhausted?: boolean,
205
+ * reason?: 'unscoped' | 'lock-unavailable' | 'internal-error',
206
+ * }>}
207
+ */
208
+ export async function preflightOmpTaskDispatch({ projectRoot, sessionId, taskInstanceId, args, now = Date.now() }) {
209
+ const verdict = validateOmpTaskArgs(args);
210
+ if (verdict.valid) {
211
+ return { action: 'allow', args };
212
+ }
213
+ const removeFields = verdict.violations.map((v) => v.path);
214
+ const blockBase = { action: 'block', args, violations: verdict.violations, removeFields };
215
+ try {
216
+ // A count requires a scoppable identity. Without it the dispatch is blocked
217
+ // WITHOUT recording or borrowing any count entry.
218
+ const scoped = projectRoot && sessionId && taskInstanceId
219
+ && fs.existsSync(path.join(projectRoot, '.ukit', 'storage'));
220
+ if (!scoped) {
221
+ return { ...blockBase, guidance: guidanceFor(verdict.violations, false, 0), reason: 'unscoped' };
222
+ }
223
+ const key = countKey(sessionId, taskInstanceId, ompTaskDispatchFingerprint(args));
224
+ const file = stateFilePath(projectRoot);
225
+ const outcome = await withAsyncLock(file, { deadlineMs: LOCK_BUDGET_MS }, async () => {
226
+ const state = readState(file);
227
+ for (const [k, v] of Object.entries(state.entries)) {
228
+ if (!v || now - (v.last || 0) > ENTRY_TTL_MS) delete state.entries[k];
229
+ }
230
+ const keys = Object.keys(state.entries);
231
+ if (!state.entries[key] && keys.length >= MAX_ENTRIES) {
232
+ for (const k of keys.sort((a, b) => (state.entries[a].last || 0) - (state.entries[b].last || 0)).slice(0, keys.length - MAX_ENTRIES + 1)) {
233
+ delete state.entries[k];
234
+ }
235
+ }
236
+ const entry = state.entries[key] || (state.entries[key] = { count: 0, first: now });
237
+ entry.count += 1;
238
+ entry.last = now;
239
+ fs.mkdirSync(path.dirname(file), { recursive: true });
240
+ const tmp = `${file}.${process.pid}.tmp`;
241
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
242
+ fs.renameSync(tmp, file);
243
+ return entry.count;
244
+ });
245
+ if (!outcome.ok) {
246
+ // Fail closed: the payload is known-invalid, so block it even without a count.
247
+ return { ...blockBase, guidance: guidanceFor(verdict.violations, false, 0), reason: 'lock-unavailable' };
248
+ }
249
+ const attempts = outcome.value;
250
+ // STOP guidance starts once the count REACHES the bound (after two identical
251
+ // blocks, by default) — not on the block after them.
252
+ const exhausted = attempts >= OMP_DISPATCH_MAX_RETRIES;
253
+ return { ...blockBase, guidance: guidanceFor(verdict.violations, exhausted, attempts), attempts, exhausted };
254
+ } catch (error) {
255
+ return {
256
+ ...blockBase,
257
+ guidance: guidanceFor(verdict.violations, false, 0),
258
+ reason: 'internal-error',
259
+ note: `omp dispatch guard count unavailable: ${String(error?.message || error).slice(0, 120)}`,
260
+ };
261
+ }
262
+ }
@@ -519,6 +519,14 @@ export async function readResumableRun(projectRoot, {
519
519
  };
520
520
  }
521
521
 
522
+ // The filename is a lossy sanitization of taskId (path chars normalized, length
523
+ // truncated to 80), so distinct task ids can share one record file. A record
524
+ // whose stored taskId is not the exact requested id belongs to another task and
525
+ // must never be borrowed — its evidence and failures are not this task's.
526
+ if (taskId !== undefined && parsed.taskId !== taskId) {
527
+ return { status: 'absent', record: null, warnings: [] };
528
+ }
529
+
522
530
  // A new explicit task boundary resets the record — the persisted state belongs
523
531
  // to a different logical task.
524
532
  if (typeof taskBoundary === 'string' && taskBoundary.length > 0