@mjasnikovs/pi-task 0.29.3 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.js +2 -0
- package/dist/remote/bridge.d.ts +18 -0
- package/dist/remote/bridge.js +2 -0
- package/dist/remote/protocol.d.ts +9 -0
- package/dist/remote/ui-script.js +20 -0
- package/dist/task/accept-debt.d.ts +51 -2
- package/dist/task/accept-debt.js +140 -7
- package/dist/task/auto-orchestrator.d.ts +1 -0
- package/dist/task/auto-orchestrator.js +40 -2
- package/dist/task/final-gate-fix.d.ts +22 -1
- package/dist/task/final-gate-fix.js +53 -6
- package/dist/task/final-gate.d.ts +54 -1
- package/dist/task/final-gate.js +115 -3
- package/dist/task/gate-deps.d.ts +41 -2
- package/dist/task/gate-deps.js +141 -3
- package/dist/task/plan-io.d.ts +55 -0
- package/dist/task/plan-io.js +94 -0
- package/dist/task/plan-orchestrator.d.ts +52 -0
- package/dist/task/plan-orchestrator.js +234 -0
- package/dist/task/plan-prompts.d.ts +40 -0
- package/dist/task/plan-prompts.js +138 -0
- package/dist/task/plan-session.d.ts +184 -0
- package/dist/task/plan-session.js +373 -0
- package/dist/task/question-box.d.ts +8 -0
- package/dist/task/question-box.js +1 -1
- package/dist/task/spec-validation.d.ts +14 -0
- package/dist/task/spec-validation.js +21 -1
- package/dist/task/widget.d.ts +4 -0
- package/dist/task/widget.js +2 -2
- package/dist/task/write-guard.d.ts +42 -0
- package/dist/task/write-guard.js +108 -0
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /task-plan interaction loop.
|
|
3
|
+
*
|
|
4
|
+
* Sequential & adaptive, exactly like /task's grill (phases.ts `phaseGrill`) and
|
|
5
|
+
* /task-auto's clarify (auto-orchestrator.ts `planAuto`): ask ONE question at a
|
|
6
|
+
* time, feed every answer back into the next generation call so later questions
|
|
7
|
+
* react to earlier ones, and stop when the model emits NONE. The duplicate
|
|
8
|
+
* backstop (`isDuplicateQuestion` + `DUP_REPROMPT_HINT` + `MAX_DUP_STRIKES`), the
|
|
9
|
+
* markdown handling, the A/B answer-letter mapping and the YOLO policy are the
|
|
10
|
+
* SAME modules those two loops use — none of that is new here.
|
|
11
|
+
*
|
|
12
|
+
* What IS new is the control surface. In grill and clarify the user's only move is
|
|
13
|
+
* to answer the question in front of them. Here three moves are available at every
|
|
14
|
+
* single prompt, in that order of appearance:
|
|
15
|
+
*
|
|
16
|
+
* ❓ ask the model a question — the user asks, the model answers (PLAN_ASK)
|
|
17
|
+
* ✎ answer in your own words — the free-text card askQuestionBox already
|
|
18
|
+
* appends to every boxed picker; it is not new,
|
|
19
|
+
* it is simply always present here, and it
|
|
20
|
+
* doubles as "state a decision" when the model
|
|
21
|
+
* has nothing to ask
|
|
22
|
+
* ▶ proceed to execution — stop planning, hand the decisions to /task
|
|
23
|
+
* (PLAN_PROCEED)
|
|
24
|
+
*
|
|
25
|
+
* The loop is pure with respect to I/O: every side effect (child calls, dialogs,
|
|
26
|
+
* persistence) arrives through {@link PlanSessionDeps}, so the whole interaction
|
|
27
|
+
* is unit-testable without a TUI or a model.
|
|
28
|
+
*/
|
|
29
|
+
import type { AskSpec } from '../remote/bridge.js';
|
|
30
|
+
import { type PlanEntry, type AnswerSource } from './plan-io.js';
|
|
31
|
+
/**
|
|
32
|
+
* Sentinel values the picker resolves to when the user takes a control action
|
|
33
|
+
* instead of answering. Deliberately shaped like the existing `USER_CANCELLED`
|
|
34
|
+
* sentinel (child-runner.ts): a value no model answer and no human ever types.
|
|
35
|
+
*/
|
|
36
|
+
export declare const PLAN_ASK = "__plan_ask__";
|
|
37
|
+
export declare const PLAN_PROCEED = "__plan_proceed__";
|
|
38
|
+
export declare const PLAN_ASK_LABEL = "\u2753 Ask the model a question\u2026";
|
|
39
|
+
export declare const PLAN_PROCEED_LABEL = "\u25B6 Proceed to execution (hand off to /task)";
|
|
40
|
+
/** Free-text card label while a model question is on screen. */
|
|
41
|
+
export declare const PLAN_ANSWER_LABEL = "\u270E Answer in your own words\u2026";
|
|
42
|
+
/** …and when there is no question to answer, so the same card reads correctly. */
|
|
43
|
+
export declare const PLAN_STATE_LABEL = "\u270E Add a decision of your own\u2026";
|
|
44
|
+
/** Header shown once the model has nothing left to ask. */
|
|
45
|
+
export declare const PLAN_NO_QUESTIONS = "No further questions \u2014 the decisions so far settle how this task is built.";
|
|
46
|
+
/**
|
|
47
|
+
* Hard ceiling on model-generated questions for one plan. The loop is open-ended
|
|
48
|
+
* (it stops when the model emits NONE); this only bounds a model that never
|
|
49
|
+
* does. Matches /task-auto's MAX_CLARIFY_QUESTIONS, for the same reason.
|
|
50
|
+
*/
|
|
51
|
+
export declare const MAX_PLAN_QUESTIONS = 8;
|
|
52
|
+
/**
|
|
53
|
+
* Corrective re-prompt for a question reply that did not follow the format —
|
|
54
|
+
* either nothing parseable at all, or a question with no `SUGGESTED:` line. Same
|
|
55
|
+
* shape and same one-shot budget as GRILL_AUTO_FORMAT_HINT (prompts.ts), which
|
|
56
|
+
* exists because the local model drops a required tag every so often and a
|
|
57
|
+
* silent fallback is worse than one extra call: an unparsed reply reads as "no
|
|
58
|
+
* questions left" and a missing SUGGESTED leaves the picker with nothing to
|
|
59
|
+
* recommend.
|
|
60
|
+
*/
|
|
61
|
+
export declare const PLAN_FORMAT_HINT: string;
|
|
62
|
+
/** True when the reply is the deliberate "nothing left to ask" sentinel, as
|
|
63
|
+
* opposed to output the parser simply could not read. */
|
|
64
|
+
export declare function isNoneReply(raw: string): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Which of the parsed entries is the actual question.
|
|
67
|
+
*
|
|
68
|
+
* parseClarifyList turns EVERY numbered line into an entry, and the local model
|
|
69
|
+
* sometimes writes a numbered analysis note or two before the question it was
|
|
70
|
+
* asked for (measured live: the first numbered line was a note like
|
|
71
|
+
* "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
|
|
72
|
+
* entry 0 blindly then shows the note as the question and loses the SUGGESTED
|
|
73
|
+
* line that was attached further down.
|
|
74
|
+
*
|
|
75
|
+
* The SUGGESTED line is the reliable marker of the real question — the prompt
|
|
76
|
+
* requires exactly one, and parseClarifyList attaches it to the entry it follows.
|
|
77
|
+
* So: prefer the first entry that has one; fall back to the first entry when none
|
|
78
|
+
* does, which is the case the format re-prompt then covers.
|
|
79
|
+
*/
|
|
80
|
+
export declare function pickQuestion<T extends {
|
|
81
|
+
suggested?: string;
|
|
82
|
+
}>(parsed: T[]): T | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Does the question offer the user a choice between two named alternatives?
|
|
85
|
+
* Deliberately shallow — an "X or Y?" in the question's own clause.
|
|
86
|
+
*/
|
|
87
|
+
export declare function looksLikeFork(question: string): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Corrective re-prompt for a fork-shaped question that shipped only ONE option.
|
|
90
|
+
*
|
|
91
|
+
* Measured on the local model (scripts/live-task-plan-step0.ts, 15 reps): the
|
|
92
|
+
* SUGGESTED line is always there, but 10/15 questions named two alternatives and
|
|
93
|
+
* gave only one of them — so the picker showed a single card and the user had to
|
|
94
|
+
* type out the option the model itself had just proposed.
|
|
95
|
+
*
|
|
96
|
+
* The retry quotes the question back because the child is stateless (a fresh
|
|
97
|
+
* process per call, prompt only), so it cannot otherwise know what it just wrote.
|
|
98
|
+
* Validated before wiring (scripts/live-task-plan-fork-alt.ts): 6/6 fires
|
|
99
|
+
* recovered an ALT, and 6/6 re-asked the SAME question rather than changing the
|
|
100
|
+
* subject. It costs one extra child call on the questions where it fires.
|
|
101
|
+
*/
|
|
102
|
+
export declare function planForkHint(question: string): string;
|
|
103
|
+
/** The ask spec the session hands to the UI: an {@link AskSpec} plus the picker
|
|
104
|
+
* entries. Kept structurally identical to what phaseGrill/planAuto build so the
|
|
105
|
+
* same SessionUI.ask serves all three. */
|
|
106
|
+
export type PlanAskSpec = AskSpec & {
|
|
107
|
+
options: {
|
|
108
|
+
label: string;
|
|
109
|
+
value: string;
|
|
110
|
+
}[];
|
|
111
|
+
manualLabel: string;
|
|
112
|
+
actions: {
|
|
113
|
+
label: string;
|
|
114
|
+
value: string;
|
|
115
|
+
}[];
|
|
116
|
+
};
|
|
117
|
+
export interface PlanSessionDeps {
|
|
118
|
+
/** Run the question-generation child. `hint` is the duplicate reprompt. */
|
|
119
|
+
generateQuestion(priorQA: string, hint: string | null): Promise<string>;
|
|
120
|
+
/** Run the child that answers a question the USER asked. */
|
|
121
|
+
answerUserQuestion(priorQA: string, question: string): Promise<string>;
|
|
122
|
+
/** Show the picker; resolves to a value, a control sentinel, or undefined
|
|
123
|
+
* when the user dismissed it. */
|
|
124
|
+
ask(spec: PlanAskSpec): Promise<string | undefined>;
|
|
125
|
+
/** Collect free text (the user's own question). undefined = cancelled. */
|
|
126
|
+
promptText(title: string, question: string): Promise<string | undefined>;
|
|
127
|
+
/** Display the model's answer to the user's question. */
|
|
128
|
+
showAnswer(question: string, answer: string): void | Promise<void>;
|
|
129
|
+
/** Called after every transcript change, for persistence. */
|
|
130
|
+
onEntries?(entries: readonly PlanEntry[]): void | Promise<void>;
|
|
131
|
+
/** Theme-aware markdown renderer for displayed text; identity when absent. */
|
|
132
|
+
renderMarkdown?(text: string): string;
|
|
133
|
+
/** Status line while a child runs (the widget's `lastLine`). */
|
|
134
|
+
setStatus?(line: string | undefined): void;
|
|
135
|
+
yolo?: boolean;
|
|
136
|
+
logDebug?(msg: string): void;
|
|
137
|
+
}
|
|
138
|
+
export type PlanOutcome = {
|
|
139
|
+
kind: 'proceed';
|
|
140
|
+
entries: PlanEntry[];
|
|
141
|
+
} | {
|
|
142
|
+
kind: 'cancelled';
|
|
143
|
+
entries: PlanEntry[];
|
|
144
|
+
};
|
|
145
|
+
interface PendingQuestion {
|
|
146
|
+
/** Plain text — persisted, and fed back to the model. */
|
|
147
|
+
plain: string;
|
|
148
|
+
/** Markdown-rendered — displayed. */
|
|
149
|
+
shown: string;
|
|
150
|
+
suggested?: string;
|
|
151
|
+
shownSuggested?: string;
|
|
152
|
+
alt?: string;
|
|
153
|
+
shownAlt?: string;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build the picker for a pending model question: the recommendation first (index
|
|
157
|
+
* 0 is the green RECOMMENDED card), the alternative second when the question is a
|
|
158
|
+
* binary fork, then the two control actions. The free-text card is appended by
|
|
159
|
+
* askQuestionBox itself — that is the "answer in your own words" affordance, and
|
|
160
|
+
* it is the same card grill and clarify already show.
|
|
161
|
+
*/
|
|
162
|
+
export declare function buildQuestionSpec(p: PendingQuestion): PlanAskSpec;
|
|
163
|
+
/**
|
|
164
|
+
* Build the picker for the state with NO pending question — the model is out of
|
|
165
|
+
* questions, or the cap/duplicate backstop stopped it. The same three moves are
|
|
166
|
+
* still on offer; only "answer this question" is gone, because there is no
|
|
167
|
+
* question, so the free-text card becomes "add a decision of your own".
|
|
168
|
+
*/
|
|
169
|
+
export declare function buildIdleSpec(): PlanAskSpec;
|
|
170
|
+
/**
|
|
171
|
+
* Map what the picker returned onto the answer that gets recorded. Mirrors the
|
|
172
|
+
* identical mapping in phaseGrill and planAuto: an empty submit accepts the
|
|
173
|
+
* recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
|
|
174
|
+
* back to the option's full text, and anything else is taken verbatim.
|
|
175
|
+
*/
|
|
176
|
+
export declare function resolveAnswer(p: PendingQuestion, raw: string): {
|
|
177
|
+
answer: string;
|
|
178
|
+
source: AnswerSource;
|
|
179
|
+
};
|
|
180
|
+
/** Copy for the dialog that collects the user's own question. */
|
|
181
|
+
export declare const ASK_TITLE = "Ask the model";
|
|
182
|
+
export declare const ASK_QUESTION = "What do you want to ask about this task? The answer is recorded as a note; it does not decide anything by itself.";
|
|
183
|
+
export declare function runPlanSession(deps: PlanSessionDeps): Promise<PlanOutcome>;
|
|
184
|
+
export {};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /task-plan interaction loop.
|
|
3
|
+
*
|
|
4
|
+
* Sequential & adaptive, exactly like /task's grill (phases.ts `phaseGrill`) and
|
|
5
|
+
* /task-auto's clarify (auto-orchestrator.ts `planAuto`): ask ONE question at a
|
|
6
|
+
* time, feed every answer back into the next generation call so later questions
|
|
7
|
+
* react to earlier ones, and stop when the model emits NONE. The duplicate
|
|
8
|
+
* backstop (`isDuplicateQuestion` + `DUP_REPROMPT_HINT` + `MAX_DUP_STRIKES`), the
|
|
9
|
+
* markdown handling, the A/B answer-letter mapping and the YOLO policy are the
|
|
10
|
+
* SAME modules those two loops use — none of that is new here.
|
|
11
|
+
*
|
|
12
|
+
* What IS new is the control surface. In grill and clarify the user's only move is
|
|
13
|
+
* to answer the question in front of them. Here three moves are available at every
|
|
14
|
+
* single prompt, in that order of appearance:
|
|
15
|
+
*
|
|
16
|
+
* ❓ ask the model a question — the user asks, the model answers (PLAN_ASK)
|
|
17
|
+
* ✎ answer in your own words — the free-text card askQuestionBox already
|
|
18
|
+
* appends to every boxed picker; it is not new,
|
|
19
|
+
* it is simply always present here, and it
|
|
20
|
+
* doubles as "state a decision" when the model
|
|
21
|
+
* has nothing to ask
|
|
22
|
+
* ▶ proceed to execution — stop planning, hand the decisions to /task
|
|
23
|
+
* (PLAN_PROCEED)
|
|
24
|
+
*
|
|
25
|
+
* The loop is pure with respect to I/O: every side effect (child calls, dialogs,
|
|
26
|
+
* persistence) arrives through {@link PlanSessionDeps}, so the whole interaction
|
|
27
|
+
* is unit-testable without a TUI or a model.
|
|
28
|
+
*/
|
|
29
|
+
import { parseClarifyList } from './parsers.js';
|
|
30
|
+
import { stripInlineMarkdown } from './inline-markdown.js';
|
|
31
|
+
import { isDuplicateQuestion, DUP_REPROMPT_HINT, MAX_DUP_STRIKES } from './question-dedup.js';
|
|
32
|
+
import { yoloPickAnswer } from './yolo.js';
|
|
33
|
+
import { formatPlanTranscript } from './plan-io.js';
|
|
34
|
+
// ─── Control actions ─────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Sentinel values the picker resolves to when the user takes a control action
|
|
37
|
+
* instead of answering. Deliberately shaped like the existing `USER_CANCELLED`
|
|
38
|
+
* sentinel (child-runner.ts): a value no model answer and no human ever types.
|
|
39
|
+
*/
|
|
40
|
+
export const PLAN_ASK = '__plan_ask__';
|
|
41
|
+
export const PLAN_PROCEED = '__plan_proceed__';
|
|
42
|
+
export const PLAN_ASK_LABEL = '❓ Ask the model a question…';
|
|
43
|
+
export const PLAN_PROCEED_LABEL = '▶ Proceed to execution (hand off to /task)';
|
|
44
|
+
/** Free-text card label while a model question is on screen. */
|
|
45
|
+
export const PLAN_ANSWER_LABEL = '✎ Answer in your own words…';
|
|
46
|
+
/** …and when there is no question to answer, so the same card reads correctly. */
|
|
47
|
+
export const PLAN_STATE_LABEL = '✎ Add a decision of your own…';
|
|
48
|
+
/** Header shown once the model has nothing left to ask. */
|
|
49
|
+
export const PLAN_NO_QUESTIONS = 'No further questions — the decisions so far settle how this task is built.';
|
|
50
|
+
/**
|
|
51
|
+
* Hard ceiling on model-generated questions for one plan. The loop is open-ended
|
|
52
|
+
* (it stops when the model emits NONE); this only bounds a model that never
|
|
53
|
+
* does. Matches /task-auto's MAX_CLARIFY_QUESTIONS, for the same reason.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_PLAN_QUESTIONS = 8;
|
|
56
|
+
/**
|
|
57
|
+
* Corrective re-prompt for a question reply that did not follow the format —
|
|
58
|
+
* either nothing parseable at all, or a question with no `SUGGESTED:` line. Same
|
|
59
|
+
* shape and same one-shot budget as GRILL_AUTO_FORMAT_HINT (prompts.ts), which
|
|
60
|
+
* exists because the local model drops a required tag every so often and a
|
|
61
|
+
* silent fallback is worse than one extra call: an unparsed reply reads as "no
|
|
62
|
+
* questions left" and a missing SUGGESTED leaves the picker with nothing to
|
|
63
|
+
* recommend.
|
|
64
|
+
*/
|
|
65
|
+
export const PLAN_FORMAT_HINT = '[SYSTEM NOTE: Your previous reply did NOT follow the required format. Output exactly '
|
|
66
|
+
+ 'one numbered question line ("1. **...?** short rationale"), then on the NEXT line a '
|
|
67
|
+
+ 'line beginning with "SUGGESTED: " carrying your recommended default — that line is '
|
|
68
|
+
+ 'REQUIRED and must never be blank. Add an "ALT: " line only for a binary A-or-B fork. '
|
|
69
|
+
+ 'If nothing is left to ask, output the single token NONE and nothing else. No preamble, '
|
|
70
|
+
+ 'no analysis, no other text.]';
|
|
71
|
+
/** True when the reply is the deliberate "nothing left to ask" sentinel, as
|
|
72
|
+
* opposed to output the parser simply could not read. */
|
|
73
|
+
export function isNoneReply(raw) {
|
|
74
|
+
return /^\s*NONE\s*$/m.test(raw);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Which of the parsed entries is the actual question.
|
|
78
|
+
*
|
|
79
|
+
* parseClarifyList turns EVERY numbered line into an entry, and the local model
|
|
80
|
+
* sometimes writes a numbered analysis note or two before the question it was
|
|
81
|
+
* asked for (measured live: the first numbered line was a note like
|
|
82
|
+
* "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
|
|
83
|
+
* entry 0 blindly then shows the note as the question and loses the SUGGESTED
|
|
84
|
+
* line that was attached further down.
|
|
85
|
+
*
|
|
86
|
+
* The SUGGESTED line is the reliable marker of the real question — the prompt
|
|
87
|
+
* requires exactly one, and parseClarifyList attaches it to the entry it follows.
|
|
88
|
+
* So: prefer the first entry that has one; fall back to the first entry when none
|
|
89
|
+
* does, which is the case the format re-prompt then covers.
|
|
90
|
+
*/
|
|
91
|
+
export function pickQuestion(parsed) {
|
|
92
|
+
return parsed.find(q => q.suggested !== undefined && q.suggested.length > 0) ?? parsed[0];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Does the question offer the user a choice between two named alternatives?
|
|
96
|
+
* Deliberately shallow — an "X or Y?" in the question's own clause.
|
|
97
|
+
*/
|
|
98
|
+
export function looksLikeFork(question) {
|
|
99
|
+
return /\bor\b/i.test(question.split('?')[0] ?? '');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Corrective re-prompt for a fork-shaped question that shipped only ONE option.
|
|
103
|
+
*
|
|
104
|
+
* Measured on the local model (scripts/live-task-plan-step0.ts, 15 reps): the
|
|
105
|
+
* SUGGESTED line is always there, but 10/15 questions named two alternatives and
|
|
106
|
+
* gave only one of them — so the picker showed a single card and the user had to
|
|
107
|
+
* type out the option the model itself had just proposed.
|
|
108
|
+
*
|
|
109
|
+
* The retry quotes the question back because the child is stateless (a fresh
|
|
110
|
+
* process per call, prompt only), so it cannot otherwise know what it just wrote.
|
|
111
|
+
* Validated before wiring (scripts/live-task-plan-fork-alt.ts): 6/6 fires
|
|
112
|
+
* recovered an ALT, and 6/6 re-asked the SAME question rather than changing the
|
|
113
|
+
* subject. It costs one extra child call on the questions where it fires.
|
|
114
|
+
*/
|
|
115
|
+
export function planForkHint(question) {
|
|
116
|
+
return ('[SYSTEM NOTE: Your previous reply asked this question:\n'
|
|
117
|
+
+ `"${question}"\n`
|
|
118
|
+
+ 'That question offers the user a choice between two alternatives, but you gave only '
|
|
119
|
+
+ 'one SUGGESTED line, so the user is shown a single option and has to type the other '
|
|
120
|
+
+ 'one out by hand. Ask the SAME question again — do not change the subject — and this '
|
|
121
|
+
+ 'time emit BOTH lines:\nSUGGESTED: <the option you recommend>\nALT: <the other option>\n'
|
|
122
|
+
+ 'Nothing else.]');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build the picker for a pending model question: the recommendation first (index
|
|
126
|
+
* 0 is the green RECOMMENDED card), the alternative second when the question is a
|
|
127
|
+
* binary fork, then the two control actions. The free-text card is appended by
|
|
128
|
+
* askQuestionBox itself — that is the "answer in your own words" affordance, and
|
|
129
|
+
* it is the same card grill and clarify already show.
|
|
130
|
+
*/
|
|
131
|
+
export function buildQuestionSpec(p) {
|
|
132
|
+
const options = [];
|
|
133
|
+
if (p.suggested !== undefined && p.alt !== undefined) {
|
|
134
|
+
options.push({ label: `A: ${p.shownSuggested ?? p.suggested}`, value: p.suggested });
|
|
135
|
+
options.push({ label: `B: ${p.shownAlt ?? p.alt}`, value: p.alt });
|
|
136
|
+
}
|
|
137
|
+
else if (p.suggested !== undefined) {
|
|
138
|
+
options.push({ label: p.shownSuggested ?? p.suggested, value: p.suggested });
|
|
139
|
+
}
|
|
140
|
+
const actions = [
|
|
141
|
+
{ label: PLAN_ASK_LABEL, value: PLAN_ASK },
|
|
142
|
+
{ label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED }
|
|
143
|
+
];
|
|
144
|
+
return {
|
|
145
|
+
localTitle: p.shown,
|
|
146
|
+
displayQuestion: p.shown,
|
|
147
|
+
question: p.plain,
|
|
148
|
+
...(p.suggested !== undefined && { recommended: p.suggested }),
|
|
149
|
+
...(p.alt !== undefined && { recommended2: p.alt }),
|
|
150
|
+
// The picker always offers a way out (the control actions and the
|
|
151
|
+
// free-text card), so a Skip button would only add a fourth way to say
|
|
152
|
+
// nothing. An unanswered question is recorded by submitting empty text.
|
|
153
|
+
allowSkip: false,
|
|
154
|
+
options: [...options, ...actions],
|
|
155
|
+
actions,
|
|
156
|
+
manualLabel: PLAN_ANSWER_LABEL
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build the picker for the state with NO pending question — the model is out of
|
|
161
|
+
* questions, or the cap/duplicate backstop stopped it. The same three moves are
|
|
162
|
+
* still on offer; only "answer this question" is gone, because there is no
|
|
163
|
+
* question, so the free-text card becomes "add a decision of your own".
|
|
164
|
+
*/
|
|
165
|
+
export function buildIdleSpec() {
|
|
166
|
+
const actions = [
|
|
167
|
+
{ label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED },
|
|
168
|
+
{ label: PLAN_ASK_LABEL, value: PLAN_ASK }
|
|
169
|
+
];
|
|
170
|
+
return {
|
|
171
|
+
localTitle: PLAN_NO_QUESTIONS,
|
|
172
|
+
displayQuestion: PLAN_NO_QUESTIONS,
|
|
173
|
+
question: PLAN_NO_QUESTIONS,
|
|
174
|
+
// No `recommended`: on the remote card that field IS the accept button,
|
|
175
|
+
// and "proceed" is an action, not an answer. Remote therefore renders the
|
|
176
|
+
// text box plus these two action buttons.
|
|
177
|
+
allowSkip: false,
|
|
178
|
+
options: actions,
|
|
179
|
+
actions,
|
|
180
|
+
manualLabel: PLAN_STATE_LABEL
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Map what the picker returned onto the answer that gets recorded. Mirrors the
|
|
185
|
+
* identical mapping in phaseGrill and planAuto: an empty submit accepts the
|
|
186
|
+
* recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
|
|
187
|
+
* back to the option's full text, and anything else is taken verbatim.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveAnswer(p, raw) {
|
|
190
|
+
const typed = raw.trim();
|
|
191
|
+
const twoOption = p.suggested !== undefined && p.alt !== undefined;
|
|
192
|
+
if (typed.length === 0 && p.suggested !== undefined) {
|
|
193
|
+
return { answer: p.suggested, source: 'accepted' };
|
|
194
|
+
}
|
|
195
|
+
if (typed.length === 0)
|
|
196
|
+
return { answer: '(skipped)', source: 'skipped' };
|
|
197
|
+
if (twoOption && /^a[.)]?$/i.test(typed))
|
|
198
|
+
return { answer: p.suggested, source: 'chosen' };
|
|
199
|
+
if (twoOption && /^b[.)]?$/i.test(typed))
|
|
200
|
+
return { answer: p.alt, source: 'chosen' };
|
|
201
|
+
if (p.suggested !== undefined && typed === p.suggested) {
|
|
202
|
+
return { answer: p.suggested, source: twoOption ? 'chosen' : 'accepted' };
|
|
203
|
+
}
|
|
204
|
+
if (p.alt !== undefined && typed === p.alt)
|
|
205
|
+
return { answer: p.alt, source: 'chosen' };
|
|
206
|
+
return { answer: typed, source: 'typed' };
|
|
207
|
+
}
|
|
208
|
+
// ─── The loop ────────────────────────────────────────────────────────────────
|
|
209
|
+
/** Copy for the dialog that collects the user's own question. */
|
|
210
|
+
export const ASK_TITLE = 'Ask the model';
|
|
211
|
+
export const ASK_QUESTION = 'What do you want to ask about this task? The answer is recorded as a note; it does not decide anything by itself.';
|
|
212
|
+
export async function runPlanSession(deps) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
const asked = [];
|
|
215
|
+
const render = (s) => deps.renderMarkdown?.(s) ?? s;
|
|
216
|
+
let dupStrikes = 0;
|
|
217
|
+
let dupHint = null;
|
|
218
|
+
/** Set for exactly one corrective re-prompt after a malformed reply. */
|
|
219
|
+
let formatHint = null;
|
|
220
|
+
/** The model has nothing (more) to ask: NONE, the cap, or the dup backstop. */
|
|
221
|
+
let exhausted = false;
|
|
222
|
+
let pending = null;
|
|
223
|
+
const commit = async (entry) => {
|
|
224
|
+
entries.push(entry);
|
|
225
|
+
await deps.onEntries?.(entries);
|
|
226
|
+
};
|
|
227
|
+
for (;;) {
|
|
228
|
+
if (pending === null && !exhausted) {
|
|
229
|
+
if (asked.length >= MAX_PLAN_QUESTIONS) {
|
|
230
|
+
deps.logDebug?.(`plan: question cap (${MAX_PLAN_QUESTIONS}) reached`);
|
|
231
|
+
exhausted = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
deps.setStatus?.(`thinking of question ${asked.length + 1}…`);
|
|
235
|
+
const raw = await deps.generateQuestion(formatPlanTranscript(entries), formatHint ?? dupHint);
|
|
236
|
+
deps.setStatus?.(undefined);
|
|
237
|
+
const parsed = parseClarifyList(raw);
|
|
238
|
+
// parseClarifyList returns [] both for a deliberate NONE and for
|
|
239
|
+
// output it could not parse at all — treating the second as "no
|
|
240
|
+
// questions left" would end the planning session on a formatting
|
|
241
|
+
// slip. Tell them apart, and give a malformed reply exactly one
|
|
242
|
+
// corrective re-prompt (the same one-shot recovery the grill
|
|
243
|
+
// auto-answer does with GRILL_AUTO_FORMAT_HINT).
|
|
244
|
+
if (parsed.length === 0) {
|
|
245
|
+
if (!isNoneReply(raw) && formatHint === null) {
|
|
246
|
+
deps.logDebug?.('plan: unparseable question reply — one format re-prompt');
|
|
247
|
+
formatHint = PLAN_FORMAT_HINT;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
deps.logDebug?.('plan: model has no further questions (NONE)');
|
|
251
|
+
formatHint = null;
|
|
252
|
+
exhausted = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const picked = pickQuestion(parsed);
|
|
256
|
+
const { question, suggested, alt } = picked;
|
|
257
|
+
const plain = stripInlineMarkdown(question);
|
|
258
|
+
// The duplicate backstop runs BEFORE either quality re-prompt: a
|
|
259
|
+
// question that is about to be discarded as a re-ask must not first
|
|
260
|
+
// buy itself an extra child call to be polished.
|
|
261
|
+
if (isDuplicateQuestion(asked, plain)) {
|
|
262
|
+
dupStrikes++;
|
|
263
|
+
deps.logDebug?.(`plan: duplicate question, strike ${dupStrikes}/${MAX_DUP_STRIKES}`);
|
|
264
|
+
formatHint = null;
|
|
265
|
+
if (dupStrikes >= MAX_DUP_STRIKES) {
|
|
266
|
+
exhausted = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
dupHint = DUP_REPROMPT_HINT;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// A question with no SUGGESTED line leaves the picker with nothing to
|
|
273
|
+
// recommend, which is the one thing the prompt says must never happen.
|
|
274
|
+
// One-shot recovery; if it still comes back bare we show the question
|
|
275
|
+
// anyway — a question with no default beats no question.
|
|
276
|
+
if (suggested === undefined && formatHint === null) {
|
|
277
|
+
deps.logDebug?.('plan: question had no SUGGESTED — one format re-prompt');
|
|
278
|
+
formatHint = PLAN_FORMAT_HINT;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// A question that offers a choice but ships one option leaves the
|
|
282
|
+
// user typing out the alternative the model just named. Same one-shot
|
|
283
|
+
// budget, quoting the question back so the (stateless) child re-asks
|
|
284
|
+
// this one instead of a new one.
|
|
285
|
+
if (alt === undefined && formatHint === null && looksLikeFork(plain)) {
|
|
286
|
+
deps.logDebug?.('plan: fork-shaped question with no ALT — one re-prompt');
|
|
287
|
+
formatHint = planForkHint(plain);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
formatHint = null;
|
|
291
|
+
dupStrikes = 0;
|
|
292
|
+
dupHint = null;
|
|
293
|
+
asked.push(plain);
|
|
294
|
+
pending = {
|
|
295
|
+
plain,
|
|
296
|
+
shown: render(question),
|
|
297
|
+
...(suggested !== undefined && {
|
|
298
|
+
suggested: stripInlineMarkdown(suggested),
|
|
299
|
+
shownSuggested: render(suggested)
|
|
300
|
+
}),
|
|
301
|
+
...(alt !== undefined && {
|
|
302
|
+
alt: stripInlineMarkdown(alt),
|
|
303
|
+
shownAlt: render(alt)
|
|
304
|
+
})
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// YOLO: take the recommendation without ever building the prompt (which is
|
|
308
|
+
// also what suppresses its notification — see yolo.ts). With no question
|
|
309
|
+
// left there is nothing to take and nobody to press "proceed", so the run
|
|
310
|
+
// proceeds on its own; that is the whole contract of the mode.
|
|
311
|
+
if (deps.yolo) {
|
|
312
|
+
if (pending === null) {
|
|
313
|
+
deps.logDebug?.('plan: YOLO — proceeding to execution');
|
|
314
|
+
return { kind: 'proceed', entries };
|
|
315
|
+
}
|
|
316
|
+
const pick = yoloPickAnswer(true, {
|
|
317
|
+
...(pending.suggested !== undefined && { suggested: pending.suggested }),
|
|
318
|
+
...(pending.alt !== undefined && { alt: pending.alt })
|
|
319
|
+
});
|
|
320
|
+
const answer = pick?.kind === 'answer' ? pick.answer : `(skipped — ${pick?.note ?? 'no option'})`;
|
|
321
|
+
await commit({ kind: 'decision', question: pending.plain, answer, source: 'yolo' });
|
|
322
|
+
pending = null;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const chosen = await deps.ask(pending === null ? buildIdleSpec() : buildQuestionSpec(pending));
|
|
326
|
+
if (chosen === undefined)
|
|
327
|
+
return { kind: 'cancelled', entries };
|
|
328
|
+
if (chosen === PLAN_PROCEED)
|
|
329
|
+
return { kind: 'proceed', entries };
|
|
330
|
+
if (chosen === PLAN_ASK) {
|
|
331
|
+
const q = await deps.promptText(ASK_TITLE, ASK_QUESTION);
|
|
332
|
+
if (q === undefined || q.trim().length === 0)
|
|
333
|
+
continue; // changed their mind
|
|
334
|
+
deps.setStatus?.('answering your question…');
|
|
335
|
+
let answer;
|
|
336
|
+
try {
|
|
337
|
+
answer = (await deps.answerUserQuestion(formatPlanTranscript(entries), q.trim())).trim();
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
// A failed answer must not cost the user their plan: report it in
|
|
341
|
+
// place of the answer and stay in the loop.
|
|
342
|
+
answer = `(could not answer: ${err instanceof Error ? err.message : String(err)})`;
|
|
343
|
+
}
|
|
344
|
+
deps.setStatus?.(undefined);
|
|
345
|
+
await deps.showAnswer(q.trim(), answer);
|
|
346
|
+
await commit({ kind: 'note', question: q.trim(), answer });
|
|
347
|
+
// The user's question and its answer are new context, so a model that
|
|
348
|
+
// had run out of questions may now have one. Re-open the generator.
|
|
349
|
+
if (exhausted) {
|
|
350
|
+
exhausted = false;
|
|
351
|
+
dupStrikes = 0;
|
|
352
|
+
}
|
|
353
|
+
continue; // the pending question, if any, is still unanswered
|
|
354
|
+
}
|
|
355
|
+
if (pending === null) {
|
|
356
|
+
// Free text with no question on screen: a decision the user states
|
|
357
|
+
// unprompted. An empty submit means "nothing to add" — stay put rather
|
|
358
|
+
// than record a blank line.
|
|
359
|
+
const text = chosen.trim();
|
|
360
|
+
if (text.length === 0)
|
|
361
|
+
continue;
|
|
362
|
+
await commit({ kind: 'stated', text });
|
|
363
|
+
if (exhausted) {
|
|
364
|
+
exhausted = false;
|
|
365
|
+
dupStrikes = 0;
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const { answer, source } = resolveAnswer(pending, chosen);
|
|
370
|
+
await commit({ kind: 'decision', question: pending.plain, answer, source });
|
|
371
|
+
pending = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -76,6 +76,14 @@ export interface AskQuestionBoxSpec {
|
|
|
76
76
|
inputTitle: string;
|
|
77
77
|
options: BoxOption[];
|
|
78
78
|
signal: AbortSignal;
|
|
79
|
+
/**
|
|
80
|
+
* Label for the trailing free-text card. Defaults to
|
|
81
|
+
* {@link MANUAL_CARD_LABEL} ("type a different answer"), which is right when
|
|
82
|
+
* the picker is answering a question. /task-plan shows the same card when
|
|
83
|
+
* there is no question on screen — there, "add a decision of your own" is
|
|
84
|
+
* what the card actually does.
|
|
85
|
+
*/
|
|
86
|
+
manualLabel?: string;
|
|
79
87
|
}
|
|
80
88
|
/**
|
|
81
89
|
* Show the boxed picker and resolve to the chosen option's `value`, the text the
|
|
@@ -145,7 +145,7 @@ export async function askQuestionBox(ctx, spec) {
|
|
|
145
145
|
const { question, options, inputTitle, signal } = spec;
|
|
146
146
|
const cards = [
|
|
147
147
|
...options.map(o => ({ label: o.label, recommended: o.recommended })),
|
|
148
|
-
{ label: MANUAL_CARD_LABEL }
|
|
148
|
+
{ label: spec.manualLabel ?? MANUAL_CARD_LABEL }
|
|
149
149
|
];
|
|
150
150
|
const colors = boxColors(ctx.ui.theme);
|
|
151
151
|
const manualIndex = options.length;
|
|
@@ -10,6 +10,20 @@ export interface VerifyCommand {
|
|
|
10
10
|
raw: string;
|
|
11
11
|
}
|
|
12
12
|
export declare function parseVerifyBlock(spec: string): VerifyCommand[] | null;
|
|
13
|
+
/**
|
|
14
|
+
* parseVerifyBlock, but only when the fenced block is actually CLOSED.
|
|
15
|
+
*
|
|
16
|
+
* An unterminated fence makes the lenient parser swallow the rest of the file:
|
|
17
|
+
* mx5 run 19's `TASK_0001.md` opens ```sh and never closes it, so its "VERIFY
|
|
18
|
+
* commands" include the phase-timings table and every appended gate-trail line.
|
|
19
|
+
* That is harmless where the parser only asks "is there something runnable here",
|
|
20
|
+
* and NOT harmless where a parsed line is treated as provenance — a debt reason
|
|
21
|
+
* quoting `bun run lint` would match a gate-trail sentence and mint a stored,
|
|
22
|
+
* re-runnable command the spec never asked for (accept-debt.ts
|
|
23
|
+
* verifyCommandFromReason, `inv-command-provenance`). Callers that need the block
|
|
24
|
+
* to MEAN something use this one: an unclosed fence is no block at all.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseVerifyBlockStrict(spec: string): VerifyCommand[] | null;
|
|
13
27
|
export declare function isCritiqueClean(text: string): boolean;
|
|
14
28
|
/**
|
|
15
29
|
* Drop any preamble the model emitted before the spec's GOAL header. The
|
|
@@ -8,6 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
// ─── Verify block parser ─────────────────────────────────────────────────────
|
|
10
10
|
export function parseVerifyBlock(spec) {
|
|
11
|
+
return scanVerifyBlock(spec)?.cmds ?? null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* parseVerifyBlock, but only when the fenced block is actually CLOSED.
|
|
15
|
+
*
|
|
16
|
+
* An unterminated fence makes the lenient parser swallow the rest of the file:
|
|
17
|
+
* mx5 run 19's `TASK_0001.md` opens ```sh and never closes it, so its "VERIFY
|
|
18
|
+
* commands" include the phase-timings table and every appended gate-trail line.
|
|
19
|
+
* That is harmless where the parser only asks "is there something runnable here",
|
|
20
|
+
* and NOT harmless where a parsed line is treated as provenance — a debt reason
|
|
21
|
+
* quoting `bun run lint` would match a gate-trail sentence and mint a stored,
|
|
22
|
+
* re-runnable command the spec never asked for (accept-debt.ts
|
|
23
|
+
* verifyCommandFromReason, `inv-command-provenance`). Callers that need the block
|
|
24
|
+
* to MEAN something use this one: an unclosed fence is no block at all.
|
|
25
|
+
*/
|
|
26
|
+
export function parseVerifyBlockStrict(spec) {
|
|
27
|
+
const scan = scanVerifyBlock(spec);
|
|
28
|
+
return scan && scan.terminated ? scan.cmds : null;
|
|
29
|
+
}
|
|
30
|
+
function scanVerifyBlock(spec) {
|
|
11
31
|
const lines = spec.split('\n');
|
|
12
32
|
let i = 0;
|
|
13
33
|
while (i < lines.length && !/^VERIFY:\s*$/.test(lines[i]))
|
|
@@ -29,7 +49,7 @@ export function parseVerifyBlock(spec) {
|
|
|
29
49
|
cmds.push({ raw: line });
|
|
30
50
|
i++;
|
|
31
51
|
}
|
|
32
|
-
return cmds;
|
|
52
|
+
return { cmds, terminated: i < lines.length };
|
|
33
53
|
}
|
|
34
54
|
// ─── Critique triage gate ────────────────────────────────────────────────────
|
|
35
55
|
// The critique-triage prompt instructs the worker to emit the literal token
|
package/dist/task/widget.d.ts
CHANGED
|
@@ -60,6 +60,10 @@ export interface AutoLoaderState {
|
|
|
60
60
|
* for a repo-health verify FAIL; 'final-fix' the bounded fix pass for a
|
|
61
61
|
* final-integration-gate FAIL. */
|
|
62
62
|
kind?: 'planning' | 'enforce' | 'verify' | 'recommend' | 'lint-fix' | 'final-fix';
|
|
63
|
+
/** Command shown in the head line. Defaults to '/task-auto', which is what
|
|
64
|
+
* every existing producer is; /task-plan reuses this same loader and only
|
|
65
|
+
* needs its own name on it. */
|
|
66
|
+
command?: string;
|
|
63
67
|
}
|
|
64
68
|
export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetTheme): string[];
|
|
65
69
|
/** Structured mirror of buildAutoLoaderLines. Only the numbered planning stage
|