@mjasnikovs/pi-task 0.38.2 → 0.38.3
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/config/config.d.ts +7 -0
- package/dist/config/config.js +10 -4
- package/dist/config/register.d.ts +37 -0
- package/dist/config/register.js +89 -114
- package/dist/remote/events.js +0 -3
- package/dist/remote/register.js +12 -3
- package/dist/task/auto-orchestrator.js +119 -94
- package/dist/task/command-run.d.ts +104 -0
- package/dist/task/command-run.js +138 -0
- package/dist/task/coverage-loop.d.ts +45 -0
- package/dist/task/critique-probes.d.ts +82 -0
- package/dist/task/critique-probes.js +156 -0
- package/dist/task/enforce-guidelines.d.ts +14 -17
- package/dist/task/enforce-guidelines.js +44 -31
- package/dist/task/final-gate.d.ts +8 -10
- package/dist/task/final-gate.js +36 -74
- package/dist/task/gate-child.d.ts +104 -0
- package/dist/task/gate-child.js +177 -0
- package/dist/task/gate-deps.js +57 -205
- package/dist/task/orchestrator.js +13 -22
- package/dist/task/phases.js +109 -182
- package/dist/task/plan-session.d.ts +4 -22
- package/dist/task/plan-session.js +4 -33
- package/dist/task/question-dialog.d.ts +71 -0
- package/dist/task/question-dialog.js +89 -0
- package/dist/task/terminal-outcome.d.ts +67 -0
- package/dist/task/terminal-outcome.js +76 -0
- package/dist/task/type-only-answer.js +2 -3
- package/dist/workers/abstention.d.ts +71 -0
- package/dist/workers/abstention.js +108 -0
- package/dist/workers/docs-chunk.d.ts +74 -0
- package/dist/workers/docs-chunk.js +143 -0
- package/dist/workers/docs-core.d.ts +10 -1
- package/dist/workers/docs-core.js +22 -19
- package/dist/workers/docs-index.js +2 -69
- package/dist/workers/docs-project.d.ts +15 -1
- package/dist/workers/docs-project.js +27 -66
- package/dist/workers/fetch-core.d.ts +1 -1
- package/dist/workers/fetch-core.js +2 -1
- package/dist/workers/pi-worker-core.js +157 -86
- package/dist/workers/pi-worker-docs.js +5 -10
- package/dist/workers/pi-worker-fetch.js +8 -1
- package/dist/workers/typeonly-log.js +2 -10
- package/dist/workers/worker-failure.d.ts +91 -0
- package/dist/workers/worker-failure.js +82 -0
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ import { beginRun, endRun, takeHeldInput } from './mid-run-input.js';
|
|
|
42
42
|
import { reportDroppedInput } from './dropped-input.js';
|
|
43
43
|
import { formatTimings } from './timings.js';
|
|
44
44
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
45
|
+
import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
|
|
45
46
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
46
47
|
let activeTask = null;
|
|
47
48
|
/** Set the module-level active task (avoids `this` aliasing in TaskRunner.run). */
|
|
@@ -792,28 +793,18 @@ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
|
|
|
792
793
|
// No sibling plan → no scope fence; no parent list → no check-off.
|
|
793
794
|
});
|
|
794
795
|
active = gate.ctx;
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
case 'failed': {
|
|
808
|
-
await markResumable(cwd, res.taskId);
|
|
809
|
-
const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
|
|
810
|
-
announce(`${tag} stopped${why} — fix and run /task-resume.`, 'error');
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
case 'done':
|
|
814
|
-
announce(`${tag} complete — verified.`, 'info');
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
796
|
+
// What each outcome means for persistence and for the user is stated once, in
|
|
797
|
+
// TERMINAL_OUTCOMES, and shared with /task-auto's loop. `failParent` is
|
|
798
|
+
// ignored here: /task runs one task and has no parent run file to fail.
|
|
799
|
+
const outcome = TERMINAL_OUTCOMES[gate.kind];
|
|
800
|
+
if (outcome.markResumable)
|
|
801
|
+
await markResumable(cwd, res.taskId);
|
|
802
|
+
announce(outcome.message({
|
|
803
|
+
tag,
|
|
804
|
+
at: formatAt(),
|
|
805
|
+
why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
|
|
806
|
+
resumeCmd: '/task-resume'
|
|
807
|
+
}), outcome.level);
|
|
817
808
|
}
|
|
818
809
|
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
819
810
|
async function handleTask(args, ctx) {
|
package/dist/task/phases.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { docsFocused } from '../workers/docs-core.js';
|
|
7
7
|
import { fetchFocused } from '../workers/fetch-core.js';
|
|
8
8
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
9
|
+
import { classifyWorkerFailure } from '../workers/worker-failure.js';
|
|
9
10
|
import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
10
11
|
import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
|
|
11
12
|
import { isIntegrationUnknown } from './unknown-routing.js';
|
|
@@ -27,14 +28,10 @@ import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './quest
|
|
|
27
28
|
import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
|
|
28
29
|
import { compressTitle } from './title-label.js';
|
|
29
30
|
import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
|
|
33
|
-
import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
|
|
34
|
-
import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
|
|
31
|
+
import { collectCritiqueDefects } from './critique-probes.js';
|
|
32
|
+
import { buildOptionCards, resolveAnswer } from './question-dialog.js';
|
|
35
33
|
import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
|
|
36
|
-
import {
|
|
37
|
-
import { existsSync } from 'node:fs';
|
|
34
|
+
import { GREP_THEATER_RETRY_HINT } from './verify-quality.js';
|
|
38
35
|
import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
|
|
39
36
|
import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
|
|
40
37
|
import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
|
|
@@ -405,23 +402,55 @@ export function scopedToolingGoal(refined) {
|
|
|
405
402
|
* Returns null when the result is trustworthy.
|
|
406
403
|
*/
|
|
407
404
|
function classifyResearchWorker(name, result) {
|
|
408
|
-
if
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
405
|
+
// What KILLED the child, if anything — classified once, in the ladder that
|
|
406
|
+
// owns the precedence (workers/worker-failure.ts), because every kill path
|
|
407
|
+
// also sets `aborted` and a non-zero exit. This switch says only what each
|
|
408
|
+
// cause means to RESEARCH; being exhaustive, a new cause is a compile error
|
|
409
|
+
// here instead of falling through to the generic "exit N".
|
|
410
|
+
const failure = classifyWorkerFailure(result);
|
|
411
|
+
if (failure) {
|
|
412
|
+
switch (failure.kind) {
|
|
413
|
+
case 'loop': {
|
|
414
|
+
const argsStr = JSON.stringify(failure.hit.call.args);
|
|
415
|
+
return {
|
|
416
|
+
kind: 'runaway',
|
|
417
|
+
reason: `stuck in a loop — called ${failure.hit.call.name}(${argsStr}) `
|
|
418
|
+
+ `×${failure.hit.count} in the last ${failure.hit.windowSize} calls `
|
|
419
|
+
+ `and still looped after restarts`
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
case 'worker-timeout':
|
|
423
|
+
return { kind: 'runaway', reason: 'timed out after restarts' };
|
|
424
|
+
case 'command-timeout':
|
|
425
|
+
return {
|
|
426
|
+
kind: 'runaway',
|
|
427
|
+
reason: `ran a \`${failure.toolName}\` command that never returned and was killed `
|
|
428
|
+
+ 'after restarts'
|
|
429
|
+
};
|
|
430
|
+
case 'stream-stall':
|
|
431
|
+
return {
|
|
432
|
+
kind: 'runaway',
|
|
433
|
+
reason: `model stream went silent for ${failure.idleMs}ms after restarts`
|
|
434
|
+
};
|
|
435
|
+
case 'stalled':
|
|
436
|
+
return {
|
|
437
|
+
kind: 'fatal',
|
|
438
|
+
error: new Error(`Research ${name} worker: model server unreachable — the child produced no `
|
|
439
|
+
+ 'output and the model endpoint did not respond')
|
|
440
|
+
};
|
|
441
|
+
case 'leaked-tool-call':
|
|
442
|
+
return {
|
|
443
|
+
kind: 'fatal',
|
|
444
|
+
error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
|
|
445
|
+
+ `(${failure.text.trim()}) — it never ran`)
|
|
446
|
+
};
|
|
447
|
+
case 'aborted':
|
|
448
|
+
case 'exit':
|
|
449
|
+
return {
|
|
450
|
+
kind: 'fatal',
|
|
451
|
+
error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
|
|
452
|
+
};
|
|
453
|
+
}
|
|
425
454
|
}
|
|
426
455
|
if (result.text.trim().length === 0) {
|
|
427
456
|
// NOTHING CAME BACK — two different events wear the same face, and the whole
|
|
@@ -452,13 +481,6 @@ function classifyResearchWorker(name, result) {
|
|
|
452
481
|
}
|
|
453
482
|
return { kind: 'empty' };
|
|
454
483
|
}
|
|
455
|
-
if (result.leakedToolCall) {
|
|
456
|
-
return {
|
|
457
|
-
kind: 'fatal',
|
|
458
|
-
error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
|
|
459
|
-
+ `(${result.leakedToolCall.trim()}) — it never ran`)
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
484
|
return null;
|
|
463
485
|
}
|
|
464
486
|
/**
|
|
@@ -1221,10 +1243,6 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
1221
1243
|
else {
|
|
1222
1244
|
const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
|
|
1223
1245
|
const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
|
|
1224
|
-
// A recommendation (or suggested+alt fork) becomes the boxed picker
|
|
1225
|
-
// locally — each answer in its own bounding box, the recommended one
|
|
1226
|
-
// tinted green; an open question shows the bare text prompt.
|
|
1227
|
-
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
1228
1246
|
// YOLO: take the recommended option and never build the prompt (which
|
|
1229
1247
|
// is also what suppresses its notification — see yolo.ts). An answer the
|
|
1230
1248
|
// anti-synthesis guard demoted, or a question with no recommendation at
|
|
@@ -1240,17 +1258,22 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
1240
1258
|
qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer} ${YOLO_STAMP}`);
|
|
1241
1259
|
continue;
|
|
1242
1260
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1261
|
+
// The picker cards and the reply mapping are shared with /task-auto's
|
|
1262
|
+
// clarify loop and the plan session (question-dialog.ts) — all three
|
|
1263
|
+
// used to write them out, and had drifted.
|
|
1264
|
+
const pending = {
|
|
1265
|
+
plain: plainQ,
|
|
1266
|
+
shown: shownQ,
|
|
1267
|
+
...(plainSuggested !== undefined && {
|
|
1268
|
+
suggested: plainSuggested,
|
|
1269
|
+
shownSuggested: renderInlineMarkdown(auto.suggested, theme)
|
|
1270
|
+
}),
|
|
1271
|
+
...(plainAlt !== undefined && {
|
|
1272
|
+
alt: plainAlt,
|
|
1273
|
+
shownAlt: renderInlineMarkdown(auto.alt, theme)
|
|
1274
|
+
})
|
|
1275
|
+
};
|
|
1276
|
+
const options = buildOptionCards(pending);
|
|
1254
1277
|
widgetState.lastLine = `awaiting Q${n + 1}`;
|
|
1255
1278
|
const a = await ui.ask({
|
|
1256
1279
|
localTitle: shownQ,
|
|
@@ -1263,27 +1286,10 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
1263
1286
|
});
|
|
1264
1287
|
if (a === undefined)
|
|
1265
1288
|
throw new Error(USER_CANCELLED);
|
|
1266
|
-
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
|
|
1270
|
-
// storing the literal letter leaves the next grill-gen call a
|
|
1271
|
-
// dangling reference it can't decode.
|
|
1272
|
-
if (typed.length === 0 && plainSuggested) {
|
|
1273
|
-
answer = plainSuggested;
|
|
1274
|
-
}
|
|
1275
|
-
else if (typed.length === 0) {
|
|
1276
|
-
answer = '(skipped)';
|
|
1277
|
-
}
|
|
1278
|
-
else if (twoOption && /^a[.)]?$/i.test(typed)) {
|
|
1279
|
-
answer = plainSuggested;
|
|
1280
|
-
}
|
|
1281
|
-
else if (twoOption && /^b[.)]?$/i.test(typed)) {
|
|
1282
|
-
answer = plainAlt;
|
|
1283
|
-
}
|
|
1284
|
-
else {
|
|
1285
|
-
answer = typed;
|
|
1286
|
-
}
|
|
1289
|
+
// No provenance stamp here, unlike clarify's transcript: this string is
|
|
1290
|
+
// fed back VERBATIM into the next grill-gen prompt, so a
|
|
1291
|
+
// "(accepted recommendation)" suffix would become model input.
|
|
1292
|
+
answer = resolveAnswer(pending, a).answer;
|
|
1287
1293
|
out.push(`A${n + 1}: ${answer}`);
|
|
1288
1294
|
}
|
|
1289
1295
|
qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer}`);
|
|
@@ -1372,90 +1378,29 @@ extraDefects) {
|
|
|
1372
1378
|
// When the draft is structurally sound and triage says CLEAN, return it as
|
|
1373
1379
|
// is. Otherwise fall through to the rewrite, feeding the triage defects in
|
|
1374
1380
|
// as a focus list. Triage failures are non-fatal — we just do the rewrite.
|
|
1375
|
-
// DETERMINISTIC
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
// critique
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1381
|
+
// The DETERMINISTIC half of critique: six scanners, each finding a defect the
|
|
1382
|
+
// model does not self-discover reliably, each forced into the rewrite and each
|
|
1383
|
+
// overriding a CLEAN triage. They live as rows in CRITIQUE_PROBES
|
|
1384
|
+
// (critique-probes.ts) so the override and the merge below are DERIVED from
|
|
1385
|
+
// the table rather than retyped — a probe used to be listed by hand in three
|
|
1386
|
+
// places, and forgetting the override term shipped the very defect it was
|
|
1387
|
+
// added to catch.
|
|
1388
|
+
//
|
|
1389
|
+
// The contract registry is read here rather than inside the table because the
|
|
1390
|
+
// critique PROMPT needs it too (run-8 F3): threading the design's pinned
|
|
1391
|
+
// interface facts into the rewrite lets it RECONCILE a synthesized wiring
|
|
1392
|
+
// specific against the facts it must reproduce — the generation-side
|
|
1393
|
+
// complement of the verify-side boundary check.
|
|
1388
1394
|
const registryRaw = await readContracts(deps.cwd).catch(() => '');
|
|
1389
1395
|
const contractsBlock = buildContractsVerifyBlock(registryRaw);
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
findSynthesizedWiring(spec, registryRaw + '\n' + readReferencedDocs(deps.cwd, refined, spec), registryRaw)
|
|
1399
|
-
: [];
|
|
1400
|
-
const wiringProbe = wiring.length > 0 ? wiringProbeText(wiring, registryRaw) : null;
|
|
1401
|
-
if (wiringProbe) {
|
|
1402
|
-
deps.logDebug?.(`synthesized wiring flagged in spec: ${wiring.map(w => w.line).join(' | ')}`);
|
|
1403
|
-
}
|
|
1404
|
-
// DETERMINISTIC plan-contradiction probe (mx5 run 11, goal D): a VERIFY line
|
|
1405
|
-
// asserting the ABSENCE of an artifact the plan pins elsewhere — a path a prior
|
|
1406
|
-
// task already shipped to disk, a sibling title's deliverable, a contract-pinned
|
|
1407
|
-
// boundary. Run 11: the scope fence leaked into TASK_0009's verify as "the admin
|
|
1408
|
-
// page must NOT exist" (TASK_0008's deliverable); the guaranteed FAIL became an
|
|
1409
|
-
// accepted debt that the final-gate autofix then "fixed" by deleting the sibling's
|
|
1410
|
-
// work. The conflict must die here, at spec time — forced into the rewrite like
|
|
1411
|
-
// the skip-escape finding; delete-tasks keep their check by declaring the delete.
|
|
1412
|
-
const absenceConflicts = findAbsenceConflicts(spec, {
|
|
1413
|
-
fileExists: p => existsSync(resolve(deps.cwd, p)),
|
|
1414
|
-
siblingTitles: siblingTitlesFromPlanContext(planContext),
|
|
1415
|
-
contracts: registryRaw
|
|
1416
|
-
});
|
|
1417
|
-
const absenceProbe = absenceConflicts.length > 0 ? absenceProbeText(absenceConflicts) : null;
|
|
1418
|
-
if (absenceProbe) {
|
|
1419
|
-
deps.logDebug?.('plan-contradiction flagged in VERIFY: '
|
|
1420
|
-
+ absenceConflicts.map(c => `${c.assertion.target} (${c.against})`).join(' | '));
|
|
1421
|
-
}
|
|
1422
|
-
// DETERMINISTIC unsatisfiable-pair probe (mx5 run 12 root cause): a blanket
|
|
1423
|
-
// frozen path ("Do NOT modify `tsconfig.json` … handled in steps 1–2") whose
|
|
1424
|
-
// registration edit the spec's OWN body — or the task's RESEARCH the spec was
|
|
1425
|
-
// composed from (live drafts sometimes drop the nuance while shipping the
|
|
1426
|
-
// freeze and the creation) — says the deliverable requires ("must also be
|
|
1427
|
-
// included …"). Shipped as-is, the created files turn the repo-wide static
|
|
1428
|
-
// check permanently red and no task is allowed to fix it — every later task
|
|
1429
|
-
// burns its AUTOFIX rounds on it. Forced into the rewrite like the other
|
|
1430
|
-
// probes: the rewrite must grant scoped ownership or drop the creation.
|
|
1431
|
-
const frozenConflicts = findFrozenPathConflicts(spec, research);
|
|
1432
|
-
const frozenProbe = frozenConflicts.length > 0 ? frozenConflictProbeText(frozenConflicts) : null;
|
|
1433
|
-
if (frozenProbe) {
|
|
1434
|
-
deps.logDebug?.('unsatisfiable freeze/requires-edit pair flagged in spec: '
|
|
1435
|
-
+ frozenConflicts.map(c => c.path).join(' | '));
|
|
1436
|
-
}
|
|
1437
|
-
// DETERMINISTIC grep-theater probe (mx5 run 13, Bug B): a VERIFY block that
|
|
1438
|
-
// grep-asserts the SOURCE of a runnable deliverable while every command in
|
|
1439
|
-
// the block is static inspection — the build script "verified" by three
|
|
1440
|
-
// greps that was never run, shipping broken for 14 tasks. Forced into the
|
|
1441
|
-
// rewrite like the skip-escape finding: VERIFY must EXECUTE the artifact
|
|
1442
|
-
// and assert an observable outcome of that run.
|
|
1443
|
-
const grepOnly = findGrepOnlyVerify(spec);
|
|
1444
|
-
const grepOnlyProbe = grepOnly.length > 0 ? grepOnlyVerifyDefectText(grepOnly) : null;
|
|
1445
|
-
if (grepOnlyProbe) {
|
|
1446
|
-
deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
|
|
1447
|
-
}
|
|
1448
|
-
// DETERMINISTIC neutered-check-script probe (mx5 run 13, PROMPT 4 item 4): a
|
|
1449
|
-
// spec that DICTATES a check script which cannot fail — `"lint": "… || true"`,
|
|
1450
|
-
// or a checker laundered through an inverted grep. Whatever task implements
|
|
1451
|
-
// that spec writes the disarmed script into package.json, and from then on
|
|
1452
|
-
// every gate that runs it (repo-health verify, the final integration gate)
|
|
1453
|
-
// reads a constant. Cheapest to kill here, in the spec, before it is authored.
|
|
1454
|
-
const scriptEscapes = findScriptEscapesInText(spec);
|
|
1455
|
-
const scriptProbe = scriptEscapes.length > 0 ? scriptEscapeDefectText(scriptEscapes) : null;
|
|
1456
|
-
if (scriptProbe) {
|
|
1457
|
-
deps.logDebug?.('neutered check script dictated by spec: ' + scriptEscapes.map(f => f.name).join(' | '));
|
|
1458
|
-
}
|
|
1396
|
+
const probes = collectCritiqueDefects({
|
|
1397
|
+
spec,
|
|
1398
|
+
refined,
|
|
1399
|
+
...(research === undefined ? {} : { research }),
|
|
1400
|
+
cwd: deps.cwd,
|
|
1401
|
+
registryRaw,
|
|
1402
|
+
...(planContext === undefined ? {} : { planContext })
|
|
1403
|
+
}, deps.logDebug);
|
|
1459
1404
|
let triageDefects = null;
|
|
1460
1405
|
if (parseVerifyBlock(spec) !== null) {
|
|
1461
1406
|
const tTriage = Date.now();
|
|
@@ -1472,19 +1417,12 @@ extraDefects) {
|
|
|
1472
1417
|
}
|
|
1473
1418
|
deps.recordSubStep?.('triage', Date.now() - tTriage);
|
|
1474
1419
|
if (verdict !== null) {
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
// self-discover any of them reliably).
|
|
1420
|
+
// ANY deterministic finding overrides a CLEAN triage: the draft must be
|
|
1421
|
+
// rewritten to resolve it even if the model judged the rest clean (the
|
|
1422
|
+
// model does not self-discover any of them reliably). Derived from the
|
|
1423
|
+
// table, so a new probe joins this rule by existing.
|
|
1480
1424
|
if (isCritiqueClean(verdict)) {
|
|
1481
|
-
if (
|
|
1482
|
-
&& wiringProbe === null
|
|
1483
|
-
&& absenceProbe === null
|
|
1484
|
-
&& frozenProbe === null
|
|
1485
|
-
&& grepOnlyProbe === null
|
|
1486
|
-
&& scriptProbe === null
|
|
1487
|
-
&& (extraDefects ?? null) === null) {
|
|
1425
|
+
if (!probes.forced && (extraDefects ?? null) === null) {
|
|
1488
1426
|
return spec;
|
|
1489
1427
|
}
|
|
1490
1428
|
}
|
|
@@ -1493,21 +1431,9 @@ extraDefects) {
|
|
|
1493
1431
|
}
|
|
1494
1432
|
}
|
|
1495
1433
|
}
|
|
1496
|
-
// Merge
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
const rewriteDefects = [
|
|
1500
|
-
skipDefects,
|
|
1501
|
-
wiringProbe,
|
|
1502
|
-
absenceProbe,
|
|
1503
|
-
frozenProbe,
|
|
1504
|
-
grepOnlyProbe,
|
|
1505
|
-
scriptProbe,
|
|
1506
|
-
extraDefects ?? null,
|
|
1507
|
-
triageDefects
|
|
1508
|
-
]
|
|
1509
|
-
.filter(Boolean)
|
|
1510
|
-
.join('\n\n') || null;
|
|
1434
|
+
// Merge every deterministic defect with any triage defects for the rewrite
|
|
1435
|
+
// (all are forced FOCUS items).
|
|
1436
|
+
const rewriteDefects = [...probes.blocks, extraDefects ?? null, triageDefects].filter(Boolean).join('\n\n') || null;
|
|
1511
1437
|
const tRewrite = Date.now();
|
|
1512
1438
|
try {
|
|
1513
1439
|
return await runWithEmphasisRetry(deps, 'critique', 'read', problem => {
|
|
@@ -1526,13 +1452,14 @@ extraDefects) {
|
|
|
1526
1452
|
if (parseVerifyBlock(stripped) === null) {
|
|
1527
1453
|
return { ok: false, problem: 'no_verify_block' };
|
|
1528
1454
|
}
|
|
1529
|
-
// Detector-backed closure
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1455
|
+
// Detector-backed closure: a rewrite HANDED a defect and shipping
|
|
1456
|
+
// it anyway is a failed rewrite. Only probes that actually fired
|
|
1457
|
+
// are re-checked — a defect the draft never had is not the
|
|
1458
|
+
// rewrite's to resolve. One emphasis retry with a targeted hint;
|
|
1533
1459
|
// a second miss falls back to the draft in critiqueWithFallback.
|
|
1534
|
-
|
|
1535
|
-
|
|
1460
|
+
const unresolved = probes.unresolvedIn(stripped);
|
|
1461
|
+
if (unresolved !== null) {
|
|
1462
|
+
return { ok: false, problem: unresolved };
|
|
1536
1463
|
}
|
|
1537
1464
|
return { ok: true, value: stripped };
|
|
1538
1465
|
}, problem => new Error(problem));
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
* is unit-testable without a TUI or a model.
|
|
31
31
|
*/
|
|
32
32
|
import type { AskSpec } from '../remote/bridge.js';
|
|
33
|
-
import { type PlanEntry
|
|
33
|
+
import { type PlanEntry } from './plan-io.js';
|
|
34
|
+
import { type PendingQuestion } from './question-dialog.js';
|
|
35
|
+
export { resolveAnswer } from './question-dialog.js';
|
|
36
|
+
export type { PendingQuestion } from './question-dialog.js';
|
|
34
37
|
/**
|
|
35
38
|
* Sentinel values the picker resolves to when the user takes a control action
|
|
36
39
|
* instead of answering. Deliberately shaped like the existing `USER_CANCELLED`
|
|
@@ -172,16 +175,6 @@ export type PlanOutcome = {
|
|
|
172
175
|
kind: 'cancelled';
|
|
173
176
|
entries: PlanEntry[];
|
|
174
177
|
};
|
|
175
|
-
interface PendingQuestion {
|
|
176
|
-
/** Plain text — persisted, and fed back to the model. */
|
|
177
|
-
plain: string;
|
|
178
|
-
/** Markdown-rendered — displayed. */
|
|
179
|
-
shown: string;
|
|
180
|
-
suggested?: string;
|
|
181
|
-
shownSuggested?: string;
|
|
182
|
-
alt?: string;
|
|
183
|
-
shownAlt?: string;
|
|
184
|
-
}
|
|
185
178
|
/**
|
|
186
179
|
* Build the picker for a pending model question: the recommendation first (index
|
|
187
180
|
* 0 is the green RECOMMENDED card), the alternative second when the question is a
|
|
@@ -200,18 +193,7 @@ export declare function buildQuestionSpec(p: PendingQuestion): PlanAskSpec;
|
|
|
200
193
|
* planning, so it never sits where a reflexive first-item press can hit it.
|
|
201
194
|
*/
|
|
202
195
|
export declare function buildIdleSpec(): PlanAskSpec;
|
|
203
|
-
/**
|
|
204
|
-
* Map what the picker returned onto the answer that gets recorded. Mirrors the
|
|
205
|
-
* identical mapping in phaseGrill and planAuto: an empty submit accepts the
|
|
206
|
-
* recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
|
|
207
|
-
* back to the option's full text, and anything else is taken verbatim.
|
|
208
|
-
*/
|
|
209
|
-
export declare function resolveAnswer(p: PendingQuestion, raw: string): {
|
|
210
|
-
answer: string;
|
|
211
|
-
source: AnswerSource;
|
|
212
|
-
};
|
|
213
196
|
/** Copy for the dialog that collects the user's own question. */
|
|
214
197
|
export declare const ASK_TITLE = "Ask the model";
|
|
215
198
|
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.";
|
|
216
199
|
export declare function runPlanSession(deps: PlanSessionDeps): Promise<PlanOutcome>;
|
|
217
|
-
export {};
|
|
@@ -34,6 +34,8 @@ import { stripInlineMarkdown } from './inline-markdown.js';
|
|
|
34
34
|
import { isDuplicateQuestion, DUP_REPROMPT_HINT, MAX_DUP_STRIKES } from './question-dedup.js';
|
|
35
35
|
import { yoloPickAnswer } from './yolo.js';
|
|
36
36
|
import { formatPlanTranscript } from './plan-io.js';
|
|
37
|
+
import { buildOptionCards, resolveAnswer } from './question-dialog.js';
|
|
38
|
+
export { resolveAnswer } from './question-dialog.js';
|
|
37
39
|
// ─── Control actions ─────────────────────────────────────────────────────────
|
|
38
40
|
/**
|
|
39
41
|
* Sentinel values the picker resolves to when the user takes a control action
|
|
@@ -169,6 +171,7 @@ export function planForkHint(question) {
|
|
|
169
171
|
+ 'time emit BOTH lines:\nSUGGESTED: <the option you recommend>\nALT: <the other option>\n'
|
|
170
172
|
+ 'Nothing else.]');
|
|
171
173
|
}
|
|
174
|
+
// ─── Pending question ────────────────────────────────────────────────────────
|
|
172
175
|
/**
|
|
173
176
|
* Build the picker for a pending model question: the recommendation first (index
|
|
174
177
|
* 0 is the green RECOMMENDED card), the alternative second when the question is a
|
|
@@ -178,14 +181,7 @@ export function planForkHint(question) {
|
|
|
178
181
|
* trailing "proceed to execution" card so proceed stays last.
|
|
179
182
|
*/
|
|
180
183
|
export function buildQuestionSpec(p) {
|
|
181
|
-
const options = [];
|
|
182
|
-
if (p.suggested !== undefined && p.alt !== undefined) {
|
|
183
|
-
options.push({ label: `A: ${p.shownSuggested ?? p.suggested}`, value: p.suggested });
|
|
184
|
-
options.push({ label: `B: ${p.shownAlt ?? p.alt}`, value: p.alt });
|
|
185
|
-
}
|
|
186
|
-
else if (p.suggested !== undefined) {
|
|
187
|
-
options.push({ label: p.shownSuggested ?? p.suggested, value: p.suggested });
|
|
188
|
-
}
|
|
184
|
+
const options = buildOptionCards(p) ?? [];
|
|
189
185
|
const actions = [
|
|
190
186
|
{ label: PLAN_ASK_LABEL, value: PLAN_ASK },
|
|
191
187
|
{ label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED }
|
|
@@ -235,31 +231,6 @@ export function buildIdleSpec() {
|
|
|
235
231
|
manualPosition: actions.length - 1
|
|
236
232
|
};
|
|
237
233
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Map what the picker returned onto the answer that gets recorded. Mirrors the
|
|
240
|
-
* identical mapping in phaseGrill and planAuto: an empty submit accepts the
|
|
241
|
-
* recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
|
|
242
|
-
* back to the option's full text, and anything else is taken verbatim.
|
|
243
|
-
*/
|
|
244
|
-
export function resolveAnswer(p, raw) {
|
|
245
|
-
const typed = raw.trim();
|
|
246
|
-
const twoOption = p.suggested !== undefined && p.alt !== undefined;
|
|
247
|
-
if (typed.length === 0 && p.suggested !== undefined) {
|
|
248
|
-
return { answer: p.suggested, source: 'accepted' };
|
|
249
|
-
}
|
|
250
|
-
if (typed.length === 0)
|
|
251
|
-
return { answer: '(skipped)', source: 'skipped' };
|
|
252
|
-
if (twoOption && /^a[.)]?$/i.test(typed))
|
|
253
|
-
return { answer: p.suggested, source: 'chosen' };
|
|
254
|
-
if (twoOption && /^b[.)]?$/i.test(typed))
|
|
255
|
-
return { answer: p.alt, source: 'chosen' };
|
|
256
|
-
if (p.suggested !== undefined && typed === p.suggested) {
|
|
257
|
-
return { answer: p.suggested, source: twoOption ? 'chosen' : 'accepted' };
|
|
258
|
-
}
|
|
259
|
-
if (p.alt !== undefined && typed === p.alt)
|
|
260
|
-
return { answer: p.alt, source: 'chosen' };
|
|
261
|
-
return { answer: typed, source: 'typed' };
|
|
262
|
-
}
|
|
263
234
|
// ─── The loop ────────────────────────────────────────────────────────────────
|
|
264
235
|
/** Copy for the dialog that collects the user's own question. */
|
|
265
236
|
export const ASK_TITLE = 'Ask the model';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* question-dialog — the one-question-at-a-time picker shared by the three places
|
|
3
|
+
* that ask the user to settle a fork: `/task`'s grill phase, `/task-auto`'s
|
|
4
|
+
* clarify loop, and the Plan session.
|
|
5
|
+
*
|
|
6
|
+
* All three do the same thing. Strip markdown for storage and render it for
|
|
7
|
+
* display; decide whether the question is a binary fork; short-circuit under
|
|
8
|
+
* YOLO; build `A: …` / `B: …` cards; call `ui.ask`; treat `undefined` as a
|
|
9
|
+
* cancel; and map the reply back onto an answer — where an empty submit accepts
|
|
10
|
+
* the recommendation, a bare "A"/"B" from a remote user (or the picker's
|
|
11
|
+
* free-text fallback) maps back to the option's full text, and anything else is
|
|
12
|
+
* taken verbatim.
|
|
13
|
+
*
|
|
14
|
+
* The mapping is the load-bearing part. Storing the literal letter "A" leaves the
|
|
15
|
+
* next generation call a dangling reference it cannot decode, so getting it wrong
|
|
16
|
+
* is not cosmetic.
|
|
17
|
+
*
|
|
18
|
+
* It was written three times. The Plan session factored its copy into a pure
|
|
19
|
+
* `resolveAnswer` returning a typed `AnswerSource`, and its own docstring said so
|
|
20
|
+
* out loud — "Mirrors the identical mapping in phaseGrill and planAuto" — but the
|
|
21
|
+
* two mirrors were never converted, and they had already drifted apart in three
|
|
22
|
+
* ways (which of them stamps an accepted recommendation, which has a
|
|
23
|
+
* single-option card branch, which handles a typed reply that equals an option).
|
|
24
|
+
* None was a crash. The next edit to any of them is where the bug lands, which is
|
|
25
|
+
* why they now share this.
|
|
26
|
+
*
|
|
27
|
+
* What stays at the call sites is POLICY, not mechanics: grill's auto-answer and
|
|
28
|
+
* widget line, clarify's plan-shape and triage pre-emption, plan's control
|
|
29
|
+
* actions. Those genuinely differ.
|
|
30
|
+
*/
|
|
31
|
+
import type { AnswerSource } from './plan-io.js';
|
|
32
|
+
/** One question awaiting an answer, in both the stored and the displayed form. */
|
|
33
|
+
export interface PendingQuestion {
|
|
34
|
+
/** Plain text — persisted, and fed back to the model. */
|
|
35
|
+
plain: string;
|
|
36
|
+
/** Markdown-rendered — displayed. */
|
|
37
|
+
shown: string;
|
|
38
|
+
suggested?: string;
|
|
39
|
+
shownSuggested?: string;
|
|
40
|
+
alt?: string;
|
|
41
|
+
shownAlt?: string;
|
|
42
|
+
}
|
|
43
|
+
/** True when the question is a binary fork rather than a single recommendation. */
|
|
44
|
+
export declare function isTwoOption(p: PendingQuestion): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The answer cards: the recommendation first (index 0 is the green RECOMMENDED
|
|
47
|
+
* card), the alternative second when the question is a fork. `undefined` — not an
|
|
48
|
+
* empty array — when there is nothing to recommend, because that is what makes
|
|
49
|
+
* `ui.ask` fall back to a bare text prompt instead of an empty picker.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildOptionCards(p: PendingQuestion): Array<{
|
|
52
|
+
label: string;
|
|
53
|
+
value: string;
|
|
54
|
+
}> | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Map what the picker returned onto the answer that gets recorded, and say WHERE
|
|
57
|
+
* the answer came from.
|
|
58
|
+
*
|
|
59
|
+
* The `source` is returned rather than baked into the string because the three
|
|
60
|
+
* call sites disagree about provenance stamping, and that disagreement is real:
|
|
61
|
+
* `/task-auto`'s clarify transcript marks an accepted recommendation
|
|
62
|
+
* ("… (accepted recommendation)") while grill's does not, because grill's
|
|
63
|
+
* transcript is fed back verbatim into the next grill-gen prompt and the stamp
|
|
64
|
+
* would become model input. Keeping the stamp at the call site makes that a
|
|
65
|
+
* one-line difference you can see instead of a divergence hidden inside two
|
|
66
|
+
* six-branch ladders.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveAnswer(p: PendingQuestion, raw: string): {
|
|
69
|
+
answer: string;
|
|
70
|
+
source: AnswerSource;
|
|
71
|
+
};
|