@mjasnikovs/pi-task 0.40.50 → 0.42.0

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.
Files changed (125) hide show
  1. package/README.md +7 -4
  2. package/dist/config/config.d.ts +61 -18
  3. package/dist/config/config.js +50 -6
  4. package/dist/config/register.js +18 -5
  5. package/dist/shared/content-tokens.d.ts +16 -0
  6. package/dist/shared/content-tokens.js +137 -0
  7. package/dist/task/accept-debt.d.ts +31 -1
  8. package/dist/task/accept-debt.js +49 -3
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +66 -7
  11. package/dist/task/auto-io.js +152 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +184 -112
  14. package/dist/task/child-runner.d.ts +18 -0
  15. package/dist/task/child-runner.js +7 -8
  16. package/dist/task/command-run.d.ts +4 -0
  17. package/dist/task/command-run.js +4 -2
  18. package/dist/task/constraint-policy.d.ts +52 -0
  19. package/dist/task/constraint-policy.js +66 -0
  20. package/dist/task/context-silence.d.ts +5 -4
  21. package/dist/task/context-silence.js +13 -5
  22. package/dist/task/coverage-loop.d.ts +7 -4
  23. package/dist/task/coverage-loop.js +16 -138
  24. package/dist/task/debug-log.d.ts +25 -2
  25. package/dist/task/debug-log.js +64 -10
  26. package/dist/task/decompose-fidelity.d.ts +17 -4
  27. package/dist/task/decompose-fidelity.js +95 -55
  28. package/dist/task/decompose-granularity.d.ts +7 -7
  29. package/dist/task/decompose-granularity.js +8 -8
  30. package/dist/task/env-notes.d.ts +65 -19
  31. package/dist/task/env-notes.js +152 -50
  32. package/dist/task/external-context.js +25 -5
  33. package/dist/task/fix-context.d.ts +39 -0
  34. package/dist/task/fix-context.js +58 -0
  35. package/dist/task/gate-deps.d.ts +49 -0
  36. package/dist/task/gate-deps.js +201 -39
  37. package/dist/task/gate-evidence.d.ts +51 -0
  38. package/dist/task/gate-evidence.js +90 -0
  39. package/dist/task/gate-resolution.d.ts +119 -0
  40. package/dist/task/gate-resolution.js +120 -0
  41. package/dist/task/git-state-guard.js +3 -26
  42. package/dist/task/handoff.d.ts +35 -0
  43. package/dist/task/handoff.js +100 -0
  44. package/dist/task/health-baseline.d.ts +87 -0
  45. package/dist/task/health-baseline.js +158 -0
  46. package/dist/task/health-repair.d.ts +68 -0
  47. package/dist/task/health-repair.js +124 -0
  48. package/dist/task/ledger.d.ts +10 -0
  49. package/dist/task/ledger.js +22 -11
  50. package/dist/task/lint-fix.d.ts +11 -0
  51. package/dist/task/lint-fix.js +14 -8
  52. package/dist/task/loop-detector.d.ts +52 -3
  53. package/dist/task/loop-detector.js +81 -5
  54. package/dist/task/mentions.d.ts +17 -0
  55. package/dist/task/mentions.js +86 -0
  56. package/dist/task/orchestrator.d.ts +53 -9
  57. package/dist/task/orchestrator.js +104 -28
  58. package/dist/task/orientation.d.ts +135 -29
  59. package/dist/task/orientation.js +259 -67
  60. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  61. package/dist/task/owned-freeze-reassign.js +13 -5
  62. package/dist/task/parsers.d.ts +6 -1
  63. package/dist/task/parsers.js +21 -4
  64. package/dist/task/phases.d.ts +1 -1
  65. package/dist/task/phases.js +125 -153
  66. package/dist/task/plan-orchestrator.js +5 -6
  67. package/dist/task/plan-rounds.d.ts +1 -0
  68. package/dist/task/plan-rounds.js +8 -1
  69. package/dist/task/prohibition-probe.d.ts +6 -1
  70. package/dist/task/prohibition-probe.js +26 -6
  71. package/dist/task/prompts.js +13 -2
  72. package/dist/task/qa-transcript.d.ts +17 -0
  73. package/dist/task/qa-transcript.js +28 -0
  74. package/dist/task/question-source.d.ts +16 -2
  75. package/dist/task/question-source.js +21 -8
  76. package/dist/task/repo-health-check.d.ts +27 -0
  77. package/dist/task/repo-health-check.js +39 -5
  78. package/dist/task/requirements.d.ts +67 -13
  79. package/dist/task/requirements.js +129 -67
  80. package/dist/task/research-worker.d.ts +43 -2
  81. package/dist/task/research-worker.js +103 -8
  82. package/dist/task/run-bracket.d.ts +9 -4
  83. package/dist/task/run-bracket.js +9 -0
  84. package/dist/task/run-context.d.ts +152 -0
  85. package/dist/task/run-context.js +277 -0
  86. package/dist/task/spec-doc.d.ts +78 -0
  87. package/dist/task/spec-doc.js +205 -0
  88. package/dist/task/spec-model.d.ts +70 -0
  89. package/dist/task/spec-model.js +139 -0
  90. package/dist/task/stall-detector.d.ts +1 -1
  91. package/dist/task/stall-detector.js +9 -5
  92. package/dist/task/state-dir.d.ts +29 -0
  93. package/dist/task/state-dir.js +80 -0
  94. package/dist/task/suppression-probe.d.ts +67 -0
  95. package/dist/task/suppression-probe.js +78 -0
  96. package/dist/task/task-gates.d.ts +52 -58
  97. package/dist/task/task-gates.js +161 -199
  98. package/dist/task/task-io.d.ts +10 -0
  99. package/dist/task/task-io.js +69 -18
  100. package/dist/task/task-parsers.js +6 -6
  101. package/dist/task/task-types.d.ts +7 -0
  102. package/dist/task/timings.d.ts +11 -0
  103. package/dist/task/timings.js +21 -0
  104. package/dist/task/tree-hash.d.ts +8 -0
  105. package/dist/task/tree-hash.js +55 -0
  106. package/dist/task/verify-reconcile.d.ts +4 -3
  107. package/dist/task/verify-reconcile.js +7 -8
  108. package/dist/task/verify-resolution.d.ts +32 -6
  109. package/dist/task/verify-resolution.js +78 -15
  110. package/dist/task/verify-work.d.ts +105 -35
  111. package/dist/task/verify-work.js +232 -60
  112. package/dist/task/yolo.d.ts +0 -13
  113. package/dist/task/yolo.js +9 -21
  114. package/dist/workers/pi-worker-core.d.ts +20 -0
  115. package/dist/workers/pi-worker-core.js +16 -11
  116. package/dist/workers/pi-worker-docs.d.ts +6 -5
  117. package/dist/workers/pi-worker-docs.js +8 -7
  118. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  119. package/dist/workers/pi-worker-fetch.js +5 -4
  120. package/dist/workers/pi-worker-search.js +5 -4
  121. package/dist/workers/research-cache.d.ts +23 -1
  122. package/dist/workers/research-cache.js +63 -3
  123. package/dist/workers/shared.d.ts +18 -0
  124. package/dist/workers/shared.js +38 -22
  125. package/package.json +1 -1
@@ -22,13 +22,18 @@
22
22
  import { existsSync, readFileSync } from 'node:fs';
23
23
  import * as fsp from 'node:fs/promises';
24
24
  import * as path from 'node:path';
25
- import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
25
+ import { readTaskFile, appendGateRecord, readSection, setTaskSection } from './task-io.js';
26
+ import { makeGit } from '../shared/git-runner.js';
27
+ import { worktreeTreeHash } from './tree-hash.js';
28
+ import { captureHealthBaseline, formatHealthBaseline, lazyHealthBaseline, parseHealthBaseline, HEALTH_BASELINE_SECTION } from './health-baseline.js';
26
29
  import { gitCommitAll, gitDropLastCommit, git } from './auto-commit.js';
27
30
  import { runGuidelineEnforcement } from './enforce-guidelines.js';
28
31
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
29
32
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
33
+ import { currentRunContext } from './run-context.js';
34
+ import { runGateEvidence, evidenceVerifyFindings } from './gate-evidence.js';
30
35
  import { readContracts } from './contracts.js';
31
- import { recordDebt } from './accept-debt.js';
36
+ import { closeHealthDebts, recordDebt } from './accept-debt.js';
32
37
  import { recordRepairCandidate } from './root-cause-repair.js';
33
38
  import { runRepoHealthCheck } from './repo-health-check.js';
34
39
  import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
@@ -45,12 +50,17 @@ import { runBoundedLintFix } from './lint-fix.js';
45
50
  import { findForeignPaths, foreignPathVerifyFindings, repairForeignPaths } from './foreign-path.js';
46
51
  import { findScriptEscapesInManifest, scriptEscapeVerifyFindings } from './script-escape.js';
47
52
  import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
53
+ import { compileSuppressionPatterns, findSuppressionWidening, suppressionPatternsFor } from './suppression-probe.js';
54
+ import { detectEcosystems } from '../workers/docs-ecosystems.js';
55
+ import { qaKindsFromRecord } from './qa-transcript.js';
56
+ import { extractSection } from './task-parsers.js';
48
57
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
49
58
  import { runWorker } from '../workers/pi-worker-core.js';
50
59
  import { getConfig } from '../config/config.js';
51
60
  import { groupChildArgs } from '../config/group-args.js';
52
61
  import { contextWindowForGroup } from './context-usage.js';
53
62
  import { makeDebugAppender } from './debug-log.js';
63
+ import { runLogPath } from './state-dir.js';
54
64
  import { startAutoLoader } from './widget.js';
55
65
  import { ChildStatus } from './child-status.js';
56
66
  import { makeGateChild } from './gate-child.js';
@@ -494,6 +504,50 @@ export async function readSpecForVerification(cwd, taskId) {
494
504
  return null;
495
505
  }
496
506
  }
507
+ /**
508
+ * The task's rendered Q&A, which is what a constraint's `[from: Q<n>]` tag
509
+ * resolves against (see qa-transcript's `qaKindsFromRecord`). Null when the task
510
+ * asked nothing, or the file cannot be read — both mean every tagged constraint
511
+ * reads as `derived`, and `constraint-policy.ts` says what that is worth.
512
+ */
513
+ export async function readQaRecordForVerification(cwd, taskId) {
514
+ try {
515
+ const { body } = await readTaskFile(cwd, taskId);
516
+ return extractSection(body, 'grill Q&A');
517
+ }
518
+ catch {
519
+ return null;
520
+ }
521
+ }
522
+ /**
523
+ * The health baseline the verify gate's differential needs: the one stored on the
524
+ * task file, or — for a task file that has none — one established lazily from a
525
+ * DETACHED WORKTREE at HEAD and written back, so the next resolution round does
526
+ * not pay for it again.
527
+ *
528
+ * Everything here is best-effort. A baseline that cannot be established is null,
529
+ * and `classifyHealthDelta` already states what an absent baseline means; failing
530
+ * the gate because the differential could not run would be the harness blaming
531
+ * the work for its own gap.
532
+ */
533
+ export async function healthBaselineFor(cwd, taskId, signal) {
534
+ try {
535
+ const stored = parseHealthBaseline(await readSection(cwd, taskId, HEALTH_BASELINE_SECTION));
536
+ if (stored)
537
+ return stored;
538
+ const fresh = await lazyHealthBaseline({
539
+ git: makeGit(cwd, signal),
540
+ runHealthIn: dir => runRepoHealthCheck(dir, { signal })
541
+ });
542
+ if (fresh) {
543
+ await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
544
+ }
545
+ return fresh;
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ }
497
551
  /**
498
552
  * Bind the deterministic verify probes — THE one place the collectors above meet
499
553
  * the probe table in verify-work.ts. One entry per `BoundProbeKey`; the table row
@@ -507,8 +561,30 @@ export async function readSpecForVerification(cwd, taskId) {
507
561
  * verify debug appender for the one probe that repairs as well as reports.
508
562
  */
509
563
  export function buildVerifyProbes(params) {
510
- const { cwd, signal, taskId, spec, log } = params;
564
+ const { cwd, signal, taskId, spec, qaRecord, log, onCommand, run } = params;
565
+ const kinds = qaRecord ? qaKindsFromRecord(qaRecord) : [];
511
566
  return {
567
+ // The project's own check and build commands, run ONCE per tree by the
568
+ // parent (gate-evidence.ts) and handed to the child as a result to read.
569
+ // Cached on the RUN's context, so every gate child of this session shares
570
+ // one answer and a lint-fix that moves the tree costs exactly one re-run.
571
+ // Outside a run bracket (a direct runSingleTask, a test) there is no run
572
+ // to share and each ask gets a fresh context, so the cache spans one call.
573
+ evidence: () => {
574
+ const rc = currentRunContext(cwd);
575
+ return rc
576
+ .gateEvidenceFor((commands, tree) => runGateEvidence({
577
+ cwd,
578
+ runId: rc.runId,
579
+ commands,
580
+ treeHash: tree,
581
+ timeoutMs: getConfig().requestTimeoutMs,
582
+ ...(signal === undefined ? {} : { signal }),
583
+ ...(onCommand === undefined ? {} : { onCommand }),
584
+ ...(run === undefined ? {} : { run })
585
+ }))
586
+ .then(evidenceVerifyFindings);
587
+ },
512
588
  // Deterministic self-verification probe: test files the task itself
513
589
  // authored/changed become prompt-level findings mandating the child
514
590
  // to drive the real artifact before trusting their green result.
@@ -518,7 +594,7 @@ export function buildVerifyProbes(params) {
518
594
  // under the no-waiver rule — the child otherwise rarely runs `git
519
595
  // diff` and cannot even see the violation.
520
596
  prohibition: () => {
521
- const banned = spec ? extractProhibitions(spec) : [];
597
+ const banned = spec ? extractProhibitions(spec, n => kinds[n - 1] ?? null) : [];
522
598
  if (banned.length === 0)
523
599
  return Promise.resolve([]);
524
600
  return collectChangedFiles(cwd, signal).then(files => findProhibitionViolations(banned, files));
@@ -555,9 +631,65 @@ export function buildVerifyProbes(params) {
555
631
  // production wiring — importing the leaf modules the shipped entry
556
632
  // composes and assembling their own copy — become rule-3f findings,
557
633
  // so the child drives the REAL assembly and not the copy.
558
- testAssembly: () => collectChangedFiles(cwd, signal).then(changed => collectTestAssemblyFindings(cwd, changed, signal))
634
+ testAssembly: () => collectChangedFiles(cwd, signal).then(changed => collectTestAssemblyFindings(cwd, changed, signal)),
635
+ // Deterministic suppression-widening probe: checker suppressions this
636
+ // task NET added. Injected under rule 4i — the child cannot find these
637
+ // by running the checks, because the suppressions are why they pass.
638
+ suppressionWidening: () => collectSuppressionHits(cwd, signal)
559
639
  };
560
640
  }
641
+ /**
642
+ * The task's net-new suppressions: the files its work touched (the same tree
643
+ * shape the write-guard reads), diffed against the checkpoint HEAD, counted per
644
+ * pattern.
645
+ *
646
+ * The pattern set follows the repo's detected ecosystems, so a Go project is not
647
+ * scanned for `@ts-expect-error` and a TypeScript one is not scanned for
648
+ * `//nolint`; `suppressionPatterns` in the config adds project-specific rows.
649
+ * Failures degrade to no findings — a sharpener, never a blocker.
650
+ */
651
+ export async function collectSuppressionHits(cwd, signal) {
652
+ const changes = await collectTreeChanges(cwd, signal);
653
+ const files = [...changes.modified, ...changes.added];
654
+ if (files.length === 0)
655
+ return [];
656
+ const patterns = suppressionPatternsFor(detectEcosystems(cwd), compileSuppressionPatterns(getConfig().suppressionPatterns));
657
+ const diff = await git(cwd, ['diff', ...DIFF_PREFIX_ARGS, 'HEAD', '--', ...files], signal);
658
+ const lines = diff.exitCode === 0 ? parseDiffLines(diff.stdout) : [];
659
+ const untracked = await Promise.all(changes.added.map(async (rel) => {
660
+ try {
661
+ const text = await fsp.readFile(path.join(cwd, rel), 'utf8');
662
+ return text.split('\n').map(t => ({ path: rel, text: t, added: true }));
663
+ }
664
+ catch {
665
+ return [];
666
+ }
667
+ }));
668
+ return findSuppressionWidening([...lines, ...untracked.flat()], patterns);
669
+ }
670
+ /**
671
+ * A unified diff's content lines, tagged with their side and their file. The `+++`
672
+ * header names the file and the `---`/`+++` headers themselves are excluded, which
673
+ * is the one thing a naive `startsWith('+')` scan gets wrong — it counts every
674
+ * changed file's own header as an added line.
675
+ */
676
+ export function parseDiffLines(diff) {
677
+ const out = [];
678
+ let file = '';
679
+ for (const line of diff.split('\n')) {
680
+ if (line.startsWith('+++ ')) {
681
+ file = line.slice(4).replace(/^b\//, '').trim();
682
+ continue;
683
+ }
684
+ if (line.startsWith('--- ') || line.startsWith('@@'))
685
+ continue;
686
+ if (line.startsWith('+'))
687
+ out.push({ path: file, text: line.slice(1), added: true });
688
+ else if (line.startsWith('-'))
689
+ out.push({ path: file, text: line.slice(1), added: false });
690
+ }
691
+ return out;
692
+ }
561
693
  /**
562
694
  * Build the gate deps for one command run. `runTask` is the orchestrator's
563
695
  * implementation re-runner, injected by the caller. The returned object also drives
@@ -581,13 +713,37 @@ export function buildGateDeps(params) {
581
713
  // mutationCheck dep to discard a verdict computed on a mutated tree.
582
714
  let lastGuardReconcile = null;
583
715
  // Restore tracked files to HEAD and drop files a pass created. The `.pi-tasks`
584
- // trail and log writes made during the pass survive both: the checkout excludes
585
- // that directory by pathspec and the clean excludes it with `-e`. Shared by the
716
+ // trail survives both: the checkout excludes that directory by pathspec and the
717
+ // clean excludes it with `-e`. Debug logs are outside the tree. Shared by the
586
718
  // enforce pre-commit gate (discardEdits) and the final-gate autofix guards.
587
719
  const discardTreeEdits = async (cwd2) => {
588
720
  await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
589
721
  await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
590
722
  };
723
+ // The project's own statics, under a live loader naming the running command.
724
+ // Each run is as long as that command, and a gate step that long with no widget
725
+ // is indistinguishable from a hang. Shared by the enforce pre-commit gate (a
726
+ // baseline before the edit pass, a differential after it) and by the
727
+ // pre-task baseline capture.
728
+ const runHealthUnderLoader = (healthCtx, cwd2, label) => {
729
+ const startedAt = Date.now();
730
+ let running;
731
+ const stop = startAutoLoader(healthCtx, () => ({
732
+ title: label,
733
+ kind: 'enforce',
734
+ step: 'repo health',
735
+ stepNum: 1,
736
+ stepTotal: 1,
737
+ startedAt,
738
+ lastLine: running ? `repo health · ${running}` : 'repo health'
739
+ }));
740
+ return runRepoHealthCheck(cwd2, {
741
+ signal,
742
+ onCommand: c => {
743
+ running = c;
744
+ }
745
+ }).finally(stop);
746
+ };
591
747
  // Adapter onto the shared gate-child runner (gate-child.ts). What survives
592
748
  // here is WIRING — which context, which log file, which config knobs, and
593
749
  // the shared live status; the ritual and the per-kind policy are the
@@ -597,7 +753,7 @@ export function buildGateDeps(params) {
597
753
  cwd: cwd2,
598
754
  taskTitle,
599
755
  kind,
600
- logPath: path.join(tasksDir(cwd2), logFile),
756
+ logPath: runLogPath(cwd2, logFile),
601
757
  ...(opts.loader === undefined ? {} : { loader: opts.loader }),
602
758
  commandTimeoutMs: getConfig().requestTimeoutMs,
603
759
  streamInactivityMs: getConfig().streamInactivityMs,
@@ -630,6 +786,7 @@ export function buildGateDeps(params) {
630
786
  // origin, and the final integration gate re-checks each one at run end.
631
787
  recordDebt,
632
788
  recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
789
+ closeHealthDebts,
633
790
  // file → introducing task, the provenance half of the discriminator.
634
791
  introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
635
792
  // Tracked paths, used only to resolve a bare file name a FAIL text names
@@ -733,6 +890,10 @@ export function buildGateDeps(params) {
733
890
  // file. A task that never reached compose has no spec section —
734
891
  // runWorkVerification treats a null spec as a no-op pass.
735
892
  const spec = await readSpecForVerification(cwd2, taskId);
893
+ const qaRecord = await readQaRecordForVerification(cwd2, taskId);
894
+ // One id for the whole gate: read and append must agree on which run
895
+ // these facts belong to.
896
+ const { runId } = currentRunContext(cwd2);
736
897
  // DEAD AIR (the reason this loader exists). The gate's DETERMINISTIC
737
898
  // stage — repo health plus the nine PROBE_ADAPTERS rows — runs before
738
899
  // the verify child, and the child's own loader only starts once the
@@ -788,6 +949,10 @@ export function buildGateDeps(params) {
788
949
  stageLine = `repo health · ${c}`;
789
950
  }
790
951
  }),
952
+ // What those checks said before the task started, so a red one
953
+ // is attributed rather than absolutely failed. Read only when
954
+ // the check above comes back red.
955
+ healthBaseline: () => healthBaselineFor(cwd2, taskId, signal),
791
956
  // The deterministic probes, bound in one place (buildVerifyProbes
792
957
  // above); the PROBE_ADAPTERS table in verify-work.ts runs them.
793
958
  probes: buildVerifyProbes({
@@ -795,8 +960,15 @@ export function buildGateDeps(params) {
795
960
  signal,
796
961
  taskId,
797
962
  spec,
798
- log: makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))
963
+ qaRecord,
964
+ log: makeDebugAppender(runLogPath(cwd2, 'verify-debug.log')),
965
+ // The evidence row runs the project's own checks, so it is
966
+ // the longest step of the stage the loader above exists for.
967
+ onCommand: c => {
968
+ stageLine = `project checks · ${c}`;
969
+ }
799
970
  }),
971
+ ...(qaRecord === null ? {} : { qaRecord }),
800
972
  // Git-state guard result of the most recent child run: a verdict
801
973
  // computed on a tree the child itself mutated is discarded — but ONLY
802
974
  // when the mutation touched graded state (verdictTainted). A child
@@ -812,11 +984,13 @@ export function buildGateDeps(params) {
812
984
  // the re-archaeology; its own ENV-NOTE lines are stored for the
813
985
  // next one, stamped with this task's id as their origin so a
814
986
  // later child sees a cited fact is second-hand and must
815
- // re-validate before excusing a failure. Facts only verdict
816
- // rules unaffected.
987
+ // re-validate before excusing a failure, and with the RUN's id so
988
+ // the facts measured against this tree lead the block. Facts
989
+ // only — verdict rules unaffected.
817
990
  envNotes: {
818
991
  read: () => readEnvNotes(cwd2),
819
- append: notes => appendEnvNotes(cwd2, notes, taskId)
992
+ append: notes => appendEnvNotes(cwd2, notes, taskId, runId),
993
+ runId
820
994
  },
821
995
  // Per-run cross-slice contract registry under `.pi-tasks/`: the
822
996
  // verbatim interface facts the design pins that multiple slices
@@ -854,31 +1028,17 @@ export function buildGateDeps(params) {
854
1028
  introducedBy: rel => Promise.resolve(taskThatIntroduced(cwd2, rel))
855
1029
  });
856
1030
  },
857
- // Deterministic static check + tree helpers for the enforce pre-commit gate.
858
- // task-gates.ts calls it up to twice per task in `edit` mode — a baseline
859
- // before the edit pass, and a differential check after it when edits were
860
- // made. Each run is as long as the project's own lint, so it gets the same
861
- // treatment as the verify-side run: async, under a live loader naming the
862
- // command.
863
- repoHealth: (healthCtx, cwd2, label) => {
864
- const startedAt = Date.now();
865
- let running;
866
- const stop = startAutoLoader(healthCtx, () => ({
867
- title: label,
868
- kind: 'enforce',
869
- step: 'repo health',
870
- stepNum: 1,
871
- stepTotal: 1,
872
- startedAt,
873
- lastLine: running ? `repo health · ${running}` : 'repo health'
874
- }));
875
- return runRepoHealthCheck(cwd2, {
876
- signal,
877
- onCommand: c => {
878
- running = c;
879
- }
880
- }).finally(stop);
881
- },
1031
+ // task-gates.ts calls this up to twice per task in `edit` mode — a baseline
1032
+ // before the enforce edit pass, and a differential check after it.
1033
+ repoHealth: runHealthUnderLoader,
1034
+ captureHealthBaseline: (healthCtx, cwd2, label) => captureHealthBaseline({
1035
+ runHealth: () => runHealthUnderLoader(healthCtx, cwd2, label),
1036
+ treeHash: () => worktreeTreeHash(makeGit(cwd2, signal)),
1037
+ // A project's `lint` script commonly runs `--fix`, so the baseline
1038
+ // can leave the tree edited. Undone here, or the task starts on a
1039
+ // tree the baseline authored.
1040
+ discardEdits: () => discardTreeEdits(cwd2)
1041
+ }).catch(() => null),
882
1042
  dirty: async (cwd2) => {
883
1043
  const r = await git(cwd2, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
884
1044
  return r.exitCode === 0 && r.stdout.trim().length > 0;
@@ -919,17 +1079,19 @@ export function buildGateDeps(params) {
919
1079
  // proven to depend on it.
920
1080
  ignoredSnapshot: () => collectIgnoredSnapshot(cwd2, signal),
921
1081
  ...(ignoredKnown && ignoredKnown.length > 0 ? { ignoredKnown } : {}),
922
- gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c, { signal }), makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))),
923
- log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
1082
+ gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c, { signal }), makeDebugAppender(runLogPath(cwd2, 'final-gate-debug.log'))),
1083
+ log: makeDebugAppender(runLogPath(cwd2, 'final-gate-debug.log'))
924
1084
  }),
925
1085
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
926
1086
  // Read the same composed spec the verify gate judged against, so the
927
1087
  // recommendation reasons over the real contract (degrade to the bare title).
928
1088
  const spec = (await readSpecForVerification(cwd2, taskId)) ?? taskTitle;
1089
+ const qaRecord = await readQaRecordForVerification(cwd2, taskId);
929
1090
  return researchResolution({
930
1091
  cwd: cwd2,
931
1092
  signal,
932
1093
  spec,
1094
+ ...(qaRecord === null ? {} : { qaRecord }),
933
1095
  failReason,
934
1096
  runChild: gateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
935
1097
  });
@@ -0,0 +1,51 @@
1
+ import { type CommandRunner } from './command-run.js';
2
+ import { type VerifiedCommand } from './run-context.js';
3
+ /** One command the parent ran for the children, and where its full output went. */
4
+ export interface EvidenceCommand {
5
+ cmd: string;
6
+ cwd: string;
7
+ /** Its real exit code, or {@link NOT_RUN} when `gap` says nothing was observed. */
8
+ exitCode: number;
9
+ /** Absolute path — outside the worktree — of the file holding the full output. */
10
+ outputPath: string;
11
+ /** The tree it was measured against; null when git could not say (see treeHash). */
12
+ treeHash: string | null;
13
+ /** The env-gap ladder's reason nothing was observed. Absent ⇒ the command ran. */
14
+ gap?: string;
15
+ }
16
+ /** What one gate session hands its children in place of a command to run. */
17
+ export interface GateEvidence {
18
+ commands: EvidenceCommand[];
19
+ }
20
+ export interface EvidenceRunDeps {
21
+ cwd: string;
22
+ /** Whose state dir the output files land in. */
23
+ runId: string;
24
+ /** Already filtered to the classes that terminate (see RunContext). */
25
+ commands: readonly VerifiedCommand[];
26
+ treeHash: string | null;
27
+ /** Per-command ceiling. The same one a gate child's own bash tool gets: a
28
+ * command pre-run here must not be bounded more tightly than the same command
29
+ * run by the child, or the child would see a timeout the child cannot reproduce. */
30
+ timeoutMs: number;
31
+ signal?: AbortSignal;
32
+ /** Called with each command as it STARTS. These are the project's own whole-repo
33
+ * checks and they run before the child exists, so without a live line naming
34
+ * the running one the gate's longest stage is also its quietest. */
35
+ onCommand?: (cmd: string) => void;
36
+ /** The spawner. Injected so a session's command count is assertable. */
37
+ run?: CommandRunner;
38
+ }
39
+ /** Where a run's evidence output lives. */
40
+ export declare function evidenceDir(cwd: string, runId: string): string;
41
+ /**
42
+ * Run each command and write its output to the run's state dir.
43
+ *
44
+ * Sequential: these are the project's own whole-repo checks, and running a
45
+ * typecheck, a build and a test suite at once on the machine the user is working on
46
+ * would make each of them slower and their results less reproducible.
47
+ */
48
+ export declare function runGateEvidence(deps: EvidenceRunDeps): Promise<GateEvidence>;
49
+ /** One prompt line per command: what ran, how it ended, and the file the child
50
+ * reads instead of running it again. */
51
+ export declare function evidenceVerifyFindings(evidence: GateEvidence): string[];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * gate-evidence — the project's own check and build commands, run ONCE per tree by
3
+ * the parent, so a gate child reads a result instead of producing one.
4
+ *
5
+ * The failure this closes: the verify child is told to run the project's own
6
+ * commands; so is the lint-fix child after it; so is the verify child after that.
7
+ * One real run spent 2755 s inside gate children re-running the same suites 49
8
+ * times, and a single gate session ran one of them seven times over a tree that
9
+ * changed twice.
10
+ *
11
+ * What makes pre-supplying safe is the CLASS column the run context already holds:
12
+ * `check` and `build` terminate on their own, `serve` does not, and a dev server
13
+ * launched here would hang the gate before the child ever started. So `serve` is
14
+ * never run — the filter lives in `RunContext.gateEvidenceFor`, which owns the
15
+ * verdicts.
16
+ *
17
+ * Nothing observed is nothing claimed. A command that could not run on this machine
18
+ * is recorded SKIPPED with the env-gap ladder's own reason (command-run.ts), never
19
+ * as a failure: the ladder is the single statement of what an ending MEANS, and a
20
+ * second statement of it here would drift from the gate's.
21
+ *
22
+ * The output files live in the run's state dir, OUTSIDE the worktree. The children
23
+ * that read them run under the git-state guard, which restores the tree around
24
+ * them; evidence written into the tree would be reverted between being produced and
25
+ * being read.
26
+ */
27
+ import * as fsp from 'node:fs/promises';
28
+ import * as path from 'node:path';
29
+ import { classifyCommandRun, leadingBin, spawnCommand } from './command-run.js';
30
+ import { resolveRunner, runnerEnv } from './runner-resolve.js';
31
+ import { NOT_RUN } from './run-context.js';
32
+ import { stateDir } from './state-dir.js';
33
+ /** Where a run's evidence output lives. */
34
+ export function evidenceDir(cwd, runId) {
35
+ return path.join(stateDir(cwd, runId), 'evidence');
36
+ }
37
+ /** The output file, readable on its own once the session that produced it is gone. */
38
+ function evidenceFile(cmd, ending, stdout, stderr) {
39
+ return [`$ ${cmd}`, ending, '', stdout, stderr].join('\n');
40
+ }
41
+ /**
42
+ * Run each command and write its output to the run's state dir.
43
+ *
44
+ * Sequential: these are the project's own whole-repo checks, and running a
45
+ * typecheck, a build and a test suite at once on the machine the user is working on
46
+ * would make each of them slower and their results less reproducible.
47
+ */
48
+ export async function runGateEvidence(deps) {
49
+ if (deps.commands.length === 0)
50
+ return { commands: [] };
51
+ const dir = evidenceDir(deps.cwd, deps.runId);
52
+ await fsp.mkdir(dir, { recursive: true });
53
+ const run = deps.run ?? spawnCommand;
54
+ const commands = [];
55
+ for (const [i, v] of deps.commands.entries()) {
56
+ const outputPath = path.join(dir, `${i + 1}.out`);
57
+ deps.onCommand?.(v.cmd);
58
+ // A verified command is a SHELL line, not an argv (`bun run lint`, `make -j2
59
+ // build`), so it runs through `sh -c`; its leading word still resolves through
60
+ // runner-resolve, or a login-shell-stripped PATH makes every command a gap.
61
+ const bin = leadingBin(v.cmd);
62
+ const runner = bin === null ? null : resolveRunner(bin);
63
+ const r = await run({
64
+ cwd: v.cwd,
65
+ bin: 'sh',
66
+ args: ['-c', v.cmd],
67
+ timeoutMs: deps.timeoutMs,
68
+ env: runner ? runnerEnv(runner) : { ...process.env },
69
+ ...(deps.signal === undefined ? {} : { signal: deps.signal })
70
+ });
71
+ const verdict = classifyCommandRun(r);
72
+ const gap = verdict.outcome === 'gap' ? verdict.detail : undefined;
73
+ await fsp.writeFile(outputPath, evidenceFile(v.cmd, gap === undefined ? `exit ${r.status}` : `skipped — ${gap}`, r.stdout, r.stderr), 'utf8');
74
+ commands.push({
75
+ cmd: v.cmd,
76
+ cwd: v.cwd,
77
+ exitCode: gap === undefined ? (r.status ?? NOT_RUN) : NOT_RUN,
78
+ outputPath,
79
+ treeHash: deps.treeHash,
80
+ ...(gap === undefined ? {} : { gap })
81
+ });
82
+ }
83
+ return { commands };
84
+ }
85
+ /** One prompt line per command: what ran, how it ended, and the file the child
86
+ * reads instead of running it again. */
87
+ export function evidenceVerifyFindings(evidence) {
88
+ return evidence.commands.map(c => `\`${c.cmd}\` — ${c.gap === undefined ? `exit ${c.exitCode}` : `SKIPPED (${c.gap})`}`
89
+ + ` — full output: ${c.outputPath}`);
90
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * gate-resolution — what to DO about a verify FAIL, as one ordered decision table.
3
+ *
4
+ * WHY A TABLE. The gate loop used to answer this with three overlapping booleans
5
+ * over five predicates, and then RE-DERIVE the same branches afterwards to name
6
+ * them in the trail. In AUTO_0002 that shape cost: a correct judge ACCEPT
7
+ * overridden by a "rescue" attempt (41 minutes, 16 suppressions shipped, no
8
+ * debt), a spec contradiction that looped three rounds with no exit, and five
9
+ * defects that left the gate with no ledger entry at all. One row per decision in
10
+ * precedence order is the whole policy, and the row that fired NAMES itself — so
11
+ * the durable trail can no longer disagree with the branch that produced it.
12
+ *
13
+ * THERE IS NO RESCUE ACTION. A judge ACCEPT comes from a pass that read the real
14
+ * workspace and ran the real commands; spending an implementation re-run "just in
15
+ * case" overrides that verdict with nothing.
16
+ *
17
+ * Same ordered-data shape as `FAILURE_RULES` (src/workers/worker-failure.ts):
18
+ * first match wins, and row order IS the precedence.
19
+ */
20
+ import type { VerifyFailClass } from './verify-work.js';
21
+ import type { ResolutionRecommendation } from './verify-resolution.js';
22
+ import type { DebtOrigin } from './accept-debt.js';
23
+ /**
24
+ * A criterion the task CANNOT meet without an edit its own spec forbids.
25
+ *
26
+ * Both gates that can prove one mint this shape: the bounded lint fix, whose
27
+ * static findings trace to a frozen path, and the resolution judge, through its
28
+ * `VERIFY-RESOLUTION: BLOCKED-BY-FROZEN` marker. It is the one input no re-run
29
+ * and no human ACCEPT-vs-AUTOFIX choice can move — the fix the check demands is
30
+ * the edit the spec denies — so it outranks every other row below.
31
+ */
32
+ export interface SpecContradiction {
33
+ /** The acceptance criterion (or static check) that cannot be satisfied. May be
34
+ * empty when the judge named only the path. */
35
+ criterion: string;
36
+ /** The spec-frozen path whose edit is the only way to satisfy it. */
37
+ frozenPath: string;
38
+ }
39
+ /** Everything the decision depends on. Nothing else may enter the table. */
40
+ export interface ResolutionInput {
41
+ failClass: VerifyFailClass;
42
+ /** What the resolution judge recommended, or the default when it was not consulted. */
43
+ recommend: ResolutionRecommendation;
44
+ /** Rule 5c: a spec-required check could not RUN because its tooling is absent. */
45
+ unobserved: boolean;
46
+ contradiction: SpecContradiction | null;
47
+ /** Unattended AUTOFIX attempts already spent on this task. */
48
+ attempts: number;
49
+ /** Nobody can be asked (YOLO mode) — the picker is unreachable. */
50
+ unattended: boolean;
51
+ }
52
+ export type ResolutionRuleId = 'spec-contradiction' | 'judge-accept' | 'no-autofix-budget' | 'budget-spent' | 'autofix';
53
+ /**
54
+ * What the gate loop does next. `debtOrigin` is non-null exactly on `accept`,
55
+ * which is the loop's terminal exit: a defect may not leave the gate without a
56
+ * ledger entry, and the type — not a runtime check — is what enforces it. `ask`
57
+ * and `autofix` carry null because the human's answer (or the next round) names
58
+ * the origin instead.
59
+ */
60
+ export type Disposition = {
61
+ rule: ResolutionRuleId;
62
+ action: 'autofix';
63
+ debtOrigin: null;
64
+ reason: string;
65
+ } | {
66
+ rule: ResolutionRuleId;
67
+ action: 'accept';
68
+ debtOrigin: DebtOrigin;
69
+ reason: string;
70
+ } | {
71
+ rule: ResolutionRuleId;
72
+ action: 'ask';
73
+ debtOrigin: null;
74
+ reason: string;
75
+ };
76
+ /**
77
+ * How many UNATTENDED implementation re-runs each FAIL class is worth.
78
+ *
79
+ * Zero is a statement, not a disabled feature: an absent tool is not installed by
80
+ * re-running the work that needed it, and a verification pass that could not run
81
+ * is not repaired by changing the code it failed to judge. Spending three turns
82
+ * on either is how a run burns an hour to arrive where round one already was.
83
+ */
84
+ export declare const AUTOFIX_BUDGET: Record<VerifyFailClass, number>;
85
+ /**
86
+ * How many times the /task-auto loop will START one plan entry before abandoning it.
87
+ *
88
+ * Derived, not chosen: one run to reach the gate at all, plus the re-runs the most
89
+ * forgiving fail class is worth inside that run. An entry that comes back for a
90
+ * further attempt has already spent a full autofix budget without converging, and
91
+ * the plan's remaining entries are worth more than its next round. Raising a class
92
+ * budget raises this with it, which is the relationship that should hold.
93
+ */
94
+ export declare const ENTRY_ATTEMPT_BUDGET: number;
95
+ /** The budget in force, honouring an UNOBSERVED flag on any class. */
96
+ export declare function autofixBudget(i: Pick<ResolutionInput, 'failClass' | 'unobserved'>): number;
97
+ export declare function contradictionReason(c: SpecContradiction): string;
98
+ /**
99
+ * The ordered ladder. FIRST MATCH WINS, and this order is the only statement of
100
+ * the policy:
101
+ *
102
+ * 1. `spec-contradiction` — the fix the check demands is the edit the spec
103
+ * forbids. First, because every row below would otherwise spend a budget,
104
+ * a human's attention or both on a loop that cannot converge (0053 ran three
105
+ * rounds past this exact fact).
106
+ * 2. `judge-accept` — before any budget arithmetic, because that arithmetic is
107
+ * precisely what used to override a correct ACCEPT with a rescue attempt.
108
+ * 3. `no-autofix-budget` — a class worth zero attempts. Its own row, not folded
109
+ * into the one below, because a budget that was never offered must not be
110
+ * reported as one that was spent.
111
+ * 4. `budget-spent` — consecutive unattended attempts that all still FAIL. A
112
+ * person breaks the loop, or (unattended) it terminates with a debt.
113
+ * 5. `autofix` — unconditional, so the table is total.
114
+ */
115
+ export declare const RESOLUTION_RULES: ReadonlyArray<{
116
+ id: ResolutionRuleId;
117
+ match: (i: ResolutionInput) => Disposition | null;
118
+ }>;
119
+ export declare function resolveDisposition(i: ResolutionInput): Disposition;