@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.
@@ -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('remote', {
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
@@ -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
- ...process.env,
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 r = spawnSync(bin, args, {
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: { ...process.env }
733
+ env: runnerEnv(runner)
723
734
  });
724
- if (r.error || r.status === null || r.status === 127)
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 +
@@ -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
- return [contracts, requirements].filter(b => b.length > 0).join('\n');
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
- { name: 'critique', section: 'spec', field: 'spec', run: critiqueWithFallback }
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
- const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
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: measured live, an eager model
17
- * extracts 40+ items top-down (every §1 decision row), so a plain first-N cap
18
- * systematically drops the TAIL sections exactly where mx5 keeps its testing
19
- * obligations. Rule (deterministic priority, not a knob): entries quoting an
20
- * obligation-marked passage survive first; the remainder fills in given order.
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: measured live, an eager model
84
- * extracts 40+ items top-down (every §1 decision row), so a plain first-N cap
85
- * systematically drops the TAIL sections exactly where mx5 keeps its testing
86
- * obligations. Rule (deterministic priority, not a knob): entries quoting an
87
- * obligation-marked passage survive first; the remainder fills in given order.
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
- return [...marked, ...rest].slice(0, MAX_REQUIREMENTS);
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.19.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",