@mjasnikovs/pi-task 0.19.0 → 0.20.1
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/remote/register.js +5 -2
- package/dist/task/auto-orchestrator.js +13 -2
- package/dist/task/final-gate.d.ts +29 -0
- package/dist/task/final-gate.js +86 -10
- package/dist/task/phases.js +42 -3
- package/dist/task/repo-health-check.js +11 -1
- package/dist/task/requirements.d.ts +56 -6
- package/dist/task/requirements.js +188 -7
- package/dist/task/runner-resolve.d.ts +28 -0
- package/dist/task/runner-resolve.js +95 -0
- package/package.json +1 -1
package/dist/remote/register.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
|
-
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent } from './bridge.js';
|
|
2
|
+
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand } from './bridge.js';
|
|
3
3
|
import { setupEvents } from './events.js';
|
|
4
4
|
import { reset, addUserTurn } from './session-state.js';
|
|
5
5
|
import { html } from './ui.js';
|
|
@@ -84,7 +84,10 @@ export function registerRemote(pi) {
|
|
|
84
84
|
S.send = null;
|
|
85
85
|
}
|
|
86
86
|
});
|
|
87
|
-
pi.registerCommand
|
|
87
|
+
// Bridge-registered (not pi.registerCommand) so `/remote stop` also works when
|
|
88
|
+
// typed in the browser — the web UI advertises it, and while a /task-auto run
|
|
89
|
+
// holds the host command loop the browser is the only live input surface.
|
|
90
|
+
registerBridgeCommand(pi, 'remote', {
|
|
88
91
|
description: 'Show the remote QR code & URLs.',
|
|
89
92
|
handler: async (args, ctx) => {
|
|
90
93
|
if (!getConfig().remote) {
|
|
@@ -41,7 +41,7 @@ import { configureResearchRun, resumeResearchRun } from '../workers/research-cac
|
|
|
41
41
|
import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
|
|
42
42
|
import { reconcileTitleSources } from './decompose-fidelity.js';
|
|
43
43
|
import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
|
|
44
|
-
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
44
|
+
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
45
45
|
import { decideAdoption, groundedCoverage } from './coverage-loop.js';
|
|
46
46
|
import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
|
|
47
47
|
import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
|
|
@@ -567,7 +567,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
567
567
|
}
|
|
568
568
|
// Bound with marked-passage priority — a plain first-N cap truncates the
|
|
569
569
|
// doc's tail sections (measured live: an eager model fills 40 top-down).
|
|
570
|
-
reqEntries = capRequirements(reqEntries, passages);
|
|
570
|
+
reqEntries = capRequirements(reqEntries, passages, featureForModel);
|
|
571
571
|
logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
|
|
572
572
|
}
|
|
573
573
|
catch {
|
|
@@ -903,6 +903,17 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
903
903
|
announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
|
|
904
904
|
return null;
|
|
905
905
|
}
|
|
906
|
+
// Persist the TASK-MAPPED requirements keyed by the (spec-ref-attached) title
|
|
907
|
+
// each task will carry (mx5 run 16: only cross-cutting entries travelled;
|
|
908
|
+
// the 33 mapped ones shaped the title list and vanished — TASK_0008 narrowed
|
|
909
|
+
// §9's "serves `/api` + static `dist/`" out of its spec with nothing to stop
|
|
910
|
+
// it). Inert until the owned-requirements injection is wired into the phase
|
|
911
|
+
// prompts; recorded regardless so the plan's mapping is auditable per run.
|
|
912
|
+
if (accounting && accounting.mapped.length > 0) {
|
|
913
|
+
await writeOwnedRequirements(cwd, accounting.mapped
|
|
914
|
+
.filter(m => m.task >= 1 && m.task <= titles.length)
|
|
915
|
+
.map(m => ({ quote: m.req.quote, anchor: m.req.anchor, title: titles[m.task - 1] })));
|
|
916
|
+
}
|
|
906
917
|
// persist
|
|
907
918
|
const id = await allocateAutoId(cwd);
|
|
908
919
|
const now = new Date().toISOString();
|
|
@@ -62,6 +62,9 @@ type BootOutcome = {
|
|
|
62
62
|
/** Set when the render check could not OBSERVE the served page (no browser,
|
|
63
63
|
* undeterminable port) — surfaced by the gate as an UNOBSERVED warning. */
|
|
64
64
|
renderNote?: string;
|
|
65
|
+
/** skip only: the boot command never spawned (ENOENT) — feeds the
|
|
66
|
+
* full-blindness guard (mx5 run 16), unlike a 127 where the runner ran. */
|
|
67
|
+
spawnFailed?: boolean;
|
|
65
68
|
} | {
|
|
66
69
|
outcome: 'fail';
|
|
67
70
|
detail: string;
|
|
@@ -218,6 +221,32 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
218
221
|
* this box; the same wording in a `test` run is a real failure the suite must own).
|
|
219
222
|
*/
|
|
220
223
|
export declare const INFRA_GAP_OUTPUT_RE: RegExp;
|
|
224
|
+
/**
|
|
225
|
+
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
226
|
+
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
227
|
+
* decided on statics alone and stamped a permanently blank app green. Per-command
|
|
228
|
+
* env-gap skips stay legitimate (a missing browser must not fail a suite); what
|
|
229
|
+
* may never happen again is ALL of them skipping while the gate still reports
|
|
230
|
+
* PASS — a gate that observed nothing dynamic has no basis to vouch for the
|
|
231
|
+
* assembled app. Pure so the semantics are unit-tested; the caller feeds it the
|
|
232
|
+
* attempt/observation counters and runner resolvability.
|
|
233
|
+
*/
|
|
234
|
+
export declare function observabilityGapFailure(args: {
|
|
235
|
+
/** Dynamic commands the gate discovered and tried to run. */
|
|
236
|
+
attempted: number;
|
|
237
|
+
/** Of those, how many it actually OBSERVED (a real pass OR a real fail —
|
|
238
|
+
* either proves the command ran; only skips observe nothing). */
|
|
239
|
+
observed: number;
|
|
240
|
+
/** Of the skips, how many were SPAWN failures (runner never ran, ENOENT).
|
|
241
|
+
* Tool-level gaps (missing browser, 127 inside the chain, timeout) prove
|
|
242
|
+
* the runner itself works and keep the classic env-gap contract — the
|
|
243
|
+
* blindness class fires only when EVERY attempt failed to even spawn. */
|
|
244
|
+
spawnFailures: number;
|
|
245
|
+
/** Distinct runner bins across the attempted commands. */
|
|
246
|
+
runnerBins: string[];
|
|
247
|
+
/** Is this runner spawnable (bare or via a known install location)? */
|
|
248
|
+
runnerResolvable: (bin: string) => boolean;
|
|
249
|
+
}): string | null;
|
|
221
250
|
export { taskThatIntroduced };
|
|
222
251
|
/**
|
|
223
252
|
* Run the final gate: static analysis first, then the lockfile consistency
|
package/dist/task/final-gate.js
CHANGED
|
@@ -47,6 +47,7 @@ import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtN
|
|
|
47
47
|
import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
|
|
48
48
|
import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
49
49
|
import { runRenderCheck } from './render-check.js';
|
|
50
|
+
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
50
51
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
51
52
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
52
53
|
function packageScripts(cwd) {
|
|
@@ -515,13 +516,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
515
516
|
// Only served apps get an assigned port: a CLI project has nothing to bind, and
|
|
516
517
|
// an unexpected PORT in its env is noise.
|
|
517
518
|
const assignedPort = expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null;
|
|
519
|
+
// Runner resolution (mx5 run 16): same contract as runGateCommand — resolve
|
|
520
|
+
// the runner and carry its directory on PATH so the boot script's own chain
|
|
521
|
+
// can re-invoke it.
|
|
522
|
+
const runner = resolveRunner(bin);
|
|
518
523
|
return new Promise(resolve => {
|
|
519
|
-
const child = spawn(bin, args, {
|
|
524
|
+
const child = spawn(runner.bin, args, {
|
|
520
525
|
cwd,
|
|
521
526
|
detached: true,
|
|
522
527
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
523
528
|
env: {
|
|
524
|
-
...
|
|
529
|
+
...runnerEnv(runner),
|
|
525
530
|
...(assignedPort !== null ? { PORT: String(assignedPort) } : {})
|
|
526
531
|
}
|
|
527
532
|
});
|
|
@@ -628,7 +633,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
628
633
|
}
|
|
629
634
|
passAndKill();
|
|
630
635
|
}, graceMs);
|
|
631
|
-
child.on('error', () => settle({ outcome: 'skip' }));
|
|
636
|
+
child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
|
|
632
637
|
child.on('exit', (status, signal) => {
|
|
633
638
|
if (status === 0) {
|
|
634
639
|
if (expectServer && !listenerSeen) {
|
|
@@ -713,26 +718,59 @@ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EA
|
|
|
713
718
|
* command that actually ran and exited non-zero for a real reason fails.
|
|
714
719
|
*/
|
|
715
720
|
function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
|
|
721
|
+
// Runner resolution (mx5 run 16): a login-shell-stripped PATH left `bun`
|
|
722
|
+
// unspawnable, so every dynamic check skipped and the gate went blind. The
|
|
723
|
+
// resolved binary is spawned, and its directory rides on the child's PATH so
|
|
724
|
+
// the SCRIPT CHAIN can re-invoke the runner (`bun run test` runs `bun test`
|
|
725
|
+
// inside — a bare 127 there is the same blindness one level down).
|
|
716
726
|
// env passed explicitly: bun's spawnSync resolves the binary against a
|
|
717
727
|
// startup snapshot of the environment, not the live process.env.
|
|
718
|
-
const
|
|
728
|
+
const runner = resolveRunner(bin);
|
|
729
|
+
const r = spawnSync(runner.bin, args, {
|
|
719
730
|
cwd,
|
|
720
731
|
encoding: 'utf8',
|
|
721
732
|
timeout: timeoutMs,
|
|
722
|
-
env:
|
|
733
|
+
env: runnerEnv(runner)
|
|
723
734
|
});
|
|
724
|
-
if (r.error
|
|
725
|
-
return { outcome: 'skip' };
|
|
735
|
+
if (r.error)
|
|
736
|
+
return { outcome: 'skip', spawnFailed: true };
|
|
737
|
+
if (r.status === null || r.status === 127)
|
|
738
|
+
return { outcome: 'skip', spawnFailed: false };
|
|
726
739
|
if (r.status !== 0) {
|
|
727
740
|
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
728
741
|
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
729
|
-
return { outcome: 'skip' };
|
|
742
|
+
return { outcome: 'skip', spawnFailed: false };
|
|
730
743
|
if (extraGapRe?.test(output))
|
|
731
|
-
return { outcome: 'skip' };
|
|
744
|
+
return { outcome: 'skip', spawnFailed: false };
|
|
732
745
|
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
733
746
|
}
|
|
734
747
|
return { outcome: 'pass' };
|
|
735
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
751
|
+
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
752
|
+
* decided on statics alone and stamped a permanently blank app green. Per-command
|
|
753
|
+
* env-gap skips stay legitimate (a missing browser must not fail a suite); what
|
|
754
|
+
* may never happen again is ALL of them skipping while the gate still reports
|
|
755
|
+
* PASS — a gate that observed nothing dynamic has no basis to vouch for the
|
|
756
|
+
* assembled app. Pure so the semantics are unit-tested; the caller feeds it the
|
|
757
|
+
* attempt/observation counters and runner resolvability.
|
|
758
|
+
*/
|
|
759
|
+
export function observabilityGapFailure(args) {
|
|
760
|
+
if (args.attempted === 0 || args.observed > 0)
|
|
761
|
+
return null;
|
|
762
|
+
if (args.spawnFailures < args.attempted)
|
|
763
|
+
return null;
|
|
764
|
+
const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
|
|
765
|
+
const runnerNote = unresolvable.length > 0 ?
|
|
766
|
+
` — the project's own runner ${unresolvable
|
|
767
|
+
.map(b => `\`${b}\``)
|
|
768
|
+
.join(', ')} is not spawnable here (not on PATH nor any known install location)`
|
|
769
|
+
: '';
|
|
770
|
+
return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
|
|
771
|
+
+ `could even spawn in this environment${runnerNote}; `
|
|
772
|
+
+ `the gate observed nothing dynamic and cannot vouch for the assembled app`);
|
|
773
|
+
}
|
|
736
774
|
/**
|
|
737
775
|
* Boot check hit an address-in-use bind failure. If the port is held by one of OUR
|
|
738
776
|
* own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
|
|
@@ -833,15 +871,29 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
833
871
|
return withDebts({ ok: true, reason: 'no integration command found (statics passed)' });
|
|
834
872
|
}
|
|
835
873
|
const ran = [];
|
|
874
|
+
// Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
|
|
875
|
+
// attempt; a real pass OR a real fail counts an observation; skips observe
|
|
876
|
+
// nothing. If everything discovered ends up skipped, observabilityGapFailure
|
|
877
|
+
// turns the silence into a rank-0 failure instead of a static-only PASS.
|
|
878
|
+
let dynAttempted = 0;
|
|
879
|
+
let dynObserved = 0;
|
|
880
|
+
let dynSpawnFailures = 0;
|
|
881
|
+
const dynBins = new Set();
|
|
836
882
|
for (const { prefix, list } of [
|
|
837
883
|
{ prefix: 'lockfile check: ', list: lockCmds },
|
|
838
884
|
{ prefix: '', list: cmds }
|
|
839
885
|
]) {
|
|
840
886
|
for (const cmd of list) {
|
|
841
887
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
888
|
+
dynAttempted += 1;
|
|
889
|
+
dynBins.add(cmd[0]);
|
|
842
890
|
const r = runGateCommand(cwd, cmd, timeoutMs);
|
|
843
|
-
if (r.outcome === 'skip')
|
|
891
|
+
if (r.outcome === 'skip') {
|
|
892
|
+
if (r.spawnFailed)
|
|
893
|
+
dynSpawnFailures += 1;
|
|
844
894
|
continue;
|
|
895
|
+
}
|
|
896
|
+
dynObserved += 1;
|
|
845
897
|
if (r.outcome === 'fail') {
|
|
846
898
|
fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
847
899
|
continue;
|
|
@@ -872,11 +924,16 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
872
924
|
continue;
|
|
873
925
|
const cmd = ['bun', ['run', name]];
|
|
874
926
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
927
|
+
dynAttempted += 1;
|
|
928
|
+
dynBins.add(cmd[0]);
|
|
875
929
|
const r = runGateCommand(cwd, cmd, Math.min(timeoutMs, 180_000), INFRA_GAP_OUTPUT_RE);
|
|
876
930
|
if (r.outcome === 'skip') {
|
|
931
|
+
if (r.spawnFailed)
|
|
932
|
+
dynSpawnFailures += 1;
|
|
877
933
|
skippedLaunch.push(name);
|
|
878
934
|
continue;
|
|
879
935
|
}
|
|
936
|
+
dynObserved += 1;
|
|
880
937
|
if (r.outcome === 'fail') {
|
|
881
938
|
fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
882
939
|
continue;
|
|
@@ -901,6 +958,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
901
958
|
// failures no longer shadow it. Its failures rank FIRST in the aggregate.
|
|
902
959
|
if (boot) {
|
|
903
960
|
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
961
|
+
dynAttempted += 1;
|
|
962
|
+
dynBins.add(boot[0]);
|
|
904
963
|
const expectServer = detectsServedApp(cwd, planText);
|
|
905
964
|
// Render check (mx5 runs 8/11): for a served app, load the live page in a
|
|
906
965
|
// headless browser and judge the RENDERED DOM — curl can't run JS, so a
|
|
@@ -918,6 +977,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
918
977
|
if (b.outcome === 'orphan-port') {
|
|
919
978
|
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
|
|
920
979
|
}
|
|
980
|
+
if (b.outcome !== 'skip')
|
|
981
|
+
dynObserved += 1;
|
|
982
|
+
else if (b.spawnFailed)
|
|
983
|
+
dynSpawnFailures += 1;
|
|
921
984
|
if (b.outcome === 'fail') {
|
|
922
985
|
fail(`boot check: \`${label}\` ${b.detail}`, 0);
|
|
923
986
|
}
|
|
@@ -938,6 +1001,19 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
938
1001
|
warnings.push(b.renderNote);
|
|
939
1002
|
}
|
|
940
1003
|
}
|
|
1004
|
+
// Full-skip blindness guard (mx5 run 16): commands were discovered but every
|
|
1005
|
+
// one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
|
|
1006
|
+
// is checked through resolveRunner so the failure text can name the missing
|
|
1007
|
+
// runner when that is the cause (the run-16 shape: login-shell PATH lost bun).
|
|
1008
|
+
const gap = observabilityGapFailure({
|
|
1009
|
+
attempted: dynAttempted,
|
|
1010
|
+
observed: dynObserved,
|
|
1011
|
+
spawnFailures: dynSpawnFailures,
|
|
1012
|
+
runnerBins: [...dynBins],
|
|
1013
|
+
runnerResolvable: b => resolveRunner(b).ok
|
|
1014
|
+
});
|
|
1015
|
+
if (gap)
|
|
1016
|
+
fail(gap, 0);
|
|
941
1017
|
// Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
|
|
942
1018
|
// reference with NO producer anywhere ships silently — the server read
|
|
943
1019
|
// `Bun.file('dist/index.html')` while the build emitted only app.css +
|
package/dist/task/phases.js
CHANGED
|
@@ -37,7 +37,7 @@ import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js
|
|
|
37
37
|
import { findGrepOnlyVerify, grepOnlyVerifyDefectText, GREP_THEATER_RETRY_HINT } from './verify-quality.js';
|
|
38
38
|
import { existsSync } from 'node:fs';
|
|
39
39
|
import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
|
|
40
|
-
import { readRequirements, buildRequirementsBlock } from './requirements.js';
|
|
40
|
+
import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
|
|
41
41
|
import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
42
42
|
import { SessionUI } from '../remote/bridge.js';
|
|
43
43
|
import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
|
|
@@ -152,7 +152,26 @@ export async function phaseContractsBlock(deps) {
|
|
|
152
152
|
export async function phaseCarriedBlocks(deps) {
|
|
153
153
|
const contracts = await phaseContractsBlock(deps);
|
|
154
154
|
const requirements = buildRequirementsBlock(await readRequirements(deps.cwd).catch(() => ''));
|
|
155
|
-
|
|
155
|
+
const owned = buildOwnedRequirementsBlock(await ownedForThisTask(deps));
|
|
156
|
+
return [contracts, requirements, owned].filter(b => b.length > 0).join('\n');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The owned (task-mapped) requirements for THIS task (mx5 run 16): matched by
|
|
160
|
+
* the plan title the coverage map keyed them to, which is the task's stored
|
|
161
|
+
* `raw prompt` section verbatim. Empty outside /task-auto runs, for spliced
|
|
162
|
+
* repair tasks, and when the plan recorded no mapping — all of which degrade to
|
|
163
|
+
* the pre-run-16 behavior. This is the BELT (prompt block, into refine +
|
|
164
|
+
* compose); appendOwnedConstraints on the final spec is the BRACES — the belt
|
|
165
|
+
* alone folded the clause in only 2/8 live reps per fixture.
|
|
166
|
+
*/
|
|
167
|
+
async function ownedForThisTask(deps) {
|
|
168
|
+
try {
|
|
169
|
+
const title = (await readSection(deps.cwd, deps.taskId, 'raw prompt')) ?? '';
|
|
170
|
+
return ownedForTitle(await readOwnedRequirements(deps.cwd), title.trim());
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
export const phaseRefine = async (deps, raw, planContext) => {
|
|
158
177
|
const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
|
|
@@ -1302,7 +1321,27 @@ export const PHASES = [
|
|
|
1302
1321
|
field: 'spec',
|
|
1303
1322
|
run: (d, p) => phaseCompose(d, p.refined, p.research, p.qa)
|
|
1304
1323
|
},
|
|
1305
|
-
{
|
|
1324
|
+
{
|
|
1325
|
+
name: 'critique',
|
|
1326
|
+
section: 'spec',
|
|
1327
|
+
field: 'spec',
|
|
1328
|
+
run: async (d, p) => {
|
|
1329
|
+
const spec = await critiqueWithFallback(d, p);
|
|
1330
|
+
// BRACES (mx5 run 16): after the LAST spec-producing step, append any
|
|
1331
|
+
// owned design obligation the spec still omits as a CONSTRAINTS
|
|
1332
|
+
// bullet. The belt block upstream is obeyed ~25% (measured); a
|
|
1333
|
+
// host-side append is obeyed by construction. Idempotent: quotes the
|
|
1334
|
+
// spec already carries (belt-obeying reps) are skipped.
|
|
1335
|
+
const owned = await ownedForThisTask(d);
|
|
1336
|
+
if (owned.length === 0)
|
|
1337
|
+
return spec;
|
|
1338
|
+
const out = appendOwnedConstraints(spec, owned);
|
|
1339
|
+
if (out !== spec) {
|
|
1340
|
+
d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
|
|
1341
|
+
}
|
|
1342
|
+
return out;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1306
1345
|
];
|
|
1307
1346
|
export async function postCommitPhase(phase, deps, pc, out) {
|
|
1308
1347
|
if (phase.name !== 'refine')
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
+
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
35
36
|
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
36
37
|
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
37
38
|
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
@@ -128,7 +129,16 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
for (const [bin, args] of cmds) {
|
|
131
|
-
|
|
132
|
+
// Runner resolution (mx5 run 16): a PATH-stripped environment must not
|
|
133
|
+
// silently skip the statics when the runner sits at a known install
|
|
134
|
+
// location; the resolved dir also rides on PATH for the script chain.
|
|
135
|
+
const runner = resolveRunner(bin);
|
|
136
|
+
const r = spawnSync(runner.bin, args, {
|
|
137
|
+
cwd,
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
timeout: timeoutMs,
|
|
140
|
+
env: runnerEnv(runner)
|
|
141
|
+
});
|
|
132
142
|
// Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
|
|
133
143
|
if (r.error || r.status === null)
|
|
134
144
|
continue;
|
|
@@ -13,13 +13,25 @@ export declare function parseRequirementLines(text: string): RequirementEntry[];
|
|
|
13
13
|
* passages from doc-order truncation. */
|
|
14
14
|
export declare function keepGroundedRequirements(entries: RequirementEntry[], sourceDoc: string): RequirementEntry[];
|
|
15
15
|
/**
|
|
16
|
-
* Bound the list WITHOUT doc-order truncation
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Bound the list WITHOUT doc-order truncation. Two measured failure shapes drive
|
|
17
|
+
* the rule:
|
|
18
|
+
* - an eager model extracts 40+ items top-down (every §1 decision row), so a
|
|
19
|
+
* plain first-N cap systematically drops the TAIL sections — exactly where
|
|
20
|
+
* mx5 keeps its testing obligations;
|
|
21
|
+
* - "given order" as the tie-break re-creates the same tail bias one level up
|
|
22
|
+
* (mx5 run 16, measured live: the model emitted 185 requirements, 178
|
|
23
|
+
* grounded — INCLUDING §9's "serves `/api` + static `dist/`", the clause
|
|
24
|
+
* whose loss shipped a permanently blank app — and the cap's doc-order fill
|
|
25
|
+
* cut all 138 past the cap, every one from the design's tail).
|
|
26
|
+
* Rule (deterministic priority, not a knob): entries quoting an obligation-
|
|
27
|
+
* marked passage survive first; the remaining budget is filled ROUND-ROBIN
|
|
28
|
+
* across the source doc's sections (each section's entries in doc order), so
|
|
29
|
+
* every section keeps its head obligations and no section is wholesale dropped.
|
|
30
|
+
* Bucketing is by the quote's POSITION in the source doc — grounded substring,
|
|
31
|
+
* never the model-authored anchor text. Without `sourceDoc` (or for quotes that
|
|
32
|
+
* cannot be located) the fill degrades to the old given-order behavior.
|
|
21
33
|
*/
|
|
22
|
-
export declare function capRequirements(entries: RequirementEntry[], passages: string[]): RequirementEntry[];
|
|
34
|
+
export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string): RequirementEntry[];
|
|
23
35
|
/**
|
|
24
36
|
* DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
|
|
25
37
|
* paragraphs carrying an obligation marker (word-bounded "required"/"must").
|
|
@@ -101,6 +113,44 @@ export declare function appendCarriedRequirements(cwd: string, crossCutting: Req
|
|
|
101
113
|
* and the VERIFY mandate is explicit — goal C rides here.
|
|
102
114
|
*/
|
|
103
115
|
export declare function buildRequirementsBlock(requirements: string): string;
|
|
116
|
+
export interface OwnedRequirement {
|
|
117
|
+
/** The verbatim design quote (the obligation). */
|
|
118
|
+
quote: string;
|
|
119
|
+
anchor: string;
|
|
120
|
+
/** The plan title of the task the coverage map assigned it to — matched
|
|
121
|
+
* against the executing task's title at phase time (ids don't exist yet at
|
|
122
|
+
* plan time, and spliced repair tasks shift them). */
|
|
123
|
+
title: string;
|
|
124
|
+
}
|
|
125
|
+
export declare function ownedRequirementsFile(cwd: string): string;
|
|
126
|
+
/** Persist the task-mapped requirements (host-side, plan time). Overwrites —
|
|
127
|
+
* the mapping is recomputed whole per plan round. Best-effort like the carried
|
|
128
|
+
* artifact. */
|
|
129
|
+
export declare function writeOwnedRequirements(cwd: string, owned: OwnedRequirement[]): Promise<void>;
|
|
130
|
+
export declare function readOwnedRequirements(cwd: string): Promise<OwnedRequirement[]>;
|
|
131
|
+
export declare function parseOwnedRequirements(text: string): OwnedRequirement[];
|
|
132
|
+
/** The owned entries whose plan title matches THIS task's title (normalised
|
|
133
|
+
* equality — titles travel verbatim from the plan list into task creation;
|
|
134
|
+
* spliced repair tasks simply match nothing). */
|
|
135
|
+
export declare function ownedForTitle(owned: OwnedRequirement[], title: string): OwnedRequirement[];
|
|
136
|
+
/** The injection block for a task's OWN mapped obligations. Mirrors
|
|
137
|
+
* buildRequirementsBlock (the directive pattern that measurably works) but is
|
|
138
|
+
* singular in address: these are not "wherever they touch", they ARE this
|
|
139
|
+
* task's obligations and must survive into the spec. */
|
|
140
|
+
export declare function buildOwnedRequirementsBlock(owned: OwnedRequirement[]): string;
|
|
141
|
+
/**
|
|
142
|
+
* BRACES for the owned channel (the PROMPT-1 pattern): deterministically append
|
|
143
|
+
* each owned obligation the composed spec does not already carry as a
|
|
144
|
+
* CONSTRAINTS bullet. Measured need (scripts/live-owned-requirement-compose-ab
|
|
145
|
+
* .ts, 8 reps/arm on two real run-16 losses): with the belt block alone compose
|
|
146
|
+
* folded the clause into CONSTRAINTS/ACCEPTANCE in only 2/8 reps per fixture
|
|
147
|
+
* (baseline 0/8) — an instruction the model mostly ignores, the PROMPT-4 shape.
|
|
148
|
+
* A host-side append cannot be ignored. "Already carries" = the normalised
|
|
149
|
+
* quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
|
|
150
|
+
* No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
|
|
151
|
+
* only on specs the shape gate already accepted.
|
|
152
|
+
*/
|
|
153
|
+
export declare function appendOwnedConstraints(spec: string, owned: OwnedRequirement[]): string;
|
|
104
154
|
/** The decompose-prompt ledger block (goal E's belt): the grounded requirement
|
|
105
155
|
* list rides into decompose so structure-mirroring can't discharge it. */
|
|
106
156
|
export declare function buildRequirementsLedger(requirements: RequirementEntry[]): string;
|
|
@@ -80,13 +80,25 @@ export function keepGroundedRequirements(entries, sourceDoc) {
|
|
|
80
80
|
return kept;
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* Bound the list WITHOUT doc-order truncation
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
83
|
+
* Bound the list WITHOUT doc-order truncation. Two measured failure shapes drive
|
|
84
|
+
* the rule:
|
|
85
|
+
* - an eager model extracts 40+ items top-down (every §1 decision row), so a
|
|
86
|
+
* plain first-N cap systematically drops the TAIL sections — exactly where
|
|
87
|
+
* mx5 keeps its testing obligations;
|
|
88
|
+
* - "given order" as the tie-break re-creates the same tail bias one level up
|
|
89
|
+
* (mx5 run 16, measured live: the model emitted 185 requirements, 178
|
|
90
|
+
* grounded — INCLUDING §9's "serves `/api` + static `dist/`", the clause
|
|
91
|
+
* whose loss shipped a permanently blank app — and the cap's doc-order fill
|
|
92
|
+
* cut all 138 past the cap, every one from the design's tail).
|
|
93
|
+
* Rule (deterministic priority, not a knob): entries quoting an obligation-
|
|
94
|
+
* marked passage survive first; the remaining budget is filled ROUND-ROBIN
|
|
95
|
+
* across the source doc's sections (each section's entries in doc order), so
|
|
96
|
+
* every section keeps its head obligations and no section is wholesale dropped.
|
|
97
|
+
* Bucketing is by the quote's POSITION in the source doc — grounded substring,
|
|
98
|
+
* never the model-authored anchor text. Without `sourceDoc` (or for quotes that
|
|
99
|
+
* cannot be located) the fill degrades to the old given-order behavior.
|
|
88
100
|
*/
|
|
89
|
-
export function capRequirements(entries, passages) {
|
|
101
|
+
export function capRequirements(entries, passages, sourceDoc) {
|
|
90
102
|
if (entries.length <= MAX_REQUIREMENTS)
|
|
91
103
|
return entries;
|
|
92
104
|
const norms = passages.map(normalise);
|
|
@@ -96,7 +108,73 @@ export function capRequirements(entries, passages) {
|
|
|
96
108
|
};
|
|
97
109
|
const marked = entries.filter(covers);
|
|
98
110
|
const rest = entries.filter(e => !covers(e));
|
|
99
|
-
|
|
111
|
+
const budget = MAX_REQUIREMENTS - Math.min(marked.length, MAX_REQUIREMENTS);
|
|
112
|
+
return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(rest, budget, sourceDoc)];
|
|
113
|
+
}
|
|
114
|
+
/** The doc split into heading-delimited sections, each pre-normalised for
|
|
115
|
+
* containment tests. Text before the first heading is its own section. */
|
|
116
|
+
function normalisedSections(doc) {
|
|
117
|
+
const out = [];
|
|
118
|
+
let current = [];
|
|
119
|
+
for (const line of doc.replace(/\r\n?/g, '\n').split('\n')) {
|
|
120
|
+
if (/^#{1,6}\s+\S/.test(line)) {
|
|
121
|
+
if (current.length > 0)
|
|
122
|
+
out.push(normalise(current.join('\n')));
|
|
123
|
+
current = [line];
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
current.push(line);
|
|
127
|
+
}
|
|
128
|
+
if (current.length > 0)
|
|
129
|
+
out.push(normalise(current.join('\n')));
|
|
130
|
+
return out.filter(s => s.length > 0);
|
|
131
|
+
}
|
|
132
|
+
/** Round-robin fill across doc sections: bucket each entry by the FIRST section
|
|
133
|
+
* whose normalised text contains its quote (the same containment rule that
|
|
134
|
+
* grounded it), take each bucket's entries in in-section order, one per bucket
|
|
135
|
+
* per round. Entries that cannot be located (or no doc) go to a trailing
|
|
136
|
+
* bucket in given order — the pre-run-16 behavior, never worse. */
|
|
137
|
+
function sectionFairFill(entries, budget, sourceDoc) {
|
|
138
|
+
if (budget <= 0)
|
|
139
|
+
return [];
|
|
140
|
+
if (!sourceDoc)
|
|
141
|
+
return entries.slice(0, budget);
|
|
142
|
+
const sections = normalisedSections(sourceDoc);
|
|
143
|
+
const buckets = new Map();
|
|
144
|
+
entries.forEach((e, given) => {
|
|
145
|
+
const q = normalise(e.quote);
|
|
146
|
+
let b = sections.findIndex(s => s.includes(q));
|
|
147
|
+
let at;
|
|
148
|
+
if (b < 0) {
|
|
149
|
+
b = sections.length; // unlocatable → trailing bucket, given order
|
|
150
|
+
at = given;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
at = sections[b].indexOf(q);
|
|
154
|
+
}
|
|
155
|
+
const list = buckets.get(b) ?? [];
|
|
156
|
+
list.push({ e, at });
|
|
157
|
+
buckets.set(b, list);
|
|
158
|
+
});
|
|
159
|
+
const ordered = [...buckets.keys()].sort((a, b) => a - b);
|
|
160
|
+
for (const k of ordered)
|
|
161
|
+
buckets.get(k).sort((a, b) => a.at - b.at);
|
|
162
|
+
const out = [];
|
|
163
|
+
for (let round = 0; out.length < budget; round++) {
|
|
164
|
+
let took = false;
|
|
165
|
+
for (const k of ordered) {
|
|
166
|
+
const list = buckets.get(k);
|
|
167
|
+
if (round >= list.length)
|
|
168
|
+
continue;
|
|
169
|
+
out.push(list[round].e);
|
|
170
|
+
took = true;
|
|
171
|
+
if (out.length >= budget)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
if (!took)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
100
178
|
}
|
|
101
179
|
/**
|
|
102
180
|
* DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
|
|
@@ -392,6 +470,109 @@ export function buildRequirementsBlock(requirements) {
|
|
|
392
470
|
''
|
|
393
471
|
].join('\n');
|
|
394
472
|
}
|
|
473
|
+
// ─── Owned (task-mapped) requirements — the run-16 channel gap ──────────────
|
|
474
|
+
//
|
|
475
|
+
// Of run 16's 40 kept requirements only the 6 CROSS-CUTTING ones were persisted
|
|
476
|
+
// and injected; the 33 TASK-MAPPED ones rode the decompose ledger (shaping the
|
|
477
|
+
// title list) and then vanished — nothing ever showed a task its OWN mapped
|
|
478
|
+
// obligations. TASK_0008's refine read §9's "serves `/api` + static `dist/`",
|
|
479
|
+
// quoted it in a grill question, and still narrowed the composed spec to
|
|
480
|
+
// "SPA fallback serves index.html"; the shipped server never served the client
|
|
481
|
+
// bundle and the app was permanently blank. An obligation the coverage map
|
|
482
|
+
// assigned to a task must travel INTO that task as verbatim authoritative text,
|
|
483
|
+
// exactly like the cross-cutting channel that measurably works.
|
|
484
|
+
const OWNED_REQUIREMENTS_FILE = 'requirements-owned.md';
|
|
485
|
+
export function ownedRequirementsFile(cwd) {
|
|
486
|
+
return path.join(tasksDir(cwd), OWNED_REQUIREMENTS_FILE);
|
|
487
|
+
}
|
|
488
|
+
/** Persist the task-mapped requirements (host-side, plan time). Overwrites —
|
|
489
|
+
* the mapping is recomputed whole per plan round. Best-effort like the carried
|
|
490
|
+
* artifact. */
|
|
491
|
+
export async function writeOwnedRequirements(cwd, owned) {
|
|
492
|
+
try {
|
|
493
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
494
|
+
const lines = owned.map(o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''} [title: ${o.title.replace(/\n/g, ' ')}]`);
|
|
495
|
+
await fsp.writeFile(ownedRequirementsFile(cwd), lines.join('\n') + '\n', 'utf8');
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// best-effort artifact
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
export async function readOwnedRequirements(cwd) {
|
|
502
|
+
try {
|
|
503
|
+
return parseOwnedRequirements(await fsp.readFile(ownedRequirementsFile(cwd), 'utf8'));
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
export function parseOwnedRequirements(text) {
|
|
510
|
+
const out = [];
|
|
511
|
+
for (const m of text.matchAll(/^OWNED:\s*"([^"\n]+)"(?:\s*\[anchor:\s*([^\]]*)\])?\s*\[title:\s*([^\n]+)\]\s*$/gim)) {
|
|
512
|
+
out.push({
|
|
513
|
+
quote: m[1].trim(),
|
|
514
|
+
anchor: (m[2] ?? '').trim(),
|
|
515
|
+
title: m[3].replace(/\]\s*$/, '').trim()
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
/** The owned entries whose plan title matches THIS task's title (normalised
|
|
521
|
+
* equality — titles travel verbatim from the plan list into task creation;
|
|
522
|
+
* spliced repair tasks simply match nothing). */
|
|
523
|
+
export function ownedForTitle(owned, title) {
|
|
524
|
+
const t = normalise(title);
|
|
525
|
+
if (t.length === 0)
|
|
526
|
+
return [];
|
|
527
|
+
return owned.filter(o => normalise(o.title) === t);
|
|
528
|
+
}
|
|
529
|
+
/** The injection block for a task's OWN mapped obligations. Mirrors
|
|
530
|
+
* buildRequirementsBlock (the directive pattern that measurably works) but is
|
|
531
|
+
* singular in address: these are not "wherever they touch", they ARE this
|
|
532
|
+
* task's obligations and must survive into the spec. */
|
|
533
|
+
export function buildOwnedRequirementsBlock(owned) {
|
|
534
|
+
if (owned.length === 0)
|
|
535
|
+
return '';
|
|
536
|
+
return [
|
|
537
|
+
"THIS TASK'S OWN REQUIREMENTS — obligations the SOURCE design states for exactly this",
|
|
538
|
+
"task's slice (verbatim quotes; AUTHORITATIVE — the design outranks any narrower",
|
|
539
|
+
'restatement, including the refined prompt above):',
|
|
540
|
+
...owned.map(o => `- "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''}`),
|
|
541
|
+
'Every entry must be SATISFIED BY THIS TASK and must appear in the spec: state it (or',
|
|
542
|
+
'its concrete consequence) under CONSTRAINTS or ACCEPTANCE, and make VERIFY exercise',
|
|
543
|
+
'it where runnable. Never weaken an entry to a narrower behavior — if the quote says',
|
|
544
|
+
'more than the refined prompt, the quote wins.',
|
|
545
|
+
''
|
|
546
|
+
].join('\n');
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* BRACES for the owned channel (the PROMPT-1 pattern): deterministically append
|
|
550
|
+
* each owned obligation the composed spec does not already carry as a
|
|
551
|
+
* CONSTRAINTS bullet. Measured need (scripts/live-owned-requirement-compose-ab
|
|
552
|
+
* .ts, 8 reps/arm on two real run-16 losses): with the belt block alone compose
|
|
553
|
+
* folded the clause into CONSTRAINTS/ACCEPTANCE in only 2/8 reps per fixture
|
|
554
|
+
* (baseline 0/8) — an instruction the model mostly ignores, the PROMPT-4 shape.
|
|
555
|
+
* A host-side append cannot be ignored. "Already carries" = the normalised
|
|
556
|
+
* quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
|
|
557
|
+
* No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
|
|
558
|
+
* only on specs the shape gate already accepted.
|
|
559
|
+
*/
|
|
560
|
+
export function appendOwnedConstraints(spec, owned) {
|
|
561
|
+
if (owned.length === 0)
|
|
562
|
+
return spec;
|
|
563
|
+
const m = /^CONSTRAINTS[ \t]*$/m.exec(spec);
|
|
564
|
+
if (!m)
|
|
565
|
+
return spec;
|
|
566
|
+
const already = normalise(spec);
|
|
567
|
+
const missing = owned.filter(o => !already.includes(normalise(o.quote)));
|
|
568
|
+
if (missing.length === 0)
|
|
569
|
+
return spec;
|
|
570
|
+
const insertAt = m.index + m[0].length;
|
|
571
|
+
const bullets = missing
|
|
572
|
+
.map(o => ` - "${o.quote}"${o.anchor ? ` [${o.anchor}]` : ''} — owned requirement from the source design (AUTHORITATIVE; satisfy it in this task, do not narrow it)`)
|
|
573
|
+
.join('\n');
|
|
574
|
+
return `${spec.slice(0, insertAt)}\n${bullets}${spec.slice(insertAt)}`;
|
|
575
|
+
}
|
|
395
576
|
/** The decompose-prompt ledger block (goal E's belt): the grounded requirement
|
|
396
577
|
* list rides into decompose so structure-mirroring can't discharge it. */
|
|
397
578
|
export function buildRequirementsLedger(requirements) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ResolvedRunner {
|
|
2
|
+
/** Spawnable form: the bare name when PATH serves it, else an absolute path. */
|
|
3
|
+
bin: string;
|
|
4
|
+
/** Directory to prepend to PATH for the spawned script chain; null when the
|
|
5
|
+
* bare name already resolves (nothing to add). */
|
|
6
|
+
pathPrefix: string | null;
|
|
7
|
+
/** false → the runner is not spawnable anywhere this module knows to look. */
|
|
8
|
+
ok: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Test hook: resolution is cached per bin name for the life of the process (the
|
|
11
|
+
* gate spawns the same runner many times); tests reset between cases. */
|
|
12
|
+
export declare function clearRunnerCache(): void;
|
|
13
|
+
/**
|
|
14
|
+
* A spawnable form of `bin`: bare name if PATH serves it, else the first
|
|
15
|
+
* well-known location that exists AND runs, else `{ok: false}` with the bare
|
|
16
|
+
* name unchanged (spawn sites then behave exactly as before this module —
|
|
17
|
+
* ENOENT → the caller's env-gap contract).
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveRunner(bin: string, opts?: {
|
|
20
|
+
probe?: (bin: string) => boolean;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
}): ResolvedRunner;
|
|
23
|
+
/**
|
|
24
|
+
* The env a spawn site should pass so the resolved runner's script chain can
|
|
25
|
+
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
26
|
+
* prefix (bare name resolved, or unresolvable) the base env is returned as-is.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runnerEnv(runner: ResolvedRunner, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner-resolve — make the deterministic gates able to SPAWN the project's own
|
|
3
|
+
* runner when the host PATH lost it.
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5 run 16, validated): pi was launched inside the
|
|
6
|
+
* sandbox through a LOGIN shell, whose /etc/profile reset PATH and dropped
|
|
7
|
+
* ~/.bun/bin — so `bun` was unspawnable in every gate spawn. Under the env-gap
|
|
8
|
+
* contract (ENOENT / exit 127 → skip, deliberately, so a missing tool is never a
|
|
9
|
+
* code fault) EVERY dynamic check silently skipped: bun test, the boot of
|
|
10
|
+
* `bun run dev`, and therefore the render check built for exactly the blank-page
|
|
11
|
+
* class the run shipped. The gate converged on static checks alone and stamped
|
|
12
|
+
* the run green while the binary sat at ~/.bun/bin/bun the whole time.
|
|
13
|
+
*
|
|
14
|
+
* Resolution is discovery, never installation: try the bare name first (PATH
|
|
15
|
+
* serves it → nothing changes), then probe well-known install locations. Each
|
|
16
|
+
* probe is a real `<candidate> --version` spawn — an existing but broken binary
|
|
17
|
+
* must not count as resolved.
|
|
18
|
+
*
|
|
19
|
+
* The PATH PREFIX matters as much as the binary: a resolved `bun run test` still
|
|
20
|
+
* re-invokes `bun` (and the repo's own bins) INSIDE the script chain, and those
|
|
21
|
+
* inner calls exit 127 without the runner's directory on PATH — the same silent
|
|
22
|
+
* blindness one level down (run 16's final-fix child hit exactly this and had to
|
|
23
|
+
* hand-export PATH). Spawn sites must therefore use runnerEnv(), not just the
|
|
24
|
+
* resolved binary.
|
|
25
|
+
*/
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
/** Well-known install locations per runner, probed in order AFTER the bare name.
|
|
31
|
+
* Only ever read — nothing is installed. */
|
|
32
|
+
function candidatePaths(bin, env) {
|
|
33
|
+
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
34
|
+
const exe = process.platform === 'win32' ? `${bin}.exe` : bin;
|
|
35
|
+
const list = [];
|
|
36
|
+
if (bin === 'bun') {
|
|
37
|
+
if (env.BUN_INSTALL)
|
|
38
|
+
list.push(path.join(env.BUN_INSTALL, 'bin', exe));
|
|
39
|
+
list.push(path.join(home, '.bun', 'bin', exe));
|
|
40
|
+
}
|
|
41
|
+
list.push(path.join(home, '.local', 'bin', exe), path.join('/usr/local/bin', exe), path.join('/usr/bin', exe), path.join('/opt/homebrew/bin', exe));
|
|
42
|
+
return list;
|
|
43
|
+
}
|
|
44
|
+
/** Does `bin` actually run? A real spawn, not an existence check — a present but
|
|
45
|
+
* broken binary (wrong arch, dangling symlink) must not count as resolved. */
|
|
46
|
+
function defaultProbe(bin) {
|
|
47
|
+
const r = spawnSync(bin, ['--version'], {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 8_000,
|
|
50
|
+
env: { ...process.env }
|
|
51
|
+
});
|
|
52
|
+
return !r.error && r.status === 0;
|
|
53
|
+
}
|
|
54
|
+
const cache = new Map();
|
|
55
|
+
/** Test hook: resolution is cached per bin name for the life of the process (the
|
|
56
|
+
* gate spawns the same runner many times); tests reset between cases. */
|
|
57
|
+
export function clearRunnerCache() {
|
|
58
|
+
cache.clear();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A spawnable form of `bin`: bare name if PATH serves it, else the first
|
|
62
|
+
* well-known location that exists AND runs, else `{ok: false}` with the bare
|
|
63
|
+
* name unchanged (spawn sites then behave exactly as before this module —
|
|
64
|
+
* ENOENT → the caller's env-gap contract).
|
|
65
|
+
*/
|
|
66
|
+
export function resolveRunner(bin, opts = {}) {
|
|
67
|
+
const cached = cache.get(bin);
|
|
68
|
+
if (cached)
|
|
69
|
+
return cached;
|
|
70
|
+
const probe = opts.probe ?? defaultProbe;
|
|
71
|
+
const env = opts.env ?? process.env;
|
|
72
|
+
let resolved;
|
|
73
|
+
if (probe(bin)) {
|
|
74
|
+
resolved = { bin, pathPrefix: null, ok: true };
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const hit = candidatePaths(bin, env).find(p => existsSync(p) && probe(p));
|
|
78
|
+
resolved =
|
|
79
|
+
hit ?
|
|
80
|
+
{ bin: hit, pathPrefix: path.dirname(hit), ok: true }
|
|
81
|
+
: { bin, pathPrefix: null, ok: false };
|
|
82
|
+
}
|
|
83
|
+
cache.set(bin, resolved);
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The env a spawn site should pass so the resolved runner's script chain can
|
|
88
|
+
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
89
|
+
* prefix (bare name resolved, or unresolvable) the base env is returned as-is.
|
|
90
|
+
*/
|
|
91
|
+
export function runnerEnv(runner, base = process.env) {
|
|
92
|
+
if (!runner.pathPrefix)
|
|
93
|
+
return { ...base };
|
|
94
|
+
return { ...base, PATH: `${runner.pathPrefix}${path.delimiter}${base.PATH ?? ''}` };
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|