@mjasnikovs/pi-task 0.18.41 → 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 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
@@ -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
- /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
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>;
@@ -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
- /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
198
- export async function findResumableAuto(cwd) {
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], mtime: st.mtimeMs });
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.mtime - a.mtime);
221
- return candidates.length > 0 ? candidates[0].id : null;
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, findResumableAuto } from './auto-io.js';
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';
@@ -1593,15 +1594,22 @@ async function handleTaskAuto(args, ctx) {
1593
1594
  disarmCancelListener();
1594
1595
  }
1595
1596
  }
1596
- async function handleTaskAutoResume(_args, ctx) {
1597
+ async function handleTaskAutoResume(args, ctx) {
1597
1598
  await ctx.waitForIdle();
1598
1599
  const cwd = ctx.cwd;
1599
- const id = await findResumableAuto(cwd);
1600
- if (!id) {
1601
- ctx.ui.notify('No resumable /task-auto run.', 'info');
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)
1602
1611
  return;
1603
- }
1604
- ctx.ui.notify(`Resuming ${id}…`, 'info');
1612
+ const id = candidate.id;
1605
1613
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1606
1614
  autoRunning = true;
1607
1615
  armTerminalCancel(ctx);
@@ -1670,7 +1678,8 @@ export function registerTaskAuto(pi) {
1670
1678
  handler: handleTaskAuto
1671
1679
  });
1672
1680
  registerBridgeCommand(pi, 'task-auto-resume', {
1673
- 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).',
1674
1683
  handler: handleTaskAutoResume
1675
1684
  });
1676
1685
  registerBridgeCommand(pi, 'task-auto-cancel', {
@@ -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.41",
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",