@mjasnikovs/pi-task 0.18.40 → 0.18.42
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/README.md +2 -1
- package/dist/task/auto-io.d.ts +8 -1
- package/dist/task/auto-io.js +13 -5
- package/dist/task/auto-orchestrator.js +41 -10
- package/dist/task/auto-prompts.d.ts +5 -1
- package/dist/task/auto-prompts.js +7 -2
- package/dist/task/batch-test-task.d.ts +71 -0
- package/dist/task/batch-test-task.js +320 -0
- package/dist/task/resume-gap.d.ts +52 -0
- package/dist/task/resume-gap.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
|
|
|
63
63
|
| `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
|
|
64
64
|
| `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
|
|
65
65
|
| `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
|
|
66
|
-
| `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
|
|
66
|
+
| `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
|
|
67
67
|
| `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
|
|
68
68
|
| `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, stream watchdog, and the extension whitelist for child sessions. |
|
|
69
69
|
| `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
|
|
@@ -100,6 +100,7 @@ A real feature is usually several tasks, not one. `/task-auto` is a thin planner
|
|
|
100
100
|
- **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
|
|
101
101
|
- **Sequential, blocking.** Each title runs through `/task` to a spec, the spec is implemented, and the loop waits for that to finish before starting the next title. No overlap.
|
|
102
102
|
- **Crash- and cancel-safe.** Progress is the markdown checkboxes in the AUTO file. `/task-auto-resume` (no id) automatically picks up the active run at the first unchecked title. If a title's `/task` run fails, the loop stops and leaves the run resumable.
|
|
103
|
+
- **Restart-safe, unattended.** `/task-auto-resume --unattended` is the same resume with no human in the loop — for a boot hook or a container entrypoint. It continues **in-flight** runs only: a `failed` or `cancelled` run stopped for a reason a power cycle does not clear, so it is reported and left alone rather than re-entered against the same wall. Either way the resume banner states exactly what it measured — how long since the run last wrote, and that nothing was rolled back — and attributes no cause, because a stopped host, a hung child, and a slow task look identical from here. Pair it with `restart: unless-stopped` on long-running containers and an overnight outage costs minutes instead of the whole night.
|
|
103
104
|
- **One commit per task.** When **auto-commit** is on (the default) and you're in a git repo, the working tree is snapshotted into a single commit after each title passes, so the run produces a clean per-task history. It's best-effort: outside a repo, with nothing to commit, or on any git error, the loop reports the reason and keeps going. Toggle it in `/task-config`.
|
|
104
105
|
|
|
105
106
|
## Remote — drive a task from your phone
|
package/dist/task/auto-io.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AutoResumeCandidate } from './resume-gap.js';
|
|
1
2
|
export interface TaskEntry {
|
|
2
3
|
index: number;
|
|
3
4
|
title: string;
|
|
@@ -58,5 +59,11 @@ export declare function stampTaskInProgress(cwd: string, id: string, index: numb
|
|
|
58
59
|
* next step by "first unchecked" rather than by a cached index.
|
|
59
60
|
*/
|
|
60
61
|
export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
64
|
+
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
65
|
+
* is nothing resumable.
|
|
66
|
+
*/
|
|
67
|
+
export declare function findResumableAutoDetailed(cwd: string): Promise<AutoResumeCandidate | null>;
|
|
68
|
+
/** Id-only form of {@link findResumableAutoDetailed}. */
|
|
62
69
|
export declare function findResumableAuto(cwd: string): Promise<string | null>;
|
package/dist/task/auto-io.js
CHANGED
|
@@ -194,8 +194,12 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
|
|
|
194
194
|
await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
|
|
195
195
|
return true;
|
|
196
196
|
}
|
|
197
|
-
/**
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
199
|
+
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
200
|
+
* is nothing resumable.
|
|
201
|
+
*/
|
|
202
|
+
export async function findResumableAutoDetailed(cwd) {
|
|
199
203
|
await ensureTasksDir(cwd);
|
|
200
204
|
const entries = await fsp.readdir(tasksDir(cwd));
|
|
201
205
|
const candidates = [];
|
|
@@ -211,12 +215,16 @@ export async function findResumableAuto(cwd) {
|
|
|
211
215
|
if (!RESUMABLE_STATES.includes(fm.state))
|
|
212
216
|
continue;
|
|
213
217
|
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
214
|
-
candidates.push({ id: m[1],
|
|
218
|
+
candidates.push({ id: m[1], state: fm.state, lastWriteMs: st.mtimeMs });
|
|
215
219
|
}
|
|
216
220
|
catch {
|
|
217
221
|
/* skip unreadable */
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
|
-
candidates.sort((a, b) => b.
|
|
221
|
-
return candidates.length > 0 ? candidates[0]
|
|
224
|
+
candidates.sort((a, b) => b.lastWriteMs - a.lastWriteMs);
|
|
225
|
+
return candidates.length > 0 ? candidates[0] : null;
|
|
226
|
+
}
|
|
227
|
+
/** Id-only form of {@link findResumableAutoDetailed}. */
|
|
228
|
+
export async function findResumableAuto(cwd) {
|
|
229
|
+
return (await findResumableAutoDetailed(cwd))?.id ?? null;
|
|
222
230
|
}
|
|
@@ -14,7 +14,8 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
|
|
|
14
14
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
|
|
15
15
|
import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
|
|
16
16
|
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
17
|
-
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter,
|
|
17
|
+
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
|
|
18
|
+
import { decideResume } from './resume-gap.js';
|
|
18
19
|
import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
|
|
19
20
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
20
21
|
import { readTextFile } from '../shared/fs-text.js';
|
|
@@ -39,6 +40,7 @@ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './y
|
|
|
39
40
|
import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
|
|
40
41
|
import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
|
|
41
42
|
import { reconcileTitleSources } from './decompose-fidelity.js';
|
|
43
|
+
import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
|
|
42
44
|
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
43
45
|
import { decideAdoption, groundedCoverage } from './coverage-loop.js';
|
|
44
46
|
import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
|
|
@@ -590,8 +592,18 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
590
592
|
catch {
|
|
591
593
|
// best-effort channel
|
|
592
594
|
}
|
|
595
|
+
// Tests-in-the-same-change cadence (mx5 run 14, PROMPT item 6): when the
|
|
596
|
+
// decisions mandate it, a whole-project batch test task contradicts them —
|
|
597
|
+
// run 14 shipped one anyway (TASK_0037, 4.7h, yolo-accepted FAIL) because
|
|
598
|
+
// decompose mirrors the spec's milestone shape. The decisions channel
|
|
599
|
+
// OVERRIDES the spec doc, so this resolves toward the decision without asking.
|
|
600
|
+
const noBatchTests = mandatesTestsInSameChange(clarifications, featureForModel);
|
|
601
|
+
if (noBatchTests) {
|
|
602
|
+
logPlanDebug(cwd, 'decisions mandate tests-in-the-same-change — batch test tasks are banned '
|
|
603
|
+
+ 'from this plan (prompt rule + host rewrite)');
|
|
604
|
+
}
|
|
593
605
|
// decompose
|
|
594
|
-
const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
|
|
606
|
+
const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries), noBatchTests);
|
|
595
607
|
// Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
|
|
596
608
|
// [source: "…"] citation against the doc, strip the clause, and re-attach any
|
|
597
609
|
// `+`-joined constraint fragment the paraphrased title dropped (the silently
|
|
@@ -606,7 +618,18 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
606
618
|
.map(r => ` [task ${r.index + 1}: ${r.fragments.join(', ')}]`)
|
|
607
619
|
.join(''));
|
|
608
620
|
}
|
|
609
|
-
|
|
621
|
+
// Batch-test ban (item 6): drop or scope a whole-project "write all the
|
|
622
|
+
// tests" task. Identity unless the cadence decision is present, and the
|
|
623
|
+
// sweep replacement re-grounds every requirement the drop would cost — so
|
|
624
|
+
// planned coverage cannot fall (run 12's lesson).
|
|
625
|
+
const debatched = rewriteBatchTestPlan(plan.titles, clarifications, featureForModel, reqEntries.map(e => e.quote), isCrossCuttingRequirement);
|
|
626
|
+
for (const a of debatched.actions) {
|
|
627
|
+
logPlanDebug(cwd, `batch test task ${a.kind} (tests-in-same-change decision): "${a.title}"`
|
|
628
|
+
+ (a.kind === 'scoped' ?
|
|
629
|
+
` → scoped sweep over ${a.orphaned.length} orphaned requirement(s)`
|
|
630
|
+
: ' — every requirement it touched is owned by another task'));
|
|
631
|
+
}
|
|
632
|
+
return debatched.titles;
|
|
610
633
|
};
|
|
611
634
|
const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
|
|
612
635
|
let planTitles = parsePlan(listRaw);
|
|
@@ -1571,15 +1594,22 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1571
1594
|
disarmCancelListener();
|
|
1572
1595
|
}
|
|
1573
1596
|
}
|
|
1574
|
-
async function handleTaskAutoResume(
|
|
1597
|
+
async function handleTaskAutoResume(args, ctx) {
|
|
1575
1598
|
await ctx.waitForIdle();
|
|
1576
1599
|
const cwd = ctx.cwd;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1600
|
+
// `--unattended` is the boot-hook path: no human decided to continue this
|
|
1601
|
+
// run, so it resumes in-flight states only and refuses the rest by name.
|
|
1602
|
+
const unattended = /(^|\s)--unattended(\s|$)/.test(args);
|
|
1603
|
+
const candidate = await findResumableAutoDetailed(cwd);
|
|
1604
|
+
const decision = decideResume(candidate, Date.now(), unattended);
|
|
1605
|
+
ctx.ui.notify(decision.banner, decision.level);
|
|
1606
|
+
// An unattended refusal happens with nobody watching the terminal — the
|
|
1607
|
+
// remote view is the only surface that will still be there in the morning.
|
|
1608
|
+
if (unattended)
|
|
1609
|
+
publishLifecycleNotice(decision.banner, decision.level);
|
|
1610
|
+
if (!decision.resume || !candidate)
|
|
1580
1611
|
return;
|
|
1581
|
-
|
|
1582
|
-
ctx.ui.notify(`Resuming ${id}…`, 'info');
|
|
1612
|
+
const id = candidate.id;
|
|
1583
1613
|
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
1584
1614
|
autoRunning = true;
|
|
1585
1615
|
armTerminalCancel(ctx);
|
|
@@ -1648,7 +1678,8 @@ export function registerTaskAuto(pi) {
|
|
|
1648
1678
|
handler: handleTaskAuto
|
|
1649
1679
|
});
|
|
1650
1680
|
registerBridgeCommand(pi, 'task-auto-resume', {
|
|
1651
|
-
description: 'Resume the active /task-auto run.'
|
|
1681
|
+
description: 'Resume the active /task-auto run. Usage: /task-auto-resume [--unattended] '
|
|
1682
|
+
+ '(--unattended is for boot hooks: in-flight runs only, never a failed one).',
|
|
1652
1683
|
handler: handleTaskAutoResume
|
|
1653
1684
|
});
|
|
1654
1685
|
registerBridgeCommand(pi, 'task-auto-cancel', {
|
|
@@ -12,8 +12,12 @@ export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) =>
|
|
|
12
12
|
* belt): the spec's obligations ride into decompose explicitly, so mirroring the
|
|
13
13
|
* spec's own milestone/section structure cannot silently discharge them. '' ⇒
|
|
14
14
|
* the prompt is unchanged.
|
|
15
|
+
*
|
|
16
|
+
* `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
|
|
17
|
+
* when the decisions mandate tests-in-the-same-change, so every other run sees the
|
|
18
|
+
* prompt it always saw. It is the belt; the host-side rewrite is the lever.
|
|
15
19
|
*/
|
|
16
|
-
export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string) => string;
|
|
20
|
+
export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string, noBatchTests?: boolean) => string;
|
|
17
21
|
/**
|
|
18
22
|
* Coverage triage: judge whether a decomposed task list covers the whole
|
|
19
23
|
* feature. Guards the plan — the highest-leverage artifact in /task-auto —
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* LIST only; all research/spec depth is /task's job, run per-title later.
|
|
4
4
|
*/
|
|
5
5
|
import { DECOMPOSE_SOURCE_RULE } from './decompose-fidelity.js';
|
|
6
|
+
import { DECOMPOSE_NO_BATCH_TESTS_RULE } from './batch-test-task.js';
|
|
6
7
|
/**
|
|
7
8
|
* Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
|
|
8
9
|
* single numbered question followed by a "SUGGESTED: <default>" line, an optional
|
|
@@ -71,8 +72,12 @@ NONE`;
|
|
|
71
72
|
* belt): the spec's obligations ride into decompose explicitly, so mirroring the
|
|
72
73
|
* spec's own milestone/section structure cannot silently discharge them. '' ⇒
|
|
73
74
|
* the prompt is unchanged.
|
|
75
|
+
*
|
|
76
|
+
* `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
|
|
77
|
+
* when the decisions mandate tests-in-the-same-change, so every other run sees the
|
|
78
|
+
* prompt it always saw. It is the belt; the host-side rewrite is the lever.
|
|
74
79
|
*/
|
|
75
|
-
export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '') => `Split this feature into an ordered list of implementation tasks. Each task
|
|
80
|
+
export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '', noBatchTests = false) => `Split this feature into an ordered list of implementation tasks. Each task
|
|
76
81
|
will be handed, by its title, to a separate pipeline that does its own research
|
|
77
82
|
and writes its own spec — so here you produce TITLES ONLY, not specs.
|
|
78
83
|
|
|
@@ -94,7 +99,7 @@ RULES:
|
|
|
94
99
|
none. These are explicit user choices that may contradict the referenced spec
|
|
95
100
|
doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
|
|
96
101
|
not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
|
|
97
|
-
${DECOMPOSE_SOURCE_RULE}
|
|
102
|
+
${DECOMPOSE_SOURCE_RULE}${noBatchTests ? `\n${DECOMPOSE_NO_BATCH_TESTS_RULE}` : ''}
|
|
98
103
|
- Output the checkbox list and NOTHING else (no preamble, no numbering).`;
|
|
99
104
|
/**
|
|
100
105
|
* Coverage triage: judge whether a decomposed task list covers the whole
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the decisions/spec text mandate tests-in-the-same-change?
|
|
3
|
+
*
|
|
4
|
+
* Scans sentence by sentence and requires BOTH signals in the SAME sentence, so
|
|
5
|
+
* a testing section that happens to sit near an unrelated "don't defer" line
|
|
6
|
+
* cannot trigger the ban. `decisions` is checked first and alone is sufficient;
|
|
7
|
+
* the spec is scanned too because run 14's cadence rule is stated in §10 of the
|
|
8
|
+
* doc and only echoed into the decisions channel.
|
|
9
|
+
*/
|
|
10
|
+
export declare function mandatesTestsInSameChange(decisions: string, spec?: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Indices of titles that are whole-project BATCH test tasks.
|
|
13
|
+
*
|
|
14
|
+
* Three conditions must all hold — the title's deliverable is tests, its scope is
|
|
15
|
+
* the whole project, and it is not test INFRASTRUCTURE. A per-feature task that
|
|
16
|
+
* carries "+ tests" never matches (its head names the feature), which is the
|
|
17
|
+
* point: those are the cadence the decision asks for.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findBatchTestTitles(titles: string[]): number[];
|
|
20
|
+
/**
|
|
21
|
+
* The spec's own coverage source — the command whose report scopes the sweep.
|
|
22
|
+
*
|
|
23
|
+
* Only backticked spans are considered, so the result is a command the spec
|
|
24
|
+
* actually writes down rather than a phrase inferred from prose; a span carrying
|
|
25
|
+
* a coverage flag wins over a plain test run. Falls back to a generic phrase when
|
|
26
|
+
* the spec names no command, which keeps the sweep title well-formed either way.
|
|
27
|
+
*/
|
|
28
|
+
export declare function coverageSourceFromSpec(spec: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* The scoped replacement: a gap-filling sweep, bounded by what the coverage
|
|
31
|
+
* source reports and by the requirements that lost their only owner. It states
|
|
32
|
+
* the ban explicitly, because the child that receives this title sees nothing
|
|
33
|
+
* else.
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildSweepTitle(orphaned: string[], coverageSource: string): string;
|
|
36
|
+
export interface BatchTestAction {
|
|
37
|
+
/** Index of the offending title in the INPUT list. */
|
|
38
|
+
index: number;
|
|
39
|
+
/** The offending title, verbatim. */
|
|
40
|
+
title: string;
|
|
41
|
+
kind: 'dropped' | 'scoped';
|
|
42
|
+
/** The sweep title that replaced it (`kind: 'scoped'` only). */
|
|
43
|
+
replacement?: string;
|
|
44
|
+
/** Requirement quotes that no OTHER title grounds — what the sweep must cover. */
|
|
45
|
+
orphaned: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface BatchTestRewrite {
|
|
48
|
+
titles: string[];
|
|
49
|
+
actions: BatchTestAction[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Rewrite a decomposed plan so it carries no whole-project batch test task when
|
|
53
|
+
* the decisions mandate tests-in-the-same-change.
|
|
54
|
+
*
|
|
55
|
+
* No mandate, or no batch title ⇒ the plan is returned untouched (identity), so
|
|
56
|
+
* every non-cadence run behaves exactly as before.
|
|
57
|
+
*
|
|
58
|
+
* With a mandate, ALL batch titles are removed at once before coverage is
|
|
59
|
+
* re-measured — otherwise two batch tasks would each mask the other's orphans and
|
|
60
|
+
* both would look droppable. The orphaned set is then whatever grounded coverage
|
|
61
|
+
* the removal costs; it is non-empty only when no other task's title claims that
|
|
62
|
+
* requirement, and it becomes the sweep's scope. At most ONE sweep is emitted (in
|
|
63
|
+
* the first batch title's position, so plan order is preserved).
|
|
64
|
+
*/
|
|
65
|
+
export declare function rewriteBatchTestPlan(titles: string[], decisions: string, spec: string, requirementQuotes: string[], isCrossCutting: (quote: string) => boolean): BatchTestRewrite;
|
|
66
|
+
/**
|
|
67
|
+
* The decompose-prompt rule (the belt; the host rewrite above is the lever).
|
|
68
|
+
* Emitted ONLY when the decisions mandate the cadence, so ordinary runs see the
|
|
69
|
+
* prompt they always saw. Kept next to the detector so the two cannot drift.
|
|
70
|
+
*/
|
|
71
|
+
export declare const DECOMPOSE_NO_BATCH_TESTS_RULE: string;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* batch-test-task — ban the whole-project "write all the tests" task when the
|
|
3
|
+
* DECISIONS channel mandates tests-in-the-same-change (mx5 run 14, PROMPT item 6).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes: run 14's plan contained
|
|
6
|
+
*
|
|
7
|
+
* TASK_0037 "Write component and page tests — Playwright CT tests with
|
|
8
|
+
* screenshot baselines for all components and pages"
|
|
9
|
+
*
|
|
10
|
+
* — ~4.7h, the worst active-time task of the run, ended in a yolo-accepted verify
|
|
11
|
+
* FAIL with a frozen-config violation — while the very decision carried ON THAT
|
|
12
|
+
* TITLE said the opposite:
|
|
13
|
+
*
|
|
14
|
+
* "a test lands *as fast as possible* — in the same change — as each new route
|
|
15
|
+
* or React component/page. No route or component is considered done until its
|
|
16
|
+
* test exists and passes. Don't batch testing to the end of a milestone."
|
|
17
|
+
*
|
|
18
|
+
* Decompose did not invent the shape: the spec's own §10/§12 structure induced it,
|
|
19
|
+
* and decompose mirrors the spec. So the conflict is SPEC-INTERNAL (the spec's
|
|
20
|
+
* milestone shape vs the spec's own cadence rule), and the decisions channel
|
|
21
|
+
* OVERRIDES the spec doc by definition — the same precedence the task titles
|
|
22
|
+
* already state. Resolve toward the decision; never ask the user.
|
|
23
|
+
*
|
|
24
|
+
* The mechanism is a deterministic post-decompose rewrite (applied on EVERY
|
|
25
|
+
* decompose output, like fidelity reconciliation), plus a conditional prompt rule
|
|
26
|
+
* as the belt. Only the batch-EVERYTHING shape is banned — coverage is never
|
|
27
|
+
* nuked (run 12's lesson). Which of the two outcomes fires is decided by
|
|
28
|
+
* `groundedCoverage`, not by wording:
|
|
29
|
+
*
|
|
30
|
+
* • the batch title grounds NO requirement that survives its removal ⇒ every
|
|
31
|
+
* requirement it touched is owned by some other task, the per-change cadence
|
|
32
|
+
* already covers the work, and the task is DROPPED;
|
|
33
|
+
* • it is the ONLY title grounding some requirement(s) ⇒ dropping it would
|
|
34
|
+
* reduce planned coverage, so it is REPLACED by a scoped sweep that names
|
|
35
|
+
* exactly those orphaned requirements and points at the spec's own coverage
|
|
36
|
+
* source ("fill the gaps <that command> reports"), never "test everything".
|
|
37
|
+
*
|
|
38
|
+
* Because the replacement's owned-set is computed from the same groundedCoverage
|
|
39
|
+
* the monotonic adoption guard uses, total planned coverage cannot fall.
|
|
40
|
+
*/
|
|
41
|
+
import { groundedCoverage } from './coverage-loop.js';
|
|
42
|
+
/**
|
|
43
|
+
* Sentences that MANDATE tests landing with the change they cover. Deliberately
|
|
44
|
+
* narrow: a spec that merely REQUIRES tests ("every route has tests") does not
|
|
45
|
+
* ban a batch task — only an explicit cadence/anti-batch directive does.
|
|
46
|
+
*/
|
|
47
|
+
const SAME_CHANGE_RE = /\bin the same (?:change|commit|pr|patch|diff|task|step)\b|\b(?:do ?n['’]?t|do not|never|no)\s+(?:batch|defer|postpone|save|leave)\b|\bnot\b[^.]{0,60}\bdone until\b[^.]{0,60}\btest/i;
|
|
48
|
+
/** Any mention of testing — the mandate must be ABOUT tests, not about docs. */
|
|
49
|
+
const TEST_WORD_RE = /\btests?\b|\btesting\b|\btest-first\b/i;
|
|
50
|
+
/**
|
|
51
|
+
* Does the decisions/spec text mandate tests-in-the-same-change?
|
|
52
|
+
*
|
|
53
|
+
* Scans sentence by sentence and requires BOTH signals in the SAME sentence, so
|
|
54
|
+
* a testing section that happens to sit near an unrelated "don't defer" line
|
|
55
|
+
* cannot trigger the ban. `decisions` is checked first and alone is sufficient;
|
|
56
|
+
* the spec is scanned too because run 14's cadence rule is stated in §10 of the
|
|
57
|
+
* doc and only echoed into the decisions channel.
|
|
58
|
+
*/
|
|
59
|
+
export function mandatesTestsInSameChange(decisions, spec = '') {
|
|
60
|
+
for (const text of [decisions, spec]) {
|
|
61
|
+
for (const sentence of text.split(/(?<=[.!?])\s+|\n{2,}/)) {
|
|
62
|
+
if (TEST_WORD_RE.test(sentence) && SAME_CHANGE_RE.test(sentence))
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/** Trailing `[decisions: …]` clauses decompose appends — carried onto the
|
|
69
|
+
* replacement title verbatim so the rewrite never loses a directive. */
|
|
70
|
+
const DECISIONS_CLAUSE_RE = /\s*\[decisions:/i;
|
|
71
|
+
/**
|
|
72
|
+
* Where decompose's trailing metadata starts. `[source: …]` is included because
|
|
73
|
+
* reconcileTitleSources only strips a WELL-FORMED trailing citation: measured
|
|
74
|
+
* live, the model also emits `[source: "…" [10. Testing]]`, which fails that
|
|
75
|
+
* regex and leaks the QUOTED SPEC LINE into the title. Judging scope on such a
|
|
76
|
+
* title reads the citation's words as the task's own — the cadence quote "…as
|
|
77
|
+
* each new route or React component/page" made a properly scoped
|
|
78
|
+
* "Write route/API tests for auth" look like a batch task (live false positive,
|
|
79
|
+
* rep 1). A citation is provenance, never scope.
|
|
80
|
+
*/
|
|
81
|
+
const CLAUSE_START_RE = /\s*\[(?:source|decisions)\s*:/i;
|
|
82
|
+
/** Split a title into the part decompose authored, and the `[decisions: …]` tail
|
|
83
|
+
* that must survive onto a replacement title. A leaked `[source: …]` remnant is
|
|
84
|
+
* dropped from both — the sweep does not derive from that line. */
|
|
85
|
+
function splitDecisions(title) {
|
|
86
|
+
const clause = CLAUSE_START_RE.exec(title);
|
|
87
|
+
const body = (clause ? title.slice(0, clause.index) : title).trim();
|
|
88
|
+
const decisions = DECISIONS_CLAUSE_RE.exec(title);
|
|
89
|
+
return { body, tail: decisions ? title.slice(decisions.index) : '' };
|
|
90
|
+
}
|
|
91
|
+
/** The head of a title: everything before the " — <detail>" separator. */
|
|
92
|
+
function head(body) {
|
|
93
|
+
return body.split(/\s+[—–-]\s+|\s*\|\s*/)[0];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Remove double-quoted spans — they are QUOTED SPEC TEXT, never the task's own
|
|
97
|
+
* scope. Measured live: the model frequently appends its citation as a bare
|
|
98
|
+
* quote with no `[source: …]` wrapper ("Write auth route tests — … — "…a test
|
|
99
|
+
* lands as fast as possible — in the same change — as each new route or React
|
|
100
|
+
* component/page.""), and that borrowed "each" made five correctly scoped
|
|
101
|
+
* per-area test tasks read as batch tasks (rep 5). Trading a possible miss (a
|
|
102
|
+
* batch title whose only quantifier sits inside a quote) for never deleting a
|
|
103
|
+
* properly scoped test task: the false positive is far the more expensive error.
|
|
104
|
+
*/
|
|
105
|
+
function stripQuotedSpans(s) {
|
|
106
|
+
return s.replace(/"[^"]*"/g, ' ').replace(/[“][^”]*[”]/g, ' ');
|
|
107
|
+
}
|
|
108
|
+
/** A title whose DELIVERABLE is tests: an authoring verb whose object is tests,
|
|
109
|
+
* with nothing else claimed in between ("Write component and page tests" ✓,
|
|
110
|
+
* "Add listings CRUD + tests" ✗ — the `+` marks tests as an additive constraint
|
|
111
|
+
* on a feature task, which is exactly the cadence the decision asks for). */
|
|
112
|
+
const TEST_AUTHORING_RE = /^\s*(?:(?:write|add|create|implement|author|build|develop|produce|backfill)\b[\w\s,/-]*\b(?:tests?|test suite|test coverage|testing)\b|(?:tests?|testing)\b)/i;
|
|
113
|
+
/** Enabling/infrastructure work — a runner, a config, fixtures, CI. Those are
|
|
114
|
+
* NOT batched test authoring; banning them would remove the very thing the
|
|
115
|
+
* per-change tests need to exist first. */
|
|
116
|
+
const TEST_INFRA_RE = /\b(?:harness|infrastructure|infra|runner|config|configuration|scaffold|scaffolding|set ?up|fixtures?|helpers?|utilities|utility|ci|pipeline|database|seed)\b/i;
|
|
117
|
+
/**
|
|
118
|
+
* WHOLE-PROJECT scope: a universal quantifier that governs a PROJECT-LEVEL target
|
|
119
|
+
* ("all components and pages", "every route"), not merely any noun.
|
|
120
|
+
*
|
|
121
|
+
* Both halves are load-bearing, each learned from a live false positive:
|
|
122
|
+
* - "full"/"complete" are excluded — they routinely scope a single area
|
|
123
|
+
* ("full login flow");
|
|
124
|
+
* - the quantifier must reach a project-level plural within ~20 chars, because
|
|
125
|
+
* "Test PartCard component — ALL badge combinations…" is a one-component task
|
|
126
|
+
* that a bare-quantifier rule flagged and DROPPED (live rep 3).
|
|
127
|
+
*/
|
|
128
|
+
const WHOLE_SCOPE_RE = /\b(?:all|every|each|entire|whole|comprehensive)\b[\w\s,/-]{0,20}?\b(?:components?|pages?|routes?|endpoints?|screens?|views?|modules?|layers?|files?|features?|app|application|project|codebase|repo|repository|system|surface)\b|\bacross the (?:app|application|project|codebase|repo|repository)\b|\bend[- ]to[- ]end coverage\b/i;
|
|
129
|
+
/**
|
|
130
|
+
* Indices of titles that are whole-project BATCH test tasks.
|
|
131
|
+
*
|
|
132
|
+
* Three conditions must all hold — the title's deliverable is tests, its scope is
|
|
133
|
+
* the whole project, and it is not test INFRASTRUCTURE. A per-feature task that
|
|
134
|
+
* carries "+ tests" never matches (its head names the feature), which is the
|
|
135
|
+
* point: those are the cadence the decision asks for.
|
|
136
|
+
*/
|
|
137
|
+
export function findBatchTestTitles(titles) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (let i = 0; i < titles.length; i++) {
|
|
140
|
+
const { body } = splitDecisions(titles[i]);
|
|
141
|
+
const scope = stripQuotedSpans(body);
|
|
142
|
+
const h = head(scope);
|
|
143
|
+
if (!TEST_AUTHORING_RE.test(h))
|
|
144
|
+
continue;
|
|
145
|
+
if (TEST_INFRA_RE.test(h))
|
|
146
|
+
continue;
|
|
147
|
+
if (!WHOLE_SCOPE_RE.test(scope))
|
|
148
|
+
continue;
|
|
149
|
+
out.push(i);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** Test-runner commands a spec may name as the thing that REPORTS coverage. */
|
|
154
|
+
const TEST_COMMAND_RE = /\b(?:bun test|npm (?:run )?test|yarn test|pnpm (?:run )?test|npx (?:vitest|jest|playwright test)|playwright test|vitest|jest|pytest|go test|cargo test|mix test|rspec)\b[^`\n]*/i;
|
|
155
|
+
/**
|
|
156
|
+
* The spec's own coverage source — the command whose report scopes the sweep.
|
|
157
|
+
*
|
|
158
|
+
* Only backticked spans are considered, so the result is a command the spec
|
|
159
|
+
* actually writes down rather than a phrase inferred from prose; a span carrying
|
|
160
|
+
* a coverage flag wins over a plain test run. Falls back to a generic phrase when
|
|
161
|
+
* the spec names no command, which keeps the sweep title well-formed either way.
|
|
162
|
+
*/
|
|
163
|
+
export function coverageSourceFromSpec(spec) {
|
|
164
|
+
const spans = [...spec.matchAll(/`([^`\n]+)`/g)].map(m => m[1].trim());
|
|
165
|
+
const commands = spans.filter(s => TEST_COMMAND_RE.test(s));
|
|
166
|
+
const withCoverage = commands.find(s => /--coverage|\bcoverage\b/i.test(s));
|
|
167
|
+
return withCoverage ?? commands[0] ?? "the project's own test suite";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Vocabulary that every testing task and every testing requirement shares, so it
|
|
171
|
+
* carries NO ownership signal here. Without this scrub the bare token "test"
|
|
172
|
+
* connects a screenshot-baseline requirement to "Write auth route tests", and the
|
|
173
|
+
* orphan check goes blind. (Scrubbed locally rather than added to
|
|
174
|
+
* COVERAGE_STOPWORDS: those govern the A/B-validated monotonic adoption guard,
|
|
175
|
+
* where "test" IS a discriminating noun for a plan that has no testing task at
|
|
176
|
+
* all.) Token-level, not regex — `mx5_test` must scrub to `mx5`.
|
|
177
|
+
*/
|
|
178
|
+
const GENERIC_TEST_TOKENS = new Set([
|
|
179
|
+
'test',
|
|
180
|
+
'tests',
|
|
181
|
+
'testing',
|
|
182
|
+
'tested',
|
|
183
|
+
'spec',
|
|
184
|
+
'specs',
|
|
185
|
+
'suite',
|
|
186
|
+
'suites',
|
|
187
|
+
'coverage',
|
|
188
|
+
'assertion',
|
|
189
|
+
'assertions',
|
|
190
|
+
'case',
|
|
191
|
+
'cases',
|
|
192
|
+
'unit',
|
|
193
|
+
'e2e'
|
|
194
|
+
]);
|
|
195
|
+
/** Drop the generic testing vocabulary, keeping the tokenization coverage-loop
|
|
196
|
+
* itself uses so the scrubbed text grounds exactly the same way. */
|
|
197
|
+
function scrubTestVocabulary(s) {
|
|
198
|
+
return s
|
|
199
|
+
.toLowerCase()
|
|
200
|
+
.split(/[^a-z0-9]+/)
|
|
201
|
+
.filter(w => w.length > 0 && !GENERIC_TEST_TOKENS.has(w))
|
|
202
|
+
.join(' ');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Requirement indices that lose their owner when the batch titles are removed.
|
|
206
|
+
*
|
|
207
|
+
* Two independent measures, unioned:
|
|
208
|
+
*
|
|
209
|
+
* 1. STRICT (the one that actually fires): generic testing vocabulary scrubbed
|
|
210
|
+
* from both sides, and a requirement that is ABOUT testing may only be owned
|
|
211
|
+
* by a title that is about testing — "Implement login page" does not
|
|
212
|
+
* discharge "every component/page test captures a screenshot".
|
|
213
|
+
* 2. PLAIN groundedCoverage, exactly as the monotonic adoption guard measures
|
|
214
|
+
* it. This is the belt: whatever that guard would call a drop is an orphan
|
|
215
|
+
* here too, so the rewrite can never trip the guard it feeds.
|
|
216
|
+
*/
|
|
217
|
+
function orphanedRequirements(quotes, titles, kept, isCrossCutting) {
|
|
218
|
+
const scrubbed = quotes.map(scrubTestVocabulary);
|
|
219
|
+
// Index-aligned cross-cutting lookup: the scrubbed text is not the quote the
|
|
220
|
+
// classifier expects, so map back to the original.
|
|
221
|
+
const crossByScrub = new Map();
|
|
222
|
+
quotes.forEach((q, i) => crossByScrub.set(scrubbed[i], isCrossCutting(q)));
|
|
223
|
+
const isCrossScrubbed = (s) => crossByScrub.get(s) ?? false;
|
|
224
|
+
const testish = (t) => TEST_WORD_RE.test(t);
|
|
225
|
+
const scrub = (list) => list.map(scrubTestVocabulary);
|
|
226
|
+
const strictBefore = groundedCoverage(scrubbed, scrub(titles), isCrossScrubbed);
|
|
227
|
+
const strictAfterAll = groundedCoverage(scrubbed, scrub(kept), isCrossScrubbed);
|
|
228
|
+
const strictAfterTest = groundedCoverage(scrubbed, scrub(kept.filter(testish)), isCrossScrubbed);
|
|
229
|
+
const plainBefore = groundedCoverage(quotes, titles, isCrossCutting);
|
|
230
|
+
const plainAfter = groundedCoverage(quotes, kept, isCrossCutting);
|
|
231
|
+
const out = [];
|
|
232
|
+
for (let i = 0; i < quotes.length; i++) {
|
|
233
|
+
const owner = testish(quotes[i]) ? strictAfterTest : strictAfterAll;
|
|
234
|
+
const strictLost = strictBefore.has(i) && !owner.has(i);
|
|
235
|
+
const plainLost = plainBefore.has(i) && !plainAfter.has(i);
|
|
236
|
+
if (strictLost || plainLost)
|
|
237
|
+
out.push(i);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** How many orphaned requirement quotes the sweep title names, and how long each
|
|
242
|
+
* may be — a title is a one-line handoff, not a ledger. */
|
|
243
|
+
const MAX_SWEEP_QUOTES = 6;
|
|
244
|
+
const MAX_QUOTE_CHARS = 120;
|
|
245
|
+
function shorten(q) {
|
|
246
|
+
const s = q.replace(/\s+/g, ' ').trim();
|
|
247
|
+
return s.length <= MAX_QUOTE_CHARS ? s : s.slice(0, MAX_QUOTE_CHARS - 1).trimEnd() + '…';
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The scoped replacement: a gap-filling sweep, bounded by what the coverage
|
|
251
|
+
* source reports and by the requirements that lost their only owner. It states
|
|
252
|
+
* the ban explicitly, because the child that receives this title sees nothing
|
|
253
|
+
* else.
|
|
254
|
+
*/
|
|
255
|
+
export function buildSweepTitle(orphaned, coverageSource) {
|
|
256
|
+
const named = orphaned.slice(0, MAX_SWEEP_QUOTES).map(q => `"${shorten(q)}"`);
|
|
257
|
+
const more = orphaned.length > named.length ? ` (+${orphaned.length - named.length} more)` : '';
|
|
258
|
+
return (`Fill the test-coverage gaps left by the per-change tests — run \`${coverageSource}\`,`
|
|
259
|
+
+ ' and add tests ONLY for what it reports uncovered, specifically:'
|
|
260
|
+
+ ` ${named.join('; ')}${more}.`
|
|
261
|
+
+ ' Do NOT re-test what earlier tasks already covered, and do NOT modify'
|
|
262
|
+
+ ' existing test config or existing tests.');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Rewrite a decomposed plan so it carries no whole-project batch test task when
|
|
266
|
+
* the decisions mandate tests-in-the-same-change.
|
|
267
|
+
*
|
|
268
|
+
* No mandate, or no batch title ⇒ the plan is returned untouched (identity), so
|
|
269
|
+
* every non-cadence run behaves exactly as before.
|
|
270
|
+
*
|
|
271
|
+
* With a mandate, ALL batch titles are removed at once before coverage is
|
|
272
|
+
* re-measured — otherwise two batch tasks would each mask the other's orphans and
|
|
273
|
+
* both would look droppable. The orphaned set is then whatever grounded coverage
|
|
274
|
+
* the removal costs; it is non-empty only when no other task's title claims that
|
|
275
|
+
* requirement, and it becomes the sweep's scope. At most ONE sweep is emitted (in
|
|
276
|
+
* the first batch title's position, so plan order is preserved).
|
|
277
|
+
*/
|
|
278
|
+
export function rewriteBatchTestPlan(titles, decisions, spec, requirementQuotes, isCrossCutting) {
|
|
279
|
+
if (!mandatesTestsInSameChange(decisions, spec))
|
|
280
|
+
return { titles, actions: [] };
|
|
281
|
+
const batch = findBatchTestTitles(titles);
|
|
282
|
+
if (batch.length === 0)
|
|
283
|
+
return { titles, actions: [] };
|
|
284
|
+
const batchSet = new Set(batch);
|
|
285
|
+
const kept = titles.filter((_, i) => !batchSet.has(i));
|
|
286
|
+
const orphaned = orphanedRequirements(requirementQuotes, titles, kept, isCrossCutting).map(i => requirementQuotes[i]);
|
|
287
|
+
const actions = [];
|
|
288
|
+
const out = [];
|
|
289
|
+
let sweepEmitted = false;
|
|
290
|
+
for (let i = 0; i < titles.length; i++) {
|
|
291
|
+
if (!batchSet.has(i)) {
|
|
292
|
+
out.push(titles[i]);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (orphaned.length === 0 || sweepEmitted) {
|
|
296
|
+
actions.push({ index: i, title: titles[i], kind: 'dropped', orphaned: [] });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Carry the offending title's own [decisions: …] clauses onto the sweep —
|
|
300
|
+
// they are user directives and survive the reshaping of the task.
|
|
301
|
+
const { tail } = splitDecisions(titles[i]);
|
|
302
|
+
const replacement = buildSweepTitle(orphaned, coverageSourceFromSpec(spec)) + tail;
|
|
303
|
+
actions.push({ index: i, title: titles[i], kind: 'scoped', replacement, orphaned });
|
|
304
|
+
out.push(replacement);
|
|
305
|
+
sweepEmitted = true;
|
|
306
|
+
}
|
|
307
|
+
return { titles: out, actions };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The decompose-prompt rule (the belt; the host rewrite above is the lever).
|
|
311
|
+
* Emitted ONLY when the decisions mandate the cadence, so ordinary runs see the
|
|
312
|
+
* prompt they always saw. Kept next to the detector so the two cannot drift.
|
|
313
|
+
*/
|
|
314
|
+
export const DECOMPOSE_NO_BATCH_TESTS_RULE = '- The CLARIFICATIONS mandate that tests land in the SAME change as the code they'
|
|
315
|
+
+ ' cover. So do NOT emit a task whose job is writing tests for all components /'
|
|
316
|
+
+ ' all routes / the whole project — that shape is rejected host-side. Fold each'
|
|
317
|
+
+ " test into the task that builds the thing it tests (name it in that task's"
|
|
318
|
+
+ ' title), and emit a separate testing task ONLY as a narrowly scoped sweep of'
|
|
319
|
+
+ ' gaps a coverage run reports. Test INFRASTRUCTURE (runner, config, fixtures)'
|
|
320
|
+
+ ' may still be its own early task.';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume gating & the honest resume banner.
|
|
3
|
+
*
|
|
4
|
+
* mx5 run 14 lost ~10 hours to dead air that looked like a stall: the host was
|
|
5
|
+
* powered off overnight, both containers stopped at 20:00Z, and the run picked
|
|
6
|
+
* up cleanly the moment they were restarted at 06:01Z. Nothing was wrong with
|
|
7
|
+
* the run — the only defect was that nobody could tell. Two things follow.
|
|
8
|
+
*
|
|
9
|
+
* (1) A restart-time resume can be automated (`restart: unless-stopped` on the
|
|
10
|
+
* containers plus a boot hook that runs `/task-auto-resume --unattended`), which
|
|
11
|
+
* turns that 10h of nothing into minutes.
|
|
12
|
+
*
|
|
13
|
+
* (2) An automated resume must not resume everything. `RESUMABLE_STATES`
|
|
14
|
+
* deliberately includes `failed` and `cancelled` because a HUMAN typing
|
|
15
|
+
* /task-auto-resume has decided to continue; a boot hook has decided nothing. A
|
|
16
|
+
* failed run stopped for a reason a power cycle does not clear, and re-entering
|
|
17
|
+
* it unattended burns the whole loop against the same wall. Unattended resume
|
|
18
|
+
* therefore covers in-flight states only (see UNATTENDED_STATES), and refuses
|
|
19
|
+
* the rest by name rather than silently doing nothing.
|
|
20
|
+
*
|
|
21
|
+
* The banner follows the honest-restart-hint rule (70a8497): say exactly what
|
|
22
|
+
* was observed and exactly what it does not tell you. All this process knows is
|
|
23
|
+
* when the AUTO file was last written — it cannot distinguish a stopped host
|
|
24
|
+
* from a hung child from a slow task, so it reports the gap and attributes no
|
|
25
|
+
* cause. It is equally careful about the tree: nothing is rolled back between
|
|
26
|
+
* the interruption and the resume.
|
|
27
|
+
*/
|
|
28
|
+
import type { TaskState } from './task-types.js';
|
|
29
|
+
/** States an UNATTENDED resume may continue. In-flight only — see file header. */
|
|
30
|
+
export declare const UNATTENDED_STATES: TaskState[];
|
|
31
|
+
/** The most recent resumable AUTO run, with the two facts the banner needs. */
|
|
32
|
+
export interface AutoResumeCandidate {
|
|
33
|
+
id: string;
|
|
34
|
+
state: TaskState;
|
|
35
|
+
/** AUTO-file mtime: the last moment the run demonstrably wrote progress. */
|
|
36
|
+
lastWriteMs: number;
|
|
37
|
+
}
|
|
38
|
+
export interface ResumeDecision {
|
|
39
|
+
/** Whether the caller should proceed with the resume. */
|
|
40
|
+
resume: boolean;
|
|
41
|
+
/** The line to show the user (and publish to the remote view). */
|
|
42
|
+
banner: string;
|
|
43
|
+
level: 'info' | 'warning';
|
|
44
|
+
}
|
|
45
|
+
/** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
|
|
46
|
+
export declare function formatGap(ms: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
|
|
49
|
+
* and the clock. `unattended` is the boot-hook path (`--unattended`), which is
|
|
50
|
+
* the only one that refuses on state.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decideResume(candidate: AutoResumeCandidate | null, nowMs: number, unattended: boolean): ResumeDecision;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** States an UNATTENDED resume may continue. In-flight only — see file header. */
|
|
2
|
+
export const UNATTENDED_STATES = ['in_progress'];
|
|
3
|
+
/**
|
|
4
|
+
* Below this the gap is ordinary hand-driven latency (you looked at the failure,
|
|
5
|
+
* then typed the command) and the unaccounted-time paragraph would be noise.
|
|
6
|
+
*/
|
|
7
|
+
const GAP_NARRATION_MS = 5 * 60_000;
|
|
8
|
+
/** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
|
|
9
|
+
export function formatGap(ms) {
|
|
10
|
+
const s = Math.floor(ms / 1000);
|
|
11
|
+
if (s < 60)
|
|
12
|
+
return `${s}s`;
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
if (m < 60)
|
|
15
|
+
return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
if (h < 24)
|
|
18
|
+
return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
|
|
19
|
+
const d = Math.floor(h / 24);
|
|
20
|
+
return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What the resume says about the time it was away. Attributes no cause: a gap
|
|
24
|
+
* is wall-clock silence, and this process cannot see which of the several very
|
|
25
|
+
* different explanations produced it.
|
|
26
|
+
*/
|
|
27
|
+
function gapClause(lastWriteMs, nowMs) {
|
|
28
|
+
const gap = nowMs - lastWriteMs;
|
|
29
|
+
const at = new Date(lastWriteMs).toISOString();
|
|
30
|
+
// A future mtime means the clock moved, not that the run wrote ahead of
|
|
31
|
+
// time; saying "0s ago" there would be the one thing this banner must not do.
|
|
32
|
+
if (gap < -60_000) {
|
|
33
|
+
return `its file is timestamped ${at}, which is in the future — the clock moved under it, so no gap can be measured`;
|
|
34
|
+
}
|
|
35
|
+
const shown = formatGap(Math.max(0, gap));
|
|
36
|
+
if (gap < GAP_NARRATION_MS)
|
|
37
|
+
return `last written ${shown} ago (${at})`;
|
|
38
|
+
return (`last written ${shown} ago (${at}). That gap is unaccounted-for wall time, not work: `
|
|
39
|
+
+ `this process cannot tell a stopped host from a hung child from a slow task. `
|
|
40
|
+
+ `Nothing was rolled back — the working tree is exactly as the interrupted run left it`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
|
|
44
|
+
* and the clock. `unattended` is the boot-hook path (`--unattended`), which is
|
|
45
|
+
* the only one that refuses on state.
|
|
46
|
+
*/
|
|
47
|
+
export function decideResume(candidate, nowMs, unattended) {
|
|
48
|
+
if (!candidate) {
|
|
49
|
+
return { resume: false, banner: 'No resumable /task-auto run.', level: 'info' };
|
|
50
|
+
}
|
|
51
|
+
const { id, state, lastWriteMs } = candidate;
|
|
52
|
+
if (unattended && !UNATTENDED_STATES.includes(state)) {
|
|
53
|
+
return {
|
|
54
|
+
resume: false,
|
|
55
|
+
banner: `Not auto-resuming ${id}: state=${state}. Unattended resume continues `
|
|
56
|
+
+ `in-flight runs (${UNATTENDED_STATES.join(', ')}) only — a ${state} run stopped `
|
|
57
|
+
+ `for a reason a restart does not clear. Look at it, then resume by hand with `
|
|
58
|
+
+ `/task-auto-resume.`,
|
|
59
|
+
level: 'warning'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
resume: true,
|
|
64
|
+
banner: `Resuming ${id} (state=${state}) — ${gapClause(lastWriteMs, nowMs)}.`,
|
|
65
|
+
level: 'info'
|
|
66
|
+
};
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.42",
|
|
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",
|