@mjasnikovs/pi-task 0.42.2 → 0.42.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/task/accept-debt.d.ts +3 -2
- package/dist/task/accept-debt.js +5 -4
- package/dist/task/auto-orchestrator.js +13 -6
- package/dist/task/command-run.d.ts +12 -1
- package/dist/task/command-run.js +13 -0
- package/dist/task/deferred-breakage.d.ts +23 -0
- package/dist/task/deferred-breakage.js +110 -0
- package/dist/task/final-gate.js +15 -52
- package/dist/task/gate-deps.js +37 -14
- package/dist/task/gate-resolution.js +1 -0
- package/dist/task/health-baseline.d.ts +11 -4
- package/dist/task/health-baseline.js +21 -9
- package/dist/task/health-repair.d.ts +19 -4
- package/dist/task/health-repair.js +18 -6
- package/dist/task/parsers.d.ts +1 -1
- package/dist/task/phases.js +76 -44
- package/dist/task/prompts.d.ts +9 -0
- package/dist/task/prompts.js +14 -3
- package/dist/task/repo-health-check.d.ts +34 -7
- package/dist/task/repo-health-check.js +95 -19
- package/dist/task/run-context.d.ts +14 -0
- package/dist/task/run-context.js +29 -2
- package/dist/task/verify-work.d.ts +7 -1
- package/dist/task/verify-work.js +41 -11
- package/dist/task/yolo.d.ts +10 -5
- package/dist/task/yolo.js +19 -7
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseRepairTitleFile } from './root-cause-repair.js';
|
|
2
|
+
import { failClassOfReason } from './verify-work.js';
|
|
2
3
|
/** A path-like token: at least one directory separator, ending in a file name. */
|
|
3
4
|
const PATH_TOKEN_RE = /(?:[\w.@-]+[\\/])+[\w.@-]+\.\w+/g;
|
|
4
5
|
function normalisePath(p) {
|
|
@@ -22,17 +23,17 @@ function resolveTracked(token, cwd, tracked) {
|
|
|
22
23
|
return bySuffix.length === 1 ? bySuffix[0] : null;
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
|
-
* What a red health result is about
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* What a red health result is about: its first failing command that `mayRepair`
|
|
27
|
+
* admits. Null when there is none — a legacy baseline, a signal with no
|
|
28
|
+
* per-command detail, or only reds no repair can fix — so nothing to pin to.
|
|
28
29
|
*/
|
|
29
|
-
export function healthRedSubject(health, cwd, tracked) {
|
|
30
|
-
const failing = (health.commands ?? []).find(c => c.outcome === 'fail');
|
|
30
|
+
export function healthRedSubject(health, cwd, tracked, mayRepair = () => true) {
|
|
31
|
+
const failing = (health.commands ?? []).find(c => c.outcome === 'fail' && mayRepair(c));
|
|
31
32
|
if (!failing)
|
|
32
33
|
return null;
|
|
33
34
|
const files = [];
|
|
34
35
|
if (tracked) {
|
|
35
|
-
for (const m of (health.output ?? '').matchAll(PATH_TOKEN_RE)) {
|
|
36
|
+
for (const m of (failing.output ?? health.output ?? '').matchAll(PATH_TOKEN_RE)) {
|
|
36
37
|
const rel = resolveTracked(m[0], cwd, tracked);
|
|
37
38
|
if (rel !== null && !files.includes(rel))
|
|
38
39
|
files.push(rel);
|
|
@@ -40,6 +41,17 @@ export function healthRedSubject(health, cwd, tracked) {
|
|
|
40
41
|
}
|
|
41
42
|
return { command: failing.cmd, exitCode: failing.exitCode, files };
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Is a red TEST command owed? True when an open debt records a task's regression
|
|
46
|
+
* of it — an accepted `test suite:` FAIL naming the command. An inherited-health
|
|
47
|
+
* debt does not count: every task in a run whose suite needs a missing database
|
|
48
|
+
* records one.
|
|
49
|
+
*/
|
|
50
|
+
export function suiteRegressionOwed(cmd, openDebts) {
|
|
51
|
+
return openDebts.some(d => d.origin !== 'inherited-health'
|
|
52
|
+
&& failClassOfReason(d.reason) === 'test-suite'
|
|
53
|
+
&& d.reason.includes(`\`${cmd}\``));
|
|
54
|
+
}
|
|
43
55
|
// ─── Plan entry ──────────────────────────────────────────────────────────────
|
|
44
56
|
/**
|
|
45
57
|
* The plan title, in one of two fixed shapes the parser below recovers:
|
package/dist/task/parsers.d.ts
CHANGED
|
@@ -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;
|
package/dist/task/phases.js
CHANGED
|
@@ -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';
|
|
@@ -672,6 +673,55 @@ export async function phaseResearch(deps, refined, rawPrompt = '') {
|
|
|
672
673
|
}
|
|
673
674
|
return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
|
|
674
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Hold an answer to every guard. Each guard re-asks ONCE, and the answer that
|
|
678
|
+
* comes back faces every guard again: one that fixed a deferral by inventing an
|
|
679
|
+
* API is still caught. An answer that trips a guard it was already re-asked for,
|
|
680
|
+
* or a re-ask that produced no tagged answer, is surfaced as an unknown carrying
|
|
681
|
+
* that guard's reason. yolo.ts skips those, and a human sees them.
|
|
682
|
+
*/
|
|
683
|
+
async function guardAutoAnswer(deps, first, guards) {
|
|
684
|
+
const reasked = new Set();
|
|
685
|
+
let parsed = first;
|
|
686
|
+
while (parsed.kind === 'answered') {
|
|
687
|
+
const answer = parsed.text;
|
|
688
|
+
let tripped;
|
|
689
|
+
for (const g of guards) {
|
|
690
|
+
const prompt = g.reask(answer);
|
|
691
|
+
if (prompt !== null) {
|
|
692
|
+
tripped = { reason: g.reason, prompt };
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!tripped)
|
|
697
|
+
return parsed;
|
|
698
|
+
const surfaced = {
|
|
699
|
+
kind: 'unknown',
|
|
700
|
+
suggested: answer,
|
|
701
|
+
raw: parsed.raw,
|
|
702
|
+
reason: tripped.reason
|
|
703
|
+
};
|
|
704
|
+
if (reasked.has(tripped.reason)) {
|
|
705
|
+
deps.logDebug?.(`grill-auto: ${tripped.reason} survived its re-ask — surfacing to user`);
|
|
706
|
+
return surfaced;
|
|
707
|
+
}
|
|
708
|
+
reasked.add(tripped.reason);
|
|
709
|
+
let again = null;
|
|
710
|
+
try {
|
|
711
|
+
const text = await runPhaseChild(deps, 'grill-auto', 'read', tripped.prompt);
|
|
712
|
+
if (autoAnswerHasTag(text))
|
|
713
|
+
again = parseAutoAnswer(text);
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
if (isFatalChildCause(e))
|
|
717
|
+
throw e;
|
|
718
|
+
}
|
|
719
|
+
if (again === null)
|
|
720
|
+
return surfaced;
|
|
721
|
+
parsed = again;
|
|
722
|
+
}
|
|
723
|
+
return parsed;
|
|
724
|
+
}
|
|
675
725
|
export async function phaseAutoAnswer(deps, refined, research, question) {
|
|
676
726
|
const docsFocusedFn = deps.docsFocused ?? docsFocused;
|
|
677
727
|
const fetchFocusedFn = deps.fetchFocused ?? fetchFocused;
|
|
@@ -723,53 +773,35 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
|
|
|
723
773
|
// otherwise a preamble line leaks out as the recommended answer.
|
|
724
774
|
text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
|
|
725
775
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
let reasked = null;
|
|
742
|
-
try {
|
|
743
|
-
const text2 = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(synthesizedApiReaskHint(synth, research), basePrompt));
|
|
744
|
-
if (autoAnswerHasTag(text2))
|
|
745
|
-
reasked = parseAutoAnswer(text2);
|
|
776
|
+
const parsed = await guardAutoAnswer(deps, parseAutoAnswer(text), [
|
|
777
|
+
// Anti-synthesis: the auto-answer invented `Bun.mkdirSync` while
|
|
778
|
+
// research's APIS section carried the correct list, and the invention
|
|
779
|
+
// was promoted into requirements + VERIFY. An API-shaped identifier
|
|
780
|
+
// absent from the research AND the question, in a namespace the
|
|
781
|
+
// research claims to cover, is re-asked with the verified lines injected.
|
|
782
|
+
{
|
|
783
|
+
reason: 'api-synthesis',
|
|
784
|
+
reask: answer => {
|
|
785
|
+
const synth = findSynthesizedApis(answer, question, research);
|
|
786
|
+
if (synth.length === 0)
|
|
787
|
+
return null;
|
|
788
|
+
deps.logDebug?.('grill-auto: unverified API identifier(s) in answer — '
|
|
789
|
+
+ synth.map(f => f.identifier).join(', '));
|
|
790
|
+
return prependHint(synthesizedApiReaskHint(synth, research), basePrompt);
|
|
746
791
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
|
|
758
|
-
parsed = {
|
|
759
|
-
kind: 'unknown',
|
|
760
|
-
suggested,
|
|
761
|
-
raw: still.raw,
|
|
762
|
-
// Tagged so a call site can tell this producer from the other
|
|
763
|
-
// two: the suggestion is PROVEN to name an unverified API, so
|
|
764
|
-
// it may only be judged by a human (yolo.ts must not take it).
|
|
765
|
-
reason: 'api-synthesis'
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
else {
|
|
769
|
-
parsed = reasked;
|
|
792
|
+
},
|
|
793
|
+
// Behind the prompt's GREEN-SUITE CHECK: promoting "flag it for the test
|
|
794
|
+
// owner" is how mx5-n TASK_0004 turned the suite red for the rest of the run.
|
|
795
|
+
{
|
|
796
|
+
reason: 'deferred-breakage',
|
|
797
|
+
reask: answer => {
|
|
798
|
+
if (!defersBreakage(answer))
|
|
799
|
+
return null;
|
|
800
|
+
deps.logDebug?.('grill-auto: answer defers a breakage to a nonexistent owner');
|
|
801
|
+
return prependHint(deferredBreakageReaskHint(answer), basePrompt);
|
|
770
802
|
}
|
|
771
803
|
}
|
|
772
|
-
|
|
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
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -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;
|
package/dist/task/prompts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
@@ -15,20 +15,26 @@ export interface HealthCommandResult {
|
|
|
15
15
|
outcome: 'pass' | 'fail' | 'skip';
|
|
16
16
|
/** Real exit status on a `fail`; null when nothing conclusive ran. */
|
|
17
17
|
exitCode: number | null;
|
|
18
|
+
/** Absent on a record written before the suite joined the check, which ran
|
|
19
|
+
* statics only. A test red is judged, owed and repaired differently. */
|
|
20
|
+
kind?: 'static' | 'test';
|
|
21
|
+
/** This command's own captured output, on a `fail` only. */
|
|
22
|
+
output?: string;
|
|
18
23
|
}
|
|
19
24
|
export interface HealthOutcome {
|
|
20
|
-
/** true → every discovered
|
|
21
|
-
* false → a discovered command actually ran and exited non-zero. */
|
|
25
|
+
/** true → every discovered check passed or could not run, or there was nothing
|
|
26
|
+
* to run. false → a discovered command actually ran and exited non-zero. */
|
|
22
27
|
ok: boolean;
|
|
23
|
-
/** Human-readable reason. On a fail, names
|
|
28
|
+
/** Human-readable reason. On a fail, names every failing command and its exit code. */
|
|
24
29
|
reason: string;
|
|
25
30
|
/** Which manifest drove discovery, or null when none was found. */
|
|
26
31
|
ecosystem: string | null;
|
|
27
|
-
/** Every command
|
|
28
|
-
*
|
|
32
|
+
/** Every discovered command, in run order. A red one does not stop the run: a
|
|
33
|
+
* command it skipped would be absent from both sides of the differential, which
|
|
34
|
+
* then cannot see that command break. */
|
|
29
35
|
commands: HealthCommandResult[];
|
|
30
36
|
/**
|
|
31
|
-
* First lines of the failing command's combined stderr+stdout — captured so a
|
|
37
|
+
* First lines of the first failing command's combined stderr+stdout — captured so a
|
|
32
38
|
* FAIL is explainable from artifacts alone. The exit code alone does not say
|
|
33
39
|
* what happened: eslint exits 1 for findings and 2 when it could not run at
|
|
34
40
|
* all (a missing config, say), so "`bun run lint` exited 2" is unreproducible
|
|
@@ -56,6 +62,22 @@ export declare function discoverHealthCommands(cwd: string): {
|
|
|
56
62
|
ecosystem: string | null;
|
|
57
63
|
cmds: HealthCommand[];
|
|
58
64
|
};
|
|
65
|
+
/**
|
|
66
|
+
* The project's OWN test commands, in the order the run-end gate runs them. One
|
|
67
|
+
* statement for both gates: final-gate.ts appends `build` to this list for the
|
|
68
|
+
* integration half, and `runRepoHealthCheck` runs it under `withTests` for the
|
|
69
|
+
* per-task differential, so the two cannot drift apart.
|
|
70
|
+
*
|
|
71
|
+
* Every test-shaped script, not just the one literally named `test`: a project's
|
|
72
|
+
* only browser-executing suite is often `test:ct`, and looking for `test` alone
|
|
73
|
+
* never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
|
|
74
|
+
* declaration order (Array#sort is stable). A watch-mode script is left out: it
|
|
75
|
+
* never exits, so all it can add is a timeout.
|
|
76
|
+
*/
|
|
77
|
+
export declare function discoverTestCommands(cwd: string): {
|
|
78
|
+
ecosystem: string | null;
|
|
79
|
+
cmds: HealthCommand[];
|
|
80
|
+
};
|
|
59
81
|
/** Progress hook: called with each command's label as it STARTS, so a caller can
|
|
60
82
|
* keep a live status line naming what is currently running. */
|
|
61
83
|
export type HealthProgress = (command: string) => void;
|
|
@@ -65,7 +87,7 @@ export type HealthProgress = (command: string) => void;
|
|
|
65
87
|
* - No manifest / no static command → ok (nothing can regress).
|
|
66
88
|
* - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
|
|
67
89
|
* treated as an environment gap, not a fault.
|
|
68
|
-
* - A command that ran and exited non-zero →
|
|
90
|
+
* - A command that ran and exited non-zero → red. Every command still runs.
|
|
69
91
|
*
|
|
70
92
|
* This module owns DISCOVERY and its own output policy. Running a command and
|
|
71
93
|
* deciding what its ending MEANS is `command-run.ts`'s — one statement of the
|
|
@@ -86,4 +108,9 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
|
|
|
86
108
|
onCommand?: HealthProgress;
|
|
87
109
|
/** The spawner. Injected so a verdict is testable without a real shell. */
|
|
88
110
|
run?: CommandRunner;
|
|
111
|
+
/** Also run the project's test commands, after the statics. Only a caller
|
|
112
|
+
* that will judge the result DIFFERENTIALLY may set this — see the header. */
|
|
113
|
+
withTests?: boolean;
|
|
89
114
|
}): Promise<HealthOutcome>;
|
|
115
|
+
/** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
|
|
116
|
+
export declare function describeHealthFailures(commands: readonly HealthCommandResult[]): string;
|
|
@@ -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
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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,48 @@ export function discoverHealthCommands(cwd) {
|
|
|
118
125
|
}
|
|
119
126
|
return { ecosystem: null, cmds: [] };
|
|
120
127
|
}
|
|
128
|
+
/** `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`. */
|
|
129
|
+
function isWatchScript(name, body) {
|
|
130
|
+
return (/watch/i.test(name) || /(?:^|\s)--watch(?:All)?(?=[\s=]|$)|(?:^|\s)watch(?=\s|$)/.test(body));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The project's OWN test commands, in the order the run-end gate runs them. One
|
|
134
|
+
* statement for both gates: final-gate.ts appends `build` to this list for the
|
|
135
|
+
* integration half, and `runRepoHealthCheck` runs it under `withTests` for the
|
|
136
|
+
* per-task differential, so the two cannot drift apart.
|
|
137
|
+
*
|
|
138
|
+
* Every test-shaped script, not just the one literally named `test`: a project's
|
|
139
|
+
* only browser-executing suite is often `test:ct`, and looking for `test` alone
|
|
140
|
+
* never runs it. Plain `test` leads, then every `test:`/`test_`/`test-` name in
|
|
141
|
+
* declaration order (Array#sort is stable). A watch-mode script is left out: it
|
|
142
|
+
* never exits, so all it can add is a timeout.
|
|
143
|
+
*/
|
|
144
|
+
export function discoverTestCommands(cwd) {
|
|
145
|
+
if (existsSync(path.join(cwd, 'package.json'))) {
|
|
146
|
+
const s = packageScripts(cwd);
|
|
147
|
+
const names = Object.keys(s).filter(n => (n === 'test' || /^test[:_-]/.test(n)) && !isWatchScript(n, s[n]));
|
|
148
|
+
names.sort((a, b) => a === 'test' ? -1
|
|
149
|
+
: b === 'test' ? 1
|
|
150
|
+
: 0);
|
|
151
|
+
return { ecosystem: 'package.json', cmds: names.map(n => ['bun', ['run', n]]) };
|
|
152
|
+
}
|
|
153
|
+
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
154
|
+
return {
|
|
155
|
+
ecosystem: 'Makefile',
|
|
156
|
+
cmds: makeHasTarget(cwd, 'test') ? [['make', ['test']]] : []
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (existsSync(path.join(cwd, 'Cargo.toml'))) {
|
|
160
|
+
return { ecosystem: 'Cargo.toml', cmds: [['cargo', ['test', '--quiet']]] };
|
|
161
|
+
}
|
|
162
|
+
if (existsSync(path.join(cwd, 'go.mod'))) {
|
|
163
|
+
return { ecosystem: 'go.mod', cmds: [['go', ['test', './...']]] };
|
|
164
|
+
}
|
|
165
|
+
if (existsSync(path.join(cwd, 'pyproject.toml'))) {
|
|
166
|
+
return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
|
|
167
|
+
}
|
|
168
|
+
return { ecosystem: null, cmds: [] };
|
|
169
|
+
}
|
|
121
170
|
/** The nothing-to-run outcome, shared by both runners. */
|
|
122
171
|
function noCommandOutcome(ecosystem) {
|
|
123
172
|
return {
|
|
@@ -134,7 +183,7 @@ function noCommandOutcome(ecosystem) {
|
|
|
134
183
|
* - No manifest / no static command → ok (nothing can regress).
|
|
135
184
|
* - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
|
|
136
185
|
* treated as an environment gap, not a fault.
|
|
137
|
-
* - A command that ran and exited non-zero →
|
|
186
|
+
* - A command that ran and exited non-zero → red. Every command still runs.
|
|
138
187
|
*
|
|
139
188
|
* This module owns DISCOVERY and its own output policy. Running a command and
|
|
140
189
|
* deciding what its ending MEANS is `command-run.ts`'s — one statement of the
|
|
@@ -150,12 +199,18 @@ function noCommandOutcome(ecosystem) {
|
|
|
150
199
|
* widget has just been cleared.
|
|
151
200
|
*/
|
|
152
201
|
export async function runRepoHealthCheck(cwd, opts = {}) {
|
|
153
|
-
const
|
|
202
|
+
const statics = discoverHealthCommands(cwd);
|
|
203
|
+
const tests = opts.withTests ? discoverTestCommands(cwd) : { ecosystem: null, cmds: [] };
|
|
204
|
+
const ecosystem = statics.ecosystem ?? tests.ecosystem;
|
|
205
|
+
const cmds = [
|
|
206
|
+
...statics.cmds.map(([bin, args]) => ({ bin, args, test: false })),
|
|
207
|
+
...tests.cmds.map(([bin, args]) => ({ bin, args, test: true }))
|
|
208
|
+
];
|
|
154
209
|
if (!ecosystem || cmds.length === 0)
|
|
155
210
|
return noCommandOutcome(ecosystem);
|
|
156
211
|
const run = opts.run ?? spawnCommand;
|
|
157
212
|
const commands = [];
|
|
158
|
-
for (const
|
|
213
|
+
for (const { bin, args, test } of cmds) {
|
|
159
214
|
const cmd = `${bin} ${args.join(' ')}`;
|
|
160
215
|
opts.onCommand?.(cmd);
|
|
161
216
|
// Runner resolution: a PATH-stripped environment must not
|
|
@@ -175,31 +230,52 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
|
|
|
175
230
|
// ladder's `tail` keeps 400 characters, and that difference is real — a
|
|
176
231
|
// truncated lint report is unactionable. So the run is classified, not
|
|
177
232
|
// consumed: the verdict decides, the raw streams are what we show.
|
|
178
|
-
// `runtimeGap
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
const
|
|
233
|
+
// `runtimeGap` and `emptySuite` only for a TEST command. Both rows read the
|
|
234
|
+
// command's output, and on lint and typecheck a genuine report quoting
|
|
235
|
+
// "browsers are not installed" would skip the static check and certify
|
|
236
|
+
// the repo healthy.
|
|
237
|
+
const verdict = classifyCommandRun(r, [], { runtimeGap: test, emptySuite: test });
|
|
238
|
+
const kind = test ? 'test' : 'static';
|
|
184
239
|
if (verdict.outcome !== 'fail') {
|
|
185
240
|
const passed = verdict.outcome === 'pass';
|
|
186
|
-
commands.push({
|
|
241
|
+
commands.push({
|
|
242
|
+
cmd,
|
|
243
|
+
outcome: passed ? 'pass' : 'skip',
|
|
244
|
+
exitCode: passed ? 0 : null,
|
|
245
|
+
kind
|
|
246
|
+
});
|
|
187
247
|
continue;
|
|
188
248
|
}
|
|
189
|
-
commands.push({
|
|
249
|
+
commands.push({
|
|
250
|
+
cmd,
|
|
251
|
+
outcome: 'fail',
|
|
252
|
+
exitCode: verdict.status,
|
|
253
|
+
kind,
|
|
254
|
+
output: captureHealthOutput(r.stdout, r.stderr)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const firstFail = commands.find(c => c.outcome === 'fail');
|
|
258
|
+
if (firstFail) {
|
|
190
259
|
return {
|
|
191
260
|
ok: false,
|
|
192
|
-
reason:
|
|
261
|
+
reason: describeHealthFailures(commands),
|
|
193
262
|
ecosystem,
|
|
194
263
|
commands,
|
|
195
|
-
output:
|
|
264
|
+
output: firstFail.output ?? ''
|
|
196
265
|
};
|
|
197
266
|
}
|
|
198
267
|
return {
|
|
199
268
|
ok: true,
|
|
200
|
-
reason: `${ecosystem}: static checks passed`,
|
|
269
|
+
reason: `${ecosystem}: static checks${tests.cmds.length > 0 ? ' and tests' : ''} passed`,
|
|
201
270
|
ecosystem,
|
|
202
271
|
commands,
|
|
203
272
|
output: ''
|
|
204
273
|
};
|
|
205
274
|
}
|
|
275
|
+
/** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
|
|
276
|
+
export function describeHealthFailures(commands) {
|
|
277
|
+
return commands
|
|
278
|
+
.filter(c => c.outcome === 'fail')
|
|
279
|
+
.map(c => `\`${c.cmd}\` exited ${c.exitCode}`)
|
|
280
|
+
.join('; ');
|
|
281
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { SpawnFn } from '../shared/child-process.js';
|
|
|
2
2
|
import { type EcosystemId } from '../workers/docs-ecosystems.js';
|
|
3
3
|
import type { GateEvidence } from './gate-evidence.js';
|
|
4
4
|
import { type OrientationResult } from './orientation.js';
|
|
5
|
+
import { type HealthOutcome } from './repo-health-check.js';
|
|
5
6
|
/**
|
|
6
7
|
* What a verified command is FOR, and the only column that decides whether the
|
|
7
8
|
* gate-evidence runner may execute it: `check` and `build` terminate on their own,
|
|
@@ -79,6 +80,8 @@ export declare class RunContext {
|
|
|
79
80
|
private _toolingHash;
|
|
80
81
|
private _evidence;
|
|
81
82
|
private _evidenceQueue;
|
|
83
|
+
private _health;
|
|
84
|
+
private _healthQueue;
|
|
82
85
|
constructor(opts: RunContextOptions);
|
|
83
86
|
/** `git ls-files` for this run; '' outside a git tree (see file-inventory.ts). */
|
|
84
87
|
inventory(): Promise<string>;
|
|
@@ -134,6 +137,17 @@ export declare class RunContext {
|
|
|
134
137
|
*/
|
|
135
138
|
gateEvidenceFor(produce: EvidenceRunner): Promise<GateEvidence>;
|
|
136
139
|
private freshEvidence;
|
|
140
|
+
/**
|
|
141
|
+
* The repo-health check with its suite, at most once per tree, on the same terms
|
|
142
|
+
* as {@link gateEvidenceFor}. A verify, the enforce baseline on the commit it
|
|
143
|
+
* just judged and the next task's checkpoint all measure one tree; each running
|
|
144
|
+
* the whole suite again is the cost this removes.
|
|
145
|
+
*
|
|
146
|
+
* Stored under the tree the check LEFT, not the one it found: a `--fix` lint
|
|
147
|
+
* moves the tree, and the result describes the fixed one.
|
|
148
|
+
*/
|
|
149
|
+
healthFor(produce: () => Promise<HealthOutcome>): Promise<HealthOutcome>;
|
|
150
|
+
private freshHealth;
|
|
137
151
|
}
|
|
138
152
|
/** Open a run: every task inside it now shares one context. Nests — an inner
|
|
139
153
|
* bracket returns the outer run's context untouched. */
|
package/dist/task/run-context.js
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* run found it and is not re-read: a mid-run inventory refresh would hand two
|
|
20
20
|
* tasks different orientation cores for the same question.
|
|
21
21
|
*
|
|
22
|
-
* TREE HASH is the other half, used by the gate-evidence
|
|
23
|
-
* implementation lives in tree-hash.ts.
|
|
22
|
+
* TREE HASH is the other half, used by the gate-evidence and repo-health caches;
|
|
23
|
+
* its one implementation lives in tree-hash.ts.
|
|
24
24
|
*/
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
26
|
import * as fsp from 'node:fs/promises';
|
|
@@ -90,6 +90,8 @@ export class RunContext {
|
|
|
90
90
|
_toolingHash;
|
|
91
91
|
_evidence;
|
|
92
92
|
_evidenceQueue = Promise.resolve();
|
|
93
|
+
_health;
|
|
94
|
+
_healthQueue = Promise.resolve();
|
|
93
95
|
constructor(opts) {
|
|
94
96
|
this.cwd = opts.cwd;
|
|
95
97
|
this.runId = opts.runId ?? newRunToken();
|
|
@@ -238,6 +240,31 @@ export class RunContext {
|
|
|
238
240
|
this._evidence = { hash, value };
|
|
239
241
|
return value;
|
|
240
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* The repo-health check with its suite, at most once per tree, on the same terms
|
|
245
|
+
* as {@link gateEvidenceFor}. A verify, the enforce baseline on the commit it
|
|
246
|
+
* just judged and the next task's checkpoint all measure one tree; each running
|
|
247
|
+
* the whole suite again is the cost this removes.
|
|
248
|
+
*
|
|
249
|
+
* Stored under the tree the check LEFT, not the one it found: a `--fix` lint
|
|
250
|
+
* moves the tree, and the result describes the fixed one.
|
|
251
|
+
*/
|
|
252
|
+
healthFor(produce) {
|
|
253
|
+
const next = this._healthQueue.then(() => this.freshHealth(produce));
|
|
254
|
+
this._healthQueue = next.catch(() => { });
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
async freshHealth(produce) {
|
|
258
|
+
const opts = this._signal ? { signal: this._signal } : {};
|
|
259
|
+
const found = await treeHash(this.cwd, opts);
|
|
260
|
+
if (found !== null && this._health?.hash === found)
|
|
261
|
+
return this._health.value;
|
|
262
|
+
const value = await produce();
|
|
263
|
+
const left = await treeHash(this.cwd, opts);
|
|
264
|
+
if (left !== null)
|
|
265
|
+
this._health = { hash: left, value };
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
241
268
|
}
|
|
242
269
|
/**
|
|
243
270
|
* The context of the run that owns the session right now, set by the run bracket.
|
|
@@ -90,8 +90,12 @@ export type VerifyOutcome = VerifyPass | VerifyFail;
|
|
|
90
90
|
* `static-checks` is the RUN-level twin of `repo-health`: final-gate.ts mints
|
|
91
91
|
* `VERIFY_FAIL_PREFIX['static-checks']` for the same concept at the other
|
|
92
92
|
* altitude, and `isStaticClass` answers true for both.
|
|
93
|
+
*
|
|
94
|
+
* `test-suite` is the same deterministic check when a TEST command regressed. It
|
|
95
|
+
* is its own class because a passing lint proves nothing about a suite: a static
|
|
96
|
+
* debt closes when the statics pass, and a lint fix cannot green a test.
|
|
93
97
|
*/
|
|
94
|
-
export type VerifyFailClass = 'repo-health' | 'static-checks' | 'unobserved' | 'model-verdict' | 'harness-fault';
|
|
98
|
+
export type VerifyFailClass = 'repo-health' | 'static-checks' | 'test-suite' | 'unobserved' | 'model-verdict' | 'harness-fault';
|
|
95
99
|
/**
|
|
96
100
|
* The prefix each class MINTS, stated once.
|
|
97
101
|
*
|
|
@@ -117,6 +121,8 @@ export declare function verifyFailClass(o: {
|
|
|
117
121
|
export declare function failClassOfReason(reason: string): VerifyFailClass | undefined;
|
|
118
122
|
/** Does this class name a deterministic whole-repo static check, at either altitude? */
|
|
119
123
|
export declare function isStaticClass(cls: VerifyFailClass | undefined): boolean;
|
|
124
|
+
/** Does this class name the deterministic whole-repo check, suite included? */
|
|
125
|
+
export declare function isHealthClass(cls: VerifyFailClass | undefined): boolean;
|
|
120
126
|
/**
|
|
121
127
|
* The delivered spec's TEXT, for the children that must read its prose verbatim.
|
|
122
128
|
* The slicing itself lives in spec-model.ts beside the parser, so "the spec
|