@mjasnikovs/pi-task 0.22.0 → 0.23.0

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
@@ -65,7 +65,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
65
65
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
66
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
- | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, and one `ext:` toggle per installed host extension. |
68
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, debug logs, and one `ext:` toggle per installed host extension. |
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. |
70
70
 
71
71
  ## The pipeline
@@ -183,6 +183,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
183
183
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
184
184
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
185
185
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
186
+ | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
186
187
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
187
188
 
188
189
  ## Configuration
@@ -195,6 +196,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
195
196
  | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
196
197
  | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
197
198
  | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
199
+ | `PI_TASK_DEBUG_LOG` | task trail | Overrides the **debug logs** setting for one session: `off`, `events`, or `full`. For reproducing a report without walking someone through `/task-config`. An unrecognised value is ignored, not treated as `off`. |
198
200
 
199
201
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
200
202
 
@@ -96,7 +96,44 @@ export interface PiTaskConfig {
96
96
  * DEFAULT OFF — this is never the behaviour of a normal, watched run.
97
97
  */
98
98
  yoloMode: boolean;
99
+ /**
100
+ * How much the run writes to its `.pi-tasks/*-debug.log` forensic trail
101
+ * (task/debug-log.ts). NOTHING in pi-task ever reads these files back —
102
+ * `task-io.ts` only globs `TASK_NNNN.md`, and auto-commit's trail snapshot
103
+ * copies bytes without parsing them — so this knob is behaviour-neutral by
104
+ * construction. It trades disk and repo noise against the ability to explain
105
+ * a run after it has finished.
106
+ *
107
+ * `full` is every line the child model emitted plus every tool result;
108
+ * `events` keeps only decisions and guard actions; `off` writes nothing.
109
+ *
110
+ * DEFAULT `events`, not `off`, because the two levels are not the same kind
111
+ * of record. Measured on a real 247 KB `verify-debug.log` (IAR1, 1315 lines):
112
+ * the child's own output and the `↳` tool dumps are 85% of the bytes, while
113
+ * the `=== … ===` markers are 15% — and that 15% is the ONLY record of what
114
+ * the guards did (`GIT-STATE GUARD — child mutated graded state`, the
115
+ * write-capable child's `tree changes`, the FAIL reason). mx5 run 11's
116
+ * final-fix child deleted a source file; the tree-changes line is why that
117
+ * was findable at all. Silencing the chatter costs nothing; silencing the
118
+ * guard record makes the next incident unreconstructible, and a debug log
119
+ * cannot be recovered after the fact.
120
+ */
121
+ debugLogs: DebugLogLevel;
99
122
  }
123
+ /** How verbose the `.pi-tasks/*-debug.log` trail is. See {@link PiTaskConfig.debugLogs}. */
124
+ export type DebugLogLevel = 'off' | 'events' | 'full';
125
+ /**
126
+ * The debug-log choices offered by /task-config, in cycle order (quietest →
127
+ * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
128
+ * stored value IS the label — the level is already a word.
129
+ */
130
+ export declare const DEBUG_LOG_OPTIONS: readonly DebugLogLevel[];
131
+ /**
132
+ * Same pinning as the timeout sanitizers: a hand-edited `"debugLogs": true` or a
133
+ * level from a future version must not reach the writer as an unknown string —
134
+ * it falls back to the default rather than silently disabling the trail.
135
+ */
136
+ export declare function sanitizeDebugLogs(value: unknown): DebugLogLevel;
100
137
  /**
101
138
  * The command-watchdog timeout choices offered by /task-config, newest-first in
102
139
  * the cycle order the picker shows. The stored config value is the ms number;
@@ -4,6 +4,23 @@ import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { isSearchProvider } from '../workers/search-types.js';
6
6
  import { DEFAULT_STREAM_INACTIVITY_MS } from '../shared/stream-watchdog.js';
7
+ /**
8
+ * The debug-log choices offered by /task-config, in cycle order (quietest →
9
+ * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
10
+ * stored value IS the label — the level is already a word.
11
+ */
12
+ export const DEBUG_LOG_OPTIONS = ['off', 'events', 'full'];
13
+ const DEFAULT_DEBUG_LOGS = 'events';
14
+ /**
15
+ * Same pinning as the timeout sanitizers: a hand-edited `"debugLogs": true` or a
16
+ * level from a future version must not reach the writer as an unknown string —
17
+ * it falls back to the default rather than silently disabling the trail.
18
+ */
19
+ export function sanitizeDebugLogs(value) {
20
+ return DEBUG_LOG_OPTIONS.includes(value) ?
21
+ value
22
+ : DEFAULT_DEBUG_LOGS;
23
+ }
7
24
  /**
8
25
  * The command-watchdog timeout choices offered by /task-config, newest-first in
9
26
  * the cycle order the picker shows. The stored config value is the ms number;
@@ -61,7 +78,10 @@ const DEFAULTS = {
61
78
  requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
62
79
  streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
63
80
  // OFF: auto-answering is for unattended throwaway runs only.
64
- yoloMode: false
81
+ yoloMode: false,
82
+ // EVENTS: the model chatter is 85% of the bytes and nobody reads it; the
83
+ // guard/verdict markers are the 15% that explains a failed run.
84
+ debugLogs: DEFAULT_DEBUG_LOGS
65
85
  };
66
86
  /**
67
87
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -96,6 +116,7 @@ if (!G.loaded) {
96
116
  // boolean counts; anything else falls back to the OFF default.
97
117
  if (typeof parsed.yoloMode !== 'boolean')
98
118
  delete parsed.yoloMode;
119
+ parsed.debugLogs = sanitizeDebugLogs(parsed.debugLogs);
99
120
  G.config = { ...DEFAULTS, ...parsed };
100
121
  }
101
122
  catch {
@@ -2,7 +2,7 @@ import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
3
  import { readPkgVersion } from '../shared/pkg-version.js';
4
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
5
- import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
5
+ import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
6
6
  import { listInstalledExtensions } from './extension-list.js';
7
7
  // Version in the title so a bug report or screenshot says which build it came
8
8
  // from without anyone having to go look it up.
@@ -160,6 +160,16 @@ const ITEMS = [
160
160
  + 'check is accepted and written down as debt, and a failed final check is retried '
161
161
  + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
162
162
  + 'For throwaway projects you are not watching'
163
+ },
164
+ {
165
+ id: 'debugLogs',
166
+ label: 'debug logs',
167
+ description: 'How much of a run gets written to .pi-tasks/*-debug.log. "events" keeps the '
168
+ + 'decisions and the guard actions — what a checking step changed, why something '
169
+ + 'failed — a few lines per task. "full" adds everything the model said and every '
170
+ + 'command it ran, which is most of the size and only useful while you are digging '
171
+ + 'into a problem. "off" writes nothing, and nothing can be reconstructed later',
172
+ values: [...DEBUG_LOG_OPTIONS]
163
173
  }
164
174
  ];
165
175
  /** Human label for the stored command-timeout ms (falls back to the raw ms). */
@@ -295,6 +305,12 @@ async function handleTaskConfig(_args, ctx) {
295
305
  if (opt)
296
306
  cfg.streamInactivityMs = opt.ms;
297
307
  }
308
+ else if (id === 'debugLogs') {
309
+ // The stored value IS the label here, but it must still go
310
+ // through the sanitizer — the generic `else` below would
311
+ // write the boolean `newValue === 'on'` into an enum field.
312
+ cfg.debugLogs = sanitizeDebugLogs(newValue);
313
+ }
298
314
  else {
299
315
  ;
300
316
  cfg[id] = newValue === 'on';
@@ -38,6 +38,7 @@ import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
38
38
  import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
39
39
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
40
40
  import { getConfig } from '../config/config.js';
41
+ import { debugLogLevel, shouldLogDebug } from './debug-log.js';
41
42
  import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
42
43
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
43
44
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
@@ -122,8 +123,16 @@ function mentionPath(token) {
122
123
  * before any task file — hence any per-task `TASK_XXXX-debug.log` — exists. Writes
123
124
  * to `.pi-tasks/plan-debug.log`; the `*-debug.log` suffix keeps it grep-compatible
124
125
  * with the per-task logs. Never throws (mkdir + append are best-effort).
126
+ *
127
+ * Every call site here records a plan DECISION (how many titles a round produced,
128
+ * whether a retry was adopted, which clarify answer was auto-resolved), so all of
129
+ * them are `'event'` — this file carries no model chatter and survives at the
130
+ * default level. It is also the only channel the plan phase has: it runs before
131
+ * any task file, hence any `TASK_NNNN-debug.log`, exists.
125
132
  */
126
133
  function logPlanDebug(cwd, msg) {
134
+ if (!shouldLogDebug('event', debugLogLevel()))
135
+ return;
127
136
  const line = `${new Date().toISOString()} ${msg}\n`;
128
137
  const dir = tasksDir(cwd);
129
138
  fsp.mkdir(dir, { recursive: true })
@@ -6,6 +6,7 @@
6
6
  * for phase-level child pi invocations.
7
7
  */
8
8
  import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
9
+ import type { DebugLine } from './debug-log.js';
9
10
  export declare const LOOP_WINDOW = 20;
10
11
  export declare const LOOP_THRESHOLD = 5;
11
12
  export declare const MAX_LOOP_RESTARTS = 2;
@@ -44,8 +45,16 @@ interface PhaseDeps {
44
45
  */
45
46
  recordSubStep?: (label: string, ms: number) => void;
46
47
  spawn?: SpawnFn;
47
- /** Write a timestamped line to the per-task debug log. Fire-and-forget. */
48
- logDebug?: (msg: string) => void;
48
+ /**
49
+ * Write a timestamped line to the per-task debug log. Fire-and-forget, and
50
+ * UNSET entirely when the trail is off — so a caller must keep the `?.` and
51
+ * must not do work to build a message outside the call.
52
+ *
53
+ * `kind` defaults to `'event'` (a decision or guard action, kept at the
54
+ * default level). Pass `'stream'` for raw child output and tool results,
55
+ * which only the `full` level keeps. See debug-log.ts.
56
+ */
57
+ logDebug?: (msg: string, kind?: DebugLine) => void;
49
58
  /** Injectable delay for connection-error backoff; defaults to a real timer.
50
59
  * Tests override it with a no-op so retries don't actually sleep. */
51
60
  sleepFor?: (ms: number) => Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { type DebugLogLevel } from '../config/config.js';
2
+ /**
3
+ * Escape hatch for reproducing a user's bug without walking them through
4
+ * /task-config. Follows the existing `PI_TASK_*` instrumentation convention
5
+ * (`PI_TASK_TYPEONLY_LOG`, `PI_REMOTE_PUSH_DEBUG`). An unrecognised value is
6
+ * ignored rather than treated as `off` — a typo in an env var must not silently
7
+ * throw the trail away.
8
+ */
9
+ export declare const DEBUG_LOG_ENV = "PI_TASK_DEBUG_LOG";
10
+ /** A trail line's kind — see the module note. Producers default to `'event'`. */
11
+ export type DebugLine = 'event' | 'stream';
12
+ /**
13
+ * The level in force: env override first, then the saved config. Read per call
14
+ * rather than cached, so flipping the setting mid-run takes effect on the next
15
+ * line instead of at the next restart.
16
+ */
17
+ export declare function debugLogLevel(getEnv?: (k: string) => string | undefined): DebugLogLevel;
18
+ /** Whether a line of this kind should be written at `level`. */
19
+ export declare function shouldLogDebug(kind: DebugLine, level: DebugLogLevel): boolean;
20
+ /**
21
+ * A timestamped fire-and-forget appender for one trail file, level-gated.
22
+ *
23
+ * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
24
+ * useful direction: forgetting to classify a marker keeps it in the audit trail,
25
+ * whereas forgetting to classify chatter would only make the log bigger. Errors
26
+ * are swallowed — an unwritable trail must never fail the run it is describing.
27
+ */
28
+ export declare function makeDebugAppender(logPath: string, appendFile?: (p: string, data: string) => Promise<unknown>): (msg: string, kind?: DebugLine) => void;
29
+ /**
30
+ * Wrap a raw append in the level gate. Returns `undefined` when the level is
31
+ * `off`, so callers that hold an OPTIONAL `logDebug` leave it unset and every
32
+ * `logDebug?.(…)` in the pipeline short-circuits before it formats a string.
33
+ */
34
+ export declare function gateDebugWriter(write: (msg: string) => void, getEnv?: (k: string) => string | undefined): ((msg: string, kind?: DebugLine) => void) | undefined;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One place that decides whether a `.pi-tasks/*-debug.log` line gets written.
3
+ *
4
+ * THE TRAIL IS WRITE-ONLY. Nothing in pi-task reads these files back: `task-io.ts`
5
+ * globs `TASK_NNNN.md` and skips everything else, and auto-commit's `snapshotTrail`
6
+ * copies the bytes across a `reset --hard` without ever parsing them. Every producer
7
+ * is a `logDebug?.(…)` / `log(…)` side effect whose return value is discarded. So
8
+ * this gate cannot change what a run DOES — only what it can explain afterwards.
9
+ *
10
+ * TWO KINDS OF LINE, and the distinction is the whole point of having three levels
11
+ * rather than a boolean:
12
+ *
13
+ * 'stream' — what the child model said, and what its tools returned. Reproducible
14
+ * by re-running, useful while you are actively debugging, and 85% of the
15
+ * bytes (measured: a 247 KB IAR1 `verify-debug.log` is 1315 lines, of
16
+ * which 521 are `↳` tool dumps and most of the rest is raw model text).
17
+ *
18
+ * 'event' — a decision or a guard action: which phase started, why a worker was
19
+ * retried or degraded, what the git-state guard restored, what a
20
+ * write-capable child changed on disk, why a gate returned FAIL. Ten-ish
21
+ * lines per task, and NOT reproducible — it is the only record that the
22
+ * guard fired at all. mx5 run 11's final-fix child deleted a source file
23
+ * and the `tree changes:` line is the reason anyone could tell.
24
+ *
25
+ * Hence the default is `events`, not `off`: the quiet default users want costs the
26
+ * chatter, not the audit trail.
27
+ */
28
+ import * as fsp from 'node:fs/promises';
29
+ import { getConfig, sanitizeDebugLogs } from '../config/config.js';
30
+ /**
31
+ * Escape hatch for reproducing a user's bug without walking them through
32
+ * /task-config. Follows the existing `PI_TASK_*` instrumentation convention
33
+ * (`PI_TASK_TYPEONLY_LOG`, `PI_REMOTE_PUSH_DEBUG`). An unrecognised value is
34
+ * ignored rather than treated as `off` — a typo in an env var must not silently
35
+ * throw the trail away.
36
+ */
37
+ export const DEBUG_LOG_ENV = 'PI_TASK_DEBUG_LOG';
38
+ /**
39
+ * The level in force: env override first, then the saved config. Read per call
40
+ * rather than cached, so flipping the setting mid-run takes effect on the next
41
+ * line instead of at the next restart.
42
+ */
43
+ export function debugLogLevel(getEnv = k => process.env[k]) {
44
+ const raw = getEnv(DEBUG_LOG_ENV)?.trim();
45
+ // sanitizeDebugLogs falls back to the DEFAULT for anything unrecognised, so
46
+ // an unset/typo'd var must be filtered out here rather than handed to it —
47
+ // otherwise `PI_TASK_DEBUG_LOG=verbose` would quietly override a saved `off`.
48
+ if (raw && sanitizeDebugLogs(raw) === raw)
49
+ return raw;
50
+ return getConfig().debugLogs;
51
+ }
52
+ /** Whether a line of this kind should be written at `level`. */
53
+ export function shouldLogDebug(kind, level) {
54
+ if (level === 'off')
55
+ return false;
56
+ if (level === 'full')
57
+ return true;
58
+ return kind === 'event';
59
+ }
60
+ /**
61
+ * A timestamped fire-and-forget appender for one trail file, level-gated.
62
+ *
63
+ * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
64
+ * useful direction: forgetting to classify a marker keeps it in the audit trail,
65
+ * whereas forgetting to classify chatter would only make the log bigger. Errors
66
+ * are swallowed — an unwritable trail must never fail the run it is describing.
67
+ */
68
+ export function makeDebugAppender(logPath, appendFile = (p, data) => fsp.appendFile(p, data)) {
69
+ return (msg, kind = 'event') => {
70
+ if (!shouldLogDebug(kind, debugLogLevel()))
71
+ return;
72
+ void appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
73
+ };
74
+ }
75
+ /**
76
+ * Wrap a raw append in the level gate. Returns `undefined` when the level is
77
+ * `off`, so callers that hold an OPTIONAL `logDebug` leave it unset and every
78
+ * `logDebug?.(…)` in the pipeline short-circuits before it formats a string.
79
+ */
80
+ export function gateDebugWriter(write, getEnv) {
81
+ if (debugLogLevel(getEnv) === 'off')
82
+ return undefined;
83
+ return (msg, kind = 'event') => {
84
+ if (shouldLogDebug(kind, debugLogLevel(getEnv)))
85
+ write(msg);
86
+ };
87
+ }
@@ -43,6 +43,7 @@ import { captureGitState, reconcileGitState } from './git-state-guard.js';
43
43
  import { runWorker } from '../workers/pi-worker-core.js';
44
44
  import { formatLoopHint } from './child-runner.js';
45
45
  import { getConfig } from '../config/config.js';
46
+ import { makeDebugAppender } from './debug-log.js';
46
47
  import { startAutoLoader } from './widget.js';
47
48
  import { resolveContextUsage } from './context-usage.js';
48
49
  /** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
@@ -352,10 +353,11 @@ export function buildGateDeps(params) {
352
353
  contextUsage = undefined;
353
354
  lastGuardReconcile = null;
354
355
  const startedAt = Date.now();
355
- const logPath = path.join(tasksDir(cwd2), logFile);
356
- const log = (msg) => {
357
- void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
358
- };
356
+ // `kind` defaults to 'event': every marker below (start/end, the
357
+ // git-state guard's restore, the loop warning, a write-capable child's
358
+ // tree changes) is a guard record that survives at the default level.
359
+ // Only the child's own stdout and its tool results pass 'stream'.
360
+ const log = makeDebugAppender(path.join(tasksDir(cwd2), logFile));
359
361
  log(`=== ${kind} start: ${taskTitle} ===`);
360
362
  // GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
361
363
  // contract is prompt-level and the live model breaks it (mx5 run 6: the
@@ -399,14 +401,17 @@ export function buildGateDeps(params) {
399
401
  streamInactivityMs: getConfig().streamInactivityMs,
400
402
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
401
403
  onLine: line => {
404
+ // `lastLine` feeds the LIVE status widget and is not
405
+ // logging — it stays outside the gate, or a quiet
406
+ // trail would also blank the progress display.
402
407
  lastLine = line;
403
- log(line);
408
+ log(line, 'stream');
404
409
  },
405
410
  // Log tool OUTPUTS, not just the command (mx5 run 10 item 6):
406
411
  // without the result "verify claimed curl PASS on a server that
407
412
  // cannot serve" is undecidable from the log. Truncated, tail-kept
408
413
  // (a bind failure / status usually lands at the end), error-flagged.
409
- onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`),
414
+ onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`, 'stream'),
410
415
  onContextUsage: snapshot => {
411
416
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
412
417
  }
@@ -549,12 +554,7 @@ export function buildGateDeps(params) {
549
554
  contextUsage = undefined;
550
555
  const startedAt = Date.now();
551
556
  // Per-pass debug log; the enforce child is otherwise unobservable.
552
- const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
553
- const logEnforce = (msg) => {
554
- void fsp
555
- .appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
556
- .catch(() => { });
557
- };
557
+ const logEnforce = makeDebugAppender(path.join(tasksDir(cwd2), 'enforce-debug.log'));
558
558
  logEnforce(`=== enforce start: ${taskTitle} ===`);
559
559
  const stopLoader = startAutoLoader(enforceCtx, () => ({
560
560
  title: taskTitle,
@@ -587,8 +587,9 @@ export function buildGateDeps(params) {
587
587
  // literally-identical call repeated past threshold does.
588
588
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
589
589
  onLine: line => {
590
+ // `lastLine` drives the live widget, not the trail.
590
591
  lastLine = line;
591
- logEnforce(line);
592
+ logEnforce(line, 'stream');
592
593
  },
593
594
  onContextUsage: snapshot => {
594
595
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
@@ -666,9 +667,7 @@ export function buildGateDeps(params) {
666
667
  // resolves; the remainder is injected under rule 4e, whose point is
667
668
  // that such a path breaks the BUILD — so the checks that would have
668
669
  // caught it report nothing rather than failing.
669
- foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, msg => void fsp
670
- .appendFile(path.join(tasksDir(cwd2), 'verify-debug.log'), `${new Date().toISOString()} ${msg}\n`)
671
- .catch(() => { })),
670
+ foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, makeDebugAppender(path.join(tasksDir(cwd2), 'verify-debug.log'))),
672
671
  // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
673
672
  // item 4): a check script this task authored that cannot fail
674
673
  // (`… || true`, an inverted-grep launder). Injected under rule 4f,
@@ -785,11 +784,7 @@ export function buildGateDeps(params) {
785
784
  // preserve registry), never a per-task union.
786
785
  treeChanges: () => collectTreeChanges(cwd2, signal),
787
786
  probeScan: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
788
- log: msg => {
789
- void fsp
790
- .appendFile(path.join(tasksDir(cwd2), 'final-gate-debug.log'), `${new Date().toISOString()} ${msg}\n`)
791
- .catch(() => { });
792
- }
787
+ log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
793
788
  }),
794
789
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
795
790
  // Read the same composed spec the verify gate judged against, so the
@@ -28,6 +28,7 @@ import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.
28
28
  import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
29
29
  import { pushNotify } from '../remote/push.js';
30
30
  import { getConfig } from '../config/config.js';
31
+ import { gateDebugWriter } from './debug-log.js';
31
32
  import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
32
33
  import { buildGateDeps } from './gate-deps.js';
33
34
  import { runGatesForTask } from './task-gates.js';
@@ -191,13 +192,15 @@ export class TaskRunner {
191
192
  await this._onStart(id);
192
193
  // Wire up per-task debug log (<cwd>/.pi-tasks/TASK_XXXX-debug.log).
193
194
  const debugLogPath = path.join(tasksDir(cwd), `${id}-debug.log`);
194
- this._deps.logDebug = (msg) => {
195
+ // Left UNSET at level `off`, so the ~39 `logDebug?.(…)` sites downstream
196
+ // short-circuit before they format a string and the file is never created.
197
+ this._deps.logDebug = gateDebugWriter((msg) => {
195
198
  const line = `${new Date().toISOString()} ${msg}\n`;
196
199
  fsp.appendFile(debugLogPath, line).catch(() => {
197
200
  /* ignore */
198
201
  });
199
- };
200
- this._deps.logDebug(`run: start phase=${resumePhase}`);
202
+ });
203
+ this._deps.logDebug?.(`run: start phase=${resumePhase}`);
201
204
  // Register as active.
202
205
  this._widgetState.taskId = id;
203
206
  this._widgetState.title = title;
@@ -630,7 +630,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
630
630
  ...(spec.tools ? { tools: spec.tools } : {}),
631
631
  ...(spec.extensions ? { extensions: spec.extensions } : {}),
632
632
  onLine: line => {
633
- deps.logDebug?.(`${spec.label}: ${line}`);
633
+ // The one 'stream' site in this file: raw research-worker
634
+ // output. Every other logDebug here records a decision.
635
+ // onChildOutput drives the widget and is not gated.
636
+ deps.logDebug?.(`${spec.label}: ${line}`, 'stream');
634
637
  deps.onChildOutput?.(`${spec.label}: ${line}`);
635
638
  }
636
639
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
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",