@mjasnikovs/pi-task 0.13.19 → 0.13.21

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.
@@ -26,6 +26,7 @@ import { startWidget } from './widget.js';
26
26
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
27
27
  import { pushNotify } from '../remote/push.js';
28
28
  import { parseVerifyBlock } from './spec-validation.js';
29
+ import { titleForDisplay } from './parsers.js';
29
30
  import { formatTimings } from './timings.js';
30
31
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
31
32
  // ─── Module-level state ──────────────────────────────────────────────────────
@@ -132,11 +133,13 @@ export class TaskRunner {
132
133
  // Initialise or resume the TASK file.
133
134
  let id;
134
135
  let title;
136
+ let label;
135
137
  let resumePhase = 'refine';
136
138
  if (this._resumeId) {
137
139
  id = this._resumeId;
138
140
  const { frontMatter } = await readTaskFile(cwd, id);
139
141
  title = frontMatter.title;
142
+ label = frontMatter.label;
140
143
  resumePhase = frontMatter.phase;
141
144
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
142
145
  }
@@ -171,6 +174,7 @@ export class TaskRunner {
171
174
  // Register as active.
172
175
  this._widgetState.taskId = id;
173
176
  this._widgetState.title = title;
177
+ this._widgetState.label = label;
174
178
  this._widgetState.phase = resumePhase;
175
179
  this._widgetState.startedAt = this._startedAt;
176
180
  this._deps.taskId = id;
@@ -214,7 +218,7 @@ export class TaskRunner {
214
218
  }
215
219
  await setTaskSection(cwd, id, phase.section, out);
216
220
  this._pc[phase.field] = out;
217
- await postCommitPhase(phase, this._pc, out);
221
+ await postCommitPhase(phase, this._deps, this._pc, out);
218
222
  }
219
223
  // All phases done — hand off the spec.
220
224
  await advance('done');
@@ -445,7 +449,7 @@ async function handleTaskList(_args, ctx) {
445
449
  const idx = PHASE_INDEX[fm.phase];
446
450
  const phasePart = `phase ${Math.min(idx + 1, PHASE_ORDER.length)}/${PHASE_ORDER.length} ${fm.phase}`;
447
451
  const date = fm.updated_at.replace('T', ' ').slice(0, 16);
448
- lines.push(`${fm.id} ${fm.state.padEnd(12)} ${phasePart.padEnd(24)} ${date} "${fm.title}"`);
452
+ lines.push(`${fm.id} ${fm.state.padEnd(12)} ${phasePart.padEnd(24)} ${date} "${titleForDisplay(fm)}"`);
449
453
  }
450
454
  if (lines.length === 0)
451
455
  lines.push('(no tasks in .pi-tasks/)');
@@ -35,3 +35,20 @@ export declare function parseVerifyToolingOutput(output: string): {
35
35
  }>;
36
36
  };
37
37
  export declare function deriveTitle(refined: string): string;
38
+ /** Max characters shown for a task's display label. */
39
+ export declare const LABEL_MAX = 72;
40
+ /**
41
+ * Collapse whitespace and clamp `s` to `max` chars, cutting on a word boundary
42
+ * (when one falls reasonably late) and appending an ellipsis. Returns the
43
+ * collapsed string unchanged when it already fits.
44
+ */
45
+ export declare function truncateLabel(s: string, max?: number): string;
46
+ /**
47
+ * The string to show for a task: the stored short `label` when present,
48
+ * otherwise a truncation of the full `title`. Both paths are clamped to
49
+ * LABEL_MAX so a hand-edited or legacy over-long label can't blow up a line.
50
+ */
51
+ export declare function titleForDisplay(task: {
52
+ title: string;
53
+ label?: string;
54
+ }): string;
@@ -221,3 +221,39 @@ export function deriveTitle(refined) {
221
221
  }
222
222
  return '(untitled)';
223
223
  }
224
+ // ─── Display label ───────────────────────────────────────────────────────────
225
+ //
226
+ // `title` is stored in full (a refine GOAL paragraph can be 1000+ chars — see
227
+ // the "no truncation at storage" contract in deriveTitle). These helpers shrink
228
+ // it for status surfaces (widget head, /task list) WITHOUT touching what's
229
+ // stored: the pipeline always reads the full title. A model-compressed `label`
230
+ // is preferred when present (see title-label.ts); otherwise we fall back to a
231
+ // deterministic, word-boundary truncation so a label-less task still reads
232
+ // cleanly.
233
+ /** Max characters shown for a task's display label. */
234
+ export const LABEL_MAX = 72;
235
+ /**
236
+ * Collapse whitespace and clamp `s` to `max` chars, cutting on a word boundary
237
+ * (when one falls reasonably late) and appending an ellipsis. Returns the
238
+ * collapsed string unchanged when it already fits.
239
+ */
240
+ export function truncateLabel(s, max = LABEL_MAX) {
241
+ const flat = s.replace(/\s+/g, ' ').trim();
242
+ if (flat.length <= max)
243
+ return flat;
244
+ const slice = flat.slice(0, max - 1);
245
+ const lastSpace = slice.lastIndexOf(' ');
246
+ const cut = lastSpace > max * 0.6 ? slice.slice(0, lastSpace) : slice;
247
+ return cut.trimEnd() + '…';
248
+ }
249
+ /**
250
+ * The string to show for a task: the stored short `label` when present,
251
+ * otherwise a truncation of the full `title`. Both paths are clamped to
252
+ * LABEL_MAX so a hand-edited or legacy over-long label can't blow up a line.
253
+ */
254
+ export function titleForDisplay(task) {
255
+ const label = task.label?.trim();
256
+ if (label)
257
+ return truncateLabel(label);
258
+ return truncateLabel(task.title);
259
+ }
@@ -40,6 +40,22 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
40
40
  export interface PhaseResearchDeps extends ExternalContextDeps {
41
41
  getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
42
42
  }
43
+ /**
44
+ * The TOOLING worker only needs to know which verification commands the task
45
+ * cares about — never the per-file edit list. Big refined prompts embed a long
46
+ * bulleted "fix these files" checklist *inside* the GOAL block; handing that to
47
+ * a weak local model drags it into reading/grepping source it doesn't need and
48
+ * it loops (TASK_0017: read(sql-adapter.ts) ×5 → loop-kill → fails the phase).
49
+ * So scope TOOLING's view to the GOAL prose, truncated at the first bullet.
50
+ *
51
+ * Fallbacks, in order: no bare `GOAL` header (free-form refined) → the whole
52
+ * refined unchanged; a GOAL block with no bullets → the full GOAL block. The
53
+ * GOAL boundary is the next bare ALL-CAPS header (CONSTRAINTS, KNOWN-UNKNOWNS,
54
+ * …) or end of text. Verified against every recorded refined prompt: the
55
+ * bullet-heavy failure case drops 3319→329 chars with zero source-file
56
+ * mentions; the rest are unchanged or only lightly trimmed.
57
+ */
58
+ export declare function scopedToolingGoal(refined: string): string;
43
59
  export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
44
60
  export interface PhaseAutoAnswerDeps {
45
61
  docsFocused?: typeof docsFocused;
@@ -52,4 +68,4 @@ export declare function phaseCompose(deps: PhaseDeps, refined: string, research:
52
68
  export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string): Promise<string>;
53
69
  export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
54
70
  export declare const PHASES: PhaseConfig[];
55
- export declare function postCommitPhase(phase: PhaseConfig, pc: PhaseContext, out: string): Promise<void>;
71
+ export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
@@ -12,10 +12,11 @@ import { getFileInventory } from './file-inventory.js';
12
12
  import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
13
13
  import { gatherExternalContext } from './external-context.js';
14
14
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
15
- import { setTaskSection, updateTaskFrontMatter } from './task-io.js';
15
+ import { readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
16
16
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
17
17
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
18
18
  import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
19
+ import { compressTitle } from './title-label.js';
19
20
  import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
20
21
  import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
21
22
  import { SessionUI } from '../remote/bridge.js';
@@ -78,6 +79,68 @@ export async function phaseVerifyTooling(deps, research) {
78
79
  return replaceToolingWithVerified(research, parsed.verified);
79
80
  }
80
81
  const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
82
+ /**
83
+ * Task-file heading under which a research worker's validated output is cached.
84
+ * A resumed research phase reads these to skip workers that already succeeded,
85
+ * instead of re-running all four from scratch when one of them fails — the
86
+ * expensive case (e.g. 3 healthy workers thrown away because the 4th looped).
87
+ */
88
+ function researchWorkerCacheHeading(section) {
89
+ return `research worker ${section}`;
90
+ }
91
+ /**
92
+ * The TOOLING worker only needs to know which verification commands the task
93
+ * cares about — never the per-file edit list. Big refined prompts embed a long
94
+ * bulleted "fix these files" checklist *inside* the GOAL block; handing that to
95
+ * a weak local model drags it into reading/grepping source it doesn't need and
96
+ * it loops (TASK_0017: read(sql-adapter.ts) ×5 → loop-kill → fails the phase).
97
+ * So scope TOOLING's view to the GOAL prose, truncated at the first bullet.
98
+ *
99
+ * Fallbacks, in order: no bare `GOAL` header (free-form refined) → the whole
100
+ * refined unchanged; a GOAL block with no bullets → the full GOAL block. The
101
+ * GOAL boundary is the next bare ALL-CAPS header (CONSTRAINTS, KNOWN-UNKNOWNS,
102
+ * …) or end of text. Verified against every recorded refined prompt: the
103
+ * bullet-heavy failure case drops 3319→329 chars with zero source-file
104
+ * mentions; the rest are unchanged or only lightly trimmed.
105
+ */
106
+ export function scopedToolingGoal(refined) {
107
+ const m = /^GOAL[ \t]*\n([\s\S]*?)(?=\n[A-Z][A-Z][A-Z -]*[ \t]*\n|$(?![\s\S]))/m.exec(refined);
108
+ if (!m)
109
+ return refined;
110
+ const goal = m[1].trim();
111
+ const firstBullet = goal.search(/\n[ \t]*[-*]\s/);
112
+ return firstBullet === -1 ? goal : goal.slice(0, firstBullet).trim();
113
+ }
114
+ /**
115
+ * Throw a descriptive error if a research worker's result is not trustworthy
116
+ * output, so only good output is ever cached and assembled. Precedence mirrors
117
+ * the original inline checks: loop/timeout exhaustion first (a loop-kill is a
118
+ * SIGTERM/exit 143 OR a clean exit 0 with partial text, so exitCode alone is
119
+ * ambiguous — the worker already burned its MAX_LOOP_RESTARTS restarts before
120
+ * setting these), then exit code, empty output, and finally a leaked
121
+ * (never-executed) tool call.
122
+ */
123
+ function assertResearchWorkerOk(name, result) {
124
+ if (result.loopHit) {
125
+ const argsStr = JSON.stringify(result.loopHit.call.args);
126
+ throw new Error(`Research ${name} worker stuck in a loop — called `
127
+ + `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
128
+ + `${result.loopHit.windowSize} calls and still looped after restarts`);
129
+ }
130
+ if (result.timedOut) {
131
+ throw new Error(`Research ${name} worker timed out after restarts`);
132
+ }
133
+ if (result.exitCode !== 0) {
134
+ throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
135
+ }
136
+ if (result.text.trim().length === 0) {
137
+ throw new Error(`Research ${name} worker produced no output`);
138
+ }
139
+ if (result.leakedToolCall) {
140
+ throw new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
141
+ + `(${result.leakedToolCall.trim()}) — it never ran`);
142
+ }
143
+ }
81
144
  export async function phaseResearch(deps, refined, researchDeps = {}) {
82
145
  const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
83
146
  const externalContext = await gatherExternalContext(refined, deps, researchDeps);
@@ -151,11 +214,27 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
151
214
  {
152
215
  section: 'TOOLING',
153
216
  label: 'worker:tooling',
154
- prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(refined))
217
+ // Scope to the goal prose only — not the per-file edit checklist that
218
+ // makes a weak model spelunk source and loop. See scopedToolingGoal.
219
+ prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined)))
155
220
  }
156
221
  ];
157
- const workerResults = [];
222
+ // Run workers one at a time, persisting each worker's validated output the
223
+ // moment it succeeds. On a resume, a worker whose cached output is already on
224
+ // disk is skipped — so when one worker fails and the phase is re-run, the
225
+ // others don't burn minutes regenerating work that was already good. Each
226
+ // worker is validated inline (not in a second pass) so a failure throws
227
+ // before later workers run, and only trustworthy text is ever cached.
228
+ const sections = [];
158
229
  for (const spec of workerSpecs) {
230
+ const cacheHeading = researchWorkerCacheHeading(spec.section);
231
+ const cached = (await readSection(deps.cwd, deps.taskId, cacheHeading)) ?? '';
232
+ if (cached.trim().length > 0) {
233
+ deps.logDebug?.(`${spec.label}: cached — skipping re-run`);
234
+ updateProgress();
235
+ sections.push({ name: spec.section, text: cached.trim() });
236
+ continue;
237
+ }
159
238
  deps.logDebug?.(`${spec.label}: start`);
160
239
  const r = await recordWorker(spec.label, runWorker({
161
240
  prompt: spec.prompt,
@@ -173,39 +252,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
173
252
  + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
174
253
  + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
175
254
  updateProgress();
176
- workerResults.push(r);
255
+ // Throws (failing the phase) on a loop/timeout/exit/empty/leak — so the
256
+ // workers that already succeeded above stay cached for the resume.
257
+ assertResearchWorkerOk(spec.section, r);
258
+ await setTaskSection(deps.cwd, deps.taskId, cacheHeading, r.text);
259
+ sections.push({ name: spec.section, text: r.text.trim() });
177
260
  }
178
- // Validate + assemble by mapping spec.section over the results, so adding or
179
- // reordering a worker is a single edit to workerSpecs (the result order
180
- // mirrors workerSpecs order the loop above pushes in sequence).
181
- const sections = workerSpecs.map((spec, i) => ({ name: spec.section, result: workerResults[i] }));
182
- for (const { name, result } of sections) {
183
- // Loop/timeout exhaustion is checked before the generic exit-code branch:
184
- // a loop-kill arrives as a SIGTERM (exit 143) AND sometimes as a clean
185
- // exit 0 with partial text, so keying off exitCode alone would either give
186
- // a useless "exit 143" message or silently accept truncated output. The
187
- // worker already burned its MAX_LOOP_RESTARTS restarts before setting these.
188
- if (result.loopHit) {
189
- const argsStr = JSON.stringify(result.loopHit.call.args);
190
- throw new Error(`Research ${name} worker stuck in a loop — called `
191
- + `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
192
- + `${result.loopHit.windowSize} calls and still looped after restarts`);
193
- }
194
- if (result.timedOut) {
195
- throw new Error(`Research ${name} worker timed out after restarts`);
196
- }
197
- if (result.exitCode !== 0) {
198
- throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
199
- }
200
- if (result.text.trim().length === 0) {
201
- throw new Error(`Research ${name} worker produced no output`);
202
- }
203
- if (result.leakedToolCall) {
204
- throw new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
205
- + `(${result.leakedToolCall.trim()}) — it never ran`);
206
- }
261
+ // All workers succeeded the assembled output below becomes the canonical
262
+ // 'research' section (written by the orchestrator). The per-worker caches
263
+ // exist only to survive a mid-phase failure, so drop them now to avoid
264
+ // carrying every section twice in the task file.
265
+ for (const spec of workerSpecs) {
266
+ await removeTaskSection(deps.cwd, deps.taskId, researchWorkerCacheHeading(spec.section));
207
267
  }
208
- return sections.map(({ name, result }) => `${name}\n${result.text}`).join('\n\n');
268
+ return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
209
269
  }
210
270
  export async function phaseAutoAnswer(deps, refined, research, question, autoDeps = {}) {
211
271
  const docsFocusedFn = autoDeps.docsFocused ?? docsFocused;
@@ -527,10 +587,16 @@ export const PHASES = [
527
587
  },
528
588
  { name: 'critique', section: 'spec', field: 'spec', run: critiqueWithFallback }
529
589
  ];
530
- export async function postCommitPhase(phase, pc, out) {
590
+ export async function postCommitPhase(phase, deps, pc, out) {
531
591
  if (phase.name !== 'refine')
532
592
  return;
533
593
  const title = deriveTitle(out);
534
594
  pc.widgetState.title = title;
595
+ // Compress the (often paragraph-long) title into a short display label. This
596
+ // is best-effort and self-falls-back to a truncation, so it never blocks the
597
+ // pipeline — but persist title first so a label failure can't lose the title.
535
598
  await updateTaskFrontMatter(pc.cwd, pc.id, { title });
599
+ const label = await compressTitle(deps, title);
600
+ pc.widgetState.label = label;
601
+ await updateTaskFrontMatter(pc.cwd, pc.id, { label });
536
602
  }
@@ -28,6 +28,13 @@ export declare const MAX_GRILL_QUESTIONS = 20;
28
28
  export declare const NO_THINK = "/no_think";
29
29
  /** Append the Qwen3 `/no_think` soft switch to a prompt. See {@link NO_THINK}. */
30
30
  export declare function appendNoThink(prompt: string): string;
31
+ /**
32
+ * Compress a verbose task title into a short status-bar label. Pure judgment, no
33
+ * tools — the child reads only the title we hand it. The output is sanitised and
34
+ * length-clamped by the caller (title-label.ts), so the prompt optimises for the
35
+ * right *content* (identifying nouns) rather than trusting the model on length.
36
+ */
37
+ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) => string;
31
38
  declare const REFINE_PROMPT: (raw: string) => string;
32
39
  declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
33
40
  declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
@@ -33,6 +33,22 @@ export const NO_THINK = '/no_think';
33
33
  export function appendNoThink(prompt) {
34
34
  return `${prompt}\n\n${NO_THINK}`;
35
35
  }
36
+ /**
37
+ * Compress a verbose task title into a short status-bar label. Pure judgment, no
38
+ * tools — the child reads only the title we hand it. The output is sanitised and
39
+ * length-clamped by the caller (title-label.ts), so the prompt optimises for the
40
+ * right *content* (identifying nouns) rather than trusting the model on length.
41
+ */
42
+ export const COMPRESS_LABEL_PROMPT = (title, maxChars) => `Shorten the following task title into a brief label for a one-line status display.
43
+
44
+ Rules:
45
+ - Output ONLY the label text. No preamble, no quotes, no markdown, no trailing period.
46
+ - One line, at most ${maxChars} characters.
47
+ - Keep the most identifying nouns: the component, file, feature, or domain names.
48
+ - Drop filler, constraints, verbatim file lists, and any "spec: @..." reference.
49
+
50
+ TITLE:
51
+ ${title}`;
36
52
  const REFINE_PROMPT = (raw) => `You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
37
53
 
38
54
  Output structure (four sections, exact headings, in this order):
@@ -17,3 +17,5 @@ export declare function writeTaskFile(cwd: string, fm: TaskFrontMatter, body: st
17
17
  export declare function updateTaskFrontMatter(cwd: string, id: string, patch: Partial<TaskFrontMatter>): Promise<void>;
18
18
  export declare function readSection(cwd: string, id: string, heading: string): Promise<string | null>;
19
19
  export declare function setTaskSection(cwd: string, id: string, heading: string, content: string): Promise<void>;
20
+ /** Remove a section (heading + body) if present; a no-op when it's absent. */
21
+ export declare function removeTaskSection(cwd: string, id: string, heading: string): Promise<void>;
@@ -76,3 +76,12 @@ export async function setTaskSection(cwd, id, heading, content) {
76
76
  }
77
77
  await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
78
78
  }
79
+ /** Remove a section (heading + body) if present; a no-op when it's absent. */
80
+ export async function removeTaskSection(cwd, id, heading) {
81
+ const { frontMatter, body } = await readTaskFile(cwd, id);
82
+ const re = sectionRegex(heading);
83
+ if (!re.test(body))
84
+ return;
85
+ const next = body.replace(re, '');
86
+ await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
87
+ }
@@ -12,6 +12,7 @@ const FRONT_MATTER_KEYS = [
12
12
  'created_at',
13
13
  'updated_at',
14
14
  'title',
15
+ 'label',
15
16
  'reason'
16
17
  ];
17
18
  // ─── Front matter ────────────────────────────────────────────────────────────
@@ -20,7 +21,9 @@ export function emitFrontMatter(fm) {
20
21
  for (const k of FRONT_MATTER_KEYS) {
21
22
  const v = fm[k];
22
23
  if (v === undefined || v === '') {
23
- if (k === 'reason')
24
+ // Optional fields are omitted entirely when empty rather than emitted
25
+ // as a blank `key:` line.
26
+ if (k === 'reason' || k === 'label')
24
27
  continue;
25
28
  }
26
29
  lines.push(`${k}: ${typeof v === 'string' ? v : String(v)}`);
@@ -50,6 +53,7 @@ export function parseFrontMatter(content) {
50
53
  created_at: obj.created_at,
51
54
  updated_at: obj.updated_at ?? obj.created_at,
52
55
  title: obj.title ?? '',
56
+ label: obj.label || undefined,
53
57
  reason: obj.reason || undefined
54
58
  };
55
59
  }
@@ -13,6 +13,13 @@ export interface TaskFrontMatter {
13
13
  created_at: string;
14
14
  updated_at: string;
15
15
  title: string;
16
+ /**
17
+ * Short, human-readable display label compressed from `title`. Visual only —
18
+ * `title` always holds the full text the pipeline reads. Absent on pre-label
19
+ * tasks and on small tasks whose title is already short; display falls back to
20
+ * a deterministic truncation of `title` when missing. See title-label.ts.
21
+ */
22
+ label?: string;
16
23
  reason?: string;
17
24
  }
18
25
  export declare const PHASE_ORDER: PhaseName[];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Display-label compression.
3
+ *
4
+ * A refined GOAL paragraph becomes the stored `title`, which the pipeline reads
5
+ * in full — but it can run to 1000+ characters, which makes the widget head and
6
+ * `/task list` unreadable. `compressTitle` spawns a tool-less local-model child
7
+ * to distill that title into a short, identifying label that we store ALONGSIDE
8
+ * (never replacing) the full title, purely for display. Every failure mode —
9
+ * model error, empty output, leaked tool call, abort, an over-long answer —
10
+ * degrades to the deterministic `truncateLabel`, so a label always comes back
11
+ * and the pipeline never blocks on this best-effort step.
12
+ */
13
+ import type { PhaseDeps } from './child-runner.js';
14
+ /**
15
+ * Reduce a raw model completion to a single clean label line: first non-empty
16
+ * line, stripped of surrounding quotes/backticks and a leading "label:" echo,
17
+ * whitespace collapsed. Returns '' when nothing usable remains.
18
+ */
19
+ export declare function sanitizeLabel(raw: string): string;
20
+ /**
21
+ * Compress `title` into a short display label via a local-model child. Always
22
+ * resolves (never throws): on any failure it returns `truncateLabel(title)`.
23
+ * The returned label is guaranteed to be at most LABEL_MAX characters.
24
+ */
25
+ export declare function compressTitle(deps: PhaseDeps, title: string): Promise<string>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Display-label compression.
3
+ *
4
+ * A refined GOAL paragraph becomes the stored `title`, which the pipeline reads
5
+ * in full — but it can run to 1000+ characters, which makes the widget head and
6
+ * `/task list` unreadable. `compressTitle` spawns a tool-less local-model child
7
+ * to distill that title into a short, identifying label that we store ALONGSIDE
8
+ * (never replacing) the full title, purely for display. Every failure mode —
9
+ * model error, empty output, leaked tool call, abort, an over-long answer —
10
+ * degrades to the deterministic `truncateLabel`, so a label always comes back
11
+ * and the pipeline never blocks on this best-effort step.
12
+ */
13
+ import { runPhaseChild } from './child-runner.js';
14
+ import { LABEL_MAX, truncateLabel } from './parsers.js';
15
+ import { COMPRESS_LABEL_PROMPT, appendNoThink } from './prompts.js';
16
+ /**
17
+ * Reduce a raw model completion to a single clean label line: first non-empty
18
+ * line, stripped of surrounding quotes/backticks and a leading "label:" echo,
19
+ * whitespace collapsed. Returns '' when nothing usable remains.
20
+ */
21
+ export function sanitizeLabel(raw) {
22
+ const firstLine = raw.split('\n').find(l => l.trim().length > 0) ?? '';
23
+ return firstLine
24
+ .replace(/^\s*label\s*:\s*/i, '')
25
+ .replace(/^["'`]+|["'`]+$/g, '')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+ /**
30
+ * Compress `title` into a short display label via a local-model child. Always
31
+ * resolves (never throws): on any failure it returns `truncateLabel(title)`.
32
+ * The returned label is guaranteed to be at most LABEL_MAX characters.
33
+ */
34
+ export async function compressTitle(deps, title) {
35
+ const fallback = truncateLabel(title);
36
+ // Already short enough — no point paying for a model call.
37
+ if (title.replace(/\s+/g, ' ').trim().length <= LABEL_MAX)
38
+ return fallback;
39
+ try {
40
+ const raw = await runPhaseChild(deps, 'compress-label', '', appendNoThink(COMPRESS_LABEL_PROMPT(title, LABEL_MAX)));
41
+ const cleaned = sanitizeLabel(raw);
42
+ if (cleaned.length === 0)
43
+ return fallback;
44
+ // Clamp regardless of what the model returned; truncateLabel is a no-op
45
+ // when it already fits.
46
+ return truncateLabel(cleaned);
47
+ }
48
+ catch {
49
+ return fallback;
50
+ }
51
+ }
@@ -9,6 +9,8 @@ import { type PhaseName, type TaskState } from './task-types.js';
9
9
  export interface WidgetState {
10
10
  taskId: string;
11
11
  title: string;
12
+ /** Short display label compressed from `title`; falls back to a truncation when absent. */
13
+ label?: string;
12
14
  phase: PhaseName;
13
15
  startedAt: number;
14
16
  lastLine?: string;
@@ -5,6 +5,7 @@
5
5
  * context usage, and the latest child-process line.
6
6
  */
7
7
  import { PHASE_INDEX, PHASE_ORDER } from './task-types.js';
8
+ import { titleForDisplay } from './parsers.js';
8
9
  import { setTaskWidget } from '../remote/session-state.js';
9
10
  // ─── Constants ───────────────────────────────────────────────────────────────
10
11
  export const WIDGET_KEY = 'pi-tasks';
@@ -71,7 +72,7 @@ function lastLineTrailer(lastLine, theme) {
71
72
  }
72
73
  export function buildWidgetLines(s, theme) {
73
74
  const elapsed = formatDuration(Date.now() - s.startedAt);
74
- const head = `${s.taskId} · ${s.title}`;
75
+ const head = `${s.taskId} · ${titleForDisplay(s)}`;
75
76
  const idx = PHASE_INDEX[s.phase];
76
77
  const total = PHASE_ORDER.length;
77
78
  const stepNum = Math.min(idx + 1, total);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.19",
3
+ "version": "0.13.21",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",