@mjasnikovs/pi-task 0.13.2 → 0.13.4

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
@@ -1,8 +1,8 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://cdn.jsdelivr.net/npm/@mjasnikovs/pi-task/assets/pipeline.svg" alt="pi-task pipeline: a /task request runs through refine, research, grill, compose and critique, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable." width="820"/>
3
+ <img src="https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pipeline.svg" alt="pi-task pipeline: a /task request runs through refine, research, grill, compose and critique, then the final spec is delivered to your main pi session in the same chat. Every phase boundary is persisted to .pi-tasks/TASK_NNNN.md, so the task is crash-safe and resumable." width="820"/>
4
4
 
5
- # <img src="https://cdn.jsdelivr.net/npm/@mjasnikovs/pi-task/assets/pi-logo.svg" alt="" height="30" align="top"/> pi-task
5
+ # <img src="https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pi-logo.svg" alt="" height="30" align="top"/> pi-task
6
6
 
7
7
  **Deterministic spec-orchestration for local models — with bundled web, docs, fetch, and worker sub-agent tools.**
8
8
 
@@ -69,7 +69,7 @@ A real feature is usually several tasks, not one. `/task-auto` is a thin planner
69
69
 
70
70
  <div align="center">
71
71
 
72
- <img src="https://cdn.jsdelivr.net/npm/@mjasnikovs/pi-task/assets/task-auto.svg" alt="/task-auto plans a feature: it clarifies the gray areas, decomposes the answers into an ordered list of task titles written to TASK_AUTO_NNNN.md, then runs each unchecked title through the full /task pipeline one at a time, ticking the box before moving on." width="820"/>
72
+ <img src="https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/task-auto.svg" alt="/task-auto plans a feature: it clarifies the gray areas, decomposes the answers into an ordered list of task titles written to TASK_AUTO_NNNN.md, then runs each unchecked title through the full /task pipeline one at a time, ticking the box before moving on." width="820"/>
73
73
 
74
74
  </div>
75
75
 
@@ -22,8 +22,10 @@ export interface AskSpec {
22
22
  localTitle: string;
23
23
  /** Plain question text for the browser card. */
24
24
  question: string;
25
- /** Plain recommended default (prefilled in both surfaces), if any. */
25
+ /** Primary recommended option, prefilled in the local TUI. */
26
26
  recommended?: string;
27
+ /** Secondary recommended option shown as a second button on the remote. */
28
+ recommended2?: string;
27
29
  /** Whether the browser card shows a Skip button (answers with empty string). */
28
30
  allowSkip: boolean;
29
31
  }
@@ -52,6 +52,7 @@ export class SessionUI {
52
52
  id,
53
53
  question: spec.question,
54
54
  recommended: spec.recommended,
55
+ ...(spec.recommended2 !== undefined && { recommended2: spec.recommended2 }),
55
56
  allowSkip: spec.allowSkip
56
57
  };
57
58
  setPrompt(prompt);
@@ -3,6 +3,7 @@ export interface PromptMessage {
3
3
  id: string;
4
4
  question: string;
5
5
  recommended?: string;
6
+ recommended2?: string;
6
7
  allowSkip: boolean;
7
8
  }
8
9
  export interface PromptResolvedMessage {
package/dist/remote/ui.js CHANGED
@@ -200,7 +200,7 @@ export function html(wsUrl) {
200
200
  #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);
201
201
  border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
202
202
  font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
203
- #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: center;
203
+ #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;
204
204
  flex-wrap: wrap; }
205
205
  #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
206
206
  font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
@@ -209,8 +209,8 @@ export function html(wsUrl) {
209
209
  font-weight: 700; flex: 1; min-width: 160px; }
210
210
  #prompt-card button.secondary { background: var(--surface1); color: var(--text);
211
211
  flex: 1; min-width: 160px; }
212
- #prompt-card button.cancel { margin-left: auto; background: transparent; color: var(--subtext0);
213
- font-size: 12px; font-weight: 500; padding: 8px 10px; }
212
+ #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;
213
+ color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }
214
214
  #prompt-card button.cancel:hover { color: var(--red); filter: none; }
215
215
  #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
216
216
  .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);
@@ -297,6 +297,7 @@ export function html(wsUrl) {
297
297
  document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
298
298
  let activePromptId = null;
299
299
  let activeRecommended = '';
300
+ let activeRecommended2 = '';
300
301
  let cancelArmTimer = null;
301
302
  const toolCallMap = {};
302
303
  let currentBubble = null;
@@ -737,6 +738,7 @@ export function html(wsUrl) {
737
738
  promptInput.value = '';
738
739
  promptInput.style.display = 'none';
739
740
  promptRec.style.display = 'none';
741
+ activeRecommended2 = '';
740
742
  if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
741
743
  setEnabled(true);
742
744
  }
@@ -776,36 +778,44 @@ export function html(wsUrl) {
776
778
  promptButtons.appendChild(makeCancelBtn());
777
779
  }
778
780
 
779
- // Manual-entry view: editable textarea + Submit, reachable from the
780
- // recommendation view via "Answer manually".
781
- function showManualEntry(prefill) {
781
+ // Manual-entry view: empty textarea + Submit, reachable from the
782
+ // recommendation view via "Manual answer".
783
+ function showManualEntry() {
782
784
  promptRec.style.display = 'none';
783
785
  promptInput.style.display = 'block';
784
- promptInput.value = prefill || '';
786
+ promptInput.value = '';
785
787
  renderButtons([
786
- makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); }),
788
+ makeBtn('Submit', 'primary', function () { answer(promptInput.value); }),
787
789
  makeBtn('← Back', 'secondary', function () { showRecommendation(); })
788
790
  ]);
789
791
  promptInput.focus();
790
792
  }
791
793
 
792
- // Recommendation view (Mode A): show the suggested answer and let the user
793
- // accept it in one tap or switch to manual entry.
794
+ // Recommendation view: 2-button mode when both options present, panel mode for one.
794
795
  function showRecommendation() {
795
796
  promptInput.style.display = 'none';
796
- promptRec.style.display = 'block';
797
- renderButtons([
798
- makeBtn('✓ Accept recommended', 'primary', function () { answer(''); }),
799
- makeBtn('✎ Answer manually', 'secondary', function () { showManualEntry(activeRecommended); })
800
- ]);
797
+ const buttons = [];
798
+ if (activeRecommended2) {
799
+ // Two-option mode: each recommendation is a direct-accept button.
800
+ promptRec.style.display = 'none';
801
+ buttons.push(makeBtn(activeRecommended, 'primary', function () { answer(activeRecommended); }));
802
+ buttons.push(makeBtn(activeRecommended2, 'secondary', function () { answer(activeRecommended2); }));
803
+ } else {
804
+ // Single recommendation: show it in the green panel.
805
+ promptRec.style.display = 'block';
806
+ buttons.push(makeBtn('✓ Accept', 'primary', function () { answer(activeRecommended); }));
807
+ }
808
+ buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
809
+ renderButtons(buttons);
801
810
  }
802
811
 
803
812
  function showPrompt(msg) {
804
813
  activePromptId = msg.id;
805
814
  promptQ.textContent = msg.question;
806
815
  activeRecommended = msg.recommended || '';
816
+ activeRecommended2 = msg.recommended2 || '';
807
817
  if (msg.recommended) {
808
- // Mode A: there's a recommendation — lead with "Accept recommended".
818
+ // Mode A: recommendation(s) present.
809
819
  promptRecText.textContent = msg.recommended;
810
820
  showRecommendation();
811
821
  } else {
@@ -813,9 +823,9 @@ export function html(wsUrl) {
813
823
  promptRec.style.display = 'none';
814
824
  promptInput.style.display = 'block';
815
825
  promptInput.value = '';
816
- const buttons = [makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); })];
826
+ const buttons = [makeBtn('Submit', 'primary', function () { answer(promptInput.value); })];
817
827
  if (msg.allowSkip) {
818
- buttons.push(makeBtn('Skip question', 'secondary', function () { answer(''); }));
828
+ buttons.push(makeBtn('Skip', 'secondary', function () { answer(''); }));
819
829
  }
820
830
  renderButtons(buttons);
821
831
  promptInput.focus();
@@ -199,9 +199,9 @@ export function summarizeToolArgs(toolName, args) {
199
199
  if (toolName === 'bash' && typeof a.command === 'string') {
200
200
  return a.command.replace(/\s+/g, ' ').trim();
201
201
  }
202
- if (toolName === 'pi-worker-docs' &&
203
- typeof a.module === 'string' &&
204
- typeof a.query === 'string') {
202
+ if (toolName === 'pi-worker-docs'
203
+ && typeof a.module === 'string'
204
+ && typeof a.query === 'string') {
205
205
  const q = a.query.replace(/\s+/g, ' ').trim();
206
206
  const truncated = q.length > 60 ? q.slice(0, 59) + '…' : q;
207
207
  return `${a.module} "${truncated}"`;
@@ -168,7 +168,9 @@ export class TaskRunner {
168
168
  const debugLogPath = path.join(tasksDir(cwd), `${id}-debug.log`);
169
169
  this._deps.logDebug = (msg) => {
170
170
  const line = `${new Date().toISOString()} ${msg}\n`;
171
- fsp.appendFile(debugLogPath, line).catch(() => { });
171
+ fsp.appendFile(debugLogPath, line).catch(() => {
172
+ /* ignore */
173
+ });
172
174
  };
173
175
  this._deps.logDebug(`run: start phase=${resumePhase}`);
174
176
  // Register as active.
@@ -13,6 +13,7 @@ export type AutoAnswer = {
13
13
  } | {
14
14
  kind: 'unknown';
15
15
  suggested?: string;
16
+ alt?: string;
16
17
  raw: string;
17
18
  };
18
19
  /** One /task-auto clarify question with its model-recommended default answer. */
@@ -102,22 +102,44 @@ export function parseAutoAnswer(raw) {
102
102
  .split('\n')
103
103
  .map(l => l.trim())
104
104
  .filter(l => l.length > 0);
105
+ let suggested;
106
+ let alt;
107
+ let sawUnknown = false;
105
108
  for (let i = 0; i < lines.length; i++) {
106
109
  const t = lines[i];
107
110
  const a = /^AN[SW]{1,3}E?R:\s*(.+)$/i.exec(t);
108
111
  if (a)
109
112
  return { kind: 'answered', text: a[1].trim(), raw };
110
- const u = /^UNKNOWN:\s*(.*)$/i.exec(t);
111
- if (u) {
112
- const inline = u[1].trim();
113
- if (inline.length > 0)
114
- return { kind: 'unknown', suggested: inline, raw };
115
- const next = lines[i + 1];
116
- if (next && next.length > 0)
117
- return { kind: 'unknown', suggested: next, raw };
118
- return { kind: 'unknown', raw };
113
+ if (!sawUnknown) {
114
+ const u = /^UNKNOWN:\s*(.*)$/i.exec(t);
115
+ if (u) {
116
+ sawUnknown = true;
117
+ const inline = u[1].trim();
118
+ if (inline.length > 0) {
119
+ suggested = inline;
120
+ }
121
+ else {
122
+ const next = lines[i + 1];
123
+ if (next && !/^ALT:/i.test(next))
124
+ suggested = next;
125
+ }
126
+ continue;
127
+ }
128
+ }
129
+ if (alt === undefined) {
130
+ const altM = /^ALT:\s*(.+)$/i.exec(t);
131
+ if (altM)
132
+ alt = altM[1].trim();
119
133
  }
120
134
  }
135
+ if (sawUnknown || suggested !== undefined || alt !== undefined) {
136
+ return {
137
+ kind: 'unknown',
138
+ ...(suggested !== undefined && { suggested }),
139
+ ...(alt !== undefined && { alt }),
140
+ raw
141
+ };
142
+ }
121
143
  if (lines.length > 0)
122
144
  return { kind: 'unknown', suggested: lines[0], raw };
123
145
  return { kind: 'unknown', raw };
@@ -373,8 +373,6 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
373
373
  const shownQ = renderInlineMarkdown(q, theme);
374
374
  const plainQ = stripInlineMarkdown(q);
375
375
  out.push(`Q${n + 1}: ${plainQ}`);
376
- const rawTrim = auto.raw.trim();
377
- out.push(` (auto-worker raw: ${rawTrim.length === 0 ? '(empty)' : rawTrim.replace(/\n/g, ' ⏎ ')})`);
378
376
  let answer;
379
377
  if (auto.kind === 'answered') {
380
378
  answer = stripInlineMarkdown(auto.text);
@@ -382,21 +380,25 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
382
380
  }
383
381
  else {
384
382
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
385
- const localTitle = auto.suggested ?
386
- `${shownQ}\n${theme.fg('muted', 'Recommended:')}\n\n${renderInlineMarkdown(auto.suggested, theme)}\n\n${theme.fg('muted', 'press Enter to accept')}`
387
- : `${shownQ}\n${theme.fg('muted', '(no recommendation — please answer)')}`;
383
+ const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
384
+ const localTitle = plainSuggested ?
385
+ plainAlt ?
386
+ `${shownQ}\nA: ${renderInlineMarkdown(auto.suggested, theme)}\nB: ${renderInlineMarkdown(auto.alt, theme)}`
387
+ : `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
388
+ : shownQ;
388
389
  widgetState.lastLine = `awaiting Q${n + 1}`;
389
390
  const a = await ui.ask({
390
391
  localTitle,
391
392
  question: plainQ,
392
393
  recommended: plainSuggested,
393
- allowSkip: plainSuggested === undefined
394
+ recommended2: plainAlt,
395
+ allowSkip: plainSuggested === undefined && plainAlt === undefined
394
396
  });
395
397
  if (a === undefined)
396
398
  throw new Error(USER_CANCELLED);
397
399
  const typed = a.trim();
398
400
  if (typed.length === 0 && plainSuggested) {
399
- answer = `${plainSuggested} (accepted recommendation)`;
401
+ answer = plainSuggested;
400
402
  }
401
403
  else if (typed.length === 0) {
402
404
  answer = '(skipped)';
@@ -176,57 +176,37 @@ ${research}
176
176
 
177
177
  Answers so far:
178
178
  ${priorQA.trim() || '(none yet)'}`;
179
- const GRILL_AUTO_ANSWER_PROMPT = (refined, research, question) => `You are pre-answering a clarifying question for an AI coding task. You have the refined task and the research notes. You can also use the read tool to open any file mentioned in the research (e.g. package.json) if it helps you answer.
180
-
181
- Your job is to produce a recommended default answer. If the default is one the user would almost certainly accept, you tag it ANSWER and we skip the user entirely. Otherwise you tag it UNKNOWN and we show the suggestion in the input box for the user to confirm or override.
182
-
183
- YOU MUST PROPOSE A DEFAULT, no matter what. NEVER refuse. NEVER leave the answer empty.
184
-
185
- LIVE-DATA RULE read carefully:
186
- - If EXTERNAL CONTEXT contains an "### npm: <pkg>" block, those version numbers are LIVE registry data and are MORE RECENT than anything you remember from training. Use them as the source of truth.
187
- - For any question about "latest", "current", "newest", or "which version" of an npm package, you MUST cite the version from the "### npm: <pkg>" block in EXTERNAL CONTEXT if one is present. Do NOT contradict it with a remembered version.
188
- - If EXTERNAL CONTEXT contains a "### service: <name>" block, those search results are LIVE web data and are authoritative over training data for that service's current API surface, deprecation status, and replacement systems. For any question about that service's API, status, or replacement, cite from the block; do not contradict it from memory.
189
- - If EXTERNAL CONTEXT contains a "### freshness-check skipped" block, you have no current data for the listed services. If the question is about one of them, tag UNKNOWN and say the current state needs user verification — do NOT answer from memory.
190
- - If no "### npm: <pkg>" block is present and the question is about latest/current versions, tag UNKNOWN — do not invent a version from training data, since that data goes stale within months.
191
-
192
- Use the REVERSIBILITY TEST to choose the tag:
193
-
194
- ANSWER: accepting your default is cheap to undo — output style, reporting
195
- format, treat-as-error policy, summary vs full output, scope when
196
- obviously implied, recommended convention for a typical project.
197
- If the user would only "fix" your default by editing prose in the
198
- task file, it's ANSWER.
199
-
200
- UNKNOWN: accepting your default would do work that is costly to reverse —
201
- file mutations, destructive operations, irreversible writes, tool
202
- or dependency choices, format/structure decisions that change
203
- downstream artifacts, anything that touches state outside the
204
- task file. This INCLUDES choosing the implementation approach,
205
- algorithm, or strategy — *how* the task is solved, not just
206
- whether (e.g. "extract the value with a post-processing regex" vs
207
- "rewrite the system prompt", "add a fallback step" vs "swap the
208
- model", "parse manually" vs "use a library"). Approach decisions
209
- shape the entire spec and are expensive to unwind once the agent
210
- builds on them, so the user must vet the strategy: tag UNKNOWN and
211
- surface your recommended approach as the default.
212
-
213
- Output format — ONE LINE only, no preamble, no markdown:
179
+ const GRILL_AUTO_ANSWER_PROMPT = (refined, research, question) => `You are pre-answering a clarifying question for an AI coding task. You have the refined task and the research notes. You may use the read tool on files mentioned in the research (e.g. package.json) if it helps.
180
+
181
+ Your job is to produce a recommended default. If the default is one the user would almost certainly accept without thinking, tag it ANSWER and skip the user. Otherwise tag it UNKNOWN. YOU MUST PROPOSE A DEFAULT never refuse, never leave it empty.
182
+
183
+ LIVE-DATA RULE:
184
+ - "### npm: <pkg>" blocks in EXTERNAL CONTEXT are LIVE registry data; use those version numbers, do NOT invent them from memory.
185
+ - "### service: <name>" blocks are LIVE web data; authoritative over training data for that service.
186
+ - "### freshness-check skipped" tag UNKNOWN and say current state needs verification.
187
+ - No npm block + question is about latest/current version tag UNKNOWN (training data goes stale).
188
+
189
+ REVERSIBILITY TEST:
190
+ ANSWER: cheap to undo (output style, policy, report format, obvious scope, standard convention).
191
+ UNKNOWN: costly to reverse (file mutations, tool/dependency choice, approach/algorithm, format that shapes downstream artifacts).
192
+
193
+ When the question is a binary "A or B?" choice, emit BOTH options:
194
+ UNKNOWN: <primary recommendation>
195
+ ALT: <alternative>
196
+
197
+ Output no preamble, no markdown:
214
198
  ANSWER: <one-line answer>
215
- UNKNOWN: <one-line default>
199
+ UNKNOWN: <primary option>
200
+ ALT: <secondary option> ← required when question is "A or B?"; omit otherwise
216
201
 
217
202
  Examples:
218
- ANSWER: report a summary with counts and representative examples ← reporting style is cheap to undo
219
- ANSWER: treat all warnings and errors as genuine issues, do not ignore ← policy is cheap to undo
220
- ANSWER: run the read-only check variant (prettier --check, eslint, tsc) ← read-only side, safer default; flip later if wanted
221
- UNKNOWN: use npm ← package manager choice is costly to reverse mid-task
222
- UNKNOWN: write output to ./report.md ← creates a file; user may want a different path or no file
223
- UNKNOWN: extract the value with a post-processing regex step ← picks the implementation approach; user must vet the strategy
224
-
225
- Examples of FORBIDDEN outputs:
226
- UNKNOWN:
227
- UNKNOWN: it depends
228
- (empty)
229
- I think the user should decide.
203
+ ANSWER: report a summary with counts and representative examples
204
+ ANSWER: treat all warnings and errors as genuine issues
205
+ UNKNOWN: use npm
206
+ ALT: use pnpm
207
+ UNKNOWN: write output to ./report.md
208
+ UNKNOWN: extract with a post-processing regex step
209
+ ALT: rewrite the system prompt
230
210
 
231
211
  Refined task:
232
212
  ${refined}
@@ -52,8 +52,8 @@ function walkTsFiles(root) {
52
52
  const full = path.join(dir, entry.name);
53
53
  if (entry.isDirectory())
54
54
  stack.push(full);
55
- else if (entry.isFile() &&
56
- (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
55
+ else if (entry.isFile()
56
+ && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
57
57
  out.push(full);
58
58
  }
59
59
  }
@@ -82,7 +82,10 @@ export function registerPiWorkerDocs(pi, internals = {}) {
82
82
  return textResult(`Project docs error: ${projectResult.message}`, {});
83
83
  }
84
84
  if (projectResult.kind === 'no_chunks') {
85
- return textResult(`Project "${projectResult.projectName}" has no .ts/.tsx files indexed.`, { hitCache: projectResult.hitCache, indexedFiles: projectResult.filesIngested });
85
+ return textResult(`Project "${projectResult.projectName}" has no .ts/.tsx files indexed.`, {
86
+ hitCache: projectResult.hitCache,
87
+ indexedFiles: projectResult.filesIngested
88
+ });
86
89
  }
87
90
  const { projectName, chunks, hitCache, filesIngested, indexingMs } = projectResult;
88
91
  const baseDetails = {
@@ -110,10 +113,14 @@ export function registerPiWorkerDocs(pi, internals = {}) {
110
113
  });
111
114
  }
112
115
  const parsed = parseChildOutput(child.stdout);
113
- const verified = parsed.excerpt ?
114
- isExcerptInContent(parsed.excerpt, concatenated)
115
- : undefined;
116
- const text = formatResultText({ name: projectName, version: 'local', root: ctx.cwd, entryDts: null, readme: null }, parsed, verified);
116
+ const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
117
+ const text = formatResultText({
118
+ name: projectName,
119
+ version: 'local',
120
+ root: ctx.cwd,
121
+ entryDts: null,
122
+ readme: null
123
+ }, parsed, verified);
117
124
  return textResult(text, {
118
125
  ...baseDetails,
119
126
  childExitCode: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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",