@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
package/README.md CHANGED
@@ -233,7 +233,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
233
233
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
234
234
  | **profile** | default | How much the helper sessions think, in one word, for every step at once. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. **default** uses the per-step table pi-task has measured, **on** and **off** force one answer everywhere and ignore that table, and **custom** is whatever the step rows say — changing any of them switches this to custom. A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to, which is what every step did before this setting existed. |
235
235
  | **steps: …** | models `inherit`; levels per the shipped table | One row per group of steps, carrying BOTH dials: the model those children run on and the level they think at, shown as `level · model`. Enter walks a two-step picker — model first, then level — and **the level step offers only what that model declares, opening on the one that will actually run**. That is the whole point of the merge: pi silently CLAMPS a level a model cannot do (a level you set can be erased, and an `off` can be clamped back up to `medium`), so instead of discovering that later you watch the cursor land on the level you are really getting. Models are offered from `pi.modelRegistry.getAvailable()` and stored as the canonical `provider/id` that pi's own `--model` takes. **inherit** on the model half emits no flag, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the step. Two need care — a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **implementation** is not free, because it is *your* session moved for the turn and moved back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
236
- | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
236
+ | **debug logs** | events | How much of a run is written to its log directory, `${XDG_STATE_HOME:-~/.local/state}/pi-task/<repo-hash>/<run-id>/*-debug.log` — outside the repository, so the trail is never committed, never rewound by a gate, and never re-read when one is. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Control characters are stripped and long lines are capped, so the files stay greppable. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. A completed `/task-auto` run keeps the 20 most recent runs' logs for the repository and deletes the rest. |
237
237
  | **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
238
238
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
239
239
 
@@ -246,6 +246,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
246
246
  | `CARGO_HOME` | `pi-worker-docs` | Where crate source checkouts are read from (defaults to `~/.cargo`). |
247
247
  | `CABAL_DIR` | `pi-worker-docs` | Where cabal's downloaded package tarballs are read from (also checks `~/.cabal/packages` and `${XDG_CACHE_HOME:-~/.cache}/cabal/packages`). |
248
248
  | `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
249
+ | `XDG_STATE_HOME` | task trail | Where a run's `*-debug.log` files are kept, under `pi-task/<repo-hash>/<run-id>/` (defaults to `~/.local/state`). A relative value is ignored, per the XDG spec. |
249
250
  | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
250
251
  | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
251
252
  | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
@@ -255,15 +256,17 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
255
256
 
256
257
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. A run also keeps small
257
258
  line-oriented ledgers beside them — contracts, launch contract, environment
258
- notes, accepted debt, repair queue, requirements plus `*-debug.log` when
259
- **debug logs** is on. Add `.pi-tasks/` to your `.gitignore` if you don't want
259
+ notes, accepted debt, repair queue, requirements. The `*-debug.log` trail is not
260
+ among them: it lives outside the tree, under
261
+ `${XDG_STATE_HOME:-~/.local/state}/pi-task/<repo-hash>/<run-id>/`.
262
+ Add `.pi-tasks/` to your `.gitignore` if you don't want
260
263
  them checked in.
261
264
 
262
265
  ## Development
263
266
 
264
267
  ```sh
265
268
  bun install
266
- bun run test # 4631 tests pass across 247 files (1 skip)
269
+ bun run test # 4770 tests pass across 252 files (3 skips)
267
270
  bun run lint # prettier + eslint + tsc --noEmit
268
271
  bun run build # tsc → dist/
269
272
  ```
@@ -7,13 +7,27 @@ export interface PiTaskConfig {
7
7
  enforceGuidelines: boolean;
8
8
  verifyWork: boolean;
9
9
  /**
10
- * Run the four research workers concurrently instead of one at a time
11
- * (task/phases.ts). DEFAULT OFF. Serial is also what lets a worker read the
12
- * sections finished before it APIS builds on the FILES map, and gets
13
- * nothing under the parallel branch. Measure your own backend before
14
- * turning this on.
10
+ * What shape the research phase runs its four workers in (task/phases.ts).
11
+ *
12
+ * `graph` (DEFAULT) runs each worker as soon as the sections it declares it
13
+ * comes `after` are finished: FILES first, then APIS, with CONTEXT and TOOLING
14
+ * alongside. The FILES→APIS handoff survives, which the old all-at-once
15
+ * parallel mode lost.
16
+ *
17
+ * `serial` runs them one at a time, for a single local GPU where concurrent
18
+ * streams share one device and slow each other down.
19
+ */
20
+ researchConcurrency: ResearchConcurrency;
21
+ /**
22
+ * Extra paths the project tour must never pre-read, as `.gitignore`-style
23
+ * patterns (`docs/generated/**`, `fixtures/`).
24
+ *
25
+ * The shipped exclusions — vendored dependencies, build output, the agent's
26
+ * own directories — are `VENDORED_DIRS` in task/orientation.ts, and the
27
+ * repo's `.gitignore` is read on top of them. This is the hand-edited third
28
+ * source, for a tracked directory that is bulk to this project alone.
15
29
  */
16
- parallelResearchWorkers: boolean;
30
+ orientationExclude: string[];
17
31
  /**
18
32
  * Cache docs/search/fetch worker RESULTS for the duration of one /task-auto
19
33
  * run, so sibling tasks re-asking the same (package/url, query) reuse the
@@ -44,6 +58,15 @@ export interface PiTaskConfig {
44
58
  * /task-config, which enumerates the currently installed extensions.
45
59
  */
46
60
  extensionWhitelist: string[];
61
+ /**
62
+ * Extra suppression spellings the verify gate's widening probe counts, as
63
+ * regular-expression SOURCES (see task/suppression-probe.ts). The shipped
64
+ * registry covers the common checkers across ecosystems; this is for a
65
+ * project's own — a house `// SAFETY-OVERRIDE` marker, a generated-code
66
+ * pragma. An entry that does not compile is dropped, never fatal.
67
+ * DEFAULT empty: the shipped rows are the policy until a project says more.
68
+ */
69
+ suppressionPatterns: string[];
47
70
  /**
48
71
  * Wall-clock ceiling (ms) on a SINGLE tool execution before the command
49
72
  * watchdog steps in. pi's bash tool declares its `timeout` parameter as
@@ -117,14 +140,13 @@ export interface PiTaskConfig {
117
140
  */
118
141
  yoloMode: boolean;
119
142
  /**
120
- * How much the run writes to its `.pi-tasks/*-debug.log` forensic trail
121
- * (task/debug-log.ts). Nothing in `src/` reads these files back —
122
- * `task-io.ts` only matches `TASK_NNNN.md`, and auto-commit's trail snapshot
123
- * reads every `.pi-tasks/` file as bytes and writes them back unparsed — so
124
- * this knob is behaviour-neutral by construction. It trades disk and repo
125
- * noise against the ability to explain a run after it has finished. The
126
- * TESTS do read the trail back, which is why the test preload pins the
127
- * config path away from the developer's own file.
143
+ * How much the run writes to its `*-debug.log` forensic trail
144
+ * (task/debug-log.ts), kept in the run's state dir (task/state-dir.ts).
145
+ * Nothing in `src/` reads these files back, so this knob is
146
+ * behaviour-neutral by construction. It trades disk against the ability to
147
+ * explain a run after it has finished. The TESTS do read the trail back,
148
+ * which is why the test preload pins the config path and the state home —
149
+ * away from the developer's own.
128
150
  *
129
151
  * `full` is every line the child model emitted plus every tool result;
130
152
  * `events` keeps only decisions and guard actions; `off` writes nothing.
@@ -171,8 +193,26 @@ export interface PiTaskConfig {
171
193
  */
172
194
  groupModels: Record<ChildGroup, string>;
173
195
  }
174
- /** How verbose the `.pi-tasks/*-debug.log` trail is. See {@link PiTaskConfig.debugLogs}. */
196
+ /** How verbose the `*-debug.log` trail is. See {@link PiTaskConfig.debugLogs}. */
175
197
  export type DebugLogLevel = 'off' | 'events' | 'full';
198
+ /** How the research workers are scheduled. See {@link PiTaskConfig.researchConcurrency}. */
199
+ export type ResearchConcurrency = 'graph' | 'serial';
200
+ /** The concurrency choices offered by /task-config, in cycle order. */
201
+ export declare const RESEARCH_CONCURRENCY_OPTIONS: ReadonlyArray<{
202
+ label: string;
203
+ value: ResearchConcurrency;
204
+ }>;
205
+ /** Pin a hand-edited or stale value to one of the offered choices. */
206
+ export declare function sanitizeResearchConcurrency(value: unknown): ResearchConcurrency;
207
+ /**
208
+ * The boolean this setting shipped as, and how a stored one is read.
209
+ *
210
+ * `true` meant "all four at once", which is the graph without its one edge, so it
211
+ * maps to `graph`; `false` meant one at a time. Kept as a documented alias rather
212
+ * than dropped, because dropping it silently re-enables concurrency on the local
213
+ * GPU of every user who turned it off.
214
+ */
215
+ export declare const DEPRECATED_PARALLEL_KEY = "parallelResearchWorkers";
176
216
  /**
177
217
  * The debug-log choices offered by /task-config, in cycle order (quietest →
178
218
  * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
@@ -200,6 +240,8 @@ export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
200
240
  * one of the offered choices so the watchdog never arms on a nonsense value.
201
241
  */
202
242
  export declare function sanitizeRequestTimeoutMs(value: unknown): number;
243
+ /** Keep only non-empty string patterns from a hand-edited exclusion list. */
244
+ export declare function sanitizeIgnorePatterns(value: unknown): string[];
203
245
  /** Keep only exact, unique Pi tool names from an advanced config override. */
204
246
  export declare function sanitizeCommandTimeoutExemptTools(value: unknown): string[];
205
247
  /**
@@ -222,10 +264,11 @@ export declare function sanitizeStreamInactivityMs(value: unknown): number;
222
264
  */
223
265
  export declare const DEFAULT_CONFIG: PiTaskConfig;
224
266
  /**
225
- * A hand-edited config can hold anything; keep only string entries so a stray
226
- * object/number can't reach the child argv as `-e [object Object]`.
267
+ * A hand-edited config can hold anything; keep only non-blank string entries so a
268
+ * stray object/number can't reach the child argv as `-e [object Object]`, or
269
+ * `new RegExp`.
227
270
  */
228
- export declare function sanitizeExtensionWhitelist(value: unknown): string[];
271
+ export declare function sanitizeStringList(value: unknown): string[];
229
272
  /**
230
273
  * How each setting's STORED value becomes a safe in-memory value — one loader per
231
274
  * key, keyed on the config's own type.
@@ -6,6 +6,27 @@ import { isSearchProvider } from '../workers/search-types.js';
6
6
  import { DEFAULT_REASONING_TABLE, sanitizeReasoningLevels, sanitizeReasoningMode } from './reasoning.js';
7
7
  import { DEFAULT_GROUP_MODELS, sanitizeGroupModels } from './group-models.js';
8
8
  import { DEFAULT_STREAM_INACTIVITY_MS } from '../shared/stream-watchdog.js';
9
+ /** The concurrency choices offered by /task-config, in cycle order. */
10
+ export const RESEARCH_CONCURRENCY_OPTIONS = [
11
+ { label: 'by dependency', value: 'graph' },
12
+ { label: 'one at a time', value: 'serial' }
13
+ ];
14
+ /** Pin a hand-edited or stale value to one of the offered choices. */
15
+ export function sanitizeResearchConcurrency(value) {
16
+ return RESEARCH_CONCURRENCY_OPTIONS.some(o => o.value === value) ?
17
+ value
18
+ : DEFAULT_RESEARCH_CONCURRENCY;
19
+ }
20
+ const DEFAULT_RESEARCH_CONCURRENCY = 'graph';
21
+ /**
22
+ * The boolean this setting shipped as, and how a stored one is read.
23
+ *
24
+ * `true` meant "all four at once", which is the graph without its one edge, so it
25
+ * maps to `graph`; `false` meant one at a time. Kept as a documented alias rather
26
+ * than dropped, because dropping it silently re-enables concurrency on the local
27
+ * GPU of every user who turned it off.
28
+ */
29
+ export const DEPRECATED_PARALLEL_KEY = 'parallelResearchWorkers';
9
30
  /**
10
31
  * The debug-log choices offered by /task-config, in cycle order (quietest →
11
32
  * loudest, so the cycle reads as a volume dial). Unlike the timeout options the
@@ -46,6 +67,15 @@ export function sanitizeRequestTimeoutMs(value) {
46
67
  value
47
68
  : DEFAULT_REQUEST_TIMEOUT_MS;
48
69
  }
70
+ /** Keep only non-empty string patterns from a hand-edited exclusion list. */
71
+ export function sanitizeIgnorePatterns(value) {
72
+ if (!Array.isArray(value))
73
+ return [];
74
+ return value
75
+ .filter((p) => typeof p === 'string')
76
+ .map(p => p.trim())
77
+ .filter(p => p.length > 0);
78
+ }
49
79
  /** Keep only exact, unique Pi tool names from an advanced config override. */
50
80
  export function sanitizeCommandTimeoutExemptTools(value) {
51
81
  if (!Array.isArray(value))
@@ -94,10 +124,14 @@ export const DEFAULT_CONFIG = {
94
124
  orientation: true,
95
125
  enforceGuidelines: true,
96
126
  verifyWork: true,
97
- parallelResearchWorkers: false,
127
+ // GRAPH: three of the four workers have no dependency on each other, and the
128
+ // one that does keeps its input. See ResearchConcurrency.
129
+ researchConcurrency: DEFAULT_RESEARCH_CONCURRENCY,
130
+ orientationExclude: [],
98
131
  researchCache: true,
99
132
  searchProvider: 'exa',
100
133
  extensionWhitelist: [],
134
+ suppressionPatterns: [],
101
135
  requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
102
136
  commandTimeoutExemptTools: [],
103
137
  streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
@@ -115,10 +149,11 @@ export const DEFAULT_CONFIG = {
115
149
  groupModels: { ...DEFAULT_GROUP_MODELS }
116
150
  };
117
151
  /**
118
- * A hand-edited config can hold anything; keep only string entries so a stray
119
- * object/number can't reach the child argv as `-e [object Object]`.
152
+ * A hand-edited config can hold anything; keep only non-blank string entries so a
153
+ * stray object/number can't reach the child argv as `-e [object Object]`, or
154
+ * `new RegExp`.
120
155
  */
121
- export function sanitizeExtensionWhitelist(value) {
156
+ export function sanitizeStringList(value) {
122
157
  if (!Array.isArray(value))
123
158
  return [];
124
159
  return value.filter((p) => typeof p === 'string' && p.trim().length > 0);
@@ -146,13 +181,15 @@ export const CONFIG_LOADERS = {
146
181
  orientation: asBoolean('orientation'),
147
182
  enforceGuidelines: asBoolean('enforceGuidelines'),
148
183
  verifyWork: asBoolean('verifyWork'),
149
- parallelResearchWorkers: asBoolean('parallelResearchWorkers'),
184
+ researchConcurrency: sanitizeResearchConcurrency,
185
+ orientationExclude: sanitizeIgnorePatterns,
150
186
  researchCache: asBoolean('researchCache'),
151
187
  yoloMode: asBoolean('yoloMode'),
152
188
  // A hand-edited or stale enum value must not leak an unknown provider into
153
189
  // the dispatch switch — fall back to the default.
154
190
  searchProvider: raw => (isSearchProvider(raw) ? raw : DEFAULT_CONFIG.searchProvider),
155
- extensionWhitelist: sanitizeExtensionWhitelist,
191
+ extensionWhitelist: sanitizeStringList,
192
+ suppressionPatterns: sanitizeStringList,
156
193
  requestTimeoutMs: sanitizeRequestTimeoutMs,
157
194
  commandTimeoutExemptTools: sanitizeCommandTimeoutExemptTools,
158
195
  streamInactivityMs: sanitizeStreamInactivityMs,
@@ -184,6 +221,13 @@ export function loadConfig(raw) {
184
221
  ;
185
222
  out[key] = CONFIG_LOADERS[key](stored[key]);
186
223
  }
224
+ // The deprecated boolean answers only for a config saved before the setting
225
+ // became an enum — a file carrying both was written by this version, so the
226
+ // enum is the user's live choice and the boolean is the leftover.
227
+ if (stored.researchConcurrency === undefined
228
+ && typeof stored[DEPRECATED_PARALLEL_KEY] === 'boolean') {
229
+ out.researchConcurrency = stored[DEPRECATED_PARALLEL_KEY] ? 'graph' : 'serial';
230
+ }
187
231
  return out;
188
232
  }
189
233
  /**
@@ -6,7 +6,7 @@ import { PairPicker } from './option-picker.js';
6
6
  import { registerBridgeCommand, isRemoteOrigin, publishNote } from '../remote/bridge.js';
7
7
  import { readPkgVersion } from '../shared/pkg-version.js';
8
8
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
9
- import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
9
+ import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, RESEARCH_CONCURRENCY_OPTIONS, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
10
10
  import { listInstalledExtensions } from './extension-list.js';
11
11
  import { listGuardableTools } from './tool-list.js';
12
12
  import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
@@ -174,9 +174,22 @@ export const ITEMS = [
174
174
  booleanItem('research', 'orientation', 'project tour', 'Show the research workers the shape of the project first — package manifest, '
175
175
  + 'types, schema — so they spend their steps on the question instead of on finding '
176
176
  + 'their way around'),
177
- booleanItem('research', 'parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
178
- + 'your model backend can answer several requests at the same time — on a single '
179
- + 'local GPU it is measurably slower, so leave it off there'),
177
+ {
178
+ id: 'researchConcurrency',
179
+ section: 'research',
180
+ label: 'research workers',
181
+ description: 'How the 4 research workers are run. "by dependency" starts each one as soon as '
182
+ + 'the work it builds on is done, so three of them overlap; "one at a time" runs '
183
+ + 'them in order, which is faster on a single local GPU, where parallel requests '
184
+ + 'share one device',
185
+ values: RESEARCH_CONCURRENCY_OPTIONS.map(o => o.label),
186
+ format: cfg => RESEARCH_CONCURRENCY_OPTIONS.find(o => o.value === cfg.researchConcurrency).label,
187
+ apply: (cfg, chosen) => {
188
+ const opt = RESEARCH_CONCURRENCY_OPTIONS.find(o => o.label === chosen);
189
+ if (opt)
190
+ cfg.researchConcurrency = opt.value;
191
+ }
192
+ },
180
193
  booleanItem('research', 'researchCache', 'research cache', 'Remember docs and web pages for the length of one run, so later tasks reuse what '
181
194
  + 'the first one already fetched instead of downloading it again. Only external '
182
195
  + 'sources, only successful fetches, and it is dropped when the run ends'),
@@ -259,7 +272,7 @@ export const ITEMS = [
259
272
  id: 'debugLogs',
260
273
  section: 'logging',
261
274
  label: 'debug logs',
262
- description: 'How much of a run gets written to .pi-tasks/*-debug.log. "events" keeps the '
275
+ description: 'How much of a run gets written to the run log dir. "events" keeps the '
263
276
  + 'decisions and the guard actions — what a checking step changed, why something '
264
277
  + 'failed — a few lines per task. "full" adds everything the model said and every '
265
278
  + 'command it ran, which is most of the size and only useful while you are digging '
@@ -0,0 +1,16 @@
1
+ /**
2
+ * content-tokens — the distinctive words of a phrase, and the one tokenizer that
3
+ * decides them.
4
+ *
5
+ * Two consumers ask the same question of a string and must get the same answer:
6
+ * the coverage guard, which grounds requirement/title ownership in shared
7
+ * distinctive nouns, and the research cache, whose key is the sorted token set of
8
+ * a query so two phrasings of one question share one digest. A second tokenizer
9
+ * would be a second answer.
10
+ */
11
+ export declare const CONTENT_STOPWORDS: Set<string>;
12
+ /** Distinctive content tokens of a phrase: lowercased alphanumeric words ≥3 chars,
13
+ * minus the ubiquitous stopwords. `--json` → `json`, `dead-letter` → `dead`,`letter`.
14
+ * A single trailing `s` is stripped (len ≥4) so `scan`/`scans`, `file`/`files`,
15
+ * `serialize`/`serializes` match — plain plural/3rd-person, no full stemmer. */
16
+ export declare function contentTokens(s: string): Set<string>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * content-tokens — the distinctive words of a phrase, and the one tokenizer that
3
+ * decides them.
4
+ *
5
+ * Two consumers ask the same question of a string and must get the same answer:
6
+ * the coverage guard, which grounds requirement/title ownership in shared
7
+ * distinctive nouns, and the research cache, whose key is the sorted token set of
8
+ * a query so two phrasings of one question share one digest. A second tokenizer
9
+ * would be a second answer.
10
+ */
11
+ // Ubiquitous words that carry no coverage signal: they appear across most task
12
+ // titles and requirement quotes, so overlap on them would falsely connect a
13
+ // requirement to any plan. Stopped so grounding keys on the DISTINCTIVE nouns
14
+ // (json, dead-letter, serialize, symlink…) that actually name a deliverable.
15
+ //
16
+ // Confirmed: titles built only from these words own NOTHING, while titles naming
17
+ // `JSON output` and `dead-letter queue` own the matching requirements.
18
+ //
19
+ // English function words + generic task verbs + generic project nouns — all
20
+ // domain-agnostic.
21
+ export const CONTENT_STOPWORDS = new Set([
22
+ // function words
23
+ 'the',
24
+ 'a',
25
+ 'an',
26
+ 'and',
27
+ 'or',
28
+ 'of',
29
+ 'to',
30
+ 'in',
31
+ 'on',
32
+ 'for',
33
+ 'with',
34
+ 'by',
35
+ 'at',
36
+ 'as',
37
+ 'is',
38
+ 'are',
39
+ 'be',
40
+ 'it',
41
+ 'its',
42
+ 'that',
43
+ 'this',
44
+ 'from',
45
+ 'into',
46
+ 'out',
47
+ 'up',
48
+ 'per',
49
+ 'via',
50
+ 'not',
51
+ 'no',
52
+ 'but',
53
+ 'if',
54
+ 'then',
55
+ 'than',
56
+ 'so',
57
+ 'such',
58
+ 'each',
59
+ 'any',
60
+ 'all',
61
+ 'every',
62
+ 'when',
63
+ 'where',
64
+ 'must',
65
+ 'should',
66
+ 'shall',
67
+ 'may',
68
+ 'can',
69
+ 'will',
70
+ 'end',
71
+ 'new',
72
+ // generic task verbs
73
+ 'add',
74
+ 'implement',
75
+ 'create',
76
+ 'build',
77
+ 'scaffold',
78
+ 'setup',
79
+ 'set',
80
+ 'support',
81
+ 'handle',
82
+ 'apply',
83
+ 'use',
84
+ 'used',
85
+ 'using',
86
+ 'make',
87
+ 'makes',
88
+ 'made',
89
+ 'enable',
90
+ 'provide',
91
+ 'ensure',
92
+ 'allow',
93
+ 'run',
94
+ 'runs',
95
+ 'get',
96
+ 'gets',
97
+ 'define',
98
+ 'configure',
99
+ 'init',
100
+ 'update',
101
+ 'manage',
102
+ // generic project nouns
103
+ 'cli',
104
+ 'tool',
105
+ 'app',
106
+ 'application',
107
+ 'project',
108
+ 'feature',
109
+ 'task',
110
+ 'tasks',
111
+ 'user',
112
+ 'users',
113
+ 'mode',
114
+ 'flag',
115
+ 'flags',
116
+ 'option',
117
+ 'options',
118
+ 'system',
119
+ 'code',
120
+ 'thing',
121
+ 'things',
122
+ 'work'
123
+ ]);
124
+ /** Distinctive content tokens of a phrase: lowercased alphanumeric words ≥3 chars,
125
+ * minus the ubiquitous stopwords. `--json` → `json`, `dead-letter` → `dead`,`letter`.
126
+ * A single trailing `s` is stripped (len ≥4) so `scan`/`scans`, `file`/`files`,
127
+ * `serialize`/`serializes` match — plain plural/3rd-person, no full stemmer. */
128
+ export function contentTokens(s) {
129
+ const out = new Set();
130
+ for (const raw of s.toLowerCase().split(/[^a-z0-9]+/)) {
131
+ if (raw.length < 3 || CONTENT_STOPWORDS.has(raw))
132
+ continue;
133
+ const w = raw.length >= 4 && raw.endsWith('s') && !raw.endsWith('ss') ? raw.slice(0, -1) : raw;
134
+ out.add(w);
135
+ }
136
+ return out;
137
+ }
@@ -47,8 +47,22 @@ import { type CommandRunner } from './command-run.js';
47
47
  * defect is recorded here and a scoped repair task is queued into the plan
48
48
  * (root-cause-repair.ts). Recording without queuing would let the same root
49
49
  * cause be re-recorded by each task it fails, with nothing ever fixing it.
50
+ * - 'spec-contradiction' — the gate's decision table proved the failing criterion
51
+ * can only be met by editing a path the SAME spec freezes, on any fail class
52
+ * (gate-resolution.ts). Supersedes 'frozen-blocked', which said the same thing
53
+ * about repo-health alone; that origin stays registered because debts already
54
+ * on disk carry it.
55
+ * - 'dismissed' — a human was shown the verify-FAIL picker and walked away. The
56
+ * defect is no less real for not being answered, and this is the class that
57
+ * used to leave no ledger entry at all.
58
+ * - 'inherited-health' — the repo entered the task ALREADY failing its own static
59
+ * check, so the FAIL is not this task's regression (see the differential health
60
+ * delta). Recorded rather than blamed: the task proceeds, the pre-existing
61
+ * breakage is still surfaced at run end.
62
+ * - 'abandoned' — the loop gave up on the task past its attempt budget without a
63
+ * verified artifact. Nothing else records what was left unfinished.
50
64
  */
51
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'enforce-kept' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
65
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'enforce-kept' | 'frozen-blocked' | 'spec-contradiction' | 'dismissed' | 'inherited-health' | 'abandoned' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
52
66
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
53
67
  export interface AcceptDebt {
54
68
  taskId: string;
@@ -75,6 +89,12 @@ export interface AcceptDebt {
75
89
  * string is the VERIFY-block line itself (`inv-command-provenance`).
76
90
  */
77
91
  verifyCommand?: string;
92
+ /**
93
+ * The task whose verified work CLOSED this debt (a health repair whose check
94
+ * went green — see closeHealthDebts). A closed debt stays in the ledger as the
95
+ * record of what fixed it, and no re-check reads it again.
96
+ */
97
+ resolvedBy?: string;
78
98
  }
79
99
  export declare function acceptDebtFile(cwd: string): string;
80
100
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
@@ -132,6 +152,16 @@ export declare function crossTaskDeletionReason(deletion: {
132
152
  export declare function extractDeletedDebtPath(reason: string): string | null;
133
153
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
134
154
  export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
155
+ /** The debts nothing has closed yet — the only ones a re-check may read. */
156
+ export declare function readOpenAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
157
+ /**
158
+ * Close every open static-class debt that names `command`, stamping the task
159
+ * whose verified work made that check pass again. Returns the debts closed.
160
+ * A reason that quotes the command is the whole match: the health-check reason
161
+ * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
162
+ * and nothing else in the ledger quotes a health command. Best-effort.
163
+ */
164
+ export declare function closeHealthDebts(cwd: string, command: string, resolvedBy: string): Promise<AcceptDebt[]>;
135
165
  /**
136
166
  * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
137
167
  * health check (`repo health: …`, the prefix runWorkVerification's repoHealth branch
@@ -66,6 +66,10 @@ const DEBT_LABELS = {
66
66
  'enforce-revert': 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)',
67
67
  'enforce-kept': 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped',
68
68
  'frozen-blocked': 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)',
69
+ 'spec-contradiction': "the failing criterion can only be met by editing a path this task's own spec freezes (no re-run under the same freeze converges)",
70
+ dismissed: 'the verify-FAIL picker was DISMISSED — a human saw the defect and answered nothing',
71
+ 'inherited-health': "the repo was ALREADY failing its own static check before this task ran (pre-existing, not this task's regression)",
72
+ abandoned: 'the task was abandoned past its attempt budget with no verified artifact',
69
73
  'cross-task-deletion': "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)",
70
74
  'yolo-accepted': 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)',
71
75
  'final-gate': 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)',
@@ -109,6 +113,7 @@ export function parseAcceptDebts(raw) {
109
113
  // 4th field: the verbatim VERIFY command the reason names.
110
114
  // Absent in every legacy record, and absent in most new ones.
111
115
  const verifyCommand = parts[3]?.trim();
116
+ const resolvedBy = parts[4]?.trim();
112
117
  out.push({
113
118
  taskId: parts[0].trim(),
114
119
  reason: parts[1].trim(),
@@ -116,7 +121,8 @@ export function parseAcceptDebts(raw) {
116
121
  // implicit class, so an absent origin and a spelled-out 'accepted' must
117
122
  // parse to the same record.
118
123
  ...(isKnownOrigin(origin) && origin !== 'accepted' ? { origin } : {}),
119
- ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {})
124
+ ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {}),
125
+ ...(resolvedBy !== undefined && resolvedBy.length > 0 ? { resolvedBy } : {})
120
126
  });
121
127
  }
122
128
  return out;
@@ -137,6 +143,16 @@ function serialize(d) {
137
143
  // only for the non-accepted classes, so old readers/files round-trip unchanged.
138
144
  // The 4th verify-command field forces the origin field to be written (positional
139
145
  // format) — 'accepted' spelled out there parses back to the same absent origin.
146
+ // The 5th field likewise forces an (empty) 4th.
147
+ if (d.resolvedBy !== undefined && d.resolvedBy.length > 0) {
148
+ return [
149
+ d.taskId,
150
+ d.reason,
151
+ d.origin ?? 'accepted',
152
+ d.verifyCommand ?? '',
153
+ d.resolvedBy
154
+ ].join(FIELD_SEP);
155
+ }
140
156
  if (d.verifyCommand !== undefined && d.verifyCommand.length > 0) {
141
157
  return [d.taskId, d.reason, d.origin ?? 'accepted', d.verifyCommand].join(FIELD_SEP);
142
158
  }
@@ -230,6 +246,33 @@ export function extractDeletedDebtPath(reason) {
230
246
  export async function writeAcceptDebts(cwd, debts) {
231
247
  await ledger.write(cwd, debts);
232
248
  }
249
+ /** The debts nothing has closed yet — the only ones a re-check may read. */
250
+ export async function readOpenAcceptDebts(cwd) {
251
+ return (await readAcceptDebts(cwd)).filter(d => d.resolvedBy === undefined);
252
+ }
253
+ /**
254
+ * Close every open static-class debt that names `command`, stamping the task
255
+ * whose verified work made that check pass again. Returns the debts closed.
256
+ * A reason that quotes the command is the whole match: the health-check reason
257
+ * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
258
+ * and nothing else in the ledger quotes a health command. Best-effort.
259
+ */
260
+ export async function closeHealthDebts(cwd, command, resolvedBy) {
261
+ try {
262
+ const all = await readAcceptDebts(cwd);
263
+ const quoted = `\`${command}\``;
264
+ const closing = all.filter(d => d.resolvedBy === undefined
265
+ && isStaticClassDebt(d.reason)
266
+ && d.reason.includes(quoted));
267
+ if (closing.length === 0)
268
+ return [];
269
+ await ledger.write(cwd, all.map(d => (closing.includes(d) ? { ...d, resolvedBy } : d)));
270
+ return closing;
271
+ }
272
+ catch {
273
+ return [];
274
+ }
275
+ }
233
276
  /**
234
277
  * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
235
278
  * health check (`repo health: …`, the prefix runWorkVerification's repoHealth branch
@@ -508,7 +551,10 @@ export function describeDebt(d) {
508
551
  export async function deriveOpenDebts(cwd, staticOk,
509
552
  /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
510
553
  run = spawnCommand, signal) {
511
- const { open: openRaw, resolved, trail } = await recheckAcceptDebts(await readAcceptDebts(cwd), {
554
+ const all = await readAcceptDebts(cwd);
555
+ // Closed debts are kept as the record of what fixed them, and never re-checked.
556
+ const closed = all.filter(d => d.resolvedBy !== undefined);
557
+ const { open: openRaw, resolved, trail } = await recheckAcceptDebts(all.filter(d => d.resolvedBy === undefined), {
512
558
  staticOk,
513
559
  // Cross-task-deletion debts auto-close iff the deleted file is back in the
514
560
  // tree — a deterministic existence check, corroborating the per-file
@@ -520,7 +566,7 @@ run = spawnCommand, signal) {
520
566
  rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd, run, signal)
521
567
  });
522
568
  if (resolved.length > 0)
523
- await writeAcceptDebts(cwd, openRaw);
569
+ await writeAcceptDebts(cwd, [...closed, ...openRaw]);
524
570
  // Conflicting-claim annotation: an existence-as-failure debt whose
525
571
  // named file is another task's committed deliverable is a plan defect — surface
526
572
  // the contradiction with the debt so nobody (human or child) treats the claim as