@mjasnikovs/pi-task 0.13.18 → 0.13.20
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/dist/task/auto-orchestrator.js +31 -4
- package/dist/task/orchestrator.js +6 -2
- package/dist/task/parsers.d.ts +17 -0
- package/dist/task/parsers.js +36 -0
- package/dist/task/phases.d.ts +1 -1
- package/dist/task/phases.js +33 -3
- package/dist/task/prompts.d.ts +8 -1
- package/dist/task/prompts.js +20 -1
- package/dist/task/question-dedup.d.ts +28 -0
- package/dist/task/question-dedup.js +172 -0
- package/dist/task/task-parsers.js +5 -1
- package/dist/task/task-types.d.ts +7 -0
- package/dist/task/title-label.d.ts +25 -0
- package/dist/task/title-label.js +51 -0
- package/dist/task/widget.d.ts +2 -0
- package/dist/task/widget.js +2 -1
- package/package.json +1 -1
|
@@ -11,15 +11,20 @@ import { runSingleTask } from './orchestrator.js';
|
|
|
11
11
|
import { parseClarifyList, deriveTitle } from './parsers.js';
|
|
12
12
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
13
13
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT } from './auto-prompts.js';
|
|
14
|
+
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
14
15
|
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
|
|
15
16
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath } from './task-io.js';
|
|
16
17
|
import { gitCommitAll } from './auto-commit.js';
|
|
17
|
-
import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
|
|
18
|
+
import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
18
19
|
import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
|
|
19
20
|
import { pushNotify } from '../remote/push.js';
|
|
20
21
|
import { getConfig } from '../config/config.js';
|
|
21
22
|
import { startAutoLoader } from './widget.js';
|
|
22
23
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
24
|
+
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
25
|
+
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
26
|
+
// barrage the user — the real mx5 run asked 10, several of them redundant.
|
|
27
|
+
const MAX_CLARIFY_QUESTIONS = 8;
|
|
23
28
|
// Matches pi's @-file completion token (a path after @, until whitespace).
|
|
24
29
|
const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
|
|
25
30
|
/**
|
|
@@ -128,17 +133,39 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
128
133
|
// the real content, not a one-line "Implement @file" that reads as trivial.
|
|
129
134
|
const featureForModel = await expandFeatureMentions(cwd, feature);
|
|
130
135
|
const answers = [];
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
// Plain text of every question already shown, for the duplicate backstop.
|
|
137
|
+
const askedQuestions = [];
|
|
138
|
+
// Deterministic guard against a model that ignores "never re-ask": consecutive
|
|
139
|
+
// near-duplicate questions are reprompted with an explicit hint, and once it
|
|
140
|
+
// strikes out (can't produce anything novel) we stop instead of barraging the
|
|
141
|
+
// user with the same decision worded N ways. Also caps the absolute count.
|
|
142
|
+
let dupStrikes = 0;
|
|
143
|
+
let dupHint = null;
|
|
144
|
+
// Open-ended: keep asking until the model emits NONE or the user dismisses —
|
|
145
|
+
// but never past MAX_CLARIFY_QUESTIONS distinct questions.
|
|
146
|
+
while (askedQuestions.length < MAX_CLARIFY_QUESTIONS) {
|
|
147
|
+
const qRaw = await deps.runChild('auto-clarify', 'read', prependHint(dupHint, AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n'))));
|
|
134
148
|
const parsed = parseClarifyList(qRaw);
|
|
135
149
|
if (parsed.length === 0)
|
|
136
150
|
break; // NONE / nothing left to ask
|
|
151
|
+
// Backstop: if the model re-asked a topic already settled, don't surface it.
|
|
152
|
+
// Reprompt it to move on or finish; give up after MAX_DUP_STRIKES so a model
|
|
153
|
+
// stuck on one fork can't loop forever.
|
|
154
|
+
if (isDuplicateQuestion(askedQuestions, stripInlineMarkdown(parsed[0].question))) {
|
|
155
|
+
dupStrikes++;
|
|
156
|
+
if (dupStrikes >= MAX_DUP_STRIKES)
|
|
157
|
+
break;
|
|
158
|
+
dupHint = DUP_REPROMPT_HINT;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
dupStrikes = 0;
|
|
162
|
+
dupHint = null;
|
|
137
163
|
const { question, suggested, alt } = parsed[0];
|
|
138
164
|
// Render markdown (bold/code) for the displayed prompt; keep plain text
|
|
139
165
|
// for the editable default and the persisted file.
|
|
140
166
|
const shownQ = renderInlineMarkdown(question, theme);
|
|
141
167
|
const plainQ = stripInlineMarkdown(question);
|
|
168
|
+
askedQuestions.push(plainQ);
|
|
142
169
|
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
143
170
|
const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
|
|
144
171
|
// Identical to /task's grill dialog: a binary fork becomes a select()
|
|
@@ -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
|
|
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/)');
|
package/dist/task/parsers.d.ts
CHANGED
|
@@ -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;
|
package/dist/task/parsers.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -52,4 +52,4 @@ export declare function phaseCompose(deps: PhaseDeps, refined: string, research:
|
|
|
52
52
|
export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string): Promise<string>;
|
|
53
53
|
export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
|
|
54
54
|
export declare const PHASES: PhaseConfig[];
|
|
55
|
-
export declare function postCommitPhase(phase: PhaseConfig, pc: PhaseContext, out: string): Promise<void>;
|
|
55
|
+
export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
|
package/dist/task/phases.js
CHANGED
|
@@ -14,7 +14,9 @@ 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
15
|
import { setTaskSection, updateTaskFrontMatter } from './task-io.js';
|
|
16
16
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
17
|
+
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
17
18
|
import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
|
|
19
|
+
import { compressTitle } from './title-label.js';
|
|
18
20
|
import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
19
21
|
import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
20
22
|
import { SessionUI } from '../remote/bridge.js';
|
|
@@ -314,15 +316,37 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
314
316
|
const ui = new SessionUI(ctx);
|
|
315
317
|
const out = []; // human-facing Q&A transcript (with auto-worker debug lines)
|
|
316
318
|
const qa = []; // compact Q&A fed back into the next question
|
|
319
|
+
const askedQuestions = []; // plain text of each question, for the dup backstop
|
|
320
|
+
// Deterministic backstop against a model that ignores "never re-ask": a
|
|
321
|
+
// near-duplicate question is reprompted (not auto-answered or shown), and after
|
|
322
|
+
// MAX_DUP_STRIKES consecutive dups the loop stops. Each question otherwise runs
|
|
323
|
+
// the research-backed auto-answer, which fans out doc/fetch/search workers — so
|
|
324
|
+
// a re-ask isn't just an extra prompt, it's an expensive recompute. Capped at
|
|
325
|
+
// MAX_GRILL_QUESTIONS so a model that never emits NONE can't run unbounded.
|
|
326
|
+
let dupStrikes = 0;
|
|
327
|
+
let dupHint = null;
|
|
317
328
|
// Open-ended: keep asking until the model emits NONE or the user dismisses.
|
|
318
|
-
for (let n = 0
|
|
329
|
+
for (let n = 0; n < MAX_GRILL_QUESTIONS; n++) {
|
|
319
330
|
const tGenStart = Date.now();
|
|
320
|
-
const
|
|
331
|
+
const genHint = dupHint;
|
|
332
|
+
const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n')))));
|
|
321
333
|
deps.recordSubStep?.('gen', Date.now() - tGenStart);
|
|
322
334
|
const questions = parseGrillQuestions(raw);
|
|
323
335
|
if (questions.length === 0)
|
|
324
336
|
break; // NONE / nothing left to ask
|
|
325
337
|
const q = questions[0];
|
|
338
|
+
// Suppress a re-asked decision before paying for its auto-answer.
|
|
339
|
+
if (isDuplicateQuestion(askedQuestions, stripInlineMarkdown(q))) {
|
|
340
|
+
dupStrikes++;
|
|
341
|
+
if (dupStrikes >= MAX_DUP_STRIKES)
|
|
342
|
+
break;
|
|
343
|
+
dupHint = DUP_REPROMPT_HINT;
|
|
344
|
+
n--;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
dupStrikes = 0;
|
|
348
|
+
dupHint = null;
|
|
349
|
+
askedQuestions.push(stripInlineMarkdown(q));
|
|
326
350
|
widgetState.lastLine = `auto-answering Q${n + 1}…`;
|
|
327
351
|
const tAutoStart = Date.now();
|
|
328
352
|
const auto = await phaseAutoAnswer(deps, refined, research, q);
|
|
@@ -504,10 +528,16 @@ export const PHASES = [
|
|
|
504
528
|
},
|
|
505
529
|
{ name: 'critique', section: 'spec', field: 'spec', run: critiqueWithFallback }
|
|
506
530
|
];
|
|
507
|
-
export async function postCommitPhase(phase, pc, out) {
|
|
531
|
+
export async function postCommitPhase(phase, deps, pc, out) {
|
|
508
532
|
if (phase.name !== 'refine')
|
|
509
533
|
return;
|
|
510
534
|
const title = deriveTitle(out);
|
|
511
535
|
pc.widgetState.title = title;
|
|
536
|
+
// Compress the (often paragraph-long) title into a short display label. This
|
|
537
|
+
// is best-effort and self-falls-back to a truncation, so it never blocks the
|
|
538
|
+
// pipeline — but persist title first so a label failure can't lose the title.
|
|
512
539
|
await updateTaskFrontMatter(pc.cwd, pc.id, { title });
|
|
540
|
+
const label = await compressTitle(deps, title);
|
|
541
|
+
pc.widgetState.label = label;
|
|
542
|
+
await updateTaskFrontMatter(pc.cwd, pc.id, { label });
|
|
513
543
|
}
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Each template is a pure function: inputs → prompt string. No I/O, no side
|
|
5
5
|
* effects, trivially testable.
|
|
6
6
|
*/
|
|
7
|
-
export declare const MAX_GRILL_QUESTIONS =
|
|
7
|
+
export declare const MAX_GRILL_QUESTIONS = 20;
|
|
8
8
|
/**
|
|
9
9
|
* Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
|
|
10
10
|
* <think> reasoning trace for that turn and persists across the tool-call loop
|
|
@@ -28,6 +28,13 @@ export declare const MAX_GRILL_QUESTIONS = 10;
|
|
|
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;
|
package/dist/task/prompts.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Each template is a pure function: inputs → prompt string. No I/O, no side
|
|
5
5
|
* effects, trivially testable.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// Caps both the per-response question count (parseGrillQuestions/parseClarifyList)
|
|
8
|
+
// and the grill loop's total iterations, so a model that never emits NONE can't
|
|
9
|
+
// run unbounded.
|
|
10
|
+
export const MAX_GRILL_QUESTIONS = 20;
|
|
8
11
|
/**
|
|
9
12
|
* Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
|
|
10
13
|
* <think> reasoning trace for that turn and persists across the tool-call loop
|
|
@@ -30,6 +33,22 @@ export const NO_THINK = '/no_think';
|
|
|
30
33
|
export function appendNoThink(prompt) {
|
|
31
34
|
return `${prompt}\n\n${NO_THINK}`;
|
|
32
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}`;
|
|
33
52
|
const REFINE_PROMPT = (raw) => `You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
|
|
34
53
|
|
|
35
54
|
Output structure (four sections, exact headings, in this order):
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects when a freshly-generated clarifying question is a near-duplicate of one
|
|
3
|
+
* already asked. The clarify/grill loops re-call the generator after every answer
|
|
4
|
+
* and rely on a prompt instruction ("Never re-ask something already answered") to
|
|
5
|
+
* stop repeats. A weaker local model ignores that and re-asks the same fork worded
|
|
6
|
+
* differently (observed: a "Bun bundler vs Vite" question asked four ways across a
|
|
7
|
+
* single run). This is a deterministic backstop so a model that won't self-police
|
|
8
|
+
* can't barrage the user with the same decision.
|
|
9
|
+
*
|
|
10
|
+
* Pure logic, no I/O — trivially unit-testable, and validated against the actual
|
|
11
|
+
* repeated questions from a real /task-auto run (see question-dedup.test.ts).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Lowercase, drop markdown emphasis/backticks, split on non-alphanumeric, then
|
|
15
|
+
* drop stopwords and 1-char tokens. `pg_trgm` → `pg`,`trgm`; `bun:sql` → `bun`,`sql`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function tokenizeQuestion(q: string): Set<string>;
|
|
18
|
+
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|. 0 when either is empty. */
|
|
19
|
+
export declare function jaccard(a: Set<string>, b: Set<string>): number;
|
|
20
|
+
export declare const DEFAULT_DUP_THRESHOLD = 0.12;
|
|
21
|
+
/**
|
|
22
|
+
* True when `next` is a near-duplicate of any question in `prior` (Jaccard of
|
|
23
|
+
* salient tokens ≥ threshold). Compares against the actual question text already
|
|
24
|
+
* asked, so a re-worded re-ask of an already-answered fork is caught.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isDuplicateQuestion(prior: readonly string[], next: string, threshold?: number): boolean;
|
|
27
|
+
export declare const MAX_DUP_STRIKES = 2;
|
|
28
|
+
export declare const DUP_REPROMPT_HINT: string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects when a freshly-generated clarifying question is a near-duplicate of one
|
|
3
|
+
* already asked. The clarify/grill loops re-call the generator after every answer
|
|
4
|
+
* and rely on a prompt instruction ("Never re-ask something already answered") to
|
|
5
|
+
* stop repeats. A weaker local model ignores that and re-asks the same fork worded
|
|
6
|
+
* differently (observed: a "Bun bundler vs Vite" question asked four ways across a
|
|
7
|
+
* single run). This is a deterministic backstop so a model that won't self-police
|
|
8
|
+
* can't barrage the user with the same decision.
|
|
9
|
+
*
|
|
10
|
+
* Pure logic, no I/O — trivially unit-testable, and validated against the actual
|
|
11
|
+
* repeated questions from a real /task-auto run (see question-dedup.test.ts).
|
|
12
|
+
*/
|
|
13
|
+
// Function words plus the boilerplate the prompt makes every question share
|
|
14
|
+
// ("should we use", "or should", "this determines whether the task", …). Stripping
|
|
15
|
+
// these keeps the comparison on the salient nouns that actually identify a topic
|
|
16
|
+
// (vite, bundler, sharp, multer) instead of the scaffolding common to all of them.
|
|
17
|
+
const STOPWORDS = new Set([
|
|
18
|
+
'the',
|
|
19
|
+
'a',
|
|
20
|
+
'an',
|
|
21
|
+
'and',
|
|
22
|
+
'or',
|
|
23
|
+
'of',
|
|
24
|
+
'to',
|
|
25
|
+
'in',
|
|
26
|
+
'on',
|
|
27
|
+
'for',
|
|
28
|
+
'with',
|
|
29
|
+
'as',
|
|
30
|
+
'by',
|
|
31
|
+
'at',
|
|
32
|
+
'be',
|
|
33
|
+
'is',
|
|
34
|
+
'are',
|
|
35
|
+
'should',
|
|
36
|
+
'we',
|
|
37
|
+
'use',
|
|
38
|
+
'used',
|
|
39
|
+
'using',
|
|
40
|
+
'it',
|
|
41
|
+
'its',
|
|
42
|
+
'this',
|
|
43
|
+
'that',
|
|
44
|
+
'these',
|
|
45
|
+
'those',
|
|
46
|
+
'into',
|
|
47
|
+
'from',
|
|
48
|
+
'than',
|
|
49
|
+
'then',
|
|
50
|
+
'so',
|
|
51
|
+
'if',
|
|
52
|
+
'whether',
|
|
53
|
+
'which',
|
|
54
|
+
'what',
|
|
55
|
+
'how',
|
|
56
|
+
'do',
|
|
57
|
+
'does',
|
|
58
|
+
'add',
|
|
59
|
+
'added',
|
|
60
|
+
'also',
|
|
61
|
+
'only',
|
|
62
|
+
'just',
|
|
63
|
+
'like',
|
|
64
|
+
'such',
|
|
65
|
+
'them',
|
|
66
|
+
'they',
|
|
67
|
+
'their',
|
|
68
|
+
'there',
|
|
69
|
+
'here',
|
|
70
|
+
'will',
|
|
71
|
+
'would',
|
|
72
|
+
'can',
|
|
73
|
+
'could',
|
|
74
|
+
'instead',
|
|
75
|
+
'rather',
|
|
76
|
+
'either',
|
|
77
|
+
'both',
|
|
78
|
+
'each',
|
|
79
|
+
'all',
|
|
80
|
+
'any',
|
|
81
|
+
'some',
|
|
82
|
+
'more',
|
|
83
|
+
'most',
|
|
84
|
+
'over',
|
|
85
|
+
'up',
|
|
86
|
+
'out',
|
|
87
|
+
'no',
|
|
88
|
+
'not',
|
|
89
|
+
'without',
|
|
90
|
+
'before',
|
|
91
|
+
'after',
|
|
92
|
+
'while',
|
|
93
|
+
'during',
|
|
94
|
+
'team',
|
|
95
|
+
'want',
|
|
96
|
+
'wants',
|
|
97
|
+
'approach',
|
|
98
|
+
'simpler',
|
|
99
|
+
'simple',
|
|
100
|
+
'specified',
|
|
101
|
+
'mentioned',
|
|
102
|
+
'set',
|
|
103
|
+
'sets',
|
|
104
|
+
'task',
|
|
105
|
+
'tasks',
|
|
106
|
+
'whole',
|
|
107
|
+
'entire',
|
|
108
|
+
'single',
|
|
109
|
+
'separate',
|
|
110
|
+
'new',
|
|
111
|
+
'extra',
|
|
112
|
+
'changes',
|
|
113
|
+
'change',
|
|
114
|
+
'determines',
|
|
115
|
+
'critical',
|
|
116
|
+
'because',
|
|
117
|
+
'mode',
|
|
118
|
+
'forks',
|
|
119
|
+
'fork',
|
|
120
|
+
'header',
|
|
121
|
+
'stack',
|
|
122
|
+
'lighter',
|
|
123
|
+
'built'
|
|
124
|
+
]);
|
|
125
|
+
/**
|
|
126
|
+
* Lowercase, drop markdown emphasis/backticks, split on non-alphanumeric, then
|
|
127
|
+
* drop stopwords and 1-char tokens. `pg_trgm` → `pg`,`trgm`; `bun:sql` → `bun`,`sql`.
|
|
128
|
+
*/
|
|
129
|
+
export function tokenizeQuestion(q) {
|
|
130
|
+
const cleaned = q.toLowerCase().replace(/\*\*/g, ' ').replace(/`/g, ' ');
|
|
131
|
+
const tokens = cleaned.split(/[^a-z0-9]+/).filter(t => t.length > 1 && !STOPWORDS.has(t));
|
|
132
|
+
return new Set(tokens);
|
|
133
|
+
}
|
|
134
|
+
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|. 0 when either is empty. */
|
|
135
|
+
export function jaccard(a, b) {
|
|
136
|
+
if (a.size === 0 || b.size === 0)
|
|
137
|
+
return 0;
|
|
138
|
+
let inter = 0;
|
|
139
|
+
for (const t of a)
|
|
140
|
+
if (b.has(t))
|
|
141
|
+
inter++;
|
|
142
|
+
const union = a.size + b.size - inter;
|
|
143
|
+
return union === 0 ? 0 : inter / union;
|
|
144
|
+
}
|
|
145
|
+
// Validated against the real mx5 run (question-dedup.test.ts): the re-asked
|
|
146
|
+
// SPA-build/serve forks scored 0.152 / 0.275 / 0.421 against an earlier question,
|
|
147
|
+
// while every genuinely-distinct subsystem question topped out at 0.077. 0.12 sits
|
|
148
|
+
// in that gap — above the distinct ceiling, below the lowest true duplicate — so
|
|
149
|
+
// it catches the repeats without suppressing a real question.
|
|
150
|
+
export const DEFAULT_DUP_THRESHOLD = 0.12;
|
|
151
|
+
/**
|
|
152
|
+
* True when `next` is a near-duplicate of any question in `prior` (Jaccard of
|
|
153
|
+
* salient tokens ≥ threshold). Compares against the actual question text already
|
|
154
|
+
* asked, so a re-worded re-ask of an already-answered fork is caught.
|
|
155
|
+
*/
|
|
156
|
+
export function isDuplicateQuestion(prior, next, threshold = DEFAULT_DUP_THRESHOLD) {
|
|
157
|
+
const nextTokens = tokenizeQuestion(next);
|
|
158
|
+
if (nextTokens.size === 0)
|
|
159
|
+
return false;
|
|
160
|
+
return prior.some(p => jaccard(tokenizeQuestion(p), nextTokens) >= threshold);
|
|
161
|
+
}
|
|
162
|
+
// After this many consecutive near-duplicate questions, the question loop stops:
|
|
163
|
+
// a model that can't produce a novel question after being told to move on has
|
|
164
|
+
// nothing genuinely new left to ask. Shared by /task-auto's clarify loop and
|
|
165
|
+
// /task's grill loop.
|
|
166
|
+
export const MAX_DUP_STRIKES = 2;
|
|
167
|
+
// Reprompt injected when a duplicate is caught — tells the model the topic is
|
|
168
|
+
// settled and to either move to a different open fork or finish.
|
|
169
|
+
export const DUP_REPROMPT_HINT = 'You just re-asked a decision that is ALREADY answered above. Do not ask about '
|
|
170
|
+
+ 'it again, even reworded. Ask about a DIFFERENT subsystem or fork that remains '
|
|
171
|
+
+ 'genuinely open, or output the termination token if nothing decision-changing '
|
|
172
|
+
+ 'is left.';
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/task/widget.d.ts
CHANGED
|
@@ -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;
|
package/dist/task/widget.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.13.20",
|
|
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",
|