@miller-tech/uap 1.186.2 → 1.187.3
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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts +100 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +507 -20
- package/dist/cli/deliver.js.map +1 -1
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +28 -5
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/delivery/agentic-executor.d.ts +3 -3
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +74 -4
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +6 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +31 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/edit-match.d.ts.map +1 -1
- package/dist/delivery/edit-match.js +19 -1
- package/dist/delivery/edit-match.js.map +1 -1
- package/dist/delivery/epic-controller.d.ts +6 -0
- package/dist/delivery/epic-controller.d.ts.map +1 -1
- package/dist/delivery/epic-controller.js +10 -0
- package/dist/delivery/epic-controller.js.map +1 -1
- package/dist/delivery/epic-mission.d.ts.map +1 -1
- package/dist/delivery/epic-mission.js +3 -0
- package/dist/delivery/epic-mission.js.map +1 -1
- package/dist/delivery/verifier-ladder.d.ts +99 -6
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +208 -25
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
- package/dist/mcp-router/tools/deliver.js +17 -0
- package/dist/mcp-router/tools/deliver.js.map +1 -1
- package/dist/models/long-fetch.d.ts +19 -0
- package/dist/models/long-fetch.d.ts.map +1 -1
- package/dist/models/long-fetch.js +47 -0
- package/dist/models/long-fetch.js.map +1 -1
- package/dist/telemetry/tool-failure.d.ts +9 -0
- package/dist/telemetry/tool-failure.d.ts.map +1 -1
- package/dist/telemetry/tool-failure.js +4 -0
- package/dist/telemetry/tool-failure.js.map +1 -1
- package/docs/reference/CLI.md +13 -2
- package/docs/reference/CONFIGURATION.md +2 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_infra_protect.py +68 -5
- package/src/policies/enforcers/enforcement_self_protect.py +36 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/pre-tool-use-bash.sh +10 -2
- package/templates/hooks/uap-policy-gate.sh +4 -1
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +101 -9
- package/tools/agents/tests/test_repeat_call_guard.py +228 -0
package/dist/cli/deliver.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { spawnSync } from 'child_process';
|
|
10
|
-
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync, readdirSync, statSync } from 'fs';
|
|
10
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync, writeFileSync, renameSync, realpathSync, closeSync, readdirSync, statSync } from 'fs';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
11
13
|
import { join, resolve, relative } from 'path';
|
|
12
14
|
import { ConvergenceLoop, composeIterationHooks } from '../delivery/convergence-loop.js';
|
|
13
15
|
import { createModelJudge } from '../delivery/judge.js';
|
|
@@ -215,7 +217,7 @@ import { createRunCoordinator } from '../delivery/run-coordinator.js';
|
|
|
215
217
|
import { readCuratedIdeas } from './ideate.js';
|
|
216
218
|
import { createEscalationController, defaultEscalationLadder } from '../delivery/escalation.js';
|
|
217
219
|
import { FilePracticeStore, defaultPracticePath, extractKeywords, retrievePracticesSemantic, } from '../delivery/practice.js';
|
|
218
|
-
import { detectRungs, mergeRedetectedRungs, runLadder, runTieredLadder, tierOf, TIER_ORDER, demoteBaselineFailures } from '../delivery/verifier-ladder.js';
|
|
220
|
+
import { detectRungs, mergeRedetectedRungs, runLadder, runTieredLadder, tierOf, TIER_ORDER, demoteBaselineFailures, baselineRegressions } from '../delivery/verifier-ladder.js';
|
|
219
221
|
import { runDeployDevLadder } from '../delivery/deploy-dev-gate.js';
|
|
220
222
|
import { commitPushAndWatch } from '../delivery/ci-watcher.js';
|
|
221
223
|
import { snapshotTree, restoreTree, disposeSnapshot } from '../delivery/snapshot.js';
|
|
@@ -428,6 +430,371 @@ export function isDeliverLockAbandoned(projectRoot, nowS = Math.floor(Date.now()
|
|
|
428
430
|
// A clock skew that puts the lock in the future must not read as ancient.
|
|
429
431
|
return nowS - writtenS > wedgeTimeoutS();
|
|
430
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* True when two deliver project roots share a subtree — identical, or one an
|
|
435
|
+
* ancestor of the other. Such runs edit the SAME files, so they must not run
|
|
436
|
+
* concurrently even though their `.uap/deliver.lock` paths differ.
|
|
437
|
+
*/
|
|
438
|
+
export function deliverRootsOverlap(a, b) {
|
|
439
|
+
// realpath so one tree reached through a symlink and through its target does
|
|
440
|
+
// not read as two unrelated projects — a silent miss of the exact case this
|
|
441
|
+
// guards. Falls back to resolve() for a path that does not exist yet.
|
|
442
|
+
const norm = (p) => {
|
|
443
|
+
let abs;
|
|
444
|
+
try {
|
|
445
|
+
abs = realpathSync.native(resolve(p));
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
abs = resolve(p);
|
|
449
|
+
}
|
|
450
|
+
return abs.replace(/\/+$/, '');
|
|
451
|
+
};
|
|
452
|
+
const x = norm(a);
|
|
453
|
+
const y = norm(b);
|
|
454
|
+
if (x === y)
|
|
455
|
+
return true;
|
|
456
|
+
// Segment-boundary check: `/srv/app` must not "contain" `/srv/app-two`.
|
|
457
|
+
return x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* A project root a registry entry is allowed to claim.
|
|
461
|
+
*
|
|
462
|
+
* `/` (and any root stripped to `''` by the trailing-slash trim) would overlap
|
|
463
|
+
* EVERY project on the machine, so one such entry disables deliver everywhere.
|
|
464
|
+
* Requiring at least two path segments keeps a corrupt or hostile entry from
|
|
465
|
+
* becoming a machine-wide off-switch.
|
|
466
|
+
*/
|
|
467
|
+
function isPlausibleRunRoot(root) {
|
|
468
|
+
if (!root.startsWith('/'))
|
|
469
|
+
return false;
|
|
470
|
+
return root.replace(/\/+$/, '').split('/').filter(Boolean).length >= 2;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* A pid a registry entry is allowed to claim.
|
|
474
|
+
*
|
|
475
|
+
* Bare `Number()` accepts `-1`, and `process.kill(-1, 0)` is a "does any
|
|
476
|
+
* process exist" query that always succeeds — so a negative pid would read as
|
|
477
|
+
* eternally alive and its entry could never be swept.
|
|
478
|
+
*/
|
|
479
|
+
function isPlausibleRunPid(raw) {
|
|
480
|
+
return /^[1-9]\d{0,9}$/.test(raw.trim());
|
|
481
|
+
}
|
|
482
|
+
/** Only names this module writes are read or unlinked. */
|
|
483
|
+
const ACTIVE_RUN_FILE = /^[0-9a-f]{16}\.run$/;
|
|
484
|
+
/**
|
|
485
|
+
* Registry of live deliver runs, keyed across ALL projects on this machine.
|
|
486
|
+
* `UAP_ACTIVE_RUNS_DIR` redirects it so tests never write to a real home.
|
|
487
|
+
*/
|
|
488
|
+
function activeRunsDir() {
|
|
489
|
+
const override = process.env.UAP_ACTIVE_RUNS_DIR;
|
|
490
|
+
return override && override.trim() ? resolve(override.trim()) : join(homedir(), '.uap', 'active-runs');
|
|
491
|
+
}
|
|
492
|
+
function activeRunEntry(projectRoot) {
|
|
493
|
+
// Path → filename, reversibly enough to read back and stable per root.
|
|
494
|
+
const key = createHash('sha1').update(resolve(projectRoot)).digest('hex').slice(0, 16);
|
|
495
|
+
return join(activeRunsDir(), `${key}.run`);
|
|
496
|
+
}
|
|
497
|
+
/** Publish this run's entry atomically, so no scanner can read a torn record. */
|
|
498
|
+
function publishActiveRun(projectRoot, iso) {
|
|
499
|
+
const dir = activeRunsDir();
|
|
500
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
501
|
+
// Absolute paths carry usernames and client/project names; keep them off
|
|
502
|
+
// other local uids, matching proxy-lifecycle's 0700/0600 convention.
|
|
503
|
+
const entry = activeRunEntry(projectRoot);
|
|
504
|
+
const tmp = `${entry}.${process.pid}.tmp`;
|
|
505
|
+
writeFileSync(tmp, `${process.pid}|${iso}|${resolve(projectRoot)}`, { encoding: 'utf8', mode: 0o600 });
|
|
506
|
+
renameSync(tmp, entry);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Deliver runs on OVERLAPPING project roots, anywhere on this machine.
|
|
510
|
+
*
|
|
511
|
+
* Returns the first live overlapping run that owns the subtree ahead of us
|
|
512
|
+
* (started earlier — PID breaks an exact tie), or null when we are clear to
|
|
513
|
+
* proceed. Stale entries are swept as they are read.
|
|
514
|
+
*
|
|
515
|
+
* `pidAlive` alone is NOT sufficient evidence that a run is live, and this
|
|
516
|
+
* function must not repeat the mistake the per-project lock already paid for
|
|
517
|
+
* (see isDeliverLockAbandoned): pids get recycled, and a run killed with
|
|
518
|
+
* SIGKILL never runs its exit handler, so its entry outlives it. An entry whose
|
|
519
|
+
* pid has been reissued to an unrelated process would otherwise block every
|
|
520
|
+
* overlapping deliver on the machine forever, with no operator-visible cause.
|
|
521
|
+
* So a claimed holder must ALSO still look live at its own root — it must hold
|
|
522
|
+
* that root's lock and not be abandoned there.
|
|
523
|
+
*/
|
|
524
|
+
export function findOverlappingDeliverRun(projectRoot, selfPid = process.pid, selfStartedMs = Date.now()) {
|
|
525
|
+
const pidAlive = (pid) => {
|
|
526
|
+
try {
|
|
527
|
+
process.kill(pid, 0);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const dir = activeRunsDir();
|
|
535
|
+
let entries;
|
|
536
|
+
try {
|
|
537
|
+
entries = readdirSync(dir);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return null; // no registry yet — nothing to collide with
|
|
541
|
+
}
|
|
542
|
+
for (const name of entries) {
|
|
543
|
+
// Never read or unlink a name we did not write. Without this, pointing
|
|
544
|
+
// UAP_ACTIVE_RUNS_DIR at any directory turns the sweep below into an
|
|
545
|
+
// arbitrary-file delete.
|
|
546
|
+
if (!ACTIVE_RUN_FILE.test(name))
|
|
547
|
+
continue;
|
|
548
|
+
const file = join(dir, name);
|
|
549
|
+
const sweep = () => { try {
|
|
550
|
+
rmSync(file);
|
|
551
|
+
}
|
|
552
|
+
catch { /* racing sweep */ } };
|
|
553
|
+
try {
|
|
554
|
+
const [pidRaw = '', iso = '', rootRaw = ''] = readFileSync(file, 'utf8').split('|');
|
|
555
|
+
const root = rootRaw.trim();
|
|
556
|
+
if (!isPlausibleRunPid(pidRaw) || !isPlausibleRunRoot(root)) {
|
|
557
|
+
sweep();
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const pid = Number(pidRaw.trim());
|
|
561
|
+
if (pid === selfPid)
|
|
562
|
+
continue;
|
|
563
|
+
if (!pidAlive(pid)) {
|
|
564
|
+
sweep();
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!deliverRootsOverlap(projectRoot, root))
|
|
568
|
+
continue;
|
|
569
|
+
// The pid is alive, but is it still THIS run? Cross-check the holder's
|
|
570
|
+
// own root: a recycled pid does not hold that lock, and a wedged or
|
|
571
|
+
// abandoned holder is reclaimable there rather than blocking us here.
|
|
572
|
+
if (isDeliverLockAbandoned(root) || isDeliverHolderWedged(root)) {
|
|
573
|
+
sweep();
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// Both runs see each other; exactly one must yield. The earlier start
|
|
577
|
+
// keeps the subtree, so the decision is the same from either side.
|
|
578
|
+
const theirs = Date.parse(iso.trim());
|
|
579
|
+
const olderThanUs = Number.isFinite(theirs)
|
|
580
|
+
? theirs < selfStartedMs || (theirs === selfStartedMs && pid < selfPid)
|
|
581
|
+
: true;
|
|
582
|
+
if (olderThanUs)
|
|
583
|
+
return { pid, root };
|
|
584
|
+
}
|
|
585
|
+
catch { /* unreadable entry — ignore */ }
|
|
586
|
+
}
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Cheap PREFILTER for "this directory might be a project root".
|
|
591
|
+
*
|
|
592
|
+
* Deliberately not the authority — a manifest can exist and still yield no
|
|
593
|
+
* gates (a package.json with no scripts is the common case), so a hit here is
|
|
594
|
+
* only a candidate. `dirHasGates` below is what actually decides, by asking the
|
|
595
|
+
* same detectors the real ladder uses. This list exists so we ask that question
|
|
596
|
+
* a handful of times instead of once per directory in the tree.
|
|
597
|
+
*/
|
|
598
|
+
const BUILD_MANIFESTS = new Set([
|
|
599
|
+
'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'setup.py', 'requirements.txt',
|
|
600
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'build.sbt',
|
|
601
|
+
'Makefile', 'makefile', 'GNUmakefile', 'CMakeLists.txt',
|
|
602
|
+
'Gemfile', 'composer.json', 'mix.exs', 'pubspec.yaml', 'Package.swift', 'build.zig',
|
|
603
|
+
]);
|
|
604
|
+
/**
|
|
605
|
+
* Directories never worth descending into when looking for a project root.
|
|
606
|
+
*
|
|
607
|
+
* Two classes: build output / dependency trees (which are full of manifests
|
|
608
|
+
* belonging to somebody else — node_modules alone would otherwise nominate a
|
|
609
|
+
* dependency as the project), and directories that conventionally hold sample
|
|
610
|
+
* or fixture projects, which are never what the mission meant.
|
|
611
|
+
*/
|
|
612
|
+
const NON_PROJECT_DIRS = new Set([
|
|
613
|
+
'node_modules', 'target', 'dist', 'build', 'out', 'bin', 'obj', 'vendor', 'coverage',
|
|
614
|
+
'venv', '__pycache__', 'Pods', 'bower_components', 'third_party',
|
|
615
|
+
'testdata', 'fixtures', 'examples', 'templates',
|
|
616
|
+
]);
|
|
617
|
+
/** Upper bound on directories read, so the cost never scales with the mistake. */
|
|
618
|
+
const GATED_SCAN_MAX_VISITS = 400;
|
|
619
|
+
/**
|
|
620
|
+
* Does this directory actually produce gate rungs? Asks the real detectors, so
|
|
621
|
+
* the advice cannot name a root that is just as gateless as the current one.
|
|
622
|
+
* Pure filesystem inspection — no commands run.
|
|
623
|
+
*/
|
|
624
|
+
function dirHasGates(dir) {
|
|
625
|
+
// `detectRungs` already folds in the cargo, polyglot and non-npm detectors,
|
|
626
|
+
// so calling them again here could never change the answer — it only probed
|
|
627
|
+
// the filesystem three extra times per candidate.
|
|
628
|
+
try {
|
|
629
|
+
return detectRungs(dir).length > 0;
|
|
630
|
+
}
|
|
631
|
+
catch { /* detection is best-effort; treat a thrower as gateless */ }
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* The operator-facing advisory, or null when there is nothing useful to say.
|
|
636
|
+
*
|
|
637
|
+
* Split out from the call site so the wording is testable without standing up a
|
|
638
|
+
* whole delivery run — the message IS the feature, since nothing about the run
|
|
639
|
+
* changes.
|
|
640
|
+
*/
|
|
641
|
+
/** Env escape hatch for the gateless-root refusal. */
|
|
642
|
+
export const GATELESS_ENV = 'UAP_ALLOW_GATELESS_ROOT';
|
|
643
|
+
/**
|
|
644
|
+
* True when the caller has explicitly accepted a root that cannot verify its
|
|
645
|
+
* own work. The env form exists for scripted launches that cannot easily add
|
|
646
|
+
* a flag.
|
|
647
|
+
*/
|
|
648
|
+
export function allowGatelessRoot(flag) {
|
|
649
|
+
return flag === true || process.env[GATELESS_ENV] === '1';
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Refuse a project root that cannot verify its own work. Returns false when the
|
|
653
|
+
* caller should stop.
|
|
654
|
+
*
|
|
655
|
+
* Escalated 2026-08-09 from a warning. The warning worked — it printed on line
|
|
656
|
+
* 3 of the log naming the exact right root — and the run was launched at the
|
|
657
|
+
* gateless root anyway, because a warning in a log is not a decision point for
|
|
658
|
+
* a scripted or agent-driven launch. That run then spent 34 minutes taking a
|
|
659
|
+
* Rust crate from 1 failing test to 3 while reporting "100% of gates" every
|
|
660
|
+
* turn, because with no cargo rung nothing could tell it otherwise.
|
|
661
|
+
*
|
|
662
|
+
* Refusing is safe precisely BECAUSE the trigger is narrow: this root has no
|
|
663
|
+
* gates AND a subdirectory demonstrably does. A genuinely gateless tree — a
|
|
664
|
+
* docs repo, an empty dir — finds no candidate and is untouched.
|
|
665
|
+
*
|
|
666
|
+
* The root is judged by its OWN unfiltered gate set, not by the post-`--gates`
|
|
667
|
+
* / post-tier `rungs` array: `--tiers fast` against a root whose only rungs are
|
|
668
|
+
* integration-tier empties that array, and refusing there would reject a root
|
|
669
|
+
* that is objectively well gated.
|
|
670
|
+
*/
|
|
671
|
+
export function refuseGatelessRoot(projectRoot, options) {
|
|
672
|
+
// A dry-run plans and writes nothing, and is the one diagnostic an operator
|
|
673
|
+
// reaches for AFTER being refused ("show me what gates you see here").
|
|
674
|
+
// Refusing it would remove the tool that explains the refusal. Matches how
|
|
675
|
+
// preflight and the lock already exempt it.
|
|
676
|
+
if (options.dryRun)
|
|
677
|
+
return true;
|
|
678
|
+
// Resume continues a mission whose root was adjudicated at launch. Refusing
|
|
679
|
+
// here would strand a run that was legitimately started with consent, because
|
|
680
|
+
// the consent is not persisted in run state.
|
|
681
|
+
if (options.resume)
|
|
682
|
+
return true;
|
|
683
|
+
if (dirHasGates(projectRoot))
|
|
684
|
+
return true;
|
|
685
|
+
const gated = findGatedSubprojects(projectRoot);
|
|
686
|
+
const advice = formatGatelessRootAdvice(projectRoot, gated);
|
|
687
|
+
if (!advice)
|
|
688
|
+
return true;
|
|
689
|
+
if (allowGatelessRoot(options.allowGatelessRoot)) {
|
|
690
|
+
console.log(chalk.yellow(advice));
|
|
691
|
+
console.log(chalk.yellow(' (continuing anyway: gateless root explicitly allowed)'));
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
console.error(chalk.red(advice));
|
|
695
|
+
console.error(chalk.red('⛔ refusing to start: this root cannot verify the work. Re-run with the --project-root above. ' +
|
|
696
|
+
`(Operator override, human prose only — deliberately not in the JSON: --allow-gateless-root, or ${GATELESS_ENV}=1.)`));
|
|
697
|
+
if (options.json) {
|
|
698
|
+
console.log(JSON.stringify({
|
|
699
|
+
success: false,
|
|
700
|
+
gatelessRoot: true,
|
|
701
|
+
projectRoot,
|
|
702
|
+
suggestedProjectRoot: gated[0],
|
|
703
|
+
gatedSubprojects: gated,
|
|
704
|
+
reason: 'No objective gates were detected at this project root, but a subdirectory has them. ' +
|
|
705
|
+
'Rooted here the run cannot compile or test its own work.',
|
|
706
|
+
// Deliberately does NOT name the bypass flag. This whole refusal
|
|
707
|
+
// exists because an agent-driven launch does not honour advice, and
|
|
708
|
+
// `nextStep` is the field such a caller reads first — advertising the
|
|
709
|
+
// off-switch there would hand it the way around the gate. The human
|
|
710
|
+
// prose on stderr names it; a model reading JSON gets the fix only.
|
|
711
|
+
nextStep: `Re-run with --project-root ${gated[0]}.`,
|
|
712
|
+
}, null, 2));
|
|
713
|
+
}
|
|
714
|
+
process.exitCode = 2;
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
export function formatGatelessRootAdvice(projectRoot, gated) {
|
|
718
|
+
if (!gated.length)
|
|
719
|
+
return null;
|
|
720
|
+
const rel = (p) => relative(projectRoot, p) || p;
|
|
721
|
+
return (`⚠ gateless root: no objective gates detected at ${projectRoot}, but ` +
|
|
722
|
+
`${gated.length === 1 ? 'a subdirectory has them' : `${gated.length} subdirectories have them`}: ` +
|
|
723
|
+
`${gated.map(rel).join(', ')}. Rooted here this mission has NO compile/test gate — the LLM judge is ` +
|
|
724
|
+
`the only convergence target, and rollback is sized against this whole tree. If the work is in ` +
|
|
725
|
+
`${rel(gated[0])}, re-run with \`--project-root ${gated[0]}\` to converge on its real gates.`);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Subdirectories that DO carry a build manifest, when the root itself carries none.
|
|
729
|
+
*
|
|
730
|
+
* A deliver rooted above the actual project gets no objective gates at all — the
|
|
731
|
+
* LLM judge becomes the only convergence target, so the run cannot tell whether
|
|
732
|
+
* the code it is fixing even compiles. Live on 2026-08-08: a mission to repair a
|
|
733
|
+
* Rust crate ran rooted at the crate's grandparent, which has no manifest. It got
|
|
734
|
+
* `bootstrap` (trivially passes) plus a skipped user-validation gate, reported
|
|
735
|
+
* "100% of gates" for five consecutive turns, and left the crate not compiling.
|
|
736
|
+
* The same misconfiguration also disables rollback, because the size guard walks
|
|
737
|
+
* the whole (here 22 GB) tree instead of the 117 MB crate.
|
|
738
|
+
*
|
|
739
|
+
* Breadth-first and hard-bounded: this runs on the startup path of every gateless
|
|
740
|
+
* deliver, including ones rooted at a huge tree — the cost of the advice must not
|
|
741
|
+
* scale with the mistake.
|
|
742
|
+
*/
|
|
743
|
+
export function findGatedSubprojects(projectRoot, maxDepth = 3, maxHits = 5, maxVisits = GATED_SCAN_MAX_VISITS) {
|
|
744
|
+
const hits = [];
|
|
745
|
+
let frontier = [{ dir: resolve(projectRoot), depth: 0 }];
|
|
746
|
+
let visited = 0;
|
|
747
|
+
while (frontier.length && hits.length < maxHits && visited < maxVisits) {
|
|
748
|
+
const next = [];
|
|
749
|
+
for (const { dir, depth } of frontier) {
|
|
750
|
+
if (hits.length >= maxHits || visited >= maxVisits)
|
|
751
|
+
break;
|
|
752
|
+
visited += 1;
|
|
753
|
+
let entries;
|
|
754
|
+
try {
|
|
755
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
// Do not report the root itself — the caller already knows it is gateless.
|
|
761
|
+
// A candidate is only reported once the real detectors confirm it, so the
|
|
762
|
+
// "would have gates" claim in the advice is measured, not assumed.
|
|
763
|
+
if (depth > 0 && entries.some((e) => !e.isDirectory() && BUILD_MANIFESTS.has(e.name))) {
|
|
764
|
+
if (dirHasGates(dir)) {
|
|
765
|
+
hits.push(dir);
|
|
766
|
+
// A workspace member inside a crate belongs to that crate; descending
|
|
767
|
+
// would offer a root strictly worse than the one just reported.
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (depth >= maxDepth)
|
|
772
|
+
continue;
|
|
773
|
+
for (const e of entries) {
|
|
774
|
+
if (next.length >= maxVisits)
|
|
775
|
+
break;
|
|
776
|
+
if (!e.isDirectory() || e.name.startsWith('.') || NON_PROJECT_DIRS.has(e.name))
|
|
777
|
+
continue;
|
|
778
|
+
next.push({ dir: join(dir, e.name), depth: depth + 1 });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
frontier = next;
|
|
782
|
+
}
|
|
783
|
+
return hits;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Why the LAST refusal happened, when it was an overlapping foreign root.
|
|
787
|
+
*
|
|
788
|
+
* `acquireDeliverLock` returns a bare null, but "another run owns a DIFFERENT,
|
|
789
|
+
* overlapping root" and "another run owns THIS root" need opposite advice: the
|
|
790
|
+
* caller must send the operator to the holder's root, because following our own
|
|
791
|
+
* finds nothing in flight and loops straight back into relaunching.
|
|
792
|
+
*/
|
|
793
|
+
let lastOverlapBlock = null;
|
|
794
|
+
/** The overlapping holder that caused the last refusal, if that was the cause. */
|
|
795
|
+
export function lastOverlapBlocker() {
|
|
796
|
+
return lastOverlapBlock;
|
|
797
|
+
}
|
|
431
798
|
/**
|
|
432
799
|
* Project-level deliver concurrency lock. Prevents a fan-out where an impatient
|
|
433
800
|
* caller (e.g. a model that launched `uap deliver` for some work, didn't wait
|
|
@@ -436,6 +803,15 @@ export function isDeliverLockAbandoned(projectRoot, nowS = Math.floor(Date.now()
|
|
|
436
803
|
* burning tokens/GPU in parallel. Returns a release() thunk, or null when
|
|
437
804
|
* another live deliver already holds the lock (the caller then exits cleanly).
|
|
438
805
|
* Stale locks (dead PID) are reclaimed. Off-switch: UAP_DELIVER_NO_LOCK=1.
|
|
806
|
+
*
|
|
807
|
+
* The lock is keyed on `<projectRoot>/.uap/`, so it only ever saw runs that
|
|
808
|
+
* named the SAME root. Two runs on NESTED roots therefore each took their own
|
|
809
|
+
* lock and edited one shared source tree: live on 2026-08-08, roots
|
|
810
|
+
* `cognition-engine` and `cognition-engine/src/rust-pg-ext` ran for ~2h,
|
|
811
|
+
* overwriting each other's edits to src/cooccurrence.rs until it stopped
|
|
812
|
+
* compiling — each one's gate failing on damage the other had just done. The
|
|
813
|
+
* cross-project registry below closes that, because overlap is a property of
|
|
814
|
+
* the SUBTREE, not of the lock path.
|
|
439
815
|
*/
|
|
440
816
|
export function acquireDeliverLock(projectRoot) {
|
|
441
817
|
if (process.env.UAP_DELIVER_NO_LOCK === '1')
|
|
@@ -479,8 +855,38 @@ export function acquireDeliverLock(projectRoot) {
|
|
|
479
855
|
catch {
|
|
480
856
|
return null;
|
|
481
857
|
}
|
|
482
|
-
|
|
858
|
+
const startedIso = new Date().toISOString();
|
|
859
|
+
writeSync(fd, `${process.pid}|${startedIso}`);
|
|
483
860
|
closeSync(fd);
|
|
861
|
+
// Publish to the cross-project registry BEFORE checking it, so a run that
|
|
862
|
+
// starts while we are scanning still sees us and one of the two yields.
|
|
863
|
+
let published = false;
|
|
864
|
+
try {
|
|
865
|
+
publishActiveRun(projectRoot, startedIso);
|
|
866
|
+
published = true;
|
|
867
|
+
}
|
|
868
|
+
catch { /* registry unavailable — fall back to the per-project lock alone */ }
|
|
869
|
+
// Compare against the stamp we PUBLISHED, not a fresh Date.now(). The other
|
|
870
|
+
// run can only see the published one, and a tiebreak both sides must agree
|
|
871
|
+
// on has to be computed from values both sides can read — otherwise a
|
|
872
|
+
// millisecond crossing between publish and scan makes both runs yield.
|
|
873
|
+
const overlapping = published
|
|
874
|
+
? findOverlappingDeliverRun(projectRoot, process.pid, Date.parse(startedIso))
|
|
875
|
+
: null;
|
|
876
|
+
if (overlapping) {
|
|
877
|
+
// Someone older owns this subtree. Give back everything we just took.
|
|
878
|
+
try {
|
|
879
|
+
rmSync(activeRunEntry(projectRoot));
|
|
880
|
+
}
|
|
881
|
+
catch { /* best-effort */ }
|
|
882
|
+
try {
|
|
883
|
+
rmSync(lockPath);
|
|
884
|
+
}
|
|
885
|
+
catch { /* best-effort */ }
|
|
886
|
+
lastOverlapBlock = overlapping;
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
lastOverlapBlock = null;
|
|
484
890
|
// Stamp a fresh heartbeat immediately so this brand-new holder is never
|
|
485
891
|
// classified as wedged in the window before its first iteration (and so a
|
|
486
892
|
// reclaim overwrites the previous holder's stale heartbeat).
|
|
@@ -503,6 +909,15 @@ export function acquireDeliverLock(projectRoot) {
|
|
|
503
909
|
}
|
|
504
910
|
}
|
|
505
911
|
catch { /* best-effort */ }
|
|
912
|
+
try {
|
|
913
|
+
const entry = activeRunEntry(projectRoot);
|
|
914
|
+
if (existsSync(entry)) {
|
|
915
|
+
const held = Number((readFileSync(entry, 'utf8').split('|')[0] || '').trim());
|
|
916
|
+
if (held === process.pid)
|
|
917
|
+
rmSync(entry);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch { /* best-effort */ }
|
|
506
921
|
};
|
|
507
922
|
process.once('exit', release);
|
|
508
923
|
return release;
|
|
@@ -588,6 +1003,18 @@ export async function deliverCommand(instruction, options) {
|
|
|
588
1003
|
return;
|
|
589
1004
|
}
|
|
590
1005
|
const projectRootForDetach = resolve(options.projectRoot ?? process.cwd());
|
|
1006
|
+
// Gateless-root refusal, deliberately BEFORE the detach decision — and so
|
|
1007
|
+
// before preflight, the lock and the cross-project registry.
|
|
1008
|
+
//
|
|
1009
|
+
// It is a pure, bounded filesystem inspection of this invocation's own
|
|
1010
|
+
// arguments, so it depends on nothing those steps establish. Running it later
|
|
1011
|
+
// meant a refusal still spawned a detached child, printed "mission detached",
|
|
1012
|
+
// and created .uap/ state inside the directory it was about to declare the
|
|
1013
|
+
// wrong root — and, worse, when the CORRECT run was already live in the
|
|
1014
|
+
// subdirectory the overlap check fired first, so the operator got the generic
|
|
1015
|
+
// "another run owns an overlapping root" instead of the specific diagnosis.
|
|
1016
|
+
if (!refuseGatelessRoot(projectRootForDetach, options))
|
|
1017
|
+
return;
|
|
591
1018
|
const decision = shouldDetach({
|
|
592
1019
|
alreadyDetached: isDetachedChild(),
|
|
593
1020
|
noDetach: process.env[NO_DETACH_ENV] === '1',
|
|
@@ -686,13 +1113,33 @@ async function runDeliver(instruction, options) {
|
|
|
686
1113
|
// acts on. A lock file containing newlines and prose would otherwise become
|
|
687
1114
|
// tool-result guidance. It is always a pid or it is not forwarded.
|
|
688
1115
|
const holderRaw = holder.trim();
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1116
|
+
let holderPid = /^\d{1,10}$/.test(holderRaw) ? holderRaw : '';
|
|
1117
|
+
// Was this refused by a run on a DIFFERENT, overlapping root? Then our own
|
|
1118
|
+
// lock was never left behind for us to read, and — more importantly —
|
|
1119
|
+
// `--await-run` on OUR root finds nothing in flight and answers "safe to
|
|
1120
|
+
// launch", which sends the caller straight back here. Name the holder's
|
|
1121
|
+
// root so the follow lands where the mission actually is.
|
|
1122
|
+
const overlap = lastOverlapBlocker();
|
|
1123
|
+
// The root comes from a file any local process can write and ends up in
|
|
1124
|
+
// model-facing instruction text; forward it only if it still looks like a
|
|
1125
|
+
// path, control characters stripped.
|
|
1126
|
+
const overlapRoot = overlap ? stripControl(overlap.root).trim() : '';
|
|
1127
|
+
const foreign = overlap && overlapRoot.startsWith('/') && !overlapRoot.includes('\n')
|
|
1128
|
+
? { pid: String(overlap.pid), root: overlapRoot }
|
|
1129
|
+
: null;
|
|
1130
|
+
if (foreign)
|
|
1131
|
+
holderPid = foreign.pid;
|
|
1132
|
+
console.log(chalk.yellow(foreign
|
|
1133
|
+
? `↩ another deliver run (pid ${foreign.pid}) already owns an OVERLAPPING project root: ` +
|
|
1134
|
+
`${foreign.root} — it edits the same files, so this launch was skipped. Follow THAT run: ` +
|
|
1135
|
+
`\`uap deliver --project-root ${foreign.root} --await-run\` (following this root would report ` +
|
|
1136
|
+
`nothing in flight). Do NOT use --resume on a live run. (override: UAP_DELIVER_NO_LOCK=1)`
|
|
1137
|
+
: `↩ deliver already running for this project${holderPid ? ` (pid ${holderPid})` : ''} — ` +
|
|
1138
|
+
`skipping this duplicate launch. Follow it with \`uap deliver --await-run\` (waits and reports; ` +
|
|
1139
|
+
`from a tool call it returns within about a minute — "still running" is an answer, not a failure, ` +
|
|
1140
|
+
`so just call it again. It starts nothing.) ` +
|
|
1141
|
+
`Do NOT use --resume on a live run: resume CONTINUES a mission and would start a ` +
|
|
1142
|
+
`second copy of this one. (override: UAP_DELIVER_NO_LOCK=1)`));
|
|
696
1143
|
// --json is a CONTRACT: every exit must emit a parseable result. This path
|
|
697
1144
|
// used to print the yellow line above and return, leaving a caller that ran
|
|
698
1145
|
// `deliver --json` with a stdout containing no JSON at all.
|
|
@@ -712,13 +1159,23 @@ async function runDeliver(instruction, options) {
|
|
|
712
1159
|
alreadyRunning: true,
|
|
713
1160
|
...(holderPid ? { holderPid } : {}),
|
|
714
1161
|
projectRoot,
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
'
|
|
720
|
-
|
|
721
|
-
'
|
|
1162
|
+
...(foreign ? { overlappingRoot: foreign.root } : {}),
|
|
1163
|
+
reason: foreign
|
|
1164
|
+
? `An older deliver run (pid ${foreign.pid}) holds an OVERLAPPING project root ` +
|
|
1165
|
+
`(${foreign.root}); it edits the same files, so this launch was skipped.`
|
|
1166
|
+
: 'A deliver run is already in progress for this project; this duplicate launch was skipped.',
|
|
1167
|
+
nextStep: foreign
|
|
1168
|
+
? 'The files you asked for are ALREADY being worked on by a run rooted at ' +
|
|
1169
|
+
`${foreign.root}. Follow THAT run: \`uap deliver --project-root ${foreign.root} --await-run\`. ` +
|
|
1170
|
+
'Do NOT follow this root — it has nothing in flight and will tell you it is safe to launch, ' +
|
|
1171
|
+
'which puts you back here. Do NOT start another run, do NOT pass resume, and do NOT change ' +
|
|
1172
|
+
'any gate or enforcement setting.'
|
|
1173
|
+
: 'The mission you asked for is ALREADY RUNNING — nothing is wrong and nothing is needed from you. ' +
|
|
1174
|
+
'Follow it instead: call deliver again with follow:true (or `uap deliver --await-run` from a ' +
|
|
1175
|
+
'shell). It returns within about a minute; if it says STILL RUNNING that is not a failure — ' +
|
|
1176
|
+
'call it again to keep waiting. Do NOT start another ' +
|
|
1177
|
+
'run, do NOT pass resume (resume CONTINUES a run rather than following it, and would start a ' +
|
|
1178
|
+
'second copy of this live one), and do NOT change any gate or enforcement setting.',
|
|
722
1179
|
}, null, 2));
|
|
723
1180
|
}
|
|
724
1181
|
return;
|
|
@@ -1022,6 +1479,16 @@ async function runDeliver(instruction, options) {
|
|
|
1022
1479
|
});
|
|
1023
1480
|
console.log(chalk.cyan('⚖ acceptance: LLM judge is the convergence target (no objective project gates; self-gate skipped)'));
|
|
1024
1481
|
}
|
|
1482
|
+
// A gateless root is sometimes the truth (a docs repo, a fresh dir) and
|
|
1483
|
+
// sometimes a misrouted --project-root pointing one level above the actual
|
|
1484
|
+
// project. Those two are indistinguishable in the logs, and the second is
|
|
1485
|
+
// expensive: the run cannot compile or test the thing it was asked to fix.
|
|
1486
|
+
//
|
|
1487
|
+
// Deliberately OUTSIDE the acceptancePrimary branch. Both gateless strategies
|
|
1488
|
+
// reach here — acceptance-as-primary and self-gate — and the misrouted root
|
|
1489
|
+
// hurts them identically. Scoping the advice to one of them would have left
|
|
1490
|
+
// the DEFAULT path (no --acceptance) silent, which is the more common way to
|
|
1491
|
+
// hit this.
|
|
1025
1492
|
const cfgRawEarlyForUv = cfgRawEarlyForUvFactory(projectRoot);
|
|
1026
1493
|
// Baseline-delta gating: a required rung that is ALREADY red cannot be a
|
|
1027
1494
|
// regression this mission causes, but it makes acceptance unreachable and
|
|
@@ -1052,6 +1519,16 @@ async function runDeliver(instruction, options) {
|
|
|
1052
1519
|
else {
|
|
1053
1520
|
console.log(chalk.dim(` baseline-delta: baseline green (preflight ${(bd.preflightMs / 1000).toFixed(0)}s)`));
|
|
1054
1521
|
}
|
|
1522
|
+
// KNOWN LIMIT on --resume: this re-preflights a tree the mission has
|
|
1523
|
+
// already changed, so a regression introduced before the interruption is
|
|
1524
|
+
// adopted as the new "pre-existing" state and forgiven. Skipping the
|
|
1525
|
+
// preflight instead is WORSE: rungs are not persisted in run state, so a
|
|
1526
|
+
// resumed run re-detects them as REQUIRED, the pre-existing red suite
|
|
1527
|
+
// fail-fasts, and the run cannot converge at all — the wedge demotion
|
|
1528
|
+
// exists to remove. The real fix is to persist the demotion in run state.
|
|
1529
|
+
if (options.resume && bd.demoted.length > 0) {
|
|
1530
|
+
console.log(chalk.dim(' (resumed run: baseline re-measured against the current tree)'));
|
|
1531
|
+
}
|
|
1055
1532
|
}
|
|
1056
1533
|
// User-path validation: the terminal 'final'-tier rung. The delivered
|
|
1057
1534
|
// artifact must pass its critical user journeys through the REAL client
|
|
@@ -2688,11 +3165,21 @@ async function runDeliver(instruction, options) {
|
|
|
2688
3165
|
// --keep-best: if deliver left the project worse than it started (by real
|
|
2689
3166
|
// gate score), roll back to the snapshot so the run is never a regression.
|
|
2690
3167
|
if (keepBest && regressSnapshot) {
|
|
2691
|
-
|
|
2692
|
-
|
|
3168
|
+
// Score alone cannot see a name-level regression: a demoted rung is red
|
|
3169
|
+
// both before and after, so `score` is byte-identical while the suite got
|
|
3170
|
+
// worse. That matters here specifically, because this rollback is what the
|
|
3171
|
+
// demotion docstring names as the whole-tree backstop for exactly the
|
|
3172
|
+
// breakage rung-granularity cannot catch.
|
|
3173
|
+
const endLadder = runLadder(fastRungs, projectRoot);
|
|
3174
|
+
const endScore = endLadder.score;
|
|
3175
|
+
const endRegressions = baselineRegressions(fastRungs, endLadder.results);
|
|
3176
|
+
if (endScore < baselineGateScore || endRegressions.length > 0) {
|
|
2693
3177
|
try {
|
|
2694
3178
|
restoreTree(projectRoot, regressSnapshot);
|
|
2695
|
-
console.log(chalk.yellow(
|
|
3179
|
+
console.log(chalk.yellow(endRegressions.length > 0
|
|
3180
|
+
? ` ↩ no-regress: reverted to best — tests that passed at baseline are failing now: ` +
|
|
3181
|
+
`${endRegressions.flatMap((r) => r.tests).slice(0, 5).join(', ')}`
|
|
3182
|
+
: ` ↩ no-regress: reverted to best (end gate score ${endScore.toFixed(2)} < best ${baselineGateScore.toFixed(2)})`));
|
|
2696
3183
|
disposeSnapshot(regressSnapshot);
|
|
2697
3184
|
}
|
|
2698
3185
|
catch (restoreErr) {
|