@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.
- package/CHANGELOG.md +18 -0
- package/manifests/documentation.yaml +22 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +36 -4
- package/src/core/reviewPanelAggregate.js +71 -3
- package/template_project/.claude/agents/code-reviewer.md +41 -6
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -11
- package/template_project/.claude/commands/ukit/handoff-review.md +34 -8
- package/template_project/.claude/hooks/handoff-resume.sh +17 -3
- package/template_project/.claude/hooks/skill-router.sh +89 -11
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +71 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +36 -4
- package/template_project/.claude/ukit/index/route-task.mjs +24 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +187 -45
- package/template_project/.claude/ukit/runtime/omp-task-dispatch-guard.mjs +262 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +8 -0
- package/template_project/.omp/agents/code-reviewer.md +41 -6
- package/template_project/.omp/hooks/pre/ukit-bridge.js +38 -0
|
@@ -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
|
-
//
|
|
534
|
-
//
|
|
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
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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') {
|
|
722
|
+
if (char === ';' || char === '\n' || char === '\r') { close(';'); continue; }
|
|
624
723
|
buffer += char;
|
|
625
724
|
}
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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:
|
|
650
|
-
+ '
|
|
651
|
-
+ 'Re-run
|
|
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
|