@mjasnikovs/pi-task 0.37.5 → 0.37.7

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-2077%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-3025%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -72,7 +72,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
72
72
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
73
73
  | `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
74
74
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
75
- | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, command timeout, stuck reply retry, debug logs, and one `ext:` toggle per installed host extension. |
75
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, compress thinking, auto-commit, verify work, enforce guidelines, project tour, parallel research, research cache, search engine, command timeout, stuck reply retry, yolo mode, debug logs, one `watch:` toggle per live tool, and one `ext:` toggle per installed host extension. |
76
76
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
77
77
 
78
78
  ## The pipeline
@@ -186,6 +186,7 @@ Runs a web search and returns a compact markdown list (title · URL · snippet).
186
186
  Fetches a URL, cleans HTML to markdown ([Readability](https://github.com/mozilla/readability) + [Turndown](https://github.com/mixmark-io/turndown)), then hands it to an isolated child that extracts **only** the content answering your `query`. The parent never sees the raw page.
187
187
 
188
188
  - HTML is cleaned; text formats (plain text, markdown, JSON, XML/feeds, `llms.txt`, …) pass through verbatim. Binary responses — PDFs, images, octet-streams — return a clear error.
189
+ - A GitHub `/blob/` URL is rewritten to `raw.githubusercontent.com` before fetching. The blob page renders the file client-side, so a plain fetch returns the chrome and none of the code.
189
190
  - Bodies over 2 MB are rejected.
190
191
  - The extraction child runs with `--no-tools` to mitigate visible-text prompt injection.
191
192
 
@@ -229,6 +230,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
229
230
  | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
230
231
  | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
231
232
  | `PI_TASK_DEBUG_LOG` | task trail | Overrides the **debug logs** setting for one session: `off`, `events`, or `full`. For reproducing a report without walking someone through `/task-config`. An unrecognised value is ignored, not treated as `off`. |
233
+ | `CHROME_BIN` | verify-work render check | Explicit headless Chrome-family binary. Tried before the Playwright cache and a browser on `PATH`. No browser found ⇒ the render check SKIPs; it never installs one. |
234
+ | `PLAYWRIGHT_BROWSERS_PATH` | verify-work render check | Where to look for a cached Playwright Chromium (defaults to `~/.cache/ms-playwright`, or `~/Library/Caches/ms-playwright` on macOS). |
232
235
 
233
236
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
234
237
 
@@ -236,7 +239,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
236
239
 
237
240
  ```sh
238
241
  bun install
239
- bun run test # 2078 tests across 129 files
242
+ bun run test # 3028 tests across 173 files
240
243
  bun run lint # prettier + eslint + tsc --noEmit
241
244
  bun run build # tsc → dist/
242
245
  ```
@@ -39,7 +39,7 @@
39
39
  * project that ever recorded a launch contract (mx5, npm) and one non-npm project
40
40
  * that reached extraction (IAR1, CMake). A third ecosystem would be a guess.
41
41
  */
42
- import { existsSync, readFileSync } from 'node:fs';
42
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
43
43
  import * as path from 'node:path';
44
44
  /** GNU make's own lookup order, and nothing beyond it — this is one ecosystem. */
45
45
  const MAKEFILE_NAMES = ['GNUmakefile', 'makefile', 'Makefile'];
@@ -85,6 +85,28 @@ export function makeTargets(src) {
85
85
  }
86
86
  return out;
87
87
  }
88
+ /**
89
+ * The makefiles that really exist in `cwd`, in GNU make's lookup order, named as
90
+ * the DIRECTORY spells them. `existsSync` cannot do this job: on a case-insensitive
91
+ * filesystem (Windows, macOS) `existsSync('makefile')` is true for a `Makefile`, and
92
+ * the failure text would then name a file the project does not have.
93
+ */
94
+ function makefilesOnDisk(cwd) {
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(cwd);
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ const out = [];
103
+ for (const want of MAKEFILE_NAMES) {
104
+ const hit = entries.find(e => e.toLowerCase() === want.toLowerCase());
105
+ if (hit && !out.includes(hit))
106
+ out.push(hit);
107
+ }
108
+ return out;
109
+ }
88
110
  /**
89
111
  * Resolve the manifest the launch contract may be diffed against.
90
112
  *
@@ -103,12 +125,13 @@ export function readLaunchManifest(cwd) {
103
125
  return { kind: 'none', file: '', names: [], why: 'its package.json could not be parsed' };
104
126
  }
105
127
  }
106
- for (const name of MAKEFILE_NAMES) {
107
- const mk = path.join(cwd, name);
108
- if (!existsSync(mk))
109
- continue;
128
+ for (const name of makefilesOnDisk(cwd)) {
110
129
  try {
111
- return { kind: 'make', file: name, names: makeTargets(readFileSync(mk, 'utf8')) };
130
+ return {
131
+ kind: 'make',
132
+ file: name,
133
+ names: makeTargets(readFileSync(path.join(cwd, name), 'utf8'))
134
+ };
112
135
  }
113
136
  catch {
114
137
  break;
@@ -16,6 +16,13 @@ import { type SpawnFn } from '../shared/child-process.js';
16
16
  export interface AutoInstallPin {
17
17
  source: 'declared-range' | 'npm-latest';
18
18
  range?: string;
19
+ /**
20
+ * The package the CALLER asked about — the HEAD of the resolution chain, not
21
+ * its terminal. `bun -> @types/bun -> bun-types` resolves correctly and must
22
+ * keep doing so, but the sentence a banner writes is about `bun`: the project
23
+ * cannot declare `bun-types`, and was never asked about it.
24
+ */
25
+ asked?: string;
19
26
  }
20
27
  export type DocsRawResult = {
21
28
  kind: 'ok';
@@ -85,14 +92,48 @@ export interface DocsFocusedResult {
85
92
  }
86
93
  export type DocsFocusedInput = DocsRawInput;
87
94
  export declare function extractParentPackage(moduleName: string): string;
95
+ /** What a project's package.json says about one package, whether or not the
96
+ * value is something the install path could use. */
97
+ export interface Declaration {
98
+ /** The dependency-map KEY the declaration was found under. */
99
+ pkg: string;
100
+ /** Its value, trimmed — `^1.2.0`, `latest`, `workspace:*`. */
101
+ value: string;
102
+ /** False for dist-tags, wildcards and non-registry protocols. */
103
+ usable: boolean;
104
+ }
105
+ /**
106
+ * The names a declaration for `asked` can honestly live under, nearest first:
107
+ * the package itself, its DefinitelyTyped package, and the terminal the type
108
+ * resolution chain landed on. A project that uses Bun declares `@types/bun`, not
109
+ * `bun`; asking only about the terminal `bun-types` finds nothing at all, which
110
+ * is how 35 of run 20's 48 banners came to report on a package nobody asked
111
+ * about.
112
+ */
113
+ export declare function declarationChain(asked: string, resolved?: string): string[];
114
+ /**
115
+ * The first declaration for any name in `names`, searching the four standard
116
+ * dependency maps of `cwd`'s package.json. A USABLE declaration always wins;
117
+ * only if none of the names has one does an unusable declaration (a dist-tag or
118
+ * a non-registry protocol) come back, so the caller can tell "declared as
119
+ * `latest`" apart from "not declared at all" — two different facts that used to
120
+ * produce the same sentence. Returns null when no name appears anywhere, or the
121
+ * package.json is missing or unparseable. Best-effort; never throws.
122
+ */
123
+ export declare function findDeclaration(names: string[], cwd: string): Declaration | null;
88
124
  /**
89
125
  * The version range a project DECLARES for `parentPkg` in its package.json under
90
126
  * `cwd`. Lets a not-yet-installed scaffolding dependency be documented against
91
127
  * the major the project intends, instead of whatever npm currently tags
92
- * `latest`. Scans the four standard dependency maps in priority order. Returns
93
- * null caller falls back to latest when the dep is undeclared, the
94
- * package.json is missing/unreadable, or the declared value is not a usable
95
- * range. Best-effort; never throws.
128
+ * `latest`. Returns null caller falls back to latest when the dep is
129
+ * undeclared, the package.json is missing/unreadable, or the declared value is
130
+ * not a usable range.
131
+ *
132
+ * This is the INSTALL target and takes one name deliberately: `npm install
133
+ * <parentPkg>@<range>` must use the range declared for `parentPkg` itself.
134
+ * `@types/bun`'s range is not `bun`'s. The banner's wider, chain-aware question
135
+ * is `findDeclaration`; keeping them apart is what stops a wording fix from
136
+ * silently changing what gets installed.
96
137
  */
97
138
  export declare function findDeclaredRange(parentPkg: string, cwd: string): string | null;
98
139
  /**
@@ -102,8 +143,14 @@ export declare function findDeclaredRange(parentPkg: string, cwd: string): strin
102
143
  * the prose the impl model reads, rather than burying it in tool `details`.
103
144
  * Empty string when there was no auto-install (already-installed packages need
104
145
  * no banner — their version is the project's own).
146
+ *
147
+ * `resolved` is the package the types were finally read from — the TERMINAL of
148
+ * the redirect chain. The banner names `pin.asked`, the package the caller asked
149
+ * about, and mentions the terminal only as provenance: a project can declare
150
+ * `bun`, and cannot declare `bun-types`, so a sentence about what package.json
151
+ * does or does not say has to be a sentence about `bun`.
105
152
  */
106
- export declare function buildVersionBanner(pin: AutoInstallPin | undefined, pkgName: string, version: string): string;
153
+ export declare function buildVersionBanner(pin: AutoInstallPin | undefined, resolved: string, version: string, cwd: string): string;
107
154
  export declare function getDocsModulesDir(): string;
108
155
  export declare function ensureDocsModulesDir(dir: string): void;
109
156
  export declare function runAutoInstall(spawn: SpawnFn, packageName: string, signal: AbortSignal | undefined, versionRange?: string): Promise<{
@@ -43,15 +43,32 @@ function isUsableRange(range) {
43
43
  return true;
44
44
  }
45
45
  /**
46
- * The version range a project DECLARES for `parentPkg` in its package.json under
47
- * `cwd`. Lets a not-yet-installed scaffolding dependency be documented against
48
- * the major the project intends, instead of whatever npm currently tags
49
- * `latest`. Scans the four standard dependency maps in priority order. Returns
50
- * null caller falls back to latest when the dep is undeclared, the
51
- * package.json is missing/unreadable, or the declared value is not a usable
52
- * range. Best-effort; never throws.
46
+ * The names a declaration for `asked` can honestly live under, nearest first:
47
+ * the package itself, its DefinitelyTyped package, and the terminal the type
48
+ * resolution chain landed on. A project that uses Bun declares `@types/bun`, not
49
+ * `bun`; asking only about the terminal `bun-types` finds nothing at all, which
50
+ * is how 35 of run 20's 48 banners came to report on a package nobody asked
51
+ * about.
53
52
  */
54
- export function findDeclaredRange(parentPkg, cwd) {
53
+ export function declarationChain(asked, resolved) {
54
+ const out = [asked];
55
+ const types = typesPackageName(asked);
56
+ if (types && !out.includes(types))
57
+ out.push(types);
58
+ if (resolved && !out.includes(resolved))
59
+ out.push(resolved);
60
+ return out;
61
+ }
62
+ /**
63
+ * The first declaration for any name in `names`, searching the four standard
64
+ * dependency maps of `cwd`'s package.json. A USABLE declaration always wins;
65
+ * only if none of the names has one does an unusable declaration (a dist-tag or
66
+ * a non-registry protocol) come back, so the caller can tell "declared as
67
+ * `latest`" apart from "not declared at all" — two different facts that used to
68
+ * produce the same sentence. Returns null when no name appears anywhere, or the
69
+ * package.json is missing or unparseable. Best-effort; never throws.
70
+ */
71
+ export function findDeclaration(names, cwd) {
55
72
  let json;
56
73
  try {
57
74
  json = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -59,15 +76,40 @@ export function findDeclaredRange(parentPkg, cwd) {
59
76
  catch {
60
77
  return null;
61
78
  }
62
- for (const field of DEP_FIELDS) {
63
- const map = json[field];
64
- if (!map || typeof map !== 'object')
65
- continue;
66
- const range = map[parentPkg];
67
- if (typeof range === 'string' && isUsableRange(range))
68
- return range.trim();
79
+ let unusable = null;
80
+ for (const name of names) {
81
+ for (const field of DEP_FIELDS) {
82
+ const map = json[field];
83
+ if (!map || typeof map !== 'object')
84
+ continue;
85
+ const range = map[name];
86
+ if (typeof range !== 'string')
87
+ continue;
88
+ const value = range.trim();
89
+ if (isUsableRange(value))
90
+ return { pkg: name, value, usable: true };
91
+ unusable ??= { pkg: name, value, usable: false };
92
+ }
69
93
  }
70
- return null;
94
+ return unusable;
95
+ }
96
+ /**
97
+ * The version range a project DECLARES for `parentPkg` in its package.json under
98
+ * `cwd`. Lets a not-yet-installed scaffolding dependency be documented against
99
+ * the major the project intends, instead of whatever npm currently tags
100
+ * `latest`. Returns null — caller falls back to latest — when the dep is
101
+ * undeclared, the package.json is missing/unreadable, or the declared value is
102
+ * not a usable range.
103
+ *
104
+ * This is the INSTALL target and takes one name deliberately: `npm install
105
+ * <parentPkg>@<range>` must use the range declared for `parentPkg` itself.
106
+ * `@types/bun`'s range is not `bun`'s. The banner's wider, chain-aware question
107
+ * is `findDeclaration`; keeping them apart is what stops a wording fix from
108
+ * silently changing what gets installed.
109
+ */
110
+ export function findDeclaredRange(parentPkg, cwd) {
111
+ const found = findDeclaration([parentPkg], cwd);
112
+ return found?.usable === true ? found.value : null;
71
113
  }
72
114
  /**
73
115
  * One-line version-provenance banner that LEADS a docs answer for a package the
@@ -76,18 +118,54 @@ export function findDeclaredRange(parentPkg, cwd) {
76
118
  * the prose the impl model reads, rather than burying it in tool `details`.
77
119
  * Empty string when there was no auto-install (already-installed packages need
78
120
  * no banner — their version is the project's own).
121
+ *
122
+ * `resolved` is the package the types were finally read from — the TERMINAL of
123
+ * the redirect chain. The banner names `pin.asked`, the package the caller asked
124
+ * about, and mentions the terminal only as provenance: a project can declare
125
+ * `bun`, and cannot declare `bun-types`, so a sentence about what package.json
126
+ * does or does not say has to be a sentence about `bun`.
79
127
  */
80
- export function buildVersionBanner(pin, pkgName, version) {
128
+ export function buildVersionBanner(pin, resolved, version, cwd) {
81
129
  if (!pin)
82
130
  return '';
131
+ const asked = pin.asked ?? resolved;
132
+ const grounded = resolved !== asked ? ` The types this answer reads come from ${resolved}.` : '';
83
133
  if (pin.source === 'declared-range') {
84
- return (`[VERSION] "${pkgName}" resolved to this project's declared range `
85
- + `${pin.range} (installed v${version}); the answer below is pinned to that version.\n\n`);
134
+ return (`[VERSION] "${asked}" resolved to this project's declared range `
135
+ + `${pin.range} (installed v${version}); the answer below is pinned to that `
136
+ + `version.${grounded}\n\n`);
137
+ }
138
+ // The install fell back to npm latest. A usable declaration can still exist
139
+ // further along the chain (`@types/<name>`) — it did not pin THIS install, so
140
+ // the banner reports it as provenance, not as a pin.
141
+ const decl = findDeclaration(declarationChain(asked, resolved), cwd);
142
+ // Declared, but as a dist-tag or a non-registry protocol. That is NOT the
143
+ // same fact as undeclared: the project did say what it wants, `latest` is
144
+ // exactly what this answer is grounded in, and so there is no other major to
145
+ // confirm and nothing to hold as unverified. Only the SENTENCE splits —
146
+ // `isUsableRange` still rejects the value and the install path still cannot
147
+ // use it as an `install <pkg>@<range>` target.
148
+ if (decl && !decl.usable) {
149
+ const where = decl.pkg === asked ? '' : ` only through ${decl.pkg},`;
150
+ return (`[VERSION] "${asked}" is declared in this project's package.json${where} as `
151
+ + `\`${decl.value}\` — a moving tag, not a pinned range — so this answer is based `
152
+ + `on npm latest (v${version}), which is what that declaration resolves to `
153
+ + `today.${grounded}\n\n`);
154
+ }
155
+ // A usable range on the ASKED name with an npm-latest pin means package.json
156
+ // gained the declaration between the install and this sentence. Rare, but the
157
+ // alternative wording would flatly contradict itself.
158
+ if (decl?.usable === true && decl.pkg === asked) {
159
+ return (`[VERSION — verify] "${asked}" is declared as ${decl.value}, but this answer is `
160
+ + `based on npm latest (v${version}) — the install was not pinned to that range. `
161
+ + `Confirm the version you intend before relying on an API that differs across `
162
+ + `majors.${grounded}\n\n`);
86
163
  }
87
- return (`[VERSIONverify] "${pkgName}" is not declared in this project's package.json, `
164
+ const via = decl?.usable === true ? ` — only its types are, as ${decl.pkg} ${decl.value} —` : ',';
165
+ return (`[VERSION — verify] "${asked}" is not declared in this project's package.json${via} `
88
166
  + `so this answer is based on npm latest (v${version}). Your project may target a `
89
167
  + `different MAJOR — confirm the version you intend to install and treat any API that `
90
- + `differs across majors as unverified until you check it against that version.\n\n`);
168
+ + `differs across majors as unverified until you check it against that version.${grounded}\n\n`);
91
169
  }
92
170
  export function getDocsModulesDir() {
93
171
  const base = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), '.cache');
@@ -208,8 +286,8 @@ export async function docsRaw(input) {
208
286
  const declaredRange = findDeclaredRange(parentPkg, input.cwd);
209
287
  autoInstallPin =
210
288
  declaredRange ?
211
- { source: 'declared-range', range: declaredRange }
212
- : { source: 'npm-latest' };
289
+ { source: 'declared-range', range: declaredRange, asked: parentPkg }
290
+ : { source: 'npm-latest', asked: parentPkg };
213
291
  const installResult = await runAutoInstall(spawn, parentPkg, undefined, declaredRange ?? undefined);
214
292
  if (!installResult.success) {
215
293
  return {
@@ -251,7 +251,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
251
251
  };
252
252
  }
253
253
  if (rawResult.kind === 'no_chunks') {
254
- const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version);
254
+ const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version, ctx.cwd);
255
255
  return {
256
256
  text: banner
257
257
  + npmHeader
@@ -269,7 +269,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
269
269
  }
270
270
  // kind === 'ok'
271
271
  const { pkg, chunks, hitCache, indexingMs, cacheError, autoInstalled } = rawResult;
272
- const versionBanner = buildVersionBanner(rawResult.autoInstallPin, pkg.name, pkg.version);
272
+ const versionBanner = buildVersionBanner(rawResult.autoInstallPin, pkg.name, pkg.version, ctx.cwd);
273
273
  const baseDetails = {
274
274
  version: pkg.version,
275
275
  hitCache,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.37.5",
3
+ "version": "0.37.7",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",