@mjasnikovs/pi-task 0.13.36 → 0.13.38

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.
@@ -17,7 +17,7 @@ import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasks
17
17
  import { gitCommitAll } from './auto-commit.js';
18
18
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
19
19
  import { runWorker } from '../workers/pi-worker-core.js';
20
- import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
20
+ import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
21
21
  import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
22
22
  import { pushNotify } from '../remote/push.js';
23
23
  import { getConfig } from '../config/config.js';
@@ -362,7 +362,15 @@ function defaultDeps(ctx, cwd, signal, title) {
362
362
  signal: sig,
363
363
  tools,
364
364
  timeoutMs: 0, // no wall-clock timeout — run to completion
365
- loop: false, // no loop guard revisiting one file IS the job
365
+ // Exact-match loop guard only: pathThreshold Infinity
366
+ // disables the path-revisit heuristic, so revisiting one
367
+ // file (which IS this pass's job) never trips — only a
368
+ // literally-identical call repeated past threshold does
369
+ // (e.g. the same grep fired 900× in enforce-debug.log).
370
+ // A hit nudges via the normal restart-with-hint; a loop
371
+ // that survives the nudges is WARNED, not blocked (see
372
+ // r.loopHit handling below and classifyEnforceChildFailure).
373
+ loop: { pathThreshold: Number.POSITIVE_INFINITY },
366
374
  onLine: line => {
367
375
  lastLine = line;
368
376
  logEnforce(line);
@@ -371,6 +379,13 @@ function defaultDeps(ctx, cwd, signal, title) {
371
379
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
372
380
  }
373
381
  });
382
+ // A loop that survived the restart-with-hint nudges is a
383
+ // warning, not a failure: log it and tell the user, but let
384
+ // the verdict gate (below) be the only thing that can block.
385
+ if (r.loopHit) {
386
+ logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
387
+ enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
388
+ }
374
389
  const failure = classifyEnforceChildFailure(r);
375
390
  logEnforce(failure ?
376
391
  `=== enforce end: FAIL — ${failure} ===`
@@ -56,10 +56,17 @@ export interface EnforceChildResult {
56
56
  * when it finished cleanly enough to parse a verdict from its text.
57
57
  *
58
58
  * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
59
- * `aborted` — killProc flips it on every kill path — so the specific causes
60
- * (timeout, loop, leaked tool call) must be named BEFORE the generic
61
- * `aborted → user-cancel` mapping. Checking `aborted` first (as the original
62
- * inline code did) mislabels a loop-killed enforcement child as a user cancel.
59
+ * `aborted` (and a non-zero exit) — killProc flips it on every kill path — so the
60
+ * specific causes (timeout, loop, leaked tool call) must be handled BEFORE the
61
+ * generic `aborted → user-cancel` and `exitCode` mappings. Checking `aborted`
62
+ * first (as the original inline code did) mislabels a loop-killed enforcement
63
+ * child as a user cancel.
64
+ *
65
+ * A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
66
+ * loop that survived its restart-with-hint nudges returns null here (the caller
67
+ * logs/notifies it as a warning) and the verdict gate alone decides the outcome.
68
+ * It still has to be matched before `aborted`/`exitCode` so the kill's side
69
+ * effects don't get re-classified as a user cancel or a crash.
63
70
  */
64
71
  export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
65
72
  /**
@@ -101,16 +101,23 @@ export function parseEnforceVerdict(text) {
101
101
  * when it finished cleanly enough to parse a verdict from its text.
102
102
  *
103
103
  * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
104
- * `aborted` — killProc flips it on every kill path — so the specific causes
105
- * (timeout, loop, leaked tool call) must be named BEFORE the generic
106
- * `aborted → user-cancel` mapping. Checking `aborted` first (as the original
107
- * inline code did) mislabels a loop-killed enforcement child as a user cancel.
104
+ * `aborted` (and a non-zero exit) — killProc flips it on every kill path — so the
105
+ * specific causes (timeout, loop, leaked tool call) must be handled BEFORE the
106
+ * generic `aborted → user-cancel` and `exitCode` mappings. Checking `aborted`
107
+ * first (as the original inline code did) mislabels a loop-killed enforcement
108
+ * child as a user cancel.
109
+ *
110
+ * A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
111
+ * loop that survived its restart-with-hint nudges returns null here (the caller
112
+ * logs/notifies it as a warning) and the verdict gate alone decides the outcome.
113
+ * It still has to be matched before `aborted`/`exitCode` so the kill's side
114
+ * effects don't get re-classified as a user cancel or a crash.
108
115
  */
109
116
  export function classifyEnforceChildFailure(r) {
110
117
  if (r.timedOut)
111
118
  return 'enforcement child timed out';
112
119
  if (r.loopHit)
113
- return 'enforcement child looped';
120
+ return null; // looped past the nudges → warning, handled by caller
114
121
  if (r.leakedToolCall)
115
122
  return 'enforcement child leaked a tool call';
116
123
  if (r.aborted)
@@ -4,7 +4,7 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { openCache as defaultOpenCache } from './docs-cache.js';
6
6
  import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
7
- import { resolvePackage as defaultResolvePackage, ResolveError } from './docs-resolve.js';
7
+ import { resolvePackage as defaultResolvePackage, ResolveError, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile } from './docs-resolve.js';
8
8
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { getPiInvocation } from '../shared/pi-invocation.js';
@@ -44,6 +44,52 @@ export async function runAutoInstall(spawn, packageName, signal) {
44
44
  }, installDir, signal, { mode: 'text', discardStdout: true });
45
45
  return { success: result.exitCode === 0 && !result.aborted, installDir, stderr: result.stderr };
46
46
  }
47
+ /** Resolve `name` from cwd; on `not_installed`, auto-install it and resolve from
48
+ * the install dir. Returns null on any failure (caller keeps its fallback). */
49
+ async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal) {
50
+ try {
51
+ return resolvePackage(name, cwd);
52
+ }
53
+ catch (err) {
54
+ if (!(err instanceof ResolveError) || err.kind !== 'not_installed')
55
+ return null;
56
+ const install = await runAutoInstall(spawn, extractParentPackage(name), signal);
57
+ if (!install.success)
58
+ return null;
59
+ try {
60
+ return resolvePackage(name, install.installDir);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ }
67
+ /** Follow the @types/<name> + triple-slash `<reference types>` redirect chain
68
+ * from a package that ships no usable types of its own to the one that actually
69
+ * holds the declarations (e.g. bun -> @types/bun -> bun-types). Bounded to a few
70
+ * hops; returns the original package if no better source is found. */
71
+ async function resolveTypeSource(pkg, requested, cwd, spawn, resolvePackage, signal) {
72
+ const visited = new Set([pkg.name, extractParentPackage(requested)]);
73
+ let cur = pkg;
74
+ for (let depth = 0; depth < 3; depth++) {
75
+ let next = detectTypesRedirect(cur);
76
+ if (next && visited.has(next))
77
+ next = null;
78
+ if (!next && !hasTypeFiles(cur.root)) {
79
+ const types = typesPackageName(cur.name);
80
+ if (types && !visited.has(types))
81
+ next = types;
82
+ }
83
+ if (!next)
84
+ break;
85
+ visited.add(next);
86
+ const resolved = await tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal);
87
+ if (!resolved)
88
+ break;
89
+ cur = resolved;
90
+ }
91
+ return cur;
92
+ }
47
93
  export async function docsRaw(input) {
48
94
  const resolvePackage = input.resolvePackage ?? defaultResolvePackage;
49
95
  const ensureIndexed = input.ensureIndexed ?? defaultEnsureIndexed;
@@ -114,6 +160,13 @@ export async function docsRaw(input) {
114
160
  };
115
161
  }
116
162
  }
163
+ // Step 1b: if the resolved package ships no usable type declarations (e.g.
164
+ // the `bun` runtime launcher, which is just a binary + install README), or is
165
+ // a pure `@types/<name>` redirect stub, follow the conventional
166
+ // @types/<name> + triple-slash `<reference types>` chain to the package that
167
+ // actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
168
+ // Best-effort: any failure leaves the original resolution untouched.
169
+ pkg = await resolveTypeSource(pkg, input.pkg, input.cwd, spawn, resolvePackage, input.signal);
117
170
  // Step 2: open cache
118
171
  let cache = null;
119
172
  let cacheError;
@@ -257,7 +310,7 @@ function walkDtsAlpha(root) {
257
310
  const full = path.join(dir, entry.name);
258
311
  if (entry.isDirectory())
259
312
  stack.push(full);
260
- else if (entry.isFile() && entry.name.endsWith('.d.ts'))
313
+ else if (entry.isFile() && isDtsFile(entry.name))
261
314
  out.push(full);
262
315
  }
263
316
  }
@@ -1,5 +1,5 @@
1
1
  import type { CacheHandle } from './docs-cache.js';
2
- import type { ResolvedPackage } from './docs-resolve.js';
2
+ import { type ResolvedPackage } from './docs-resolve.js';
3
3
  export interface IndexResult {
4
4
  hitCache: boolean;
5
5
  filesIngested: number;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { isDtsFile } from './docs-resolve.js';
4
5
  const MAX_CHUNK_BYTES = 8 * 1024;
5
6
  const ZERO_SEP = Buffer.from([0]);
6
7
  const DECL_SPLIT_RE = /^(?:export\s+|declare\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
@@ -48,13 +49,13 @@ function walkDts(root) {
48
49
  const stat = fs.statSync(realPath);
49
50
  if (stat.isDirectory())
50
51
  stack.push(realPath);
51
- else if (stat.isFile() && realPath.endsWith('.d.ts'))
52
+ else if (stat.isFile() && isDtsFile(realPath))
52
53
  out.push(realPath);
53
54
  continue;
54
55
  }
55
56
  if (entry.isDirectory())
56
57
  stack.push(full);
57
- else if (entry.isFile() && entry.name.endsWith('.d.ts'))
58
+ else if (entry.isFile() && isDtsFile(entry.name))
58
59
  out.push(full);
59
60
  }
60
61
  }
@@ -1,3 +1,5 @@
1
+ /** True for TypeScript declaration files: .d.ts, .d.mts, .d.cts. */
2
+ export declare function isDtsFile(name: string): boolean;
1
3
  export interface ResolvedPackage {
2
4
  name: string;
3
5
  version: string;
@@ -10,3 +12,15 @@ export declare class ResolveError extends Error {
10
12
  constructor(kind: 'not_installed' | 'invalid_name', message: string);
11
13
  }
12
14
  export declare function resolvePackage(moduleName: string, cwd: string): ResolvedPackage;
15
+ /** Conventional DefinitelyTyped package for a runtime package that ships no
16
+ * types of its own. `bun` -> `@types/bun`, `@scope/x` -> `@types/scope__x`.
17
+ * Returns null for packages that are already under the `@types` scope. */
18
+ export declare function typesPackageName(moduleName: string): string | null;
19
+ /** True if the package ships at least one declaration file. */
20
+ export declare function hasTypeFiles(root: string): boolean;
21
+ /** When a package is a pure pointer to another types package — a single-file
22
+ * `/// <reference types="X" />` (the `@types/bun -> bun-types` shape) or a lone
23
+ * `export * from "X"` re-export — return the target package name. Returns null
24
+ * for packages that ship their own declarations (more than one .d.ts file, or a
25
+ * local `/// <reference path=... />` aggregator entry). */
26
+ export declare function detectTypesRedirect(pkg: ResolvedPackage): string | null;
@@ -1,6 +1,11 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ const DTS_RE = /\.d\.(?:ts|mts|cts)$/;
5
+ /** True for TypeScript declaration files: .d.ts, .d.mts, .d.cts. */
6
+ export function isDtsFile(name) {
7
+ return DTS_RE.test(name);
8
+ }
4
9
  export class ResolveError extends Error {
5
10
  kind;
6
11
  constructor(kind, message) {
@@ -52,10 +57,18 @@ function findReadme(root) {
52
57
  function resolveEntryDts(moduleName, parent, root, pkg) {
53
58
  if (moduleName !== parent) {
54
59
  const subpath = moduleName.slice(parent.length + 1);
55
- const candidates = [`${subpath}.d.ts`, `${subpath}/index.d.ts`, subpath];
60
+ const candidates = [
61
+ `${subpath}.d.ts`,
62
+ `${subpath}.d.mts`,
63
+ `${subpath}.d.cts`,
64
+ `${subpath}/index.d.ts`,
65
+ `${subpath}/index.d.mts`,
66
+ `${subpath}/index.d.cts`,
67
+ subpath
68
+ ];
56
69
  for (const c of candidates) {
57
70
  const abs = path.join(root, c);
58
- if (fs.existsSync(abs) && abs.endsWith('.d.ts'))
71
+ if (fs.existsSync(abs) && isDtsFile(abs))
59
72
  return abs;
60
73
  }
61
74
  }
@@ -71,9 +84,11 @@ function resolveEntryDts(moduleName, parent, root, pkg) {
71
84
  if (fs.existsSync(abs))
72
85
  return abs;
73
86
  }
74
- const fallback = path.join(root, 'index.d.ts');
75
- if (fs.existsSync(fallback))
76
- return fallback;
87
+ for (const name of ['index.d.ts', 'index.d.mts', 'index.d.cts']) {
88
+ const fallback = path.join(root, name);
89
+ if (fs.existsSync(fallback))
90
+ return fallback;
91
+ }
77
92
  return null;
78
93
  }
79
94
  export function resolvePackage(moduleName, cwd) {
@@ -124,3 +139,95 @@ function findPackageJsonInNodeModules(parent, startDir) {
124
139
  dir = up;
125
140
  }
126
141
  }
142
+ /** Conventional DefinitelyTyped package for a runtime package that ships no
143
+ * types of its own. `bun` -> `@types/bun`, `@scope/x` -> `@types/scope__x`.
144
+ * Returns null for packages that are already under the `@types` scope. */
145
+ export function typesPackageName(moduleName) {
146
+ const parent = parentPackageName(moduleName);
147
+ if (parent.startsWith('@types/'))
148
+ return null;
149
+ if (parent.startsWith('@')) {
150
+ const [scope, name] = parent.slice(1).split('/');
151
+ if (!scope || !name)
152
+ return null;
153
+ return `@types/${scope}__${name}`;
154
+ }
155
+ return `@types/${parent}`;
156
+ }
157
+ /** Count declaration files under root (excluding nested node_modules), stopping
158
+ * once `cap` is reached. */
159
+ function countTypeFiles(root, cap) {
160
+ let n = 0;
161
+ const stack = [root];
162
+ while (stack.length) {
163
+ const dir = stack.pop();
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(dir, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ for (const entry of entries) {
172
+ if (entry.name === 'node_modules')
173
+ continue;
174
+ const full = path.join(dir, entry.name);
175
+ if (entry.isDirectory())
176
+ stack.push(full);
177
+ else if (entry.isFile() && isDtsFile(entry.name)) {
178
+ if (++n >= cap)
179
+ return n;
180
+ }
181
+ }
182
+ }
183
+ return n;
184
+ }
185
+ /** True if the package ships at least one declaration file. */
186
+ export function hasTypeFiles(root) {
187
+ return countTypeFiles(root, 1) > 0;
188
+ }
189
+ function findIndexDts(root) {
190
+ for (const name of ['index.d.ts', 'index.d.mts', 'index.d.cts']) {
191
+ const abs = path.join(root, name);
192
+ if (fs.existsSync(abs))
193
+ return abs;
194
+ }
195
+ return null;
196
+ }
197
+ function isBareSpecifier(spec) {
198
+ return spec.length > 0 && !spec.startsWith('.') && !spec.startsWith('/');
199
+ }
200
+ const REFERENCE_TYPES_RE = /\/\/\/\s*<reference\s+types=["']([^"']+)["']\s*\/>/;
201
+ const REEXPORT_ALL_RE = /^\s*export\s+(?:type\s+)?\*\s+from\s+["']([^"']+)["'];?\s*$/m;
202
+ /** When a package is a pure pointer to another types package — a single-file
203
+ * `/// <reference types="X" />` (the `@types/bun -> bun-types` shape) or a lone
204
+ * `export * from "X"` re-export — return the target package name. Returns null
205
+ * for packages that ship their own declarations (more than one .d.ts file, or a
206
+ * local `/// <reference path=... />` aggregator entry). */
207
+ export function detectTypesRedirect(pkg) {
208
+ // A package that ships multiple declaration files is an aggregator, not a
209
+ // redirect stub — use its own types.
210
+ if (countTypeFiles(pkg.root, 2) > 1)
211
+ return null;
212
+ const entry = pkg.entryDts ?? findIndexDts(pkg.root);
213
+ if (!entry)
214
+ return null;
215
+ let content;
216
+ try {
217
+ content = fs.readFileSync(entry, 'utf8');
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ // A local `/// <reference path="..." />` means the entry aggregates sibling
223
+ // declarations (e.g. bun-types) — not a redirect to another package.
224
+ if (/\/\/\/\s*<reference\s+path=/.test(content))
225
+ return null;
226
+ const ref = REFERENCE_TYPES_RE.exec(content);
227
+ if (ref && isBareSpecifier(ref[1]))
228
+ return parentPackageName(ref[1]);
229
+ const rex = REEXPORT_ALL_RE.exec(content);
230
+ if (rex && isBareSpecifier(rex[1]))
231
+ return parentPackageName(rex[1]);
232
+ return null;
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.36",
3
+ "version": "0.13.38",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",