@mjasnikovs/pi-task 0.13.17 → 0.13.19

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.
@@ -34,14 +34,20 @@ export declare function expandFeatureMentions(cwd: string, feature: string): Pro
34
34
  */
35
35
  export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
36
36
  /**
37
- * Thread the feature's spec references into every decomposed task title.
38
- * Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
39
- * — so without this the design doc the feature pointed at is invisible
40
- * downstream and each task drifts to a generic reading of its one-line title
41
- * (this is how an "Implement @design.md" run built a generic `posts` table the
42
- * spec never mentioned). Appending the @refs makes the per-task refine/research
43
- * read the real spec and treat it as authoritative over the title. No readable
44
- * refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
37
+ * Thread the feature's spec references AND any per-task decisions into every
38
+ * decomposed task title. A title is ALL a per-task pipeline ever sees, so both
39
+ * the design doc the feature pointed at and the user's clarification choices have
40
+ * to ride along or they're invisible downstream this is how an "Implement
41
+ * @design.md" run built a generic `posts` table the spec never mentioned, and how
42
+ * a "do not use vite" clarification got silently overridden by the doc's own
43
+ * vite.config.ts.
44
+ *
45
+ * Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
46
+ * spec doc, so the decisions clause is marked as overriding the doc, while the doc
47
+ * stays authoritative for everything the decisions don't touch. Decompose scopes
48
+ * each decision to the task(s) it governs, so most titles carry none. No readable
49
+ * refs and no decisions → title unchanged, so a doc-less /task-auto behaves
50
+ * exactly as before.
45
51
  */
46
52
  export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
47
53
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
@@ -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
  /**
@@ -75,22 +80,41 @@ export async function readableMentions(cwd, feature) {
75
80
  }
76
81
  return out;
77
82
  }
83
+ /** A trailing "[decisions: …]" clause decompose may attach to a task line. */
84
+ const DECISIONS_RE = /\s*\[decisions:\s*(.+?)\]\s*$/i;
78
85
  /**
79
- * Thread the feature's spec references into every decomposed task title.
80
- * Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
81
- * — so without this the design doc the feature pointed at is invisible
82
- * downstream and each task drifts to a generic reading of its one-line title
83
- * (this is how an "Implement @design.md" run built a generic `posts` table the
84
- * spec never mentioned). Appending the @refs makes the per-task refine/research
85
- * read the real spec and treat it as authoritative over the title. No readable
86
- * refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
86
+ * Thread the feature's spec references AND any per-task decisions into every
87
+ * decomposed task title. A title is ALL a per-task pipeline ever sees, so both
88
+ * the design doc the feature pointed at and the user's clarification choices have
89
+ * to ride along or they're invisible downstream this is how an "Implement
90
+ * @design.md" run built a generic `posts` table the spec never mentioned, and how
91
+ * a "do not use vite" clarification got silently overridden by the doc's own
92
+ * vite.config.ts.
93
+ *
94
+ * Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
95
+ * spec doc, so the decisions clause is marked as overriding the doc, while the doc
96
+ * stays authoritative for everything the decisions don't touch. Decompose scopes
97
+ * each decision to the task(s) it governs, so most titles carry none. No readable
98
+ * refs and no decisions → title unchanged, so a doc-less /task-auto behaves
99
+ * exactly as before.
87
100
  */
88
101
  export function attachSpecRefs(titles, refs) {
89
- if (refs.length === 0)
90
- return titles;
91
102
  const list = refs.map(r => '@' + r).join(' ');
92
- const suffix = ` | spec: ${list} — authoritative; read it and follow it over this title wherever they differ`;
93
- return titles.map(t => (t.includes('| spec:') ? t : t + suffix));
103
+ return titles.map(t => {
104
+ if (t.includes('| spec:') || t.includes('| decisions'))
105
+ return t; // already threaded
106
+ const dm = DECISIONS_RE.exec(t);
107
+ const base = dm ? t.slice(0, dm.index).trimEnd() : t;
108
+ const decisions = dm ? dm[1].trim() : '';
109
+ let out = base;
110
+ if (decisions) {
111
+ out += ` | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): ${decisions}`;
112
+ }
113
+ if (refs.length > 0) {
114
+ out += ` | spec: ${list} — otherwise authoritative; read it and follow it over this title wherever they differ`;
115
+ }
116
+ return out;
117
+ });
94
118
  }
95
119
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
96
120
  export async function planAuto(ctx, cwd, feature, deps) {
@@ -109,17 +133,39 @@ export async function planAuto(ctx, cwd, feature, deps) {
109
133
  // the real content, not a one-line "Implement @file" that reads as trivial.
110
134
  const featureForModel = await expandFeatureMentions(cwd, feature);
111
135
  const answers = [];
112
- // Open-ended: keep asking until the model emits NONE or the user dismisses.
113
- for (;;) {
114
- const qRaw = await deps.runChild('auto-clarify', 'read', AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n')));
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'))));
115
148
  const parsed = parseClarifyList(qRaw);
116
149
  if (parsed.length === 0)
117
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;
118
163
  const { question, suggested, alt } = parsed[0];
119
164
  // Render markdown (bold/code) for the displayed prompt; keep plain text
120
165
  // for the editable default and the persisted file.
121
166
  const shownQ = renderInlineMarkdown(question, theme);
122
167
  const plainQ = stripInlineMarkdown(question);
168
+ askedQuestions.push(plainQ);
123
169
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
124
170
  const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
125
171
  // Identical to /task's grill dialog: a binary fork becomes a select()
@@ -82,4 +82,10 @@ RULES:
82
82
  - Order tasks so earlier ones unblock later ones (foundations first).
83
83
  - Each task should be independently implementable as a single /task run.
84
84
  - Prefer a handful of substantial tasks over many trivial ones.
85
+ - When a CLARIFICATION decision governs a task, append it to that task's line as
86
+ "[decisions: <directive>]" — include ONLY the decisions that bear on that task,
87
+ and attach a cross-cutting decision to EACH task it governs. Most tasks carry
88
+ none. These are explicit user choices that may contradict the referenced spec
89
+ doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
90
+ not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
85
91
  - Output the checkbox list and NOTHING else (no preamble, no numbering).`;
@@ -14,6 +14,7 @@ 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';
18
19
  import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
19
20
  import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
@@ -314,15 +315,37 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
314
315
  const ui = new SessionUI(ctx);
315
316
  const out = []; // human-facing Q&A transcript (with auto-worker debug lines)
316
317
  const qa = []; // compact Q&A fed back into the next question
318
+ const askedQuestions = []; // plain text of each question, for the dup backstop
319
+ // Deterministic backstop against a model that ignores "never re-ask": a
320
+ // near-duplicate question is reprompted (not auto-answered or shown), and after
321
+ // MAX_DUP_STRIKES consecutive dups the loop stops. Each question otherwise runs
322
+ // the research-backed auto-answer, which fans out doc/fetch/search workers — so
323
+ // a re-ask isn't just an extra prompt, it's an expensive recompute. Capped at
324
+ // MAX_GRILL_QUESTIONS so a model that never emits NONE can't run unbounded.
325
+ let dupStrikes = 0;
326
+ let dupHint = null;
317
327
  // Open-ended: keep asking until the model emits NONE or the user dismisses.
318
- for (let n = 0;; n++) {
328
+ for (let n = 0; n < MAX_GRILL_QUESTIONS; n++) {
319
329
  const tGenStart = Date.now();
320
- const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, GRILL_GEN_PROMPT(refined, research, qa.join('\n'))));
330
+ const genHint = dupHint;
331
+ const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n')))));
321
332
  deps.recordSubStep?.('gen', Date.now() - tGenStart);
322
333
  const questions = parseGrillQuestions(raw);
323
334
  if (questions.length === 0)
324
335
  break; // NONE / nothing left to ask
325
336
  const q = questions[0];
337
+ // Suppress a re-asked decision before paying for its auto-answer.
338
+ if (isDuplicateQuestion(askedQuestions, stripInlineMarkdown(q))) {
339
+ dupStrikes++;
340
+ if (dupStrikes >= MAX_DUP_STRIKES)
341
+ break;
342
+ dupHint = DUP_REPROMPT_HINT;
343
+ n--;
344
+ continue;
345
+ }
346
+ dupStrikes = 0;
347
+ dupHint = null;
348
+ askedQuestions.push(stripInlineMarkdown(q));
326
349
  widgetState.lastLine = `auto-answering Q${n + 1}…`;
327
350
  const tAutoStart = Date.now();
328
351
  const auto = await phaseAutoAnswer(deps, refined, research, q);
@@ -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 = 10;
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
@@ -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
- export const MAX_GRILL_QUESTIONS = 10;
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
@@ -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.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.17",
3
+ "version": "0.13.19",
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",