@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
@@ -0,0 +1,120 @@
1
+ /** Attempts a class whose defect an implementation re-run can actually move. */
2
+ const REATTEMPTABLE_BUDGET = 3;
3
+ /**
4
+ * How many UNATTENDED implementation re-runs each FAIL class is worth.
5
+ *
6
+ * Zero is a statement, not a disabled feature: an absent tool is not installed by
7
+ * re-running the work that needed it, and a verification pass that could not run
8
+ * is not repaired by changing the code it failed to judge. Spending three turns
9
+ * on either is how a run burns an hour to arrive where round one already was.
10
+ */
11
+ export const AUTOFIX_BUDGET = {
12
+ 'repo-health': REATTEMPTABLE_BUDGET,
13
+ 'static-checks': REATTEMPTABLE_BUDGET,
14
+ 'model-verdict': REATTEMPTABLE_BUDGET,
15
+ unobserved: 0,
16
+ 'harness-fault': 0
17
+ };
18
+ /**
19
+ * How many times the /task-auto loop will START one plan entry before abandoning it.
20
+ *
21
+ * Derived, not chosen: one run to reach the gate at all, plus the re-runs the most
22
+ * forgiving fail class is worth inside that run. An entry that comes back for a
23
+ * further attempt has already spent a full autofix budget without converging, and
24
+ * the plan's remaining entries are worth more than its next round. Raising a class
25
+ * budget raises this with it, which is the relationship that should hold.
26
+ */
27
+ export const ENTRY_ATTEMPT_BUDGET = 1 + Math.max(...Object.values(AUTOFIX_BUDGET));
28
+ /** The budget in force, honouring an UNOBSERVED flag on any class. */
29
+ export function autofixBudget(i) {
30
+ return i.unobserved ? 0 : AUTOFIX_BUDGET[i.failClass];
31
+ }
32
+ export function contradictionReason(c) {
33
+ const criterion = c.criterion.trim();
34
+ return (`spec contradiction — \`${c.frozenPath}\` is frozen by this task's spec and `
35
+ + `${criterion.length > 0 ? criterion : 'the failing check'} cannot be satisfied `
36
+ + 'without editing it; no re-run under the same freeze converges');
37
+ }
38
+ /**
39
+ * The two ends of the ladder that are NOT an autofix: ask a human, or — when
40
+ * there is no human — accept and write the defect down under the origin that says
41
+ * a machine decided.
42
+ */
43
+ function terminal(i, rule, reason) {
44
+ return i.unattended ?
45
+ { rule, action: 'accept', debtOrigin: 'yolo-accepted', reason }
46
+ : { rule, action: 'ask', debtOrigin: null, reason };
47
+ }
48
+ function noBudgetReason(i) {
49
+ return i.unobserved || i.failClass === 'unobserved' ?
50
+ 'verify UNOBSERVED — the spec-required check could not run (tooling absent) and an '
51
+ + 'unattended re-run cannot provision it'
52
+ : 'the verification pass itself could not run — re-running the implementation cannot '
53
+ + 'fix a harness fault';
54
+ }
55
+ /**
56
+ * The ordered ladder. FIRST MATCH WINS, and this order is the only statement of
57
+ * the policy:
58
+ *
59
+ * 1. `spec-contradiction` — the fix the check demands is the edit the spec
60
+ * forbids. First, because every row below would otherwise spend a budget,
61
+ * a human's attention or both on a loop that cannot converge (0053 ran three
62
+ * rounds past this exact fact).
63
+ * 2. `judge-accept` — before any budget arithmetic, because that arithmetic is
64
+ * precisely what used to override a correct ACCEPT with a rescue attempt.
65
+ * 3. `no-autofix-budget` — a class worth zero attempts. Its own row, not folded
66
+ * into the one below, because a budget that was never offered must not be
67
+ * reported as one that was spent.
68
+ * 4. `budget-spent` — consecutive unattended attempts that all still FAIL. A
69
+ * person breaks the loop, or (unattended) it terminates with a debt.
70
+ * 5. `autofix` — unconditional, so the table is total.
71
+ */
72
+ export const RESOLUTION_RULES = [
73
+ {
74
+ id: 'spec-contradiction',
75
+ match: i => i.contradiction === null ?
76
+ null
77
+ : {
78
+ rule: 'spec-contradiction',
79
+ action: 'accept',
80
+ debtOrigin: 'spec-contradiction',
81
+ reason: contradictionReason(i.contradiction)
82
+ }
83
+ },
84
+ {
85
+ id: 'judge-accept',
86
+ match: i => i.recommend === 'accept' ?
87
+ terminal(i, 'judge-accept', `judge recommended ACCEPT after investigating the workspace (autofix budget `
88
+ + `${i.attempts}/${autofixBudget(i)})`)
89
+ : null
90
+ },
91
+ {
92
+ id: 'no-autofix-budget',
93
+ match: i => autofixBudget(i) === 0 ? terminal(i, 'no-autofix-budget', noBudgetReason(i)) : null
94
+ },
95
+ {
96
+ id: 'budget-spent',
97
+ match: i => i.attempts >= autofixBudget(i) ?
98
+ terminal(i, 'budget-spent', `autofix budget spent (${i.attempts}/${autofixBudget(i)})`)
99
+ : null
100
+ },
101
+ {
102
+ id: 'autofix',
103
+ match: i => ({
104
+ rule: 'autofix',
105
+ action: 'autofix',
106
+ debtOrigin: null,
107
+ reason: `autofix recommended, unattended ${i.attempts + 1}/${autofixBudget(i)}`
108
+ })
109
+ }
110
+ ];
111
+ export function resolveDisposition(i) {
112
+ for (const rule of RESOLUTION_RULES) {
113
+ const d = rule.match(i);
114
+ if (d !== null)
115
+ return d;
116
+ }
117
+ // Unreachable while the last row is unconditional; a reordering that breaks
118
+ // that fails loudly here instead of returning undefined into the gate loop.
119
+ throw new Error('RESOLUTION_RULES must end in an unconditional row');
120
+ }
@@ -46,8 +46,7 @@ import * as os from 'node:os';
46
46
  import * as path from 'node:path';
47
47
  import { makeGit } from '../shared/git-runner.js';
48
48
  import { isRegenerableArtifact } from './regenerable-artifacts.js';
49
- /** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
50
- const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
49
+ import { worktreeTreeHash } from './tree-hash.js';
51
50
  /**
52
51
  * Untracked paths that are regenerable test/build OUTPUT, not graded source. A gate
53
52
  * child creating or rewriting one of these has not mutated the work under
@@ -115,28 +114,6 @@ function isAlwaysRegenerable(relPath, ctCacheDirs) {
115
114
  return true;
116
115
  return ctCacheDirs.some(d => p === d || p.startsWith(d + '/'));
117
116
  }
118
- /**
119
- * Snapshot the worktree content into a tree object via a THROWAWAY index file, so
120
- * neither the real index nor the stash is touched. Returns null when git cannot
121
- * build the tree (odd states — the guard then skips tree reconciliation).
122
- */
123
- async function captureWorktreeTree(git) {
124
- const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
125
- const env = { GIT_INDEX_FILE: tmpIndex };
126
- try {
127
- const empty = await git(['read-tree', '--empty'], env);
128
- if (empty.exitCode !== 0)
129
- return null;
130
- const add = await git(['add', '-A', '--', '.', EXCLUDE_TASKS_DIR], env);
131
- if (add.exitCode !== 0)
132
- return null;
133
- const tree = await git(['write-tree'], env);
134
- return tree.exitCode === 0 ? tree.stdout.trim() : null;
135
- }
136
- finally {
137
- await fsp.rm(tmpIndex, { force: true }).catch(() => { });
138
- }
139
- }
140
117
  /** Capture the repo state a gate child must leave untouched. */
141
118
  export async function captureGitState(cwd, signal, spawnFn) {
142
119
  const git = makeGit(cwd, signal, spawnFn);
@@ -161,7 +138,7 @@ export async function captureGitState(cwd, signal, spawnFn) {
161
138
  headSha: head.stdout.trim(),
162
139
  branchRef: branch.exitCode === 0 ? branch.stdout.trim() : null,
163
140
  stashSha: stash.exitCode === 0 ? stash.stdout.trim() : null,
164
- treeSha: await captureWorktreeTree(git)
141
+ treeSha: await worktreeTreeHash(git)
165
142
  };
166
143
  }
167
144
  /** Paths tracked in the commit `headSha` points at — the "graded" codebase a gate
@@ -309,7 +286,7 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
309
286
  }
310
287
  // 2. Worktree content.
311
288
  if (before.treeSha) {
312
- const afterTree = await captureWorktreeTree(git);
289
+ const afterTree = await worktreeTreeHash(git);
313
290
  if (afterTree && afterTree !== before.treeSha) {
314
291
  const tracked = await trackedPathsAt(git, before.headSha);
315
292
  const ctCacheDirs = readCtCacheDirs(cwd);
@@ -0,0 +1,35 @@
1
+ import type { FixContext } from './fix-context.js';
2
+ import type { Disposition } from './gate-resolution.js';
3
+ import type { VerifyFailClass } from './verify-work.js';
4
+ /** The part of a {@link FixContext} worth keeping after the run that used it. */
5
+ export interface HandoffFixSummary {
6
+ /** The rule of the decision table that sent the work back. */
7
+ rule: Disposition['rule'];
8
+ failClass?: VerifyFailClass;
9
+ /** Which re-run the fix context itself was built for, 1-based. */
10
+ fixAttempt: number;
11
+ /** Probe names that found something; the findings live in the gate trail. */
12
+ probes: string[];
13
+ frozenPath?: string;
14
+ }
15
+ export interface HandoffRecord {
16
+ /** Which delivery of this task's spec this is, 1-based. */
17
+ attempt: number;
18
+ specHash: string;
19
+ delivered: 'fresh' | 'reattempt';
20
+ fixContext?: HandoffFixSummary;
21
+ at?: string;
22
+ }
23
+ /** Short content hash of the spec as delivered. */
24
+ export declare function specHash(spec: string): string;
25
+ export declare function summariseFixContext(ctx: FixContext): HandoffFixSummary;
26
+ export declare function formatHandoff(r: HandoffRecord): string;
27
+ /**
28
+ * Every record in a `## handoff` body, oldest first. A block without the
29
+ * `attempt` field is one of the single-timestamp sections this grammar replaced:
30
+ * it is still a delivery, so it is returned as attempt 1 with an unknown hash
31
+ * rather than dropped, and the next append counts from it.
32
+ */
33
+ export declare function parseHandoff(body: string | null): HandoffRecord[];
34
+ /** The section body with `record` appended after everything already in `prev`. */
35
+ export declare function appendHandoff(prev: string | null, record: HandoffRecord): string;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * handoff — what was delivered to the implementer, per attempt.
3
+ *
4
+ * The `## handoff` section used to be one `handoff_at:` timestamp, overwritten on
5
+ * every re-entry, which answered neither of the questions asked of it after a run:
6
+ * how many times was this task's spec delivered, and was the spec that came back
7
+ * the same one that failed? A record per delivery answers both — the spec hash
8
+ * makes a re-delivery that silently changed the spec visible, and the fix summary
9
+ * says which gate decision paid for it.
10
+ *
11
+ * Key-value lines because this section is read by humans as often as by code, and
12
+ * a blank line between records is the only separator the grammar needs.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ /** Short content hash of the spec as delivered. */
16
+ export function specHash(spec) {
17
+ return createHash('sha256').update(spec).digest('hex').slice(0, 12);
18
+ }
19
+ export function summariseFixContext(ctx) {
20
+ const probes = Object.entries(ctx.probes)
21
+ .filter(([, findings]) => Array.isArray(findings) && findings.length > 0)
22
+ .map(([name]) => name);
23
+ return {
24
+ rule: ctx.disposition.rule,
25
+ ...(ctx.outcome.failClass ? { failClass: ctx.outcome.failClass } : {}),
26
+ fixAttempt: ctx.attempt,
27
+ probes,
28
+ ...(ctx.contradiction ? { frozenPath: ctx.contradiction.frozenPath } : {})
29
+ };
30
+ }
31
+ const FIELD_RE = /^([a-z_]+):\s*(.*)$/;
32
+ export function formatHandoff(r) {
33
+ const lines = [
34
+ `handoff_at: ${r.at ?? new Date().toISOString()}`,
35
+ `attempt: ${r.attempt}`,
36
+ `spec_hash: ${r.specHash}`,
37
+ `delivered: ${r.delivered}`
38
+ ];
39
+ const f = r.fixContext;
40
+ if (f) {
41
+ lines.push(`fix_rule: ${f.rule}`);
42
+ if (f.failClass)
43
+ lines.push(`fix_class: ${f.failClass}`);
44
+ lines.push(`fix_attempt: ${f.fixAttempt}`);
45
+ if (f.probes.length > 0)
46
+ lines.push(`fix_probes: ${f.probes.join(', ')}`);
47
+ if (f.frozenPath)
48
+ lines.push(`fix_frozen_path: ${f.frozenPath}`);
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+ /**
53
+ * Every record in a `## handoff` body, oldest first. A block without the
54
+ * `attempt` field is one of the single-timestamp sections this grammar replaced:
55
+ * it is still a delivery, so it is returned as attempt 1 with an unknown hash
56
+ * rather than dropped, and the next append counts from it.
57
+ */
58
+ export function parseHandoff(body) {
59
+ const out = [];
60
+ for (const block of (body ?? '').split(/\n\s*\n/)) {
61
+ const fields = new Map();
62
+ for (const line of block.split('\n')) {
63
+ const m = FIELD_RE.exec(line.trim());
64
+ if (m)
65
+ fields.set(m[1], m[2].trim());
66
+ }
67
+ if (fields.size === 0)
68
+ continue;
69
+ const attempt = parseInt(fields.get('attempt') ?? '', 10);
70
+ const delivered = fields.get('delivered');
71
+ const rule = fields.get('fix_rule');
72
+ const probes = fields.get('fix_probes');
73
+ const failClass = fields.get('fix_class');
74
+ const frozenPath = fields.get('fix_frozen_path');
75
+ out.push({
76
+ attempt: Number.isNaN(attempt) ? out.length + 1 : attempt,
77
+ specHash: fields.get('spec_hash') ?? '',
78
+ delivered: delivered === 'reattempt' ? 'reattempt' : 'fresh',
79
+ ...(fields.has('handoff_at') ? { at: fields.get('handoff_at') } : {}),
80
+ ...(rule ?
81
+ {
82
+ fixContext: {
83
+ rule: rule,
84
+ ...(failClass ? { failClass: failClass } : {}),
85
+ fixAttempt: parseInt(fields.get('fix_attempt') ?? '1', 10) || 1,
86
+ probes: probes ? probes.split(',').map(p => p.trim()) : [],
87
+ ...(frozenPath ? { frozenPath } : {})
88
+ }
89
+ }
90
+ : {})
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+ /** The section body with `record` appended after everything already in `prev`. */
96
+ export function appendHandoff(prev, record) {
97
+ const before = (prev ?? '').trim();
98
+ const line = formatHandoff(record);
99
+ return before.length === 0 ? line : `${before}\n\n${line}`;
100
+ }
@@ -0,0 +1,87 @@
1
+ import type { GitRunner } from '../shared/git-runner.js';
2
+ import type { HealthCommandResult, HealthOutcome } from './repo-health-check.js';
3
+ /**
4
+ * The permissive read of a health result: everything the differential needs and
5
+ * nothing else. The gate deps hand around structurally-typed health signals (and
6
+ * tests fake them), so the differential asks for the two fields it reads rather
7
+ * than for the full `HealthOutcome`.
8
+ */
9
+ export interface HealthSignal {
10
+ ok: boolean;
11
+ /** Absent → the result predates per-command recording; the differential then
12
+ * has only the overall verdict to compare. */
13
+ commands?: readonly HealthCommandResult[];
14
+ }
15
+ export interface HealthBaseline {
16
+ /** When the baseline was taken (ISO). */
17
+ at: string;
18
+ /** The tree it describes, so a reader can tell whether it still applies. */
19
+ treeHash: string | null;
20
+ outcome: HealthOutcome;
21
+ }
22
+ /**
23
+ * Whose fault the current health result is.
24
+ * clean — nothing is failing now.
25
+ * regressed — a check fails that the baseline did not have failing. THIS task.
26
+ * pre-existing — every failing check failed the same way before the task ran.
27
+ */
28
+ export type HealthDelta = 'clean' | 'regressed' | 'pre-existing';
29
+ /**
30
+ * Classify the current health result against the baseline.
31
+ *
32
+ * A NULL baseline is `regressed`, deliberately. An unestablished baseline is not
33
+ * evidence that the breakage predates the task — it is the absence of evidence —
34
+ * and the two errors are not symmetrical: calling it pre-existing SHIPS a real
35
+ * regression as inherited debt, while calling it a regression routes the failure
36
+ * through the resolution table, where a judge, a bounded lint fix or a human can
37
+ * still say otherwise. The same reasoning retires the `healthBefore?.ok ?? true`
38
+ * default the enforce site used to carry.
39
+ */
40
+ export declare function classifyHealthDelta(baseline: HealthSignal | null, after: HealthSignal): HealthDelta;
41
+ /** The failing commands, as prompt/trail lines naming the exit code. */
42
+ export declare function inheritedHealthFindings(after: HealthSignal): string[];
43
+ export declare const HEALTH_BASELINE_SECTION = "health baseline";
44
+ /**
45
+ * Render for `## health baseline`. JSON in a fence rather than a hand-rolled
46
+ * grammar: this round-trips through a committed file that a later run parses, and
47
+ * a second grammar is a second thing to drift.
48
+ *
49
+ * The captured `output` is dropped — up to 40 lines of a linter's report, in a
50
+ * file committed with every task, for a field the differential never reads. The
51
+ * live run's own trail already carries it.
52
+ */
53
+ export declare function formatHealthBaseline(b: HealthBaseline): string;
54
+ /** Parse a `## health baseline` section back. Null on anything unreadable — an
55
+ * unparseable baseline is no baseline, never a fabricated clean one. */
56
+ export declare function parseHealthBaseline(section: string | null): HealthBaseline | null;
57
+ export interface CaptureDeps {
58
+ runHealth: () => Promise<HealthOutcome>;
59
+ treeHash: () => Promise<string | null>;
60
+ /** Undo whatever a `--fix`-style health command wrote. */
61
+ discardEdits: () => Promise<void>;
62
+ now?: () => Date;
63
+ }
64
+ /**
65
+ * Capture the baseline for the tree as it stands. The hash is taken FIRST: a
66
+ * health command that writes would otherwise be described by the hash of its own
67
+ * output rather than of the tree the task starts from.
68
+ */
69
+ export declare function captureHealthBaseline(deps: CaptureDeps): Promise<HealthBaseline>;
70
+ export interface LazyCaptureDeps {
71
+ git: GitRunner;
72
+ /** Run the health check in the given directory — the throwaway worktree. */
73
+ runHealthIn: (dir: string) => Promise<HealthOutcome>;
74
+ tmpDir?: () => string;
75
+ now?: () => Date;
76
+ }
77
+ /**
78
+ * Establish a baseline for a task file that has none, from a DETACHED WORKTREE at
79
+ * HEAD. HEAD is the last commit before the task's uncommitted work, so it is the
80
+ * state the task started from — and checking it out elsewhere means the real index,
81
+ * the real working tree and the stash are all untouched, which no `stash` or
82
+ * `checkout` of the live tree could promise.
83
+ *
84
+ * Null when git cannot produce the worktree; the caller then has no baseline, and
85
+ * `classifyHealthDelta` says what that means.
86
+ */
87
+ export declare function lazyHealthBaseline(deps: LazyCaptureDeps): Promise<HealthBaseline | null>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * health-baseline — what the repo's own static checks said BEFORE the task ran,
3
+ * and the differential that decides whose fault a red check is.
4
+ *
5
+ * THE FAILURE THIS CLOSES. The verify gate ran `runRepoHealthCheck` and failed on
6
+ * its absolute exit code. So a task that inherited a repo whose lint was already
7
+ * red failed its gate for somebody else's defect (AUTO_0002: 0034 absorbed 0033's
8
+ * lint debt), and — worse — that FAIL short-circuited before the probes, so the
9
+ * deterministic findings about the task's OWN work were never computed at all.
10
+ *
11
+ * THE COMPARISON IS PER COMMAND, not per overall verdict. `ok` is one boolean over
12
+ * a list of checks that short-circuits on the first failure, so "red before, red
13
+ * after" is true of a repo whose lint was already broken AND of a task that broke
14
+ * typecheck on top of it. Only the (cmd, exitCode) pairs can tell those apart.
15
+ *
16
+ * WHERE THE BASELINE COMES FROM. `/task-auto` captures it at the pre-task
17
+ * checkpoint commit, where the tree is clean and therefore describes exactly the
18
+ * state the task starts from; `/task` captures at its own start. Health commands
19
+ * may WRITE (`--fix` is a common `lint` script), so the capture discards edits
20
+ * afterwards — a baseline that silently fixed the repo would make the task's gate
21
+ * judge a tree nobody authored. A task file that has no baseline (it predates this,
22
+ * or the capture failed) gets one lazily at verify time from a detached worktree at
23
+ * HEAD: no stash, and the real index is never touched.
24
+ */
25
+ import * as fsp from 'node:fs/promises';
26
+ import * as os from 'node:os';
27
+ import * as path from 'node:path';
28
+ import { commitTreeHash } from './tree-hash.js';
29
+ function failures(signal) {
30
+ return (signal.commands ?? []).filter(c => c.outcome === 'fail');
31
+ }
32
+ /**
33
+ * Classify the current health result against the baseline.
34
+ *
35
+ * A NULL baseline is `regressed`, deliberately. An unestablished baseline is not
36
+ * evidence that the breakage predates the task — it is the absence of evidence —
37
+ * and the two errors are not symmetrical: calling it pre-existing SHIPS a real
38
+ * regression as inherited debt, while calling it a regression routes the failure
39
+ * through the resolution table, where a judge, a bounded lint fix or a human can
40
+ * still say otherwise. The same reasoning retires the `healthBefore?.ok ?? true`
41
+ * default the enforce site used to carry.
42
+ */
43
+ export function classifyHealthDelta(baseline, after) {
44
+ if (after.ok)
45
+ return 'clean';
46
+ if (!baseline)
47
+ return 'regressed';
48
+ const before = failures(baseline);
49
+ const now = failures(after);
50
+ // A failing result that names no failing command carries no per-command detail
51
+ // (a legacy record, or an injected signal); the overall verdict is all there is.
52
+ const detailed = now.length > 0 && (baseline.ok || before.length > 0);
53
+ if (!detailed)
54
+ return baseline.ok ? 'regressed' : 'pre-existing';
55
+ const key = (c) => JSON.stringify([c.cmd, c.exitCode]);
56
+ const wasFailing = new Set(before.map(key));
57
+ return now.every(c => wasFailing.has(key(c))) ? 'pre-existing' : 'regressed';
58
+ }
59
+ /** The failing commands, as prompt/trail lines naming the exit code. */
60
+ export function inheritedHealthFindings(after) {
61
+ return failures(after).map(c => `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
62
+ }
63
+ // ─── The task-file section ───────────────────────────────────────────────────
64
+ export const HEALTH_BASELINE_SECTION = 'health baseline';
65
+ /**
66
+ * Render for `## health baseline`. JSON in a fence rather than a hand-rolled
67
+ * grammar: this round-trips through a committed file that a later run parses, and
68
+ * a second grammar is a second thing to drift.
69
+ *
70
+ * The captured `output` is dropped — up to 40 lines of a linter's report, in a
71
+ * file committed with every task, for a field the differential never reads. The
72
+ * live run's own trail already carries it.
73
+ */
74
+ export function formatHealthBaseline(b) {
75
+ const { output: _output, ...outcome } = b.outcome;
76
+ return ['```json', JSON.stringify({ ...b, outcome }, null, 2), '```'].join('\n');
77
+ }
78
+ /** Parse a `## health baseline` section back. Null on anything unreadable — an
79
+ * unparseable baseline is no baseline, never a fabricated clean one. */
80
+ export function parseHealthBaseline(section) {
81
+ if (!section)
82
+ return null;
83
+ const json = section.replace(/^```(?:json)?\s*/, '').replace(/```\s*$/, '');
84
+ try {
85
+ const parsed = JSON.parse(json);
86
+ const outcome = parsed.outcome;
87
+ if (!outcome || typeof outcome.ok !== 'boolean')
88
+ return null;
89
+ return {
90
+ at: typeof parsed.at === 'string' ? parsed.at : '',
91
+ treeHash: typeof parsed.treeHash === 'string' ? parsed.treeHash : null,
92
+ outcome: {
93
+ ok: outcome.ok,
94
+ reason: outcome.reason ?? '',
95
+ ecosystem: outcome.ecosystem ?? null,
96
+ commands: outcome.commands ?? [],
97
+ output: ''
98
+ }
99
+ };
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Capture the baseline for the tree as it stands. The hash is taken FIRST: a
107
+ * health command that writes would otherwise be described by the hash of its own
108
+ * output rather than of the tree the task starts from.
109
+ */
110
+ export async function captureHealthBaseline(deps) {
111
+ const treeHash = await deps.treeHash();
112
+ try {
113
+ const outcome = await deps.runHealth();
114
+ return { at: (deps.now?.() ?? new Date()).toISOString(), treeHash, outcome };
115
+ }
116
+ finally {
117
+ await deps.discardEdits();
118
+ }
119
+ }
120
+ function lazyWorktreePath() {
121
+ return path.join(os.tmpdir(), `pi-task-health-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
122
+ }
123
+ /**
124
+ * Establish a baseline for a task file that has none, from a DETACHED WORKTREE at
125
+ * HEAD. HEAD is the last commit before the task's uncommitted work, so it is the
126
+ * state the task started from — and checking it out elsewhere means the real index,
127
+ * the real working tree and the stash are all untouched, which no `stash` or
128
+ * `checkout` of the live tree could promise.
129
+ *
130
+ * Null when git cannot produce the worktree; the caller then has no baseline, and
131
+ * `classifyHealthDelta` says what that means.
132
+ */
133
+ export async function lazyHealthBaseline(deps) {
134
+ const dir = (deps.tmpDir ?? lazyWorktreePath)();
135
+ const added = await deps.git(['worktree', 'add', '--detach', dir, 'HEAD']);
136
+ if (added.exitCode !== 0)
137
+ return null;
138
+ try {
139
+ const outcome = await deps.runHealthIn(dir);
140
+ return {
141
+ at: (deps.now?.() ?? new Date()).toISOString(),
142
+ treeHash: await commitTreeHash(deps.git, 'HEAD'),
143
+ outcome
144
+ };
145
+ }
146
+ finally {
147
+ // `--force` because the health run may have written into it (a `--fix`
148
+ // script, a build cache), which plain `worktree remove` refuses. If git
149
+ // still declines, drop the directory and prune the admin record by hand —
150
+ // a stale worktree entry makes every later `worktree add` in this repo
151
+ // noisier, and nothing else will clean it up.
152
+ const removed = await deps.git(['worktree', 'remove', '--force', dir]);
153
+ if (removed.exitCode !== 0) {
154
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => { });
155
+ await deps.git(['worktree', 'prune']);
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * health-repair — a red static check at a pre-task checkpoint becomes a repair
3
+ * entry in the running plan, BEFORE the next planned task builds on it.
4
+ *
5
+ * The failure this closes: red enters the tree by three doors — a task whose
6
+ * regression was accepted, a leftover from an abandoned run swept in by the
7
+ * checkpoint commit, a repo that was red when the run began — and none of them is
8
+ * a gate. The health baseline (health-baseline.ts) stops the NEXT task being
9
+ * blamed, but blaming nobody is not fixing it: every later task inherits the red,
10
+ * and only the run-end gate re-checks it. All three doors pass the checkpoint,
11
+ * which already measures health for the baseline, so that one measurement is
12
+ * where the repair is scheduled.
13
+ *
14
+ * The subject of a repair is what the health output NAMES: the tracked files the
15
+ * failing command reported, or the command itself when it named none. The plan is
16
+ * the dedup ledger — a title covering the same command, or any of the same files,
17
+ * means no second entry, checked-off ones included, which is what stops a repair
18
+ * that failed from being re-spawned.
19
+ */
20
+ import type { HealthSignal } from './health-baseline.js';
21
+ /** The failing check, and what its output named. */
22
+ export interface HealthRed {
23
+ command: string;
24
+ exitCode: number | null;
25
+ /** Repo-relative tracked paths the output named, in first-seen order. */
26
+ files: string[];
27
+ }
28
+ export interface HealthRedOwners {
29
+ /** Task ids whose commits introduced the named files; empty when nothing in
30
+ * the run owns them (a leftover, or a red the run started on). */
31
+ owners: string[];
32
+ }
33
+ /**
34
+ * What a red health result is about. Null when the result records no failing
35
+ * command (a legacy baseline, or a signal with no per-command detail) — there is
36
+ * nothing a repair could be pinned to.
37
+ */
38
+ export declare function healthRedSubject(health: HealthSignal & {
39
+ output?: string;
40
+ }, cwd: string, tracked: readonly string[] | null): HealthRed | null;
41
+ /**
42
+ * The plan title, in one of two fixed shapes the parser below recovers:
43
+ * `repair src/a.ts, src/b.ts: \`bun run lint\` exits 1 (introduced by TASK_0033)`
44
+ * `repair \`bun run lint\`: exits 1 (no task in this run owns it)`
45
+ * The subject sits right after the prefix so it is both the dedup key and what
46
+ * the scope fence pins.
47
+ */
48
+ export declare function buildHealthRepairTitle(red: HealthRed & HealthRedOwners): string;
49
+ export interface HealthRepairSubject {
50
+ command: string;
51
+ files: string[];
52
+ }
53
+ /** The command and files a health-repair title names, or null for any other title. */
54
+ export declare function parseHealthRepairTitle(title: string): HealthRepairSubject | null;
55
+ /**
56
+ * Does the plan already carry a repair for this red? Same command, or any of the
57
+ * same files — including a file-scoped root-cause repair (root-cause-repair.ts),
58
+ * which pins the same file. Checked-off entries count: a repair that ran and
59
+ * failed lands in the debt ledger, never in the plan a second time.
60
+ */
61
+ export declare function planCoversHealthRed(titles: readonly string[], red: HealthRed): boolean;
62
+ /**
63
+ * The extra scope fence a health-repair entry carries into refine. Without it
64
+ * "repair src/a.ts" refines into "overhaul the client", and a fix child left free
65
+ * to choose greens a linter fastest by suppressing it — which is how the red in
66
+ * the run this closes was painted over the first time.
67
+ */
68
+ export declare function buildHealthRepairFence(subject: HealthRepairSubject): string;