@mjasnikovs/pi-task 0.40.50 → 0.42.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.
Files changed (125) hide show
  1. package/README.md +7 -4
  2. package/dist/config/config.d.ts +61 -18
  3. package/dist/config/config.js +50 -6
  4. package/dist/config/register.js +18 -5
  5. package/dist/shared/content-tokens.d.ts +16 -0
  6. package/dist/shared/content-tokens.js +137 -0
  7. package/dist/task/accept-debt.d.ts +31 -1
  8. package/dist/task/accept-debt.js +49 -3
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +66 -7
  11. package/dist/task/auto-io.js +152 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +184 -112
  14. package/dist/task/child-runner.d.ts +18 -0
  15. package/dist/task/child-runner.js +7 -8
  16. package/dist/task/command-run.d.ts +4 -0
  17. package/dist/task/command-run.js +4 -2
  18. package/dist/task/constraint-policy.d.ts +52 -0
  19. package/dist/task/constraint-policy.js +66 -0
  20. package/dist/task/context-silence.d.ts +5 -4
  21. package/dist/task/context-silence.js +13 -5
  22. package/dist/task/coverage-loop.d.ts +7 -4
  23. package/dist/task/coverage-loop.js +16 -138
  24. package/dist/task/debug-log.d.ts +25 -2
  25. package/dist/task/debug-log.js +64 -10
  26. package/dist/task/decompose-fidelity.d.ts +17 -4
  27. package/dist/task/decompose-fidelity.js +95 -55
  28. package/dist/task/decompose-granularity.d.ts +7 -7
  29. package/dist/task/decompose-granularity.js +8 -8
  30. package/dist/task/env-notes.d.ts +65 -19
  31. package/dist/task/env-notes.js +152 -50
  32. package/dist/task/external-context.js +25 -5
  33. package/dist/task/fix-context.d.ts +39 -0
  34. package/dist/task/fix-context.js +58 -0
  35. package/dist/task/gate-deps.d.ts +49 -0
  36. package/dist/task/gate-deps.js +201 -39
  37. package/dist/task/gate-evidence.d.ts +51 -0
  38. package/dist/task/gate-evidence.js +90 -0
  39. package/dist/task/gate-resolution.d.ts +119 -0
  40. package/dist/task/gate-resolution.js +120 -0
  41. package/dist/task/git-state-guard.js +3 -26
  42. package/dist/task/handoff.d.ts +35 -0
  43. package/dist/task/handoff.js +100 -0
  44. package/dist/task/health-baseline.d.ts +87 -0
  45. package/dist/task/health-baseline.js +158 -0
  46. package/dist/task/health-repair.d.ts +68 -0
  47. package/dist/task/health-repair.js +124 -0
  48. package/dist/task/ledger.d.ts +10 -0
  49. package/dist/task/ledger.js +22 -11
  50. package/dist/task/lint-fix.d.ts +11 -0
  51. package/dist/task/lint-fix.js +14 -8
  52. package/dist/task/loop-detector.d.ts +52 -3
  53. package/dist/task/loop-detector.js +81 -5
  54. package/dist/task/mentions.d.ts +17 -0
  55. package/dist/task/mentions.js +86 -0
  56. package/dist/task/orchestrator.d.ts +53 -9
  57. package/dist/task/orchestrator.js +104 -28
  58. package/dist/task/orientation.d.ts +135 -29
  59. package/dist/task/orientation.js +259 -67
  60. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  61. package/dist/task/owned-freeze-reassign.js +13 -5
  62. package/dist/task/parsers.d.ts +6 -1
  63. package/dist/task/parsers.js +21 -4
  64. package/dist/task/phases.d.ts +1 -1
  65. package/dist/task/phases.js +125 -153
  66. package/dist/task/plan-orchestrator.js +5 -6
  67. package/dist/task/plan-rounds.d.ts +1 -0
  68. package/dist/task/plan-rounds.js +8 -1
  69. package/dist/task/prohibition-probe.d.ts +6 -1
  70. package/dist/task/prohibition-probe.js +26 -6
  71. package/dist/task/prompts.js +13 -2
  72. package/dist/task/qa-transcript.d.ts +17 -0
  73. package/dist/task/qa-transcript.js +28 -0
  74. package/dist/task/question-source.d.ts +16 -2
  75. package/dist/task/question-source.js +21 -8
  76. package/dist/task/repo-health-check.d.ts +27 -0
  77. package/dist/task/repo-health-check.js +39 -5
  78. package/dist/task/requirements.d.ts +67 -13
  79. package/dist/task/requirements.js +129 -67
  80. package/dist/task/research-worker.d.ts +43 -2
  81. package/dist/task/research-worker.js +103 -8
  82. package/dist/task/run-bracket.d.ts +9 -4
  83. package/dist/task/run-bracket.js +9 -0
  84. package/dist/task/run-context.d.ts +152 -0
  85. package/dist/task/run-context.js +277 -0
  86. package/dist/task/spec-doc.d.ts +78 -0
  87. package/dist/task/spec-doc.js +205 -0
  88. package/dist/task/spec-model.d.ts +70 -0
  89. package/dist/task/spec-model.js +139 -0
  90. package/dist/task/stall-detector.d.ts +1 -1
  91. package/dist/task/stall-detector.js +9 -5
  92. package/dist/task/state-dir.d.ts +29 -0
  93. package/dist/task/state-dir.js +80 -0
  94. package/dist/task/suppression-probe.d.ts +67 -0
  95. package/dist/task/suppression-probe.js +78 -0
  96. package/dist/task/task-gates.d.ts +52 -58
  97. package/dist/task/task-gates.js +161 -199
  98. package/dist/task/task-io.d.ts +10 -0
  99. package/dist/task/task-io.js +69 -18
  100. package/dist/task/task-parsers.js +6 -6
  101. package/dist/task/task-types.d.ts +7 -0
  102. package/dist/task/timings.d.ts +11 -0
  103. package/dist/task/timings.js +21 -0
  104. package/dist/task/tree-hash.d.ts +8 -0
  105. package/dist/task/tree-hash.js +55 -0
  106. package/dist/task/verify-reconcile.d.ts +4 -3
  107. package/dist/task/verify-reconcile.js +7 -8
  108. package/dist/task/verify-resolution.d.ts +32 -6
  109. package/dist/task/verify-resolution.js +78 -15
  110. package/dist/task/verify-work.d.ts +105 -35
  111. package/dist/task/verify-work.js +232 -60
  112. package/dist/task/yolo.d.ts +0 -13
  113. package/dist/task/yolo.js +9 -21
  114. package/dist/workers/pi-worker-core.d.ts +20 -0
  115. package/dist/workers/pi-worker-core.js +16 -11
  116. package/dist/workers/pi-worker-docs.d.ts +6 -5
  117. package/dist/workers/pi-worker-docs.js +8 -7
  118. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  119. package/dist/workers/pi-worker-fetch.js +5 -4
  120. package/dist/workers/pi-worker-search.js +5 -4
  121. package/dist/workers/research-cache.d.ts +23 -1
  122. package/dist/workers/research-cache.js +63 -3
  123. package/dist/workers/shared.d.ts +18 -0
  124. package/dist/workers/shared.js +38 -22
  125. package/package.json +1 -1
@@ -13,19 +13,18 @@ const FRONT_MATTER_KEYS = [
13
13
  'updated_at',
14
14
  'title',
15
15
  'label',
16
+ 'plan_key',
16
17
  'reason'
17
18
  ];
19
+ /** Front-matter keys omitted entirely when empty rather than emitted blank. */
20
+ const OPTIONAL_KEYS = new Set(['label', 'plan_key', 'reason']);
18
21
  // ─── Front matter ────────────────────────────────────────────────────────────
19
22
  export function emitFrontMatter(fm) {
20
23
  const lines = ['---'];
21
24
  for (const k of FRONT_MATTER_KEYS) {
22
25
  const v = fm[k];
23
- if (v === undefined || v === '') {
24
- // Optional fields are omitted entirely when empty rather than emitted
25
- // as a blank `key:` line.
26
- if (k === 'reason' || k === 'label')
27
- continue;
28
- }
26
+ if ((v === undefined || v === '') && OPTIONAL_KEYS.has(k))
27
+ continue;
29
28
  lines.push(`${k}: ${typeof v === 'string' ? v : String(v)}`);
30
29
  }
31
30
  lines.push('---');
@@ -59,6 +58,7 @@ export function parseFrontMatter(content) {
59
58
  updated_at: obj.updated_at ?? obj.created_at,
60
59
  title: obj.title ?? '',
61
60
  label: obj.label || undefined,
61
+ plan_key: obj.plan_key || undefined,
62
62
  reason: obj.reason || undefined
63
63
  };
64
64
  }
@@ -21,6 +21,13 @@ export interface TaskFrontMatter {
21
21
  * of `title` (`titleForDisplay`, parsers.ts). See title-label.ts.
22
22
  */
23
23
  label?: string;
24
+ /**
25
+ * The /task-auto plan entry this task implements (`TaskEntry.key`). It is
26
+ * what the owned-requirements ledger joins on, and it lives here rather than
27
+ * in memory because a resumed run reconstructs the task from this file alone.
28
+ * Absent on a bare /task and on any task planned before keys existed.
29
+ */
30
+ plan_key?: string;
24
31
  reason?: string;
25
32
  }
26
33
  export declare const PHASE_ORDER: PhaseName[];
@@ -18,3 +18,14 @@ export interface TimingEntry {
18
18
  }
19
19
  export declare function formatMs(ms: number): string;
20
20
  export declare function formatTimings(entries: ReadonlyArray<TimingEntry>): string;
21
+ /**
22
+ * The `## phase timings` body once this attempt's phases are added to it.
23
+ *
24
+ * A run re-entered for an autofix resumes at `phase: done` and therefore runs no
25
+ * phases at all, so rendering only what IT timed wrote an empty block over the
26
+ * five phases the first attempt measured — the timings vanished for exactly the
27
+ * tasks that took longest. Each attempt gets its own labelled block instead, and
28
+ * an unlabelled body written before this grammar is adopted as attempt 1 rather
29
+ * than discarded.
30
+ */
31
+ export declare function mergeTimings(prev: string | null, next: ReadonlyArray<TimingEntry>): string;
@@ -36,3 +36,24 @@ export function formatTimings(entries) {
36
36
  lines.push(`${'total'.padEnd(TOP_LABEL_WIDTH)}${formatMs(total).padStart(MS_COLUMN_WIDTH)}`);
37
37
  return lines.join('\n');
38
38
  }
39
+ /** Heading of one attempt's block inside the `## phase timings` section. */
40
+ const ATTEMPT_HEADING_RE = /^attempt (\d+)$/gm;
41
+ /**
42
+ * The `## phase timings` body once this attempt's phases are added to it.
43
+ *
44
+ * A run re-entered for an autofix resumes at `phase: done` and therefore runs no
45
+ * phases at all, so rendering only what IT timed wrote an empty block over the
46
+ * five phases the first attempt measured — the timings vanished for exactly the
47
+ * tasks that took longest. Each attempt gets its own labelled block instead, and
48
+ * an unlabelled body written before this grammar is adopted as attempt 1 rather
49
+ * than discarded.
50
+ */
51
+ export function mergeTimings(prev, next) {
52
+ const before = (prev ?? '').trim();
53
+ if (before.length === 0)
54
+ return `attempt 1\n\n${formatTimings(next)}`;
55
+ const seen = [...before.matchAll(ATTEMPT_HEADING_RE)];
56
+ const body = seen.length === 0 ? `attempt 1\n\n${before}` : before;
57
+ const last = seen.length === 0 ? 1 : parseInt(seen[seen.length - 1][1], 10);
58
+ return `${body}\n\nattempt ${last + 1}\n\n${formatTimings(next)}`;
59
+ }
@@ -0,0 +1,8 @@
1
+ import type { GitRunner } from '../shared/git-runner.js';
2
+ /**
3
+ * Snapshot the worktree content into a tree object. Returns null when git cannot
4
+ * build the tree (an unborn HEAD, a non-repo cwd, a missing binary).
5
+ */
6
+ export declare function worktreeTreeHash(git: GitRunner): Promise<string | null>;
7
+ /** The tree object a commit-ish points at — the committed twin of the above. */
8
+ export declare function commitTreeHash(git: GitRunner, revision: string): Promise<string | null>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * tree-hash — the content identity of the working tree, computed without touching
3
+ * the real index or the working tree.
4
+ *
5
+ * `git add -A` against a THROWAWAY `GIT_INDEX_FILE` is the whole technique: the
6
+ * staged state a user (or a half-finished gate) left in the real index survives,
7
+ * nothing is stashed, and no file moves. Every consumer that asks "is this the
8
+ * same tree as before?" — the git-state guard's reconcile, the health baseline,
9
+ * and the evidence cache when it lands — must ask it the same way, or two answers
10
+ * disagree about the same tree.
11
+ *
12
+ * `.pi-tasks` is excluded because the gate machinery writes its own trail there
13
+ * DURING the work being hashed; including it would make every hash differ from
14
+ * every other one for reasons that have nothing to do with the code.
15
+ *
16
+ * Null means "git could not tell us", never "empty tree": a caller that treats an
17
+ * unreadable tree as a match would call two different trees identical.
18
+ */
19
+ import * as fsp from 'node:fs/promises';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
22
+ /** Keep the gate machinery's own artifacts out of the hash. */
23
+ const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
24
+ function throwawayIndexPath() {
25
+ return path.join(os.tmpdir(), `pi-task-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
26
+ }
27
+ /**
28
+ * Snapshot the worktree content into a tree object. Returns null when git cannot
29
+ * build the tree (an unborn HEAD, a non-repo cwd, a missing binary).
30
+ */
31
+ export async function worktreeTreeHash(git) {
32
+ const tmpIndex = throwawayIndexPath();
33
+ const env = { GIT_INDEX_FILE: tmpIndex };
34
+ try {
35
+ const empty = await git(['read-tree', '--empty'], env);
36
+ if (empty.exitCode !== 0)
37
+ return null;
38
+ const add = await git(['add', '-A', '--', '.', EXCLUDE_TASKS_DIR], env);
39
+ if (add.exitCode !== 0)
40
+ return null;
41
+ const tree = await git(['write-tree'], env);
42
+ return tree.exitCode === 0 ? tree.stdout.trim() : null;
43
+ }
44
+ finally {
45
+ await fsp.rm(tmpIndex, { force: true }).catch(() => { });
46
+ }
47
+ }
48
+ /** The tree object a commit-ish points at — the committed twin of the above. */
49
+ export async function commitTreeHash(git, revision) {
50
+ const r = await git(['rev-parse', `${revision}^{tree}`]);
51
+ if (r.exitCode !== 0)
52
+ return null;
53
+ const sha = r.stdout.trim();
54
+ return sha.length > 0 ? sha : null;
55
+ }
@@ -35,7 +35,8 @@ export declare function findAbsenceConflicts(spec: string, ctx: AbsenceConflictC
35
35
  * each conflict, why it is one, and the reconciliation to apply. */
36
36
  export declare function absenceProbeText(conflicts: AbsenceConflict[]): string;
37
37
  /** Parse SIBLING titles out of `buildScopeFence`'s listing, whose rows read
38
- * `[N] head` and `[N] (THIS STEP) head`. The "(THIS STEP)" row is dropped — a
39
- * task may legitimately assert about its own deliverables. '' and undefined
40
- * (a bare /task with no plan) yield none. */
38
+ * `[N] [done|this|later] head`. The `[this]` row is dropped — a task may
39
+ * legitimately assert about its own deliverables; a `[done]` sibling is kept,
40
+ * since its deliverable is in the tree and asserting its ABSENCE is the very
41
+ * defect this feeds. '' and undefined (a bare /task with no plan) yield none. */
41
42
  export declare function siblingTitlesFromPlanContext(planContext: string | undefined): string[];
@@ -184,20 +184,19 @@ export function absenceProbeText(conflicts) {
184
184
  ].join('\n');
185
185
  }
186
186
  /** Parse SIBLING titles out of `buildScopeFence`'s listing, whose rows read
187
- * `[N] head` and `[N] (THIS STEP) head`. The "(THIS STEP)" row is dropped — a
188
- * task may legitimately assert about its own deliverables. '' and undefined
189
- * (a bare /task with no plan) yield none. */
187
+ * `[N] [done|this|later] head`. The `[this]` row is dropped — a task may
188
+ * legitimately assert about its own deliverables; a `[done]` sibling is kept,
189
+ * since its deliverable is in the tree and asserting its ABSENCE is the very
190
+ * defect this feeds. '' and undefined (a bare /task with no plan) yield none. */
190
191
  export function siblingTitlesFromPlanContext(planContext) {
191
192
  if (!planContext)
192
193
  return [];
193
194
  const out = [];
194
195
  for (const line of planContext.split('\n')) {
195
- const m = /^\[\d+\](.*)$/.exec(line.trim());
196
- if (!m)
196
+ const m = /^\[\d+\] \[(done|this|later)\] (.*)$/.exec(line.trim());
197
+ if (!m || m[1] === 'this')
197
198
  continue;
198
- if (m[1].startsWith(' (THIS STEP)'))
199
- continue;
200
- const title = m[1].trim();
199
+ const title = m[2].trim();
201
200
  if (title.length > 0)
202
201
  out.push(title);
203
202
  }
@@ -1,30 +1,50 @@
1
+ import type { SpecContradiction } from './gate-resolution.js';
1
2
  /** The observe-only contract handed to the child. The same string as
2
3
  * verify-work's VERIFY_TOOLS, so both passes are told the same thing. */
3
4
  declare const RESOLUTION_TOOLS = "read,bash";
4
5
  /** Which card the picker tints RECOMMENDED. */
5
6
  export type ResolutionRecommendation = 'autofix' | 'accept';
7
+ /**
8
+ * The marker a judge emits INSTEAD of a recommendation when it finds the two
9
+ * gates contradict each other: the acceptance criterion can only be met by
10
+ * editing a path the same spec freezes.
11
+ *
12
+ * It exists because the judge was previously offered AUTOFIX or ACCEPT and
13
+ * nothing else, so a contradiction it had already diagnosed came back as
14
+ * "AUTOFIX" and the loop spent its whole budget re-deriving it (0053).
15
+ */
16
+ export declare const BLOCKED_BY_FROZEN = "BLOCKED-BY-FROZEN";
6
17
  export interface ResolutionOutcome {
7
18
  /** The card to recommend. Defaults to 'autofix' (conservative: re-do, don't
8
19
  * bless) when the research could not run or emitted no verdict. */
9
20
  recommend: ResolutionRecommendation;
10
21
  /** Short, human-readable rationale shown under the picker. Always set. */
11
22
  rationale: string;
23
+ /** Set only by the BLOCKED-BY-FROZEN marker: the criterion the spec's own
24
+ * freeze makes unreachable. Outranks `recommend` in the gate's decision
25
+ * table — no re-run and no picker answer can resolve a contradiction. */
26
+ contradiction?: SpecContradiction;
12
27
  }
13
28
  /**
14
29
  * Build the resolution research child's prompt. Pure, so the wording is
15
30
  * unit-tested without spawning pi. The contract it states: investigate the real
16
31
  * workspace and end on exactly one `VERIFY-RESOLUTION:` line.
17
32
  */
18
- export declare function buildResolutionPrompt(spec: string, failReason: string): string;
33
+ export declare function buildResolutionPrompt(spec: string, failReason: string, qaRecord?: string): string;
19
34
  /**
20
- * Parse the child's recommendation out of the LAST
21
- * `VERIFY-RESOLUTION: AUTOFIX|ACCEPT` marker, case-insensitively. Last match
22
- * wins: the model reasons before concluding, and a bash command it runs can
23
- * print the token back, so an earlier occurrence is not the verdict.
35
+ * Parse the child's verdict out of the LAST `VERIFY-RESOLUTION:` marker,
36
+ * case-insensitively. Last match wins: the model reasons before concluding, and a
37
+ * bash command it runs can print the token back, so an earlier occurrence is not
38
+ * the verdict.
24
39
  *
25
40
  * No marker → AUTOFIX (re-do rather than bless), with a rationale saying the
26
41
  * research was inconclusive. A marker with no trailing sentence gets a stock
27
42
  * rationale, so `rationale` is never empty.
43
+ *
44
+ * A BLOCKED-BY-FROZEN marker yields a CONTRADICTION plus an `accept`
45
+ * recommendation, which is what the gate does with it once the contradiction is
46
+ * recorded as debt. A marker that names no path is not a usable contradiction and
47
+ * degrades to that recommendation alone.
28
48
  */
29
49
  export declare function parseResolutionVerdict(text: string): ResolutionOutcome;
30
50
  export declare const ACCEPT_VALUE = "accept";
@@ -44,7 +64,10 @@ export interface ResolutionChoice {
44
64
  * `recommended: i === 0`). Values are the bare tokens; remote buttons carry the
45
65
  * labels — {@link classifyResolutionAnswer} accepts both.
46
66
  */
47
- export declare function resolutionOptions(recommend: ResolutionRecommendation): {
67
+ export declare function resolutionOptions(recommend: ResolutionRecommendation,
68
+ /** What ACCEPT queues besides keeping the artifact — the repair a red health
69
+ * check earns — so the picker shows the whole consequence of the choice. */
70
+ acceptQueues?: string): {
48
71
  label: string;
49
72
  value: string;
50
73
  recommended: boolean;
@@ -62,6 +85,9 @@ export interface ResolutionDeps {
62
85
  signal?: AbortSignal;
63
86
  /** The composed spec the task was verified against. */
64
87
  spec: string;
88
+ /** The task's rendered Q&A, which resolves each constraint's provenance tag —
89
+ * the same input the verify child got, so both weigh the spec alike. */
90
+ qaRecord?: string;
65
91
  /** The verify gate's FAIL reason (VerifyOutcome.reason). */
66
92
  failReason: string;
67
93
  /** Runs the research child and returns its assistant text. Injected so the
@@ -14,10 +14,9 @@
14
14
  * INVESTIGATE the real workspace: a genuine defect in the work (→ AUTOFIX), or
15
15
  * an artifact the gate misjudged (→ ACCEPT).
16
16
  *
17
- * The recommendation also decides whether a human is asked at all. In
18
- * task-gates.ts an AUTOFIX recommendation is applied UNATTENDED, bounded by
19
- * MAX_AUTO_AUTOFIX; the picker is reached on an ACCEPT recommendation, once that
20
- * bound is spent, or when the FAIL is UNOBSERVED or frozen-blocked.
17
+ * The recommendation is one input to gate-resolution.ts's decision table, which
18
+ * alone decides whether a human is asked. This module never overrides itself: an
19
+ * ACCEPT reached here stands.
21
20
  *
22
21
  * Tools: `read` + `bash`, the same string as verify-work's VERIFY_TOOLS. That is
23
22
  * a CONTRACT, not a capability — `bash` can write. gate-child.ts marks the
@@ -28,15 +27,28 @@
28
27
  * rather than blessing it.
29
28
  */
30
29
  import { USER_CANCELLED } from './child-runner.js';
30
+ import { annotateConstraints, renderConstraintPolicy } from './constraint-policy.js';
31
+ import { specValue } from './verify-work.js';
31
32
  /** The observe-only contract handed to the child. The same string as
32
33
  * verify-work's VERIFY_TOOLS, so both passes are told the same thing. */
33
34
  const RESOLUTION_TOOLS = 'read,bash';
35
+ /**
36
+ * The marker a judge emits INSTEAD of a recommendation when it finds the two
37
+ * gates contradict each other: the acceptance criterion can only be met by
38
+ * editing a path the same spec freezes.
39
+ *
40
+ * It exists because the judge was previously offered AUTOFIX or ACCEPT and
41
+ * nothing else, so a contradiction it had already diagnosed came back as
42
+ * "AUTOFIX" and the loop spent its whole budget re-deriving it (0053).
43
+ */
44
+ export const BLOCKED_BY_FROZEN = 'BLOCKED-BY-FROZEN';
34
45
  /**
35
46
  * Build the resolution research child's prompt. Pure, so the wording is
36
47
  * unit-tested without spawning pi. The contract it states: investigate the real
37
48
  * workspace and end on exactly one `VERIFY-RESOLUTION:` line.
38
49
  */
39
- export function buildResolutionPrompt(spec, failReason) {
50
+ export function buildResolutionPrompt(spec, failReason, qaRecord) {
51
+ const weights = annotateConstraints(specValue(spec, qaRecord).constraints);
40
52
  return [
41
53
  "A strict verification gate just FAILED an AI coding agent's task and the",
42
54
  'run paused. You are the reviewer who decides what to recommend next. The',
@@ -49,6 +61,13 @@ export function buildResolutionPrompt(spec, failReason) {
49
61
  'THE TASK SPEC (its ACCEPTANCE criteria and VERIFY block were the contract):',
50
62
  spec.trim(),
51
63
  '',
64
+ ...(weights === null ?
65
+ []
66
+ : [
67
+ "THE SPEC'S CONSTRAINTS, BY WEIGHT (rule 5 below says what each weight is worth):",
68
+ weights,
69
+ ''
70
+ ]),
52
71
  "THE GATE'S FAILURE REASON:",
53
72
  failReason.trim(),
54
73
  '',
@@ -86,25 +105,46 @@ export function buildResolutionPrompt(spec, failReason) {
86
105
  ' when you run it — that is AUTOFIX. When you cannot positively confirm the',
87
106
  ' artifact works, recommend AUTOFIX: re-doing the work is the safe default;',
88
107
  ' blessing broken work is the failure mode this whole gate exists to stop.',
108
+ '4. If the ONLY edit that could satisfy the failing criterion is to a file the',
109
+ " spec's CONSTRAINTS forbid this task from modifying, neither verdict above",
110
+ ' is true: a re-run happens under the same freeze and cannot converge. Report',
111
+ ' the contradiction instead, naming the frozen path and the criterion.',
112
+ // The SAME policy the verify child was held to (constraint-policy.ts). Two
113
+ // children reading one spec must not weigh its constraints differently —
114
+ // that disagreement is what made a grill-invented constraint unwaivable.
115
+ ...renderConstraintPolicy('5.'),
89
116
  '',
90
117
  'When done, output EXACTLY ONE of these as the final line:',
91
118
  ' VERIFY-RESOLUTION: AUTOFIX <one sentence why the work must be re-done>',
92
119
  ' VERIFY-RESOLUTION: ACCEPT <one sentence why the artifact is good as-is>',
120
+ ` VERIFY-RESOLUTION: ${BLOCKED_BY_FROZEN} <frozen path> — <the criterion it blocks>`,
93
121
  'Output the verdict line verbatim — it is parsed mechanically.'
94
122
  ].join('\n');
95
123
  }
96
124
  /**
97
- * Parse the child's recommendation out of the LAST
98
- * `VERIFY-RESOLUTION: AUTOFIX|ACCEPT` marker, case-insensitively. Last match
99
- * wins: the model reasons before concluding, and a bash command it runs can
100
- * print the token back, so an earlier occurrence is not the verdict.
125
+ * The path/criterion separator the marker's contract states. An em dash is what
126
+ * the prompt shows; a model that retypes it as an en dash or a spaced hyphen
127
+ * means the same thing, and losing a contradiction over a dash costs the whole
128
+ * autofix budget.
129
+ */
130
+ const CONTRADICTION_SEP = /\s+[—–]\s+|\s+-{1,2}\s+/;
131
+ /**
132
+ * Parse the child's verdict out of the LAST `VERIFY-RESOLUTION:` marker,
133
+ * case-insensitively. Last match wins: the model reasons before concluding, and a
134
+ * bash command it runs can print the token back, so an earlier occurrence is not
135
+ * the verdict.
101
136
  *
102
137
  * No marker → AUTOFIX (re-do rather than bless), with a rationale saying the
103
138
  * research was inconclusive. A marker with no trailing sentence gets a stock
104
139
  * rationale, so `rationale` is never empty.
140
+ *
141
+ * A BLOCKED-BY-FROZEN marker yields a CONTRADICTION plus an `accept`
142
+ * recommendation, which is what the gate does with it once the contradiction is
143
+ * recorded as debt. A marker that names no path is not a usable contradiction and
144
+ * degrades to that recommendation alone.
105
145
  */
106
146
  export function parseResolutionVerdict(text) {
107
- const re = /VERIFY-RESOLUTION:\s*(AUTOFIX|ACCEPT)\b[ \t]*(.*)/gi;
147
+ const re = new RegExp(`VERIFY-RESOLUTION:\\s*(AUTOFIX|ACCEPT|${BLOCKED_BY_FROZEN})\\b[ \\t]*(.*)`, 'gi');
108
148
  let last = null;
109
149
  for (let m = re.exec(text); m !== null; m = re.exec(text))
110
150
  last = m;
@@ -114,11 +154,28 @@ export function parseResolutionVerdict(text) {
114
154
  rationale: 'research inconclusive — defaulting to re-doing the work'
115
155
  };
116
156
  }
117
- const recommend = last[1].toUpperCase() === 'ACCEPT' ? 'accept' : 'autofix';
118
- const rationale = last[2].trim()
157
+ const verdict = last[1].toUpperCase();
158
+ const tail = last[2].trim();
159
+ if (verdict === BLOCKED_BY_FROZEN)
160
+ return blockedVerdict(tail);
161
+ const recommend = verdict === 'ACCEPT' ? 'accept' : 'autofix';
162
+ const rationale = tail
119
163
  || (recommend === 'accept' ? 'artifact judged acceptable' : 'work judged to need fixing');
120
164
  return { recommend, rationale };
121
165
  }
166
+ function blockedVerdict(tail) {
167
+ const sep = CONTRADICTION_SEP.exec(tail);
168
+ const frozenPath = (sep ? tail.slice(0, sep.index) : (tail.split(/\s+/)[0] ?? '')).trim();
169
+ const criterion = sep ? tail.slice(sep.index + sep[0].length).trim() : '';
170
+ const rationale = frozenPath.length > 0 ?
171
+ `blocked by spec-frozen \`${frozenPath}\`${criterion ? `: ${criterion}` : ''}`
172
+ : 'judged blocked by a spec-frozen path, but the marker named none';
173
+ return {
174
+ recommend: 'accept',
175
+ rationale,
176
+ ...(frozenPath.length > 0 ? { contradiction: { criterion, frozenPath } } : {})
177
+ };
178
+ }
122
179
  // ─── Picker plumbing ─────────────────────────────────────────────────────────
123
180
  //
124
181
  // These pure helpers shape the boxed two-choice picker (task-gates.ts
@@ -136,8 +193,14 @@ export const AUTOFIX_LABEL = 'Autofix — re-run the task to fix it, then verify
136
193
  * `recommended: i === 0`). Values are the bare tokens; remote buttons carry the
137
194
  * labels — {@link classifyResolutionAnswer} accepts both.
138
195
  */
139
- export function resolutionOptions(recommend) {
140
- const accept = { label: ACCEPT_LABEL, value: ACCEPT_VALUE };
196
+ export function resolutionOptions(recommend,
197
+ /** What ACCEPT queues besides keeping the artifact — the repair a red health
198
+ * check earns — so the picker shows the whole consequence of the choice. */
199
+ acceptQueues) {
200
+ const accept = {
201
+ label: acceptQueues ? `${ACCEPT_LABEL}; queues ${acceptQueues}` : ACCEPT_LABEL,
202
+ value: ACCEPT_VALUE
203
+ };
141
204
  const autofix = { label: AUTOFIX_LABEL, value: AUTOFIX_VALUE };
142
205
  return recommend === 'accept' ?
143
206
  [
@@ -179,7 +242,7 @@ export function classifyResolutionAnswer(answer) {
179
242
  export async function researchResolution(deps) {
180
243
  let text;
181
244
  try {
182
- text = await deps.runChild(RESOLUTION_TOOLS, buildResolutionPrompt(deps.spec, deps.failReason), deps.signal);
245
+ text = await deps.runChild(RESOLUTION_TOOLS, buildResolutionPrompt(deps.spec, deps.failReason, deps.qaRecord), deps.signal);
183
246
  }
184
247
  catch (err) {
185
248
  if (err instanceof Error && err.message === USER_CANCELLED)