@mjasnikovs/pi-task 0.42.2 → 0.42.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
15
15
  import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
16
16
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
17
17
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
18
+ import { defersBreakage } from './deferred-breakage.js';
18
19
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
19
20
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
20
21
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
@@ -220,6 +221,10 @@ async function triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBl
220
221
  text = await deps.runChild('clarify-triage', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
221
222
  }
222
223
  const parsed = parseAutoAnswer(text);
224
+ if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
225
+ logPlanDebug(cwd, `clarify-triage answer defers a breakage to a nonexistent owner — surfacing: ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
226
+ return null;
227
+ }
223
228
  if (parsed.kind === 'answered') {
224
229
  logPlanDebug(cwd, `clarify-triage auto-resolved (spec-settled): ${question.replace(/\s+/g, ' ').slice(0, 100)}`
225
230
  + ` → ${parsed.text.replace(/\s+/g, ' ').slice(0, 100)}`);
@@ -0,0 +1,3 @@
1
+ export declare function defersBreakage(answer: string): boolean;
2
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
3
+ export declare function deferredBreakageReaskHint(answer: string): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Does an auto-answer hand a breakage to someone who does not exist?
3
+ *
4
+ * MEASURED (mx5-n TASK_0004, 2026-09-17): "flag the test/migrate.test.ts breakage
5
+ * as a known issue for the test owner". Nothing in a /task-auto run owns a test:
6
+ * the answer was stamped `(auto)`, verify passed the task with the suite red, and
7
+ * four tasks later an unsatisfiable spec looped until the runaway guard fired.
8
+ *
9
+ * This is the deterministic backstop behind the prompt's GREEN-SUITE CHECK: a
10
+ * model that ignores the rule still cannot promote a deferral into a decision.
11
+ * The phrases are the ones a model reaches for when it wants to defer, not the
12
+ * word "test" — "add a test later" is a plan, not a deferral.
13
+ */
14
+ const DEFERRAL_PHRASES = [
15
+ /\bknown[- ]issue\b/i,
16
+ /\b(?:test|suite|file|module)[- ]owner\b/i,
17
+ /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i,
18
+ /\bowned by\s+(?:whoever|the\s+\w+\s+owner|a\s+later\s+(?:step|task))/i,
19
+ /\b(?:a|the)\s+later\s+(?:step|task)\s+(?:will|should|can|to)\s+(?:fix|revisit|update|repair|address)/i,
20
+ /\bleave\s+(?:the\s+)?(?:test|tests|suite|failure|breakage)\s+(?:failing|red|broken|as[- ]is)\b/i,
21
+ /\baccept(?:ing)?\s+(?:that\s+)?.{0,60}?\b(?:test|tests|suite|assertions?|lint|build)\b.{0,80}?\b(?:fail|failing|red|broken)\b/i,
22
+ /\bflag(?:ged|ging)?\s+(?:it\s+|this\s+|the\s+\S+\s+)?(?:as\s+)?(?:a\s+)?(?:known|for\s+(?:the|a|whoever))/i,
23
+ /\b(?:owned|as the owned|as a)\s+follow-?up\b/i,
24
+ /\bleft\s+for\s+(?:whoever|the\s+\w+\s+owner)\b/i,
25
+ /\bownership\s+belongs\s+to\b/i
26
+ ];
27
+ /** "do NOT defer", "not a deferral to a test owner", "rather than flag it" — the
28
+ * phrase is named to reject it. MEASURED: a treatment answer did exactly that. */
29
+ const NEGATION_BEFORE = /\b(?:not|never|no|don't|do not|rather than|instead of|isn't|is not|without)\b[^.;]{0,40}$/i;
30
+ export function defersBreakage(answer) {
31
+ return DEFERRAL_PHRASES.some(re => {
32
+ const m = new RegExp(re.source, re.flags + (re.flags.includes('g') ? '' : 'g'));
33
+ for (const hit of answer.matchAll(m)) {
34
+ const before = answer.slice(Math.max(0, hit.index - 60), hit.index);
35
+ if (!NEGATION_BEFORE.test(before))
36
+ return true;
37
+ }
38
+ return false;
39
+ });
40
+ }
41
+ /** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
42
+ export function deferredBreakageReaskHint(answer) {
43
+ return ('[SYSTEM NOTE: Your previous answer deferred a breakage to someone who does not '
44
+ + `exist — "${answer.replace(/\s+/g, ' ').slice(0, 160)}". No later step owns a `
45
+ + 'failing test, lint, or build; each one inherits it and is told not to touch it. '
46
+ + 'Re-run the GREEN-SUITE CHECK: answer with the option that keeps the suite green, '
47
+ + 'naming the test file this task must also update. Output ONLY the tagged lines.]');
48
+ }
@@ -46,7 +46,7 @@
46
46
  */
47
47
  import { existsSync, readFileSync } from 'node:fs';
48
48
  import * as path from 'node:path';
49
- import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
49
+ import { runRepoHealthCheck, discoverHealthCommands, discoverTestCommands } from './repo-health-check.js';
50
50
  import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
51
51
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
52
52
  import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
@@ -83,58 +83,21 @@ import { VERIFY_FAIL_PREFIX } from './verify-work.js';
83
83
  * tool-by-tool is not the fix either.
84
84
  */
85
85
  export function discoverIntegrationCommands(cwd) {
86
- if (existsSync(path.join(cwd, 'package.json'))) {
87
- const s = packageScripts(cwd);
88
- const cmds = [];
89
- // Every test-shaped script, not just the one literally named `test`: a
90
- // project's only browser-executing suite is often `test:ct` or similar, and
91
- // looking for `test` alone never runs it. Plain `test` leads, then every
92
- // `test:`/`test_`/`test-` prefixed name in declaration order (Array#sort is
93
- // stable), then `build`. Measured on a manifest declaring test:ct, build,
94
- // test_unit, test-e2e, test, testing and pretest, the result is exactly
95
- // test, test:ct, test_unit, test-e2e, build — `testing` and `pretest` do
96
- // not match. Env-gap SKIP still applies per command: a suite whose browser
97
- // or runtime is absent skips rather than fails (see runGateCommand).
98
- const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
99
- testNames.sort((a, b) => a === 'test' ? -1
100
- : b === 'test' ? 1
101
- : 0);
102
- for (const name of testNames)
103
- cmds.push(['bun', ['run', name]]);
104
- if (s.build)
105
- cmds.push(['bun', ['run', 'build']]);
106
- return { ecosystem: 'package.json', cmds };
107
- }
108
- if (existsSync(path.join(cwd, 'Makefile'))) {
109
- const cmds = [];
110
- for (const target of ['test', 'build']) {
111
- if (makeHasTarget(cwd, target))
112
- cmds.push(['make', [target]]);
86
+ const tests = discoverTestCommands(cwd);
87
+ const build = (() => {
88
+ if (existsSync(path.join(cwd, 'package.json'))) {
89
+ return packageScripts(cwd).build ? ['bun', ['run', 'build']] : null;
113
90
  }
114
- return { ecosystem: 'Makefile', cmds };
115
- }
116
- if (existsSync(path.join(cwd, 'Cargo.toml'))) {
117
- return {
118
- ecosystem: 'Cargo.toml',
119
- cmds: [
120
- ['cargo', ['test', '--quiet']],
121
- ['cargo', ['build', '--quiet']]
122
- ]
123
- };
124
- }
125
- if (existsSync(path.join(cwd, 'go.mod'))) {
126
- return {
127
- ecosystem: 'go.mod',
128
- cmds: [
129
- ['go', ['test', './...']],
130
- ['go', ['build', './...']]
131
- ]
132
- };
133
- }
134
- if (existsSync(path.join(cwd, 'pyproject.toml'))) {
135
- return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
136
- }
137
- return { ecosystem: null, cmds: [] };
91
+ if (existsSync(path.join(cwd, 'Makefile'))) {
92
+ return makeHasTarget(cwd, 'build') ? ['make', ['build']] : null;
93
+ }
94
+ if (existsSync(path.join(cwd, 'Cargo.toml')))
95
+ return ['cargo', ['build', '--quiet']];
96
+ if (existsSync(path.join(cwd, 'go.mod')))
97
+ return ['go', ['build', './...']];
98
+ return null;
99
+ })();
100
+ return { ecosystem: tests.ecosystem, cmds: build ? [...tests.cmds, build] : tests.cmds };
138
101
  }
139
102
  /**
140
103
  * Per-ecosystem lockfile↔manifest consistency checks. A check applies only when
@@ -537,7 +537,7 @@ export async function healthBaselineFor(cwd, taskId, signal) {
537
537
  return stored;
538
538
  const fresh = await lazyHealthBaseline({
539
539
  git: makeGit(cwd, signal),
540
- runHealthIn: dir => runRepoHealthCheck(dir, { signal })
540
+ runHealthIn: dir => runRepoHealthCheck(dir, { signal, withTests: true })
541
541
  });
542
542
  if (fresh) {
543
543
  await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
@@ -739,6 +739,7 @@ export function buildGateDeps(params) {
739
739
  }));
740
740
  return runRepoHealthCheck(cwd2, {
741
741
  signal,
742
+ withTests: true,
742
743
  onCommand: c => {
743
744
  running = c;
744
745
  }
@@ -934,9 +935,11 @@ export function buildGateDeps(params) {
934
935
  onStage: label => {
935
936
  stageLine = label;
936
937
  },
937
- // Deterministic whole-repo static-analysis gate — runs the project's
938
- // own lint/typecheck and fails on a real non-zero exit, independent of
939
- // the model-authored VERIFY block, which may not lint at all. ASYNC,
938
+ // Deterministic whole-repo gate — runs the project's own
939
+ // lint/typecheck AND its test suite, independent of the
940
+ // model-authored VERIFY block, which may not lint at all. The suite
941
+ // is judged only against the baseline below (a task that turned a
942
+ // green suite red is this task's FAIL, whatever its spec says). ASYNC,
940
943
  // so the lint does not starve pi-tui's nextTick-scheduled renders.
941
944
  //
942
945
  // ONE call, both arms. A baseline arm that dropped the signal or the
@@ -945,6 +948,7 @@ export function buildGateDeps(params) {
945
948
  // the LOADER, above.
946
949
  repoHealth: () => runRepoHealthCheck(cwd2, {
947
950
  signal,
951
+ withTests: true,
948
952
  onCommand: c => {
949
953
  stageLine = `repo health · ${c}`;
950
954
  }
@@ -22,7 +22,7 @@ import type { ToolingClass } from './run-context.js';
22
22
  * a recommendation instead of being taken silently.
23
23
  * - 'threw' — the child failed; there is no recommendation at all.
24
24
  */
25
- export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw';
25
+ export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw' | 'deferred-breakage';
26
26
  export type AutoAnswer = {
27
27
  kind: 'answered';
28
28
  text: string;
@@ -18,6 +18,7 @@ import { getConfig } from '../config/config.js';
18
18
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
19
19
  import { currentRunContext } from './run-context.js';
20
20
  import { readableMentions } from './mentions.js';
21
+ import { defersBreakage, deferredBreakageReaskHint } from './deferred-breakage.js';
21
22
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
22
23
  import { appendGateRecord, readSection, readTaskFile, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
23
24
  import { extractSection } from './task-parsers.js';
@@ -770,6 +771,37 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
770
771
  }
771
772
  }
772
773
  }
774
+ // Deterministic backstop behind the prompt's GREEN-SUITE CHECK: an answer
775
+ // that defers a breakage to "the test owner" gets ONE re-ask, and a second
776
+ // deferral is surfaced as an unsafe unknown — yolo.ts skips it, a human
777
+ // sees it. Promoting it is how mx5-n TASK_0004 turned the suite red for
778
+ // the rest of the run.
779
+ if (parsed.kind === 'answered' && defersBreakage(parsed.text)) {
780
+ deps.logDebug?.('grill-auto: answer defers a breakage to a nonexistent owner — re-asking once');
781
+ let reasked = null;
782
+ try {
783
+ const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(deferredBreakageReaskHint(parsed.text), basePrompt));
784
+ if (autoAnswerHasTag(text2))
785
+ reasked = parseAutoAnswer(text2);
786
+ }
787
+ catch (e) {
788
+ if (isFatalChildCause(e))
789
+ throw e;
790
+ reasked = null;
791
+ }
792
+ if (reasked !== null && reasked.kind === 'answered' && !defersBreakage(reasked.text)) {
793
+ parsed = reasked;
794
+ }
795
+ else {
796
+ deps.logDebug?.('grill-auto: answer still defers the breakage — surfacing to user');
797
+ parsed = {
798
+ kind: 'unknown',
799
+ suggested: reasked?.kind === 'answered' ? reasked.text : parsed.text,
800
+ raw: (reasked ?? parsed).raw,
801
+ reason: 'deferred-breakage'
802
+ };
803
+ }
804
+ }
773
805
  // Surviving-unknown routing: an integration / build-wiring unknown whose
774
806
  // wrong guess is a structural landmine must NOT be silently auto-answered.
775
807
  // We first try to ground it from fetched docs (the enrichment fan-out
@@ -36,6 +36,15 @@ declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap: string | undefin
36
36
  declare const RESEARCH_CONTEXT_PROMPT: (refined: string) => string;
37
37
  declare const RESEARCH_TOOLING_PROMPT: (refined: string) => string;
38
38
  declare const GRILL_GEN_PROMPT: (refined: string, research: string, priorQA: string) => string;
39
+ /**
40
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
41
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
42
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
43
+ * as "add three files, change nothing else, make the whole suite pass" — an
44
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
45
+ * Exported on its own so a harness can measure the prompt with and without it.
46
+ */
47
+ export declare const GRILL_GREEN_SUITE_CHECK = "1. GREEN-SUITE CHECK \u2014 if an option would leave an existing test, lint, or build failing after this task (\"known issue\", \"for the test owner\", \"whoever revisits it\", \"a later step will fix it\"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as \"only create file X\" does NOT settle this \u2014 a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit \"ANSWER: <that option>, and update <the test file> so the suite stays green\". If no offered option does, emit \"ANSWER: <the smallest change that keeps the suite green, naming the test file to update>\". Never answer with a deferral.";
39
48
  declare const GRILL_AUTO_ANSWER_PROMPT: (refined: string, research: string, question: string) => string;
40
49
  export declare const GRILL_AUTO_FORMAT_HINT: string;
41
50
  declare function composeRetryEmphasis(problem: string): string;
@@ -208,6 +208,15 @@ ${research}
208
208
 
209
209
  Answers so far:
210
210
  ${priorQA.trim() || '(none yet)'}`;
211
+ /**
212
+ * The first triage check. MEASURED (mx5-n TASK_0004, 2026-09-17): the auto-answer
213
+ * chose "flag the test breakage as a known issue for the test owner". No task owns
214
+ * a test, so the suite stayed red through four tasks, and the fifth was specced
215
+ * as "add three files, change nothing else, make the whole suite pass" — an
216
+ * unsatisfiable spec whose implementer looped until the runaway guard fired.
217
+ * Exported on its own so a harness can measure the prompt with and without it.
218
+ */
219
+ export const GRILL_GREEN_SUITE_CHECK = `1. GREEN-SUITE CHECK — if an option would leave an existing test, lint, or build failing after this task ("known issue", "for the test owner", "whoever revisits it", "a later step will fix it"), that option is NOT available. Nobody downstream owns a breakage: every later task inherits a red suite it is told not to touch, and the run stalls there. A scope rule such as "only create file X" does NOT settle this — a test that asserts on X's output is part of X's change, so updating that test is in scope. If one option keeps the suite green, emit "ANSWER: <that option>, and update <the test file> so the suite stays green". If no offered option does, emit "ANSWER: <the smallest change that keeps the suite green, naming the test file to update>". Never answer with a deferral.`;
211
220
  const GRILL_AUTO_ANSWER_PROMPT = (refined, research, question) => `You are pre-answering a clarifying question for an AI coding task. You have the refined task and the research notes. You may use the read tool on files mentioned in the research (e.g. package.json) if it helps.
212
221
 
213
222
  Your job is to produce a recommended default. If the default is one the user would almost certainly accept without thinking, tag it ANSWER and skip the user. Otherwise tag it UNKNOWN. YOU MUST PROPOSE A DEFAULT — never refuse, never leave it empty.
@@ -223,11 +232,13 @@ API-GROUNDING RULE: never name a concrete API (\`Namespace.member\`, an imported
223
232
 
224
233
  TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
225
234
 
226
- 1. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
235
+ ${GRILL_GREEN_SUITE_CHECK}
236
+
237
+ 2. ALREADY-DECIDED CHECK — scan the refined task and research for a value, shape, response body, schema, route, or requirement that ALREADY determines the answer. If one does, this is a fact, not a preference. Emit "ANSWER: <value taken from that source>". If your instinct or a "nicer" alternative contradicts that source, the SOURCE WINS — never override a stated contract with a preferred default. (E.g. a stated response shape { items, total, page, pageSize } already answers a pagination question — page/offset — you may NOT answer "cursor".)
227
238
 
228
- 2. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
239
+ 3. FUNCTIONAL-REQUIREMENT CHECK — if the question is whether to include or defer a package, config file, or setup that something THIS task configures needs in order to FUNCTION (a build plugin's engine or required peer dependency, an entry file the build reads, a runtime module an import resolves to), then a "minimize / keep it minimal / defer to the step that uses it" preference does NOT override that functional requirement. A tool you wire up this step must have its required pieces present this step or the build/step is broken. Emit "ANSWER: <include it now, because configuring X requires it>". Do not defer something the step's own configuration depends on.
229
240
 
230
- 3. PREFERENCE — only if neither check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
241
+ 4. PREFERENCE — only if no check fires (a genuine choice the sources do not determine), apply the REVERSIBILITY TEST.
231
242
 
232
243
  REVERSIBILITY TEST:
233
244
  ANSWER: cheap to undo (output style, policy, report format, obvious scope, standard convention).
@@ -56,6 +56,21 @@ export declare function discoverHealthCommands(cwd: string): {
56
56
  ecosystem: string | null;
57
57
  cmds: HealthCommand[];
58
58
  };
59
+ /**
60
+ * The project's OWN test commands, in the order the run-end gate runs them. One
61
+ * statement for both gates: final-gate.ts appends `build` to this list for the
62
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
63
+ * per-task differential, so the two cannot drift apart.
64
+ *
65
+ * Every test-shaped script, not just the one literally named `test`: a project's
66
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
67
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
68
+ * declaration order (Array#sort is stable).
69
+ */
70
+ export declare function discoverTestCommands(cwd: string): {
71
+ ecosystem: string | null;
72
+ cmds: HealthCommand[];
73
+ };
59
74
  /** Progress hook: called with each command's label as it STARTS, so a caller can
60
75
  * keep a live status line naming what is currently running. */
61
76
  export type HealthProgress = (command: string) => void;
@@ -86,4 +101,7 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
86
101
  onCommand?: HealthProgress;
87
102
  /** The spawner. Injected so a verdict is testable without a real shell. */
88
103
  run?: CommandRunner;
104
+ /** Also run the project's test commands, after the statics. Only a caller
105
+ * that will judge the result DIFFERENTIALLY may set this — see the header. */
106
+ withTests?: boolean;
89
107
  }): Promise<HealthOutcome>;
@@ -14,11 +14,18 @@
14
14
  * verify gate's `repo-health` FAIL (verify-work.ts), which reaches the
15
15
  * AUTOFIX / ACCEPT picker in verify-resolution.ts.
16
16
  *
17
- * Scope is deliberately STATIC ANALYSIS ONLY (lint / typecheck / clippy / vet), never
18
- * `test`, `build`, `run`, or anything that boots a server or needs a database. Those
19
- * depend on external services the verify prompt already carves out as an environment
20
- * gap, so running them here would blame code for a missing database. Static analysis
21
- * is hermetic: it needs no network, no service and no fixtures.
17
+ * Scope is STATIC ANALYSIS (lint / typecheck / clippy / vet) by default, never
18
+ * `build`, `run`, or anything that boots a server. Static analysis is hermetic: it
19
+ * needs no network, no service and no fixtures, so its absolute exit code decides.
20
+ *
21
+ * The TEST suite joins only under `withTests`, and only for the DIFFERENTIAL
22
+ * (health-baseline.ts). MEASURED (mx5-n TASK_0004, 2026-09-17): a task turned a
23
+ * green suite red, its spec called that a "known issue for the test owner", the
24
+ * model gate passed it, and four tasks later the run stalled on an unsatisfiable
25
+ * spec. A suite that needs a database fails the same way before and after the
26
+ * task, which the differential reads as pre-existing — so running it here does
27
+ * not blame code for a missing database, and DOES blame the task that broke a
28
+ * suite the baseline saw green.
22
29
  *
23
30
  * Absence is a PASS, two ways: (1) no recognised manifest at all (a pure-docs or
24
31
  * config-only repo has nothing that can regress); (2) a manifest with no static-check
@@ -118,6 +125,43 @@ export function discoverHealthCommands(cwd) {
118
125
  }
119
126
  return { ecosystem: null, cmds: [] };
120
127
  }
128
+ /**
129
+ * The project's OWN test commands, in the order the run-end gate runs them. One
130
+ * statement for both gates: final-gate.ts appends `build` to this list for the
131
+ * integration half, and `runRepoHealthCheck` runs it under `withTests` for the
132
+ * per-task differential, so the two cannot drift apart.
133
+ *
134
+ * Every test-shaped script, not just the one literally named `test`: a project's
135
+ * only browser-executing suite is often `test:ct`, and looking for `test` alone
136
+ * never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
137
+ * declaration order (Array#sort is stable).
138
+ */
139
+ export function discoverTestCommands(cwd) {
140
+ if (existsSync(path.join(cwd, 'package.json'))) {
141
+ const s = packageScripts(cwd);
142
+ const names = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
143
+ names.sort((a, b) => a === 'test' ? -1
144
+ : b === 'test' ? 1
145
+ : 0);
146
+ return { ecosystem: 'package.json', cmds: names.map(n => ['bun', ['run', n]]) };
147
+ }
148
+ if (existsSync(path.join(cwd, 'Makefile'))) {
149
+ return {
150
+ ecosystem: 'Makefile',
151
+ cmds: makeHasTarget(cwd, 'test') ? [['make', ['test']]] : []
152
+ };
153
+ }
154
+ if (existsSync(path.join(cwd, 'Cargo.toml'))) {
155
+ return { ecosystem: 'Cargo.toml', cmds: [['cargo', ['test', '--quiet']]] };
156
+ }
157
+ if (existsSync(path.join(cwd, 'go.mod'))) {
158
+ return { ecosystem: 'go.mod', cmds: [['go', ['test', './...']]] };
159
+ }
160
+ if (existsSync(path.join(cwd, 'pyproject.toml'))) {
161
+ return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
162
+ }
163
+ return { ecosystem: null, cmds: [] };
164
+ }
121
165
  /** The nothing-to-run outcome, shared by both runners. */
122
166
  function noCommandOutcome(ecosystem) {
123
167
  return {
@@ -150,12 +194,18 @@ function noCommandOutcome(ecosystem) {
150
194
  * widget has just been cleared.
151
195
  */
152
196
  export async function runRepoHealthCheck(cwd, opts = {}) {
153
- const { ecosystem, cmds } = discoverHealthCommands(cwd);
197
+ const statics = discoverHealthCommands(cwd);
198
+ const tests = opts.withTests ? discoverTestCommands(cwd) : { ecosystem: null, cmds: [] };
199
+ const ecosystem = statics.ecosystem ?? tests.ecosystem;
200
+ const cmds = [
201
+ ...statics.cmds.map(([bin, args]) => ({ bin, args, test: false })),
202
+ ...tests.cmds.map(([bin, args]) => ({ bin, args, test: true }))
203
+ ];
154
204
  if (!ecosystem || cmds.length === 0)
155
205
  return noCommandOutcome(ecosystem);
156
206
  const run = opts.run ?? spawnCommand;
157
207
  const commands = [];
158
- for (const [bin, args] of cmds) {
208
+ for (const { bin, args, test } of cmds) {
159
209
  const cmd = `${bin} ${args.join(' ')}`;
160
210
  opts.onCommand?.(cmd);
161
211
  // Runner resolution: a PATH-stripped environment must not
@@ -175,12 +225,11 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
175
225
  // ladder's `tail` keeps 400 characters, and that difference is real — a
176
226
  // truncated lint report is unactionable. So the run is classified, not
177
227
  // consumed: the verdict decides, the raw streams are what we show.
178
- // `runtimeGap: false` this ladder is NARROWER than the gate's. The
179
- // browser/runtime row was written for the gate's TEST commands; here the
180
- // commands are lint and typecheck, and its pattern matches ordinary
181
- // English, so a genuine report quoting "browsers are not installed" would
182
- // skip the static check and certify the repo healthy.
183
- const verdict = classifyCommandRun(r, [], { runtimeGap: false });
228
+ // `runtimeGap` only for a TEST command. The browser/runtime row was
229
+ // written for the gate's test commands and its pattern matches ordinary
230
+ // English, so on lint and typecheck a genuine report quoting "browsers are
231
+ // not installed" would skip the static check and certify the repo healthy.
232
+ const verdict = classifyCommandRun(r, [], { runtimeGap: test });
184
233
  if (verdict.outcome !== 'fail') {
185
234
  const passed = verdict.outcome === 'pass';
186
235
  commands.push({ cmd, outcome: passed ? 'pass' : 'skip', exitCode: passed ? 0 : null });
@@ -197,7 +246,7 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
197
246
  }
198
247
  return {
199
248
  ok: true,
200
- reason: `${ecosystem}: static checks passed`,
249
+ reason: `${ecosystem}: static checks${tests.cmds.length > 0 ? ' and tests' : ''} passed`,
201
250
  ecosystem,
202
251
  commands,
203
252
  output: ''
@@ -44,11 +44,12 @@ export declare function yoloPickAnswer(enabled: boolean, opts: {
44
44
  }): YoloPick;
45
45
  /**
46
46
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
47
- * four `reason` tags an unknown can carry — `api-synthesis`, `integration`,
48
- * `threw`, `model-unknown` — only ANTI-SYNTHESIS is unsafe. The other three carry
49
- * an ordinary best-effort recommendation, which is precisely what a human would be
50
- * shown as the green card. The variants are told apart by that tag, never by
51
- * pattern-matching the answer text.
47
+ * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
48
+ * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
49
+ * API nobody verified, the other hands a red suite to an owner nobody is. The other
50
+ * three carry an ordinary best-effort recommendation, which is precisely what a
51
+ * human would be shown as the green card. The variants are told apart by that tag,
52
+ * never by pattern-matching the answer text.
52
53
  */
53
54
  export declare function yoloPickAutoAnswer(enabled: boolean, auto: AutoAnswer): YoloPick;
54
55
  /**
package/dist/task/yolo.js CHANGED
@@ -68,11 +68,12 @@ export function yoloPickAnswer(enabled, opts) {
68
68
  }
69
69
  /**
70
70
  * The same policy expressed over an {@link AutoAnswer}, for the grill site. Of the
71
- * four `reason` tags an unknown can carry — `api-synthesis`, `integration`,
72
- * `threw`, `model-unknown` — only ANTI-SYNTHESIS is unsafe. The other three carry
73
- * an ordinary best-effort recommendation, which is precisely what a human would be
74
- * shown as the green card. The variants are told apart by that tag, never by
75
- * pattern-matching the answer text.
71
+ * five `reason` tags an unknown can carry — `api-synthesis`, `deferred-breakage`,
72
+ * `integration`, `threw`, `model-unknown` — the first two are unsafe: one names an
73
+ * API nobody verified, the other hands a red suite to an owner nobody is. The other
74
+ * three carry an ordinary best-effort recommendation, which is precisely what a
75
+ * human would be shown as the green card. The variants are told apart by that tag,
76
+ * never by pattern-matching the answer text.
76
77
  */
77
78
  export function yoloPickAutoAnswer(enabled, auto) {
78
79
  if (!enabled)
@@ -84,6 +85,9 @@ export function yoloPickAutoAnswer(enabled, auto) {
84
85
  ...(auto.alt !== undefined && { alt: auto.alt }),
85
86
  ...(auto.reason === 'api-synthesis' && {
86
87
  unsafe: 'the suggested answer names an unverified API identifier — needs a human'
88
+ }),
89
+ ...(auto.reason === 'deferred-breakage' && {
90
+ unsafe: 'the suggested answer leaves a test or build failing for an owner that does not exist — needs a human'
87
91
  })
88
92
  });
89
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.2",
3
+ "version": "0.42.3",
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",