@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,52 @@
1
+ /**
2
+ * constraint-policy — how much a constraint's PROVENANCE is worth, as one table
3
+ * both gate children render from.
4
+ *
5
+ * The verify child was told a spec constraint is unwaivable, full stop. The
6
+ * resolution judge was told to judge by function and treat over-literal wording
7
+ * as a false alarm. Neither was wrong on its own; together they made the gate's
8
+ * answer depend on which child happened to speak. 0043 is what that costs: a
9
+ * constraint a grill AUTO-ANSWER invented ("do NOT touch `test/ct/template/
10
+ * index.html`") was held as unwaivable as one the user typed, and the task failed
11
+ * on it.
12
+ *
13
+ * A constraint a human stated, or one the refined task carried in, BINDS. A
14
+ * constraint some automation produced — an auto-answer, a YOLO pick, an untagged
15
+ * line the composer wrote itself — is ADVISORY: it sharpens the work, and it
16
+ * fails a gate only when an ACCEPTANCE bullet also fails. Acceptance is the bar;
17
+ * an advisory constraint is a hint about how to clear it.
18
+ *
19
+ * One table, one renderer, two call sites (verify rule 4b, the judge's rules).
20
+ */
21
+ import type { Constraint, ConstraintProvenance } from './spec-model.js';
22
+ export type ConstraintWeight = 'binding' | 'advisory';
23
+ /**
24
+ * What each provenance is worth. A new `ConstraintProvenance` member is a compile
25
+ * error until it declares its weight — which is the point, since the cheapest way
26
+ * back to 0043 is a new automated kind quietly defaulting to `binding`.
27
+ *
28
+ * `accepted` binds because a human read the recommendation and took it; `typed`
29
+ * and `host-set` for the same reason at either end (a person's own words, and a
30
+ * decision the host makes deterministically rather than leaving to a model).
31
+ * Everything else was produced by automation reasoning about the task, and
32
+ * automation may not mint an unwaivable rule.
33
+ */
34
+ export declare const CONSTRAINT_WEIGHTS: Record<ConstraintProvenance, ConstraintWeight>;
35
+ export declare function constraintWeight(provenance: ConstraintProvenance): ConstraintWeight;
36
+ /** The weight marker a child sees on a constraint line, and probes match on. */
37
+ export declare function weightTag(provenance: ConstraintProvenance): string;
38
+ /** Does this collection of findings name at least one BINDING constraint? */
39
+ export declare function anyBinding(findings: readonly string[]): boolean;
40
+ /**
41
+ * The spec's CONSTRAINTS, annotated with each line's weight, for a child that
42
+ * must judge them. Returns null when the spec states none — the caller then emits
43
+ * no block rather than an empty heading.
44
+ */
45
+ export declare function annotateConstraints(constraints: readonly Constraint[]): string | null;
46
+ /**
47
+ * The policy, as prompt lines. ONE renderer: the verify pass and the resolution
48
+ * judge cite the same paragraph, so the two children cannot hold opposite
49
+ * policies again. `heading` differs because the two prompts number their rules
50
+ * differently — that is the only thing a call site may vary.
51
+ */
52
+ export declare function renderConstraintPolicy(heading: string): string[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What each provenance is worth. A new `ConstraintProvenance` member is a compile
3
+ * error until it declares its weight — which is the point, since the cheapest way
4
+ * back to 0043 is a new automated kind quietly defaulting to `binding`.
5
+ *
6
+ * `accepted` binds because a human read the recommendation and took it; `typed`
7
+ * and `host-set` for the same reason at either end (a person's own words, and a
8
+ * decision the host makes deterministically rather than leaving to a model).
9
+ * Everything else was produced by automation reasoning about the task, and
10
+ * automation may not mint an unwaivable rule.
11
+ */
12
+ export const CONSTRAINT_WEIGHTS = {
13
+ typed: 'binding',
14
+ 'host-set': 'binding',
15
+ spec: 'binding',
16
+ accepted: 'binding',
17
+ auto: 'advisory',
18
+ yolo: 'advisory',
19
+ 'yolo-skip': 'advisory',
20
+ 'auto-resolved': 'advisory',
21
+ derived: 'advisory'
22
+ };
23
+ export function constraintWeight(provenance) {
24
+ return CONSTRAINT_WEIGHTS[provenance];
25
+ }
26
+ /** The weight marker a child sees on a constraint line, and probes match on. */
27
+ export function weightTag(provenance) {
28
+ return `[${constraintWeight(provenance)}: ${provenance}]`;
29
+ }
30
+ /** Does this collection of findings name at least one BINDING constraint? */
31
+ export function anyBinding(findings) {
32
+ return findings.some(f => f.includes('[binding:'));
33
+ }
34
+ /**
35
+ * The spec's CONSTRAINTS, annotated with each line's weight, for a child that
36
+ * must judge them. Returns null when the spec states none — the caller then emits
37
+ * no block rather than an empty heading.
38
+ */
39
+ export function annotateConstraints(constraints) {
40
+ if (constraints.length === 0)
41
+ return null;
42
+ return constraints.map(c => `- ${c.text} ${weightTag(c.provenance)}`).join('\n');
43
+ }
44
+ /**
45
+ * The policy, as prompt lines. ONE renderer: the verify pass and the resolution
46
+ * judge cite the same paragraph, so the two children cannot hold opposite
47
+ * policies again. `heading` differs because the two prompts number their rules
48
+ * differently — that is the only thing a call site may vary.
49
+ */
50
+ export function renderConstraintPolicy(heading) {
51
+ return [
52
+ `${heading} A CONSTRAINT IS WORTH ITS PROVENANCE. Each CONSTRAINTS line is tagged with`,
53
+ ' its weight and where it came from:',
54
+ ` - ${weightTag('typed')} / ${weightTag('spec')} — BINDING. A person stated it, or the`,
55
+ ' task itself carried it in. Shipped work that violates one is a FAIL naming the',
56
+ ' constraint; you have no waiver authority over it, and "it works anyway" is exactly',
57
+ ' the waiver you do not have.',
58
+ ` - ${weightTag('auto')} / ${weightTag('derived')} — ADVISORY. Automation produced it`,
59
+ ' (an auto-answered question, an unattended pick, a line the composer wrote itself),',
60
+ ' so it records intent, not a contract. A violated advisory constraint is a FAIL only',
61
+ ' when an ACCEPTANCE bullet ALSO fails; on its own it is a note in your report and',
62
+ ' the verdict stands on the acceptance criteria.',
63
+ ' ACCEPTANCE is the bar in both cases. Never soften a binding constraint, and never',
64
+ ' fail work solely for an advisory one.'
65
+ ];
66
+ }
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * TWO SILENT SHAPES ARE LOSSES, and both are recognisable from the output alone:
9
9
  *
10
- * LOOP_DEGRADE: the worker thrashed the same call until the loop-killer fired,
11
- * leaving the degrade banner in place of a section. research-worker.ts writes
12
- * that banner as "stuck in a loop — called <tool>(<args>) ×<n> in the last <m>
13
- * calls…", which is the substring this keys on.
10
+ * LOOP_DEGRADE: the worker thrashed until a runaway guard fired, leaving the
11
+ * degrade banner in place of a section. research-worker.ts writes that banner
12
+ * from `describeLoopHit` (loop-detector.ts) "stuck in a loop — called
13
+ * <tool>(<args>) …" or "stopped covering new ground …" — which is what
14
+ * LOOP_BANNER keys on.
14
15
  *
15
16
  * GENERATION_GARBAGE: the worker exited 0 and emitted a non-bullet fragment
16
17
  * instead of context — a stray sentence, or a hallucinated system note.
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * TWO SILENT SHAPES ARE LOSSES, and both are recognisable from the output alone:
9
9
  *
10
- * LOOP_DEGRADE: the worker thrashed the same call until the loop-killer fired,
11
- * leaving the degrade banner in place of a section. research-worker.ts writes
12
- * that banner as "stuck in a loop — called <tool>(<args>) ×<n> in the last <m>
13
- * calls…", which is the substring this keys on.
10
+ * LOOP_DEGRADE: the worker thrashed until a runaway guard fired, leaving the
11
+ * degrade banner in place of a section. research-worker.ts writes that banner
12
+ * from `describeLoopHit` (loop-detector.ts) "stuck in a loop — called
13
+ * <tool>(<args>) …" or "stopped covering new ground …" — which is what
14
+ * LOOP_BANNER keys on.
14
15
  *
15
16
  * GENERATION_GARBAGE: the worker exited 0 and emitted a non-bullet fragment
16
17
  * instead of context — a stray sentence, or a hallucinated system note.
@@ -26,7 +27,14 @@
26
27
  export function countBullets(contextText) {
27
28
  return contextText.split('\n').filter(l => /^\s*[-*]\s+/.test(l)).length;
28
29
  }
29
- const LOOP_BANNER = /stuck in a loop/i;
30
+ /**
31
+ * The runaway-degrade banner, in either wording: an exact-repeat loop, or the
32
+ * progress rule that kills a worker for covering no new ground
33
+ * (loop-detector.ts `describeLoopHit`). Both are the same loss — a worker killed
34
+ * mid-answer — and keying on the loop phrase alone filed the second as a
35
+ * hallucinated fragment.
36
+ */
37
+ const LOOP_BANNER = /stuck in a loop|stopped covering new ground|tokens of tool output/i;
30
38
  /** Honest "nothing to surface" — the ONLY non-loss silent shape. */
31
39
  const EMPTY_DECLARATION = /^\s*(none|n\/a|no relevant (context|architectural)|nothing\b)/i;
32
40
  /**
@@ -28,8 +28,8 @@
28
28
  * noun, no web-app assumption. A CLI, a data pipeline, a library, a refactor, a
29
29
  * docs task all flow through the same integers.
30
30
  */
31
- /** A scored plan candidate — the minimum the adoption rule needs. */
32
31
  import type { CoverageAccounting } from './requirements.js';
32
+ /** A scored plan candidate — the minimum the adoption rule needs. */
33
33
  export interface CoveragePlan {
34
34
  titles: string[];
35
35
  /**
@@ -80,6 +80,8 @@ export interface AdoptionDecision {
80
80
  reason: string;
81
81
  /** Owned-requirement indices the retry would drop (for the debug trail). */
82
82
  dropped: number[];
83
+ /** No further round can improve on the current plan — stop reprompting. */
84
+ terminal?: boolean;
83
85
  }
84
86
  /**
85
87
  * Whether a coverage-retry should REPLACE the current plan.
@@ -91,9 +93,10 @@ export interface AdoptionDecision {
91
93
  * guarantee; it holds regardless of how the un-ownable requirements were
92
94
  * classified, so it backstops the cross-cutting classifier completely.
93
95
  * 2b. WITH requirement signal: reject a retry that grows the plan while covering
94
- * NOTHING new the tiebreak that makes "ship the best" also mean "ship the
95
- * smallest among equals". Without it the guard is inert against the superset
96
- * the reprompt asks for; see the long note at the branch.
96
+ * NOTHING new, and mark the loop TERMINAL the tiebreak that makes "ship the
97
+ * best" also mean "ship the smallest among equals". Without it the guard is
98
+ * inert against the superset the reprompt asks for; see the long note at the
99
+ * branch.
97
100
  * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
98
101
  * refuse a retry that leaves MORE areas uncovered than the current plan — so
99
102
  * the no-requirements path also ships the best, not the last.
@@ -28,133 +28,7 @@
28
28
  * noun, no web-app assumption. A CLI, a data pipeline, a library, a refactor, a
29
29
  * docs task all flow through the same integers.
30
30
  */
31
- // Ubiquitous words that carry no coverage signal: they appear across most task
32
- // titles and requirement quotes, so overlap on them would falsely connect a
33
- // requirement to any plan. Stopped so grounding keys on the DISTINCTIVE nouns
34
- // (json, dead-letter, serialize, symlink…) that actually name a deliverable.
35
- //
36
- // Confirmed: titles built only from these words own NOTHING, while titles naming
37
- // `JSON output` and `dead-letter queue` own the matching requirements.
38
- //
39
- // English function words + generic task verbs + generic project nouns — all
40
- // domain-agnostic.
41
- const COVERAGE_STOPWORDS = new Set([
42
- // function words
43
- 'the',
44
- 'a',
45
- 'an',
46
- 'and',
47
- 'or',
48
- 'of',
49
- 'to',
50
- 'in',
51
- 'on',
52
- 'for',
53
- 'with',
54
- 'by',
55
- 'at',
56
- 'as',
57
- 'is',
58
- 'are',
59
- 'be',
60
- 'it',
61
- 'its',
62
- 'that',
63
- 'this',
64
- 'from',
65
- 'into',
66
- 'out',
67
- 'up',
68
- 'per',
69
- 'via',
70
- 'not',
71
- 'no',
72
- 'but',
73
- 'if',
74
- 'then',
75
- 'than',
76
- 'so',
77
- 'such',
78
- 'each',
79
- 'any',
80
- 'all',
81
- 'every',
82
- 'when',
83
- 'where',
84
- 'must',
85
- 'should',
86
- 'shall',
87
- 'may',
88
- 'can',
89
- 'will',
90
- 'end',
91
- 'new',
92
- // generic task verbs
93
- 'add',
94
- 'implement',
95
- 'create',
96
- 'build',
97
- 'scaffold',
98
- 'setup',
99
- 'set',
100
- 'support',
101
- 'handle',
102
- 'apply',
103
- 'use',
104
- 'used',
105
- 'using',
106
- 'make',
107
- 'makes',
108
- 'made',
109
- 'enable',
110
- 'provide',
111
- 'ensure',
112
- 'allow',
113
- 'run',
114
- 'runs',
115
- 'get',
116
- 'gets',
117
- 'define',
118
- 'configure',
119
- 'init',
120
- 'update',
121
- 'manage',
122
- // generic project nouns
123
- 'cli',
124
- 'tool',
125
- 'app',
126
- 'application',
127
- 'project',
128
- 'feature',
129
- 'task',
130
- 'tasks',
131
- 'user',
132
- 'users',
133
- 'mode',
134
- 'flag',
135
- 'flags',
136
- 'option',
137
- 'options',
138
- 'system',
139
- 'code',
140
- 'thing',
141
- 'things',
142
- 'work'
143
- ]);
144
- /** Distinctive content tokens of a phrase: lowercased alphanumeric words ≥3 chars,
145
- * minus the ubiquitous stopwords. `--json` → `json`, `dead-letter` → `dead`,`letter`.
146
- * A single trailing `s` is stripped (len ≥4) so `scan`/`scans`, `file`/`files`,
147
- * `serialize`/`serializes` match — plain plural/3rd-person, no full stemmer. */
148
- function contentTokens(s) {
149
- const out = new Set();
150
- for (const raw of s.toLowerCase().split(/[^a-z0-9]+/)) {
151
- if (raw.length < 3 || COVERAGE_STOPWORDS.has(raw))
152
- continue;
153
- const w = raw.length >= 4 && raw.endsWith('s') && !raw.endsWith('ss') ? raw.slice(0, -1) : raw;
154
- out.add(w);
155
- }
156
- return out;
157
- }
31
+ import { contentTokens } from '../shared/content-tokens.js';
158
32
  /**
159
33
  * DETERMINISTIC owned-set for the monotonic guard — grounded in requirement↔title
160
34
  * token overlap, NOT the coverage-map model's `TASK n` verdict.
@@ -224,9 +98,10 @@ export function droppedCoverage(current, retry) {
224
98
  * guarantee; it holds regardless of how the un-ownable requirements were
225
99
  * classified, so it backstops the cross-cutting classifier completely.
226
100
  * 2b. WITH requirement signal: reject a retry that grows the plan while covering
227
- * NOTHING new the tiebreak that makes "ship the best" also mean "ship the
228
- * smallest among equals". Without it the guard is inert against the superset
229
- * the reprompt asks for; see the long note at the branch.
101
+ * NOTHING new, and mark the loop TERMINAL the tiebreak that makes "ship the
102
+ * best" also mean "ship the smallest among equals". Without it the guard is
103
+ * inert against the superset the reprompt asks for; see the long note at the
104
+ * branch.
230
105
  * 3. WITHOUT requirement signal: fall back to the count floor, and additionally
231
106
  * refuse a retry that leaves MORE areas uncovered than the current plan — so
232
107
  * the no-requirements path also ships the best, not the last.
@@ -265,23 +140,26 @@ export function decideAdoption(current, retry, hasRequirements) {
265
140
  // always adopted; an independently-sampled one often is not — the guard has
266
141
  // power, just not against the shape the prompt requests).
267
142
  //
268
- // REJECT, never break. Rejection keeps the smaller plan and lets the loop
269
- // reprompt again; breaking here forfeits a later round that would have
270
- // gained: a later round can add the requirement a tied round did not. "No gain this round" is not "no gain ever".
143
+ // TERMINAL, not merely rejected. The reprompt asks for every title the
144
+ // previous list had PLUS the missing areas, so the next round is drawn from
145
+ // the same instruction that just produced a superset covering nothing new;
146
+ // "reject and ask again" spends the remaining rounds re-asking a question
147
+ // already answered. The measured shape is a plan inflating round after
148
+ // round with the owned-set pinned, every step logged as "preserves owned
149
+ // coverage".
271
150
  //
272
151
  // Safety is structural, not statistical: this branch is reachable only when
273
152
  // the retry covers NO MORE than the current plan, so it can never decline a
274
- // strictly better one. It removes plans inflated by a retry that added
275
- // titles without adding coverage, and leaves coverage itself alone — the
276
- // win is removing pathological
277
- // inflation, not shrinking plans generally.
153
+ // strictly better one and the plan that ships is the current one, which is
154
+ // the best seen.
278
155
  if (retry.covered.size <= current.covered.size
279
156
  && retry.titles.length > current.titles.length) {
280
157
  return {
281
158
  adopt: false,
282
159
  reason: `no coverage gain for +${retry.titles.length - current.titles.length} titles `
283
160
  + `(${current.covered.size} owned, unchanged)`,
284
- dropped: []
161
+ dropped: [],
162
+ terminal: true
285
163
  };
286
164
  }
287
165
  return { adopt: true, reason: 'preserves owned coverage', dropped: [] };
@@ -22,13 +22,36 @@ export declare function debugLogLevel(getEnv?: (k: string) => string | undefined
22
22
  * `off` writes nothing, `full` writes both kinds, `events` writes events and
23
23
  * drops stream. */
24
24
  export declare function shouldLogDebug(kind: DebugLine, level: DebugLogLevel): boolean;
25
+ /**
26
+ * Longest line the trail keeps. A tool result the child pasted whole, a minified
27
+ * bundle, a base64 blob — one of them can be most of the file, and past a
28
+ * paragraph or so nothing is being explained any more. The overflow is reported
29
+ * rather than dropped silently, so a truncated line still says it was truncated.
30
+ */
31
+ export declare const DEBUG_LINE_LIMIT = 4096;
32
+ /**
33
+ * What actually reaches the file: control characters stripped, every line capped.
34
+ *
35
+ * One NUL makes the whole file BINARY to grep, and the trail's only job is to be
36
+ * grepped — a 1.7 MB `verify-debug.log` full of terminal control bytes needed
37
+ * `grep -a` before it could be read at all. Newlines are the exception and stay:
38
+ * a multi-line message stays multi-line, with the timestamp on its first line.
39
+ */
40
+ export declare function sanitizeDebugLine(msg: string): string;
41
+ /**
42
+ * Timestamp, sanitise and append one trail line, fire-and-forget. Errors are
43
+ * swallowed — an unwritable trail must never fail the run it is describing.
44
+ *
45
+ * An injected `appendFile` owns its own directory: a fake has none, and mkdir-ing
46
+ * a test's imaginary path would write to the real filesystem.
47
+ */
48
+ export declare function appendDebugLine(logPath: string, msg: string, appendFile?: (p: string, data: string) => Promise<unknown>): void;
25
49
  /**
26
50
  * A timestamped fire-and-forget appender for one trail file, level-gated.
27
51
  *
28
52
  * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
29
53
  * useful direction: forgetting to classify a marker keeps it in the audit trail,
30
- * whereas forgetting to classify chatter would only make the log bigger. Errors
31
- * are swallowed — an unwritable trail must never fail the run it is describing.
54
+ * whereas forgetting to classify chatter would only make the log bigger.
32
55
  */
33
56
  export declare function makeDebugAppender(logPath: string, appendFile?: (p: string, data: string) => Promise<unknown>): (msg: string, kind?: DebugLine) => void;
34
57
  /**
@@ -1,13 +1,15 @@
1
1
  /**
2
- * One place that decides whether a `.pi-tasks/*-debug.log` line gets written.
2
+ * One place that decides whether a `*-debug.log` line gets written, and what
3
+ * reaches the file when it does. The files live in the run's state directory
4
+ * (state-dir.ts), outside the repository.
3
5
  *
4
6
  * THE TRAIL IS WRITE-ONLY in production. Nothing under src/ reads these files
5
7
  * back: `task-io.ts` globs `TASK_NNNN.md` and skips everything else, and
6
- * auto-commit's `snapshotTrail` copies the bytes across a `reset --hard` without
7
- * ever parsing them. Every producer is a `logDebug?.(…)` / `log(…)` side effect
8
- * whose return value is discarded. So this gate cannot change what a run DOES —
9
- * only what it can explain afterwards. (Tests do read the trail back, which is why
10
- * `flushPlanDebug` exists.)
8
+ * auto-commit's `snapshotTrail` copies `.pi-tasks/` across a `reset --hard`
9
+ * without ever parsing it. Every producer is a `logDebug?.(…)` / `log(…)` side
10
+ * effect whose return value is discarded. So this gate cannot change what a run
11
+ * DOES — only what it can explain afterwards. (Tests do read the trail back,
12
+ * which is why `flushPlanDebug` exists.)
11
13
  *
12
14
  * TWO KINDS OF LINE, and the distinction is the whole point of having three levels
13
15
  * rather than a boolean:
@@ -30,6 +32,7 @@
30
32
  * A machine-local `"debugLogs": "off"` still wins — this is a default, not a floor.
31
33
  */
32
34
  import * as fsp from 'node:fs/promises';
35
+ import * as path from 'node:path';
33
36
  import { getConfig, sanitizeDebugLogs } from '../config/config.js';
34
37
  /**
35
38
  * Escape hatch for reproducing a user's bug without walking them through
@@ -66,19 +69,70 @@ export function shouldLogDebug(kind, level) {
66
69
  return true;
67
70
  return kind === 'event';
68
71
  }
72
+ /**
73
+ * Longest line the trail keeps. A tool result the child pasted whole, a minified
74
+ * bundle, a base64 blob — one of them can be most of the file, and past a
75
+ * paragraph or so nothing is being explained any more. The overflow is reported
76
+ * rather than dropped silently, so a truncated line still says it was truncated.
77
+ */
78
+ export const DEBUG_LINE_LIMIT = 4096;
79
+ const CONTROL_CHARS = /\p{Cc}/gu;
80
+ function capLine(line) {
81
+ const over = line.length - DEBUG_LINE_LIMIT;
82
+ return over > 0 ? `${line.slice(0, DEBUG_LINE_LIMIT)}…+${over} chars` : line;
83
+ }
84
+ /**
85
+ * What actually reaches the file: control characters stripped, every line capped.
86
+ *
87
+ * One NUL makes the whole file BINARY to grep, and the trail's only job is to be
88
+ * grepped — a 1.7 MB `verify-debug.log` full of terminal control bytes needed
89
+ * `grep -a` before it could be read at all. Newlines are the exception and stay:
90
+ * a multi-line message stays multi-line, with the timestamp on its first line.
91
+ */
92
+ export function sanitizeDebugLine(msg) {
93
+ return msg
94
+ .split('\n')
95
+ .map(line => capLine(line.replace(CONTROL_CHARS, '')))
96
+ .join('\n');
97
+ }
98
+ const ensuredDirs = new Map();
99
+ /** A run's state directory does not exist until its first line — mkdir once per
100
+ * directory rather than once per line. */
101
+ function ensureDir(dir) {
102
+ let made = ensuredDirs.get(dir);
103
+ if (!made) {
104
+ made = fsp.mkdir(dir, { recursive: true }).catch(() => { });
105
+ ensuredDirs.set(dir, made);
106
+ }
107
+ return made;
108
+ }
109
+ async function appendToTrail(p, data) {
110
+ await ensureDir(path.dirname(p));
111
+ await fsp.appendFile(p, data);
112
+ }
113
+ /**
114
+ * Timestamp, sanitise and append one trail line, fire-and-forget. Errors are
115
+ * swallowed — an unwritable trail must never fail the run it is describing.
116
+ *
117
+ * An injected `appendFile` owns its own directory: a fake has none, and mkdir-ing
118
+ * a test's imaginary path would write to the real filesystem.
119
+ */
120
+ export function appendDebugLine(logPath, msg, appendFile = appendToTrail) {
121
+ const line = `${new Date().toISOString()} ${sanitizeDebugLine(msg)}\n`;
122
+ void appendFile(logPath, line).catch(() => { });
123
+ }
69
124
  /**
70
125
  * A timestamped fire-and-forget appender for one trail file, level-gated.
71
126
  *
72
127
  * `kind` defaults to `'event'` so a new call site is quiet-by-default in the
73
128
  * useful direction: forgetting to classify a marker keeps it in the audit trail,
74
- * whereas forgetting to classify chatter would only make the log bigger. Errors
75
- * are swallowed — an unwritable trail must never fail the run it is describing.
129
+ * whereas forgetting to classify chatter would only make the log bigger.
76
130
  */
77
- export function makeDebugAppender(logPath, appendFile = (p, data) => fsp.appendFile(p, data)) {
131
+ export function makeDebugAppender(logPath, appendFile = appendToTrail) {
78
132
  return (msg, kind = 'event') => {
79
133
  if (!shouldLogDebug(kind, debugLogLevel()))
80
134
  return;
81
- void appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
135
+ appendDebugLine(logPath, msg, appendFile);
82
136
  };
83
137
  }
84
138
  /**
@@ -1,8 +1,16 @@
1
+ import { type Block, type SpecDoc } from './spec-doc.js';
2
+ export interface GroundedSource {
3
+ /** The citation as the model wrote it. */
4
+ quote: string;
5
+ /** The document block it grounded in — null when it matches only ACROSS block
6
+ * boundaries (a model quoting two consecutive bullets as one line). */
7
+ block: Block | null;
8
+ }
1
9
  export interface SourcedTitle {
2
10
  /** The title with every source clause stripped. */
3
11
  base: string;
4
12
  /** The cited spec lines, in order, keeping only those GROUNDED in the doc. */
5
- sources: string[];
13
+ sources: GroundedSource[];
6
14
  }
7
15
  /**
8
16
  * Split a decompose title into its base and its GROUNDED source citations.
@@ -22,9 +30,9 @@ export interface SourcedTitle {
22
30
  * An absent clause yields no sources; a fabricated (ungrounded) one is dropped
23
31
  * — exactly like keepGroundedContracts rejects a paraphrased quote.
24
32
  */
25
- export declare function extractTitleSource(title: string, sourceDoc: string): SourcedTitle;
33
+ export declare function extractTitleSource(title: string, sourceDoc: string | SpecDoc): SourcedTitle;
26
34
  /**
27
- * The `+`-joined trailing constraint fragments of `sourceLine` whose words are
35
+ * The `+`-joined trailing constraint fragments of a spec block whose words are
28
36
  * absent from `title`. "2. **Auth** — sessions, login/logout/me, guards + tests."
29
37
  * yields the fragment "tests"; a title that never mentions tests gets it back.
30
38
  * Fragments before the first `+` are the task's body — a title paraphrases those
@@ -32,8 +40,13 @@ export declare function extractTitleSource(title: string, sourceDoc: string): So
32
40
  * ("+ Tailwind v4 tokens, nav, router" is three constraints), so a title missing
33
41
  * one of them gets ONLY that one restored, not the whole phrase (measured live:
34
42
  * whole-phrase restoration re-attached text the title already carried).
43
+ *
44
+ * A bare string is read as prose — the shape a caller that has no document has.
35
45
  */
36
- export declare function findDroppedPlusFragments(sourceLine: string, title: string): string[];
46
+ export declare function findDroppedPlusFragments(source: Block | string, title: string): string[];
47
+ /** Restored fragments as a DELIMITED list. A bare `join(', ')` is ambiguous the
48
+ * moment a fragment contains a comma of its own — and they routinely do. */
49
+ export declare function renderFragments(fragments: string[]): string;
37
50
  export interface TitleRestoration {
38
51
  /** Index into the reconciled titles array. */
39
52
  index: number;