@mjasnikovs/pi-task 0.38.16 → 0.38.18

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 (38) hide show
  1. package/dist/config/config.d.ts +37 -0
  2. package/dist/config/config.js +81 -18
  3. package/dist/task/accept-debt.js +2 -1
  4. package/dist/task/artifact-closure.js +18 -63
  5. package/dist/task/auto-orchestrator.js +205 -214
  6. package/dist/task/boot-probe.d.ts +46 -0
  7. package/dist/task/boot-probe.js +41 -21
  8. package/dist/task/coverage-loop.d.ts +11 -0
  9. package/dist/task/coverage-loop.js +16 -0
  10. package/dist/task/final-gate-fix.js +14 -24
  11. package/dist/task/final-gate.js +6 -1
  12. package/dist/task/fix-child.d.ts +64 -0
  13. package/dist/task/fix-child.js +66 -0
  14. package/dist/task/lint-fix.d.ts +7 -0
  15. package/dist/task/lint-fix.js +45 -9
  16. package/dist/task/orchestrator.js +9 -2
  17. package/dist/task/phases.d.ts +66 -4
  18. package/dist/task/phases.js +94 -34
  19. package/dist/task/plan-rounds.d.ts +86 -0
  20. package/dist/task/plan-rounds.js +105 -0
  21. package/dist/task/plan-session.d.ts +31 -21
  22. package/dist/task/plan-session.js +97 -120
  23. package/dist/task/qa-transcript.d.ts +100 -0
  24. package/dist/task/qa-transcript.js +99 -0
  25. package/dist/task/question-source.d.ts +117 -0
  26. package/dist/task/question-source.js +174 -0
  27. package/dist/task/serve-entry.js +6 -57
  28. package/dist/task/shipped-source.d.ts +67 -0
  29. package/dist/task/shipped-source.js +144 -0
  30. package/dist/task/task-gates.d.ts +1 -1
  31. package/dist/task/task-gates.js +4 -2
  32. package/dist/task/verify-work.d.ts +46 -0
  33. package/dist/task/verify-work.js +51 -3
  34. package/dist/task/widget.js +41 -9
  35. package/dist/workers/docs-core.d.ts +71 -1
  36. package/dist/workers/docs-core.js +131 -71
  37. package/dist/workers/pi-worker-core.js +23 -8
  38. package/package.json +1 -1
@@ -189,5 +189,42 @@ export declare const DEFAULT_CONFIG: PiTaskConfig;
189
189
  * object/number can't reach the child argv as `-e [object Object]`.
190
190
  */
191
191
  export declare function sanitizeExtensionWhitelist(value: unknown): string[];
192
+ /**
193
+ * How each setting's STORED value becomes a safe in-memory value — one loader per
194
+ * key, keyed on the config's own type.
195
+ *
196
+ * `ConfigItem` (config/register.ts) already absorbed three of the four edits its
197
+ * own header names: *"Adding one enum setting meant four coordinated edits (row,
198
+ * format arm, parse arm, sanitizer) and NONE of them failed to compile if you
199
+ * forgot it."* The sanitizer was the fourth, and it stayed behind in this file as
200
+ * a hand-ordered statement ladder covering 7 of 14 keys. This is the mapped type
201
+ * that closes it: a new field on `PiTaskConfig` is a compile error until it
202
+ * declares how a hostile value becomes a safe one.
203
+ */
204
+ export declare const CONFIG_LOADERS: {
205
+ [K in keyof PiTaskConfig]: (raw: unknown) => PiTaskConfig[K];
206
+ };
207
+ /**
208
+ * Turn parsed config JSON into a `PiTaskConfig`. Pure — this is the seam the load
209
+ * block used to have none of, so every hostile-value case was reachable only
210
+ * through `getConfig()`, which reads the developer's own machine.
211
+ *
212
+ * A non-object `raw` yields the defaults. That is not merely defensive: the old
213
+ * block spread `parsed` directly, and `{...DEFAULT_CONFIG, ...'ab'}` produces
214
+ * numeric index keys. Keys absent from the table are dropped rather than carried
215
+ * into the config object.
216
+ */
217
+ export declare function loadConfig(raw: unknown): PiTaskConfig;
218
+ /**
219
+ * Where the saved config lives. `PI_TASK_CONFIG_PATH` overrides it.
220
+ *
221
+ * The override exists because this module loads the real file at import time,
222
+ * which made every test that reads a config value depend on the developer's own
223
+ * `~/.config/pi-task/config.json` — a machine-local `"debugLogs": "off"` failed
224
+ * 12 tests here while CI (no config file at all) stayed green. The test preload
225
+ * points this at a path under the tmp dir that never exists, so tests always see
226
+ * DEFAULT_CONFIG. Read once at module eval: the preload runs before any import.
227
+ */
228
+ export declare const CONFIG_PATH_ENV = "PI_TASK_CONFIG_PATH";
192
229
  export declare function getConfig(): PiTaskConfig;
193
230
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -115,7 +115,86 @@ export function sanitizeExtensionWhitelist(value) {
115
115
  return [];
116
116
  return value.filter((p) => typeof p === 'string' && p.trim().length > 0);
117
117
  }
118
- const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
118
+ /**
119
+ * A boolean setting's loader. Only a REAL boolean counts; anything else falls
120
+ * back to the shipped default.
121
+ *
122
+ * This is `yoloMode`'s guard, generalised. Its comment — *"a hand-edited
123
+ * `"yoloMode": "false"` is a truthy string"* — was true verbatim of the other
124
+ * seven booleans, none of which had it, so a stale `"verifyWork": "off"` reached
125
+ * `getConfig().verifyWork` as truthy and a `"autoCommit": 0` reached it as falsy.
126
+ */
127
+ function asBoolean(key) {
128
+ return raw => (typeof raw === 'boolean' ? raw : DEFAULT_CONFIG[key]);
129
+ }
130
+ /**
131
+ * How each setting's STORED value becomes a safe in-memory value — one loader per
132
+ * key, keyed on the config's own type.
133
+ *
134
+ * `ConfigItem` (config/register.ts) already absorbed three of the four edits its
135
+ * own header names: *"Adding one enum setting meant four coordinated edits (row,
136
+ * format arm, parse arm, sanitizer) and NONE of them failed to compile if you
137
+ * forgot it."* The sanitizer was the fourth, and it stayed behind in this file as
138
+ * a hand-ordered statement ladder covering 7 of 14 keys. This is the mapped type
139
+ * that closes it: a new field on `PiTaskConfig` is a compile error until it
140
+ * declares how a hostile value becomes a safe one.
141
+ */
142
+ export const CONFIG_LOADERS = {
143
+ remote: asBoolean('remote'),
144
+ autoCommit: asBoolean('autoCommit'),
145
+ orientation: asBoolean('orientation'),
146
+ enforceGuidelines: asBoolean('enforceGuidelines'),
147
+ verifyWork: asBoolean('verifyWork'),
148
+ parallelResearchWorkers: asBoolean('parallelResearchWorkers'),
149
+ researchCache: asBoolean('researchCache'),
150
+ yoloMode: asBoolean('yoloMode'),
151
+ // A hand-edited or stale enum value must not leak an unknown provider into
152
+ // the dispatch switch — fall back to the default.
153
+ searchProvider: raw => (isSearchProvider(raw) ? raw : DEFAULT_CONFIG.searchProvider),
154
+ extensionWhitelist: sanitizeExtensionWhitelist,
155
+ requestTimeoutMs: sanitizeRequestTimeoutMs,
156
+ commandTimeoutExemptTools: sanitizeCommandTimeoutExemptTools,
157
+ streamInactivityMs: sanitizeStreamInactivityMs,
158
+ debugLogs: sanitizeDebugLogs
159
+ };
160
+ /**
161
+ * Turn parsed config JSON into a `PiTaskConfig`. Pure — this is the seam the load
162
+ * block used to have none of, so every hostile-value case was reachable only
163
+ * through `getConfig()`, which reads the developer's own machine.
164
+ *
165
+ * A non-object `raw` yields the defaults. That is not merely defensive: the old
166
+ * block spread `parsed` directly, and `{...DEFAULT_CONFIG, ...'ab'}` produces
167
+ * numeric index keys. Keys absent from the table are dropped rather than carried
168
+ * into the config object.
169
+ */
170
+ export function loadConfig(raw) {
171
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
172
+ return { ...DEFAULT_CONFIG };
173
+ }
174
+ const stored = raw;
175
+ const out = { ...DEFAULT_CONFIG };
176
+ for (const key of Object.keys(CONFIG_LOADERS)) {
177
+ // `undefined` reaches the loader like any other hostile value: every
178
+ // loader answers a missing key with its own default, which is what the
179
+ // old `delete parsed.X` + spread did.
180
+ ;
181
+ out[key] = CONFIG_LOADERS[key](stored[key]);
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Where the saved config lives. `PI_TASK_CONFIG_PATH` overrides it.
187
+ *
188
+ * The override exists because this module loads the real file at import time,
189
+ * which made every test that reads a config value depend on the developer's own
190
+ * `~/.config/pi-task/config.json` — a machine-local `"debugLogs": "off"` failed
191
+ * 12 tests here while CI (no config file at all) stayed green. The test preload
192
+ * points this at a path under the tmp dir that never exists, so tests always see
193
+ * DEFAULT_CONFIG. Read once at module eval: the preload runs before any import.
194
+ */
195
+ export const CONFIG_PATH_ENV = 'PI_TASK_CONFIG_PATH';
196
+ const CONFIG_PATH = process.env[CONFIG_PATH_ENV]?.trim()
197
+ || path.join(os.homedir(), '.config', 'pi-task', 'config.json');
119
198
  const _g = globalThis;
120
199
  if (!_g.__piTaskConfig) {
121
200
  _g.__piTaskConfig = { config: { ...DEFAULT_CONFIG }, loaded: false };
@@ -125,23 +204,7 @@ const G = _g.__piTaskConfig;
125
204
  // before any session_start handler fires.
126
205
  if (!G.loaded) {
127
206
  try {
128
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
129
- const parsed = JSON.parse(raw);
130
- // A hand-edited or stale enum value must not leak an unknown provider
131
- // into the dispatch switch — fall back to the default.
132
- if (!isSearchProvider(parsed.searchProvider))
133
- delete parsed.searchProvider;
134
- parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
135
- parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
136
- parsed.commandTimeoutExemptTools = sanitizeCommandTimeoutExemptTools(parsed.commandTimeoutExemptTools);
137
- parsed.streamInactivityMs = sanitizeStreamInactivityMs(parsed.streamInactivityMs);
138
- // A hand-edited `"yoloMode": "false"` is a truthy string — it must not
139
- // silently switch a watched run into unattended auto-pick. Only a real
140
- // boolean counts; anything else falls back to the OFF default.
141
- if (typeof parsed.yoloMode !== 'boolean')
142
- delete parsed.yoloMode;
143
- parsed.debugLogs = sanitizeDebugLogs(parsed.debugLogs);
144
- G.config = { ...DEFAULT_CONFIG, ...parsed };
207
+ G.config = loadConfig(JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
145
208
  }
146
209
  catch {
147
210
  G.config = { ...DEFAULT_CONFIG };
@@ -29,6 +29,7 @@ import { existsSync } from 'node:fs';
29
29
  import * as path from 'node:path';
30
30
  import * as fsp from 'node:fs/promises';
31
31
  import { runVerifyCommandLine, spawnCommand } from './command-run.js';
32
+ import { failClassOfReason, isStaticClass } from './verify-work.js';
32
33
  import { taskThatIntroduced } from './task-provenance.js';
33
34
  import { makeLedger } from './ledger.js';
34
35
  import { parseVerifyBlockStrict } from './spec-validation.js';
@@ -233,7 +234,7 @@ export async function writeAcceptDebts(cwd, debts) {
233
234
  * behavioral and cannot be proven resolved without re-running the model.
234
235
  */
235
236
  export function isStaticClassDebt(reason) {
236
- return /^\s*repo health:/i.test(reason);
237
+ return isStaticClass(failClassOfReason(reason));
237
238
  }
238
239
  /**
239
240
  * The VERIFY-COMMAND class (nexttask 5): the ONE command a recorded reason itself
@@ -40,8 +40,9 @@
40
40
  * c.notFound()`): an existence guard is exactly how the bug presents (permanent
41
41
  * 404), so guarded reads deliberately do NOT step aside.
42
42
  */
43
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
43
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
44
44
  import * as path from 'node:path';
45
+ import { shippedSources, stripCommentLines, SOURCE_HTML_RE, SOURCE_JS_RE } from './shipped-source.js';
45
46
  export function emptyProducers() {
46
47
  return {
47
48
  files: new Set(),
@@ -83,14 +84,6 @@ const hasExt = (p) => /\.[A-Za-z0-9]{1,8}$/.test(path.posix.basename(p));
83
84
  /** Source-only extensions nothing ships un-built: a missing one of these is hard
84
85
  * evidence on its own (a script entrypoint like `bun src/server/index.ts`). */
85
86
  const SOURCE_ONLY_EXT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/i;
86
- /** Strip comment-only lines (`// …`, `* …`) — a ref quoted in a comment is not a
87
- * runtime read. Inline comments are left alone (strings may contain `//`). */
88
- function stripCommentLines(src) {
89
- return src
90
- .split('\n')
91
- .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
92
- .join('\n');
93
- }
94
87
  // Literal-first-argument read-side constructs. `[^'"\`\n]` keeps the argument on
95
88
  // one line and free of a closing quote; normalizeRefPath rejects `${…}` holes.
96
89
  const JS_READ_PATTERNS = [
@@ -229,7 +222,7 @@ export function collectEmittedHtml(source) {
229
222
  const writeRe = /\b(?:Bun\.write|writeFileSync|writeFile|fs\.promises\.writeFile)\(\s*(['"`])([^'"`\n]+)\1\s*,\s*/g;
230
223
  for (let m = writeRe.exec(src); m !== null; m = writeRe.exec(src)) {
231
224
  const docPath = normalizeRefPath(m[2]);
232
- if (docPath === null || !SCAN_HTML_RE.test(docPath))
225
+ if (docPath === null || !SOURCE_HTML_RE.test(docPath))
233
226
  continue;
234
227
  const at = m.index + m[0].length;
235
228
  let html;
@@ -777,58 +770,20 @@ export function resolveDanglingRefs(refs, prod, exists) {
777
770
  out.push({ ...ref, reason });
778
771
  }
779
772
  }
780
- // Directories never scanned for referencing sources: VCS/dep/artifact trees,
781
- // every discovered produced dir (bundled output re-referencing its own chunks is
782
- // noise), and test/fixture/doc trees those reference fixture paths and quoted
783
- // examples freely and are not the runtime serving surface this guard protects.
784
- const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__fixtures__|fixtures|e2e|examples|example|docs|doc)$/;
785
- const SKIP_FILE_RE = /\.(?:test|spec|stories)\.[a-z]+$|\.d\.[mc]?ts$/i;
786
- const SCAN_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
787
- const SCAN_HTML_RE = /\.html?$/i;
788
- const MAX_SCAN_FILES = 3000;
789
- const MAX_FILE_BYTES = 400_000;
790
- /** Walk the tree for scannable sources (bounded, deterministic order). */
773
+ // The tree walk, the caps and the skip sets live in task/shipped-source.ts
774
+ // this was serve-entry's walker written a second time, and the two skip sets had
775
+ // drifted: `bench`/`benchmarks` and `*.bench.*` were skipped there and scanned
776
+ // here, so a dangling reference in a benchmark was a run-level finding while the
777
+ // same file was invisible to the sibling scan.
778
+ //
779
+ // `producedDirs` stays this scan's own: bundled output re-referencing its own
780
+ // chunks is noise, and which dirs are produced is discovered per run.
791
781
  function scanCandidates(cwd, prod) {
792
- const out = [];
793
- const producedDirs = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
794
- const walk = (rel) => {
795
- if (out.length >= MAX_SCAN_FILES)
796
- return;
797
- let entries;
798
- try {
799
- entries = readdirSync(path.join(cwd, rel)).sort();
800
- }
801
- catch {
802
- return;
803
- }
804
- for (const name of entries) {
805
- if (out.length >= MAX_SCAN_FILES)
806
- return;
807
- const relPath = rel === '' ? name : `${rel}/${name}`;
808
- let st;
809
- try {
810
- st = statSync(path.join(cwd, relPath));
811
- }
812
- catch {
813
- continue;
814
- }
815
- if (st.isDirectory()) {
816
- if (name.startsWith('.') || SKIP_DIR_RE.test(name))
817
- continue;
818
- if (rel === '' && producedDirs.has(name))
819
- continue;
820
- walk(relPath);
821
- }
822
- else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
823
- if (SKIP_FILE_RE.test(name))
824
- continue;
825
- if (SCAN_JS_RE.test(name) || SCAN_HTML_RE.test(name))
826
- out.push(relPath);
827
- }
828
- }
829
- };
830
- walk('');
831
- return out;
782
+ const producedRoots = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
783
+ return shippedSources(cwd, {
784
+ ext: new RegExp(`${SOURCE_JS_RE.source}|${SOURCE_HTML_RE.source}`, 'i'),
785
+ excludeRoots: producedRoots
786
+ });
832
787
  }
833
788
  /**
834
789
  * FINAL-GATE seam: scan the shipped tree for dangling runtime references.
@@ -855,7 +810,7 @@ export function findDanglingArtifacts(cwd) {
855
810
  catch {
856
811
  continue;
857
812
  }
858
- if (SCAN_HTML_RE.test(rel)) {
813
+ if (SOURCE_HTML_RE.test(rel)) {
859
814
  refs.push(...extractHtmlRefs(src, rel));
860
815
  }
861
816
  else {
@@ -901,7 +856,7 @@ export function collectGeneratedHtmlRefs(cwd) {
901
856
  const production = discoverProducers(cwd, { excludeDevScripts: true });
902
857
  const sources = [];
903
858
  for (const rel of scanCandidates(cwd, full)) {
904
- if (SCAN_HTML_RE.test(rel))
859
+ if (SOURCE_HTML_RE.test(rel))
905
860
  continue;
906
861
  let src;
907
862
  try {