@mjasnikovs/pi-task 0.42.1 → 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.
- package/dist/index.js +2 -0
- package/dist/task/auto-orchestrator.js +5 -0
- package/dist/task/deferred-breakage.d.ts +3 -0
- package/dist/task/deferred-breakage.js +48 -0
- package/dist/task/final-gate.js +15 -52
- package/dist/task/gate-child.js +10 -0
- package/dist/task/gate-deps.js +8 -4
- package/dist/task/implementation-guards.d.ts +0 -4
- package/dist/task/implementation-guards.js +0 -16
- package/dist/task/implementation-scope.d.ts +8 -6
- package/dist/task/implementation-scope.js +15 -10
- package/dist/task/orchestrator.d.ts +1 -0
- package/dist/task/orchestrator.js +16 -5
- package/dist/task/parsers.d.ts +1 -1
- package/dist/task/phases.js +32 -0
- package/dist/task/prompts.d.ts +9 -0
- package/dist/task/prompts.js +14 -3
- package/dist/task/repo-health-check.d.ts +18 -0
- package/dist/task/repo-health-check.js +63 -14
- package/dist/task/task-dir-custody.d.ts +24 -0
- package/dist/task/task-dir-custody.js +100 -0
- package/dist/task/yolo.d.ts +6 -5
- package/dist/task/yolo.js +9 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
|
|
|
7
7
|
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
8
8
|
import { registerStreamWatchdog } from './task/stream-watchdog.js';
|
|
9
9
|
import { registerImplementationGuards } from './task/implementation-guards.js';
|
|
10
|
+
import { registerTaskDirCustody } from './task/task-dir-custody.js';
|
|
10
11
|
import { registerModelHoldRestore } from './task/model-hold-stash.js';
|
|
11
12
|
export default function (pi) {
|
|
12
13
|
registerConfig(pi);
|
|
@@ -18,5 +19,6 @@ export default function (pi) {
|
|
|
18
19
|
registerCommandWatchdog(pi);
|
|
19
20
|
registerStreamWatchdog(pi);
|
|
20
21
|
registerImplementationGuards(pi);
|
|
22
|
+
registerTaskDirCustody(pi);
|
|
21
23
|
registerModelHoldRestore(pi);
|
|
22
24
|
}
|
|
@@ -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,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
|
+
}
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
package/dist/task/gate-child.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { formatLoopHint } from './loop-detector.js';
|
|
28
28
|
import { classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
29
29
|
import { notifyRun } from '../remote/bridge.js';
|
|
30
|
+
import { restoreTaskDir, snapshotTaskDir, taskDirRestoredNotice } from './task-dir-custody.js';
|
|
30
31
|
/**
|
|
31
32
|
* What each kind may do. Adding a child is a row; it cannot be added without
|
|
32
33
|
* deciding all four questions, which is the point.
|
|
@@ -69,6 +70,9 @@ export function makeGateChild(deps) {
|
|
|
69
70
|
const log = deps.makeDebugAppender(deps.logPath);
|
|
70
71
|
log(`=== ${deps.kind} start: ${deps.taskTitle} ===`);
|
|
71
72
|
const guardSnapshot = row.guarded ? await deps.captureGitState(deps.cwd, sig) : null;
|
|
73
|
+
// Every kind, whatever its tools: the git-state guard and the discard both
|
|
74
|
+
// leave `.pi-tasks` alone.
|
|
75
|
+
const taskDir = await snapshotTaskDir(deps.cwd);
|
|
72
76
|
const frame = deps.loader === false ?
|
|
73
77
|
null
|
|
74
78
|
: () => ({
|
|
@@ -132,6 +136,12 @@ export function makeGateChild(deps) {
|
|
|
132
136
|
finally {
|
|
133
137
|
// Restore whatever the child moved BEFORE any verdict or failure is
|
|
134
138
|
// acted on — a crashed child must not skip the restore either.
|
|
139
|
+
const restored = await restoreTaskDir(deps.cwd, taskDir);
|
|
140
|
+
if (restored.length > 0) {
|
|
141
|
+
log(`=== ${deps.kind} TASK-DIR CUSTODY — restored: ${restored.join(', ')} ===`);
|
|
142
|
+
notifyRun(deps.ctx, `${deps.taskTitle}: `
|
|
143
|
+
+ taskDirRestoredNotice(`the ${deps.kind} child`, restored), 'warning');
|
|
144
|
+
}
|
|
135
145
|
if (guardSnapshot) {
|
|
136
146
|
const rec = await deps.reconcileGitState(deps.cwd, guardSnapshot, sig);
|
|
137
147
|
deps.onReconcile?.(rec);
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -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
|
|
938
|
-
//
|
|
939
|
-
//
|
|
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
|
}
|
|
@@ -16,10 +16,6 @@ export declare function implementationGuardArmed(): boolean;
|
|
|
16
16
|
* one altered byte is a new key and a clean slate on both counters.
|
|
17
17
|
*/
|
|
18
18
|
export declare function blockedCallReason(toolName: string, count: number): string;
|
|
19
|
-
/** Every file under the task dir is host-written. MEASURED: an mx5 implementer
|
|
20
|
-
* wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
|
|
21
|
-
export declare function targetsTaskDir(toolName: string, input: unknown): boolean;
|
|
22
|
-
export declare function taskDirWriteReason(): string;
|
|
23
19
|
/** The reason on the final block, which also ends the turn. */
|
|
24
20
|
export declare function terminalCallReason(): string;
|
|
25
21
|
export declare function consumeGuardTermination(): boolean;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
|
|
2
|
-
import { TASKS_DIR_NAME } from './task-types.js';
|
|
3
2
|
/**
|
|
4
3
|
* Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
|
|
5
4
|
*
|
|
@@ -82,18 +81,6 @@ export function blockedCallReason(toolName, count) {
|
|
|
82
81
|
return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
|
|
83
82
|
+ `Use what you already have, or do something different, then continue the task.`);
|
|
84
83
|
}
|
|
85
|
-
/** Every file under the task dir is host-written. MEASURED: an mx5 implementer
|
|
86
|
-
* wrote its report over TASK_AUTO_0001.md, and the run died on its front matter. */
|
|
87
|
-
export function targetsTaskDir(toolName, input) {
|
|
88
|
-
if (!MUTATING_TOOLS.has(toolName))
|
|
89
|
-
return false;
|
|
90
|
-
const target = input?.path;
|
|
91
|
-
return typeof target === 'string' && target.split(/[\\/]/).includes(TASKS_DIR_NAME);
|
|
92
|
-
}
|
|
93
|
-
export function taskDirWriteReason() {
|
|
94
|
-
return (`Blocked: ${TASKS_DIR_NAME}/ belongs to pi-task and is written only by the host. `
|
|
95
|
-
+ `Put your report in your reply, not in a file, then continue the task.`);
|
|
96
|
-
}
|
|
97
84
|
/** The reason on the final block, which also ends the turn. */
|
|
98
85
|
export function terminalCallReason() {
|
|
99
86
|
return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
|
|
@@ -133,9 +120,6 @@ export function registerImplementationGuards(pi) {
|
|
|
133
120
|
if (state.terminating) {
|
|
134
121
|
return { block: true, terminate: true, reason: terminalCallReason() };
|
|
135
122
|
}
|
|
136
|
-
if (targetsTaskDir(event.toolName, event.input)) {
|
|
137
|
-
return { block: true, reason: taskDirWriteReason() };
|
|
138
|
-
}
|
|
139
123
|
const call = { name: event.toolName, args: event.input };
|
|
140
124
|
const mutating = MUTATING_TOOLS.has(event.toolName);
|
|
141
125
|
const hit = mutating ? state.edits.record(call) : state.loop.record(call);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implementation-turn bracket: one entry arms
|
|
3
|
-
* runaway guard, one `leave`
|
|
2
|
+
* The implementation-turn bracket: one entry arms the status widget and the
|
|
3
|
+
* runaway guard and takes custody of the task dir, one `leave` ends all three.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Each module keeps its own lifecycle handlers, and the first two draw the turn
|
|
6
6
|
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
7
|
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
8
|
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
import { type ImplWidgetMeta } from './impl-widget.js';
|
|
14
14
|
/**
|
|
15
15
|
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
16
|
-
*
|
|
16
|
+
* end it after the single turn; false (awaited /task-auto) keeps all three
|
|
17
17
|
* across resume and steer turns until `leave` is called.
|
|
18
18
|
*
|
|
19
19
|
* `leave` is idempotent: a second call is a no-op, so a caller can put it in a
|
|
20
|
-
* `finally` and a `catch` without
|
|
20
|
+
* `finally` and a `catch` without ending a bracket entered since. It resolves to
|
|
21
|
+
* the task files it restored.
|
|
21
22
|
*/
|
|
22
23
|
export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
|
|
23
24
|
oneShot: boolean;
|
|
24
|
-
|
|
25
|
+
cwd: string;
|
|
26
|
+
}): Promise<() => Promise<string[]>>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implementation-turn bracket: one entry arms
|
|
3
|
-
* runaway guard, one `leave`
|
|
2
|
+
* The implementation-turn bracket: one entry arms the status widget and the
|
|
3
|
+
* runaway guard and takes custody of the task dir, one `leave` ends all three.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Each module keeps its own lifecycle handlers, and the first two draw the turn
|
|
6
6
|
* boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
|
|
7
7
|
* over, the screen should say so), the guard survives until `agent_settled`
|
|
8
8
|
* (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
|
|
@@ -12,23 +12,28 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { armImplWidget, disarmImplWidget } from './impl-widget.js';
|
|
14
14
|
import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
|
|
15
|
+
import { takeTaskDirCustody } from './task-dir-custody.js';
|
|
15
16
|
/**
|
|
16
17
|
* `oneShot` true (fire-and-forget /task) lets each module's own settle handler
|
|
17
|
-
*
|
|
18
|
+
* end it after the single turn; false (awaited /task-auto) keeps all three
|
|
18
19
|
* across resume and steer turns until `leave` is called.
|
|
19
20
|
*
|
|
20
21
|
* `leave` is idempotent: a second call is a no-op, so a caller can put it in a
|
|
21
|
-
* `finally` and a `catch` without
|
|
22
|
+
* `finally` and a `catch` without ending a bracket entered since. It resolves to
|
|
23
|
+
* the task files it restored.
|
|
22
24
|
*/
|
|
23
|
-
export function enterImplementationTurn(meta, opts) {
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
export async function enterImplementationTurn(meta, opts) {
|
|
26
|
+
const { oneShot } = opts;
|
|
27
|
+
const releaseTaskDir = await takeTaskDirCustody(opts.cwd, { oneShot });
|
|
28
|
+
armImplWidget(meta, { oneShot });
|
|
29
|
+
armImplementationGuard({ oneShot });
|
|
26
30
|
let left = false;
|
|
27
|
-
return () => {
|
|
31
|
+
return async () => {
|
|
28
32
|
if (left)
|
|
29
|
-
return;
|
|
33
|
+
return [];
|
|
30
34
|
left = true;
|
|
31
35
|
disarmImplWidget();
|
|
32
36
|
disarmImplementationGuard();
|
|
37
|
+
return releaseTaskDir();
|
|
33
38
|
};
|
|
34
39
|
}
|
|
@@ -178,6 +178,7 @@ export declare class TaskRunner {
|
|
|
178
178
|
*/
|
|
179
179
|
private _recordHandoff;
|
|
180
180
|
private _deliverSpec;
|
|
181
|
+
private _reportRestored;
|
|
181
182
|
/**
|
|
182
183
|
* The spec as the implementer should receive it (Layer B). Layer A strips phantom
|
|
183
184
|
* specifiers from the upstream pipeline text, but a residual affirmative can survive
|
|
@@ -27,7 +27,8 @@ import { allocateTaskId, ensureTasksDir, mergeTaskSection, readSection, readTask
|
|
|
27
27
|
import { startWidget } from './widget.js';
|
|
28
28
|
import { setupImplWidget } from './impl-widget.js';
|
|
29
29
|
import { enterImplementationTurn } from './implementation-scope.js';
|
|
30
|
-
import {
|
|
30
|
+
import { taskDirRestoredNotice } from './task-dir-custody.js';
|
|
31
|
+
import { SessionUI, publishNotify, registerBridgeCommand, getBridge, notifyBoth, notifyRun, isRemoteOrigin } from '../remote/bridge.js';
|
|
31
32
|
import { pushNotify } from '../remote/push.js';
|
|
32
33
|
import { getConfig } from '../config/config.js';
|
|
33
34
|
import { appendDebugLine, gateDebugWriter } from './debug-log.js';
|
|
@@ -473,7 +474,10 @@ export class TaskRunner {
|
|
|
473
474
|
label: this._widgetState.label
|
|
474
475
|
};
|
|
475
476
|
if (this._sendSpec) {
|
|
476
|
-
const leave = enterImplementationTurn(meta, {
|
|
477
|
+
const leave = await enterImplementationTurn(meta, {
|
|
478
|
+
oneShot: !this._implAwaited,
|
|
479
|
+
cwd: this._cwd
|
|
480
|
+
});
|
|
477
481
|
let delivered = false;
|
|
478
482
|
try {
|
|
479
483
|
await this._sendSpec(spec);
|
|
@@ -486,14 +490,14 @@ export class TaskRunner {
|
|
|
486
490
|
// missing model, or a failed auth. The next unrelated turn would
|
|
487
491
|
// inherit it, and this guard can end a turn outright.
|
|
488
492
|
if (this._implAwaited || !delivered)
|
|
489
|
-
leave();
|
|
493
|
+
this._reportRestored(await leave());
|
|
490
494
|
}
|
|
491
495
|
return;
|
|
492
496
|
}
|
|
493
497
|
if (!piApi) {
|
|
494
498
|
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
495
499
|
}
|
|
496
|
-
const leave = enterImplementationTurn(meta, { oneShot: true });
|
|
500
|
+
const leave = await enterImplementationTurn(meta, { oneShot: true, cwd: this._cwd });
|
|
497
501
|
// Same reason as the awaited path's `delivered` flag: this send can throw
|
|
498
502
|
// SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
|
|
499
503
|
// `assertActive()` — and a guard left armed over a turn that never starts
|
|
@@ -507,10 +511,17 @@ export class TaskRunner {
|
|
|
507
511
|
piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
508
512
|
}
|
|
509
513
|
catch (e) {
|
|
510
|
-
leave();
|
|
514
|
+
await leave();
|
|
511
515
|
throw e;
|
|
512
516
|
}
|
|
513
517
|
}
|
|
518
|
+
_reportRestored(names) {
|
|
519
|
+
if (names.length === 0)
|
|
520
|
+
return;
|
|
521
|
+
const notice = taskDirRestoredNotice('The implementation turn', names);
|
|
522
|
+
this._deps.logDebug?.(notice);
|
|
523
|
+
notifyRun(this._ctx, notice, 'warning');
|
|
524
|
+
}
|
|
514
525
|
/**
|
|
515
526
|
* The spec as the implementer should receive it (Layer B). Layer A strips phantom
|
|
516
527
|
* specifiers from the upstream pipeline text, but a residual affirmative can survive
|
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';
|
|
@@ -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
|
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).
|
|
@@ -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
|
|
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,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
|
|
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
|
|
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
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
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: ''
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/** File name → bytes, for the held files only. */
|
|
3
|
+
export type TaskDirSnapshot = ReadonlyMap<string, Buffer>;
|
|
4
|
+
export declare function snapshotTaskDir(cwd: string): Promise<TaskDirSnapshot>;
|
|
5
|
+
/** Rewrite every snapshotted file that changed or is gone. Never throws.
|
|
6
|
+
* Returns the names it put back, sorted. */
|
|
7
|
+
export declare function restoreTaskDir(cwd: string, snapshot: TaskDirSnapshot): Promise<string[]>;
|
|
8
|
+
export declare function taskDirRestoredNotice(who: string, names: readonly string[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* Snapshot before the turn can start, replacing any custody still held.
|
|
11
|
+
* `oneShot` means the settle ends it; otherwise the returned release does, and
|
|
12
|
+
* it never releases a custody taken since.
|
|
13
|
+
*/
|
|
14
|
+
export declare function takeTaskDirCustody(cwd: string, opts: {
|
|
15
|
+
oneShot: boolean;
|
|
16
|
+
}): Promise<() => Promise<string[]>>;
|
|
17
|
+
export declare function releaseTaskDirCustody(): Promise<string[]>;
|
|
18
|
+
/** @internal Test seam: is a turn's task dir held? */
|
|
19
|
+
export declare function taskDirCustodyHeld(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* pi awaits these handlers before it wakes a command waiting for idle, so a
|
|
22
|
+
* one-shot restore finishes before the next run writes anything.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerTaskDirCustody(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custody of the task dir while a model can write to it.
|
|
3
|
+
*
|
|
4
|
+
* Every markdown file in `.pi-tasks/` is host state. A model reaches it through
|
|
5
|
+
* edit, write or bash, and pi rewrites a path before it writes (`@x`, `file://`),
|
|
6
|
+
* so a rule on the tool call sees only some of the writers. The host snapshots
|
|
7
|
+
* the files before the model runs and puts back whatever changed. MEASURED: an
|
|
8
|
+
* mx5 implementer wrote its report over TASK_AUTO_0001.md, and the run died on
|
|
9
|
+
* its front matter.
|
|
10
|
+
*
|
|
11
|
+
* Only markdown is held: pi-task's own tools write research-cache.json mid-turn.
|
|
12
|
+
* A file created in the window is kept, because a run started meanwhile
|
|
13
|
+
* allocates its own.
|
|
14
|
+
*/
|
|
15
|
+
import * as fsp from 'node:fs/promises';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { notifyRun } from '../remote/bridge.js';
|
|
18
|
+
import { tasksDir } from './task-io.js';
|
|
19
|
+
import { TASKS_DIR_NAME } from './task-types.js';
|
|
20
|
+
export async function snapshotTaskDir(cwd) {
|
|
21
|
+
const dir = tasksDir(cwd);
|
|
22
|
+
const out = new Map();
|
|
23
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
if (!e.isFile() || !e.name.endsWith('.md'))
|
|
26
|
+
continue;
|
|
27
|
+
const bytes = await fsp.readFile(path.join(dir, e.name)).catch(() => null);
|
|
28
|
+
if (bytes)
|
|
29
|
+
out.set(e.name, bytes);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Rewrite every snapshotted file that changed or is gone. Never throws.
|
|
34
|
+
* Returns the names it put back, sorted. */
|
|
35
|
+
export async function restoreTaskDir(cwd, snapshot) {
|
|
36
|
+
const dir = tasksDir(cwd);
|
|
37
|
+
const restored = [];
|
|
38
|
+
for (const [name, bytes] of snapshot) {
|
|
39
|
+
const file = path.join(dir, name);
|
|
40
|
+
const now = await fsp.readFile(file).catch(() => null);
|
|
41
|
+
if (now?.equals(bytes))
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
45
|
+
await fsp.writeFile(file, bytes);
|
|
46
|
+
restored.push(name);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// The host's next read of this file reports what is still wrong.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return restored.sort();
|
|
53
|
+
}
|
|
54
|
+
export function taskDirRestoredNotice(who, names) {
|
|
55
|
+
const list = names.map(n => `${TASKS_DIR_NAME}/${n}`).join(', ');
|
|
56
|
+
return `${who} changed ${list}, which only pi-task writes. Restored.`;
|
|
57
|
+
}
|
|
58
|
+
/** The implementation turn's custody. One slot: one task runs at a time. */
|
|
59
|
+
let held = null;
|
|
60
|
+
async function release(custody) {
|
|
61
|
+
if (held !== custody)
|
|
62
|
+
return [];
|
|
63
|
+
held = null;
|
|
64
|
+
return restoreTaskDir(custody.cwd, custody.snapshot);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Snapshot before the turn can start, replacing any custody still held.
|
|
68
|
+
* `oneShot` means the settle ends it; otherwise the returned release does, and
|
|
69
|
+
* it never releases a custody taken since.
|
|
70
|
+
*/
|
|
71
|
+
export async function takeTaskDirCustody(cwd, opts) {
|
|
72
|
+
const custody = { cwd, snapshot: await snapshotTaskDir(cwd), oneShot: opts.oneShot };
|
|
73
|
+
held = custody;
|
|
74
|
+
return () => release(custody);
|
|
75
|
+
}
|
|
76
|
+
export function releaseTaskDirCustody() {
|
|
77
|
+
return held ? release(held) : Promise.resolve([]);
|
|
78
|
+
}
|
|
79
|
+
/** @internal Test seam: is a turn's task dir held? */
|
|
80
|
+
export function taskDirCustodyHeld() {
|
|
81
|
+
return held !== null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* pi awaits these handlers before it wakes a command waiting for idle, so a
|
|
85
|
+
* one-shot restore finishes before the next run writes anything.
|
|
86
|
+
*/
|
|
87
|
+
export function registerTaskDirCustody(pi) {
|
|
88
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
89
|
+
if (!held?.oneShot)
|
|
90
|
+
return;
|
|
91
|
+
const restored = await releaseTaskDirCustody();
|
|
92
|
+
if (restored.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
notifyRun(ctx, taskDirRestoredNotice('The implementation turn', restored), 'warning');
|
|
95
|
+
});
|
|
96
|
+
// Dropped, not restored: a snapshot this old would revert the next run's writes.
|
|
97
|
+
pi.on('session_shutdown', () => {
|
|
98
|
+
held = null;
|
|
99
|
+
});
|
|
100
|
+
}
|
package/dist/task/yolo.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
48
|
-
* `threw`, `model-unknown` —
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
72
|
-
* `threw`, `model-unknown` —
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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.
|
|
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",
|