@mjasnikovs/pi-task 0.16.3 → 0.16.4

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.
@@ -9,6 +9,7 @@ import { runWorker } from '../workers/pi-worker-core.js';
9
9
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
10
10
  import { search as defaultSearch } from '../workers/search-core.js';
11
11
  import { extractEnrichTargets } from './enrichment.js';
12
+ import { isIntegrationUnknown } from './unknown-routing.js';
12
13
  import { getFileInventory } from './file-inventory.js';
13
14
  import { buildOrientation } from './orientation.js';
14
15
  import { getConfig } from '../config/config.js';
@@ -459,7 +460,21 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
459
460
  // otherwise a preamble line leaks out as the recommended answer.
460
461
  text = await runPhaseChild(deps, 'grill-auto', 'read', prependHint(GRILL_AUTO_FORMAT_HINT, basePrompt));
461
462
  }
462
- return parseAutoAnswer(text);
463
+ const parsed = parseAutoAnswer(text);
464
+ // Surviving-unknown routing: an integration / build-wiring unknown whose
465
+ // wrong guess is a structural landmine must NOT be silently auto-answered.
466
+ // We first try to ground it from fetched docs (the enrichment fan-out
467
+ // above); only when NO doc/fetch worker produced a grounding section do we
468
+ // refuse the guess and surface it to the user — carrying the model's
469
+ // best-effort answer as the pre-filled recommendation so the user accepts
470
+ // it with one keystroke or overrides it. Benign unknowns are untouched.
471
+ const docResolved = docSections.filter(Boolean).length > 0;
472
+ if (parsed.kind === 'answered' && !docResolved && isIntegrationUnknown(question)) {
473
+ deps.logDebug?.(`grill-auto: integration unknown unresolved by fetch — surfacing to user `
474
+ + `instead of auto-answering: ${question.replace(/\s+/g, ' ').slice(0, 120)}`);
475
+ return { kind: 'unknown', suggested: parsed.text, raw: parsed.raw };
476
+ }
477
+ return parsed;
463
478
  }
464
479
  catch (err) {
465
480
  const msg = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Surviving-unknown routing.
3
+ *
4
+ * The grill phase generates one clarifying question per KNOWN-UNKNOWN and lets a
5
+ * `grill-auto` child either auto-answer it (ANSWER → the user never sees it) or
6
+ * surface it (UNKNOWN → asked). The dangerous class is an integration / build-
7
+ * wiring unknown — "how is this plugin wired into the build", "should the import
8
+ * structure in the HTML entry change" — which `grill-auto` happily auto-answers
9
+ * with a GUESS that then ships as a silent landmine (a real run shipped an
10
+ * unstyled build this way).
11
+ *
12
+ * This classifier identifies that class so the orchestrator can route it: try to
13
+ * resolve it from fetched docs first, and if nothing grounded the answer, surface
14
+ * it to the user instead of letting the guess stand. Pure + deterministic so it
15
+ * is unit-tested independently of the model.
16
+ *
17
+ * Deliberately GENERIC: it keys on build/tooling CATEGORIES (plugin, bundler,
18
+ * loader, build pipeline, entry point, …) and wiring INTENT (wire, integrate,
19
+ * configure, set up, how/where to …), never on any specific tool or project.
20
+ */
21
+ /**
22
+ * True when `question` is an integration / build-wiring unknown — one whose
23
+ * wrong guess is a structural landmine, so it must be resolved by a worker or
24
+ * surfaced to the user rather than auto-answered. Requires BOTH a tooling-surface
25
+ * mention and wiring intent, so benign questions ("which log format?", "what
26
+ * default page size?", "which file holds the auth handler?") do not trip it.
27
+ */
28
+ export declare function isIntegrationUnknown(question: string): boolean;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Surviving-unknown routing.
3
+ *
4
+ * The grill phase generates one clarifying question per KNOWN-UNKNOWN and lets a
5
+ * `grill-auto` child either auto-answer it (ANSWER → the user never sees it) or
6
+ * surface it (UNKNOWN → asked). The dangerous class is an integration / build-
7
+ * wiring unknown — "how is this plugin wired into the build", "should the import
8
+ * structure in the HTML entry change" — which `grill-auto` happily auto-answers
9
+ * with a GUESS that then ships as a silent landmine (a real run shipped an
10
+ * unstyled build this way).
11
+ *
12
+ * This classifier identifies that class so the orchestrator can route it: try to
13
+ * resolve it from fetched docs first, and if nothing grounded the answer, surface
14
+ * it to the user instead of letting the guess stand. Pure + deterministic so it
15
+ * is unit-tested independently of the model.
16
+ *
17
+ * Deliberately GENERIC: it keys on build/tooling CATEGORIES (plugin, bundler,
18
+ * loader, build pipeline, entry point, …) and wiring INTENT (wire, integrate,
19
+ * configure, set up, how/where to …), never on any specific tool or project.
20
+ */
21
+ // Factor A — the question concerns a build/tooling integration surface, not a
22
+ // value, a name, an output format, or a file location.
23
+ const TOOLING_CONCERN_RE = /\b(?:plugins?|bundlers?|loaders?|middlewares?|adapters?|toolchains?|transpilers?|compilers?|build\s+(?:pipeline|step|process|chain|config|tool|system)|import\s+structure|entry[\s-]*points?|dev[\s-]*servers?|module\s+resolution|build\s+wiring|wiring)\b/i;
24
+ // Factor B — the question is about HOW to connect/configure that surface (or
25
+ // expresses uncertainty about where something hooks in), not which value to pick.
26
+ const WIRING_INTENT_RE = /\b(?:wir(?:e|ed|ing)|integrat(?:e|ed|es|ing|ion)|hook(?:ed|s|ing)?\s+(?:it\s+|them\s+)?(?:up|in|into)|plug(?:ged|s|ging)?\s+(?:it\s+|them\s+)?(?:in|into)|configur(?:e|ed|es|ing|ation)|set[\s-]*up|register(?:ed|s|ing)?|mount(?:ed|s|ing)?|how\s+(?:to|do|does|should|is|are)|where\s+(?:to|should|does|is))\b/i;
27
+ /**
28
+ * True when `question` is an integration / build-wiring unknown — one whose
29
+ * wrong guess is a structural landmine, so it must be resolved by a worker or
30
+ * surfaced to the user rather than auto-answered. Requires BOTH a tooling-surface
31
+ * mention and wiring intent, so benign questions ("which log format?", "what
32
+ * default page size?", "which file holds the auth handler?") do not trip it.
33
+ */
34
+ export function isIntegrationUnknown(question) {
35
+ if (!question)
36
+ return false;
37
+ return TOOLING_CONCERN_RE.test(question) && WIRING_INTENT_RE.test(question);
38
+ }
@@ -4,6 +4,19 @@ import { resolvePackage as defaultResolvePackage, type ResolvedPackage } from '.
4
4
  import { retrieveChunks as defaultRetrieveChunks, type RetrievedChunk } from './docs-retrieve.js';
5
5
  import { npmVersionLookup as defaultNpmVersionLookup, type NpmVersionInfo } from './npm-version.js';
6
6
  import { type SpawnFn } from '../shared/child-process.js';
7
+ /**
8
+ * Provenance of an auto-installed package's version, so the answer can state
9
+ * what the version is grounded in instead of leaving it buried in tool details.
10
+ * - 'declared-range': install was pinned to the project's own package.json range
11
+ * (the resolved version therefore matches project intent).
12
+ * - 'npm-latest': nothing in the project declared the dep, so the install fell
13
+ * back to whatever npm tags `latest` — which may be a newer MAJOR than the
14
+ * project targets (this is what a scaffolding task hits, and needs a banner).
15
+ */
16
+ export interface AutoInstallPin {
17
+ source: 'declared-range' | 'npm-latest';
18
+ range?: string;
19
+ }
7
20
  export type DocsRawResult = {
8
21
  kind: 'ok';
9
22
  pkg: ResolvedPackage;
@@ -13,6 +26,7 @@ export type DocsRawResult = {
13
26
  indexedFiles?: number;
14
27
  cacheError?: string;
15
28
  autoInstalled?: boolean;
29
+ autoInstallPin?: AutoInstallPin;
16
30
  npmVersion?: NpmVersionInfo | null;
17
31
  } | {
18
32
  kind: 'not_installed';
@@ -25,6 +39,7 @@ export type DocsRawResult = {
25
39
  indexedFiles?: number;
26
40
  cacheError?: string;
27
41
  autoInstalled?: boolean;
42
+ autoInstallPin?: AutoInstallPin;
28
43
  npmVersion?: NpmVersionInfo | null;
29
44
  } | {
30
45
  kind: 'error';
@@ -35,6 +50,7 @@ export type DocsRawResult = {
35
50
  hitCache?: boolean;
36
51
  cacheError?: string;
37
52
  autoInstalled?: boolean;
53
+ autoInstallPin?: AutoInstallPin;
38
54
  npmVersion?: NpmVersionInfo | null;
39
55
  };
40
56
  export interface DocsRawInput {
@@ -69,9 +85,28 @@ export interface DocsFocusedResult {
69
85
  }
70
86
  export type DocsFocusedInput = DocsRawInput;
71
87
  export declare function extractParentPackage(moduleName: string): string;
88
+ /**
89
+ * The version range a project DECLARES for `parentPkg` in its package.json under
90
+ * `cwd`. Lets a not-yet-installed scaffolding dependency be documented against
91
+ * 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.
96
+ */
97
+ export declare function findDeclaredRange(parentPkg: string, cwd: string): string | null;
98
+ /**
99
+ * One-line version-provenance banner that LEADS a docs answer for a package the
100
+ * worker had to auto-install (not yet present in node_modules — every
101
+ * scaffolding task). Surfaces the version the answer is grounded in directly in
102
+ * the prose the impl model reads, rather than burying it in tool `details`.
103
+ * Empty string when there was no auto-install (already-installed packages need
104
+ * no banner — their version is the project's own).
105
+ */
106
+ export declare function buildVersionBanner(pin: AutoInstallPin | undefined, pkgName: string, version: string): string;
72
107
  export declare function getDocsModulesDir(): string;
73
108
  export declare function ensureDocsModulesDir(dir: string): void;
74
- export declare function runAutoInstall(spawn: SpawnFn, packageName: string, signal: AbortSignal | undefined): Promise<{
109
+ export declare function runAutoInstall(spawn: SpawnFn, packageName: string, signal: AbortSignal | undefined, versionRange?: string): Promise<{
75
110
  success: boolean;
76
111
  installDir: string;
77
112
  stderr: string;
@@ -24,6 +24,70 @@ export function extractParentPackage(moduleName) {
24
24
  }
25
25
  return moduleName.split('/')[0];
26
26
  }
27
+ const DEP_FIELDS = [
28
+ 'dependencies',
29
+ 'devDependencies',
30
+ 'peerDependencies',
31
+ 'optionalDependencies'
32
+ ];
33
+ /** A declared value that is a real, installable npm semver range — not a
34
+ * wildcard (which pins nothing) and not a non-registry protocol (which is not
35
+ * an `install <pkg>@<range>` target). */
36
+ function isUsableRange(range) {
37
+ const r = range.trim();
38
+ if (r.length === 0 || r === '*' || r === 'x' || r === 'latest')
39
+ return false;
40
+ if (/^(?:workspace|link|file|git|github|http|https|portal|patch|npm):/i.test(r))
41
+ return false;
42
+ return true;
43
+ }
44
+ /**
45
+ * The version range a project DECLARES for `parentPkg` in its package.json under
46
+ * `cwd`. Lets a not-yet-installed scaffolding dependency be documented against
47
+ * the major the project intends, instead of whatever npm currently tags
48
+ * `latest`. Scans the four standard dependency maps in priority order. Returns
49
+ * null — caller falls back to latest — when the dep is undeclared, the
50
+ * package.json is missing/unreadable, or the declared value is not a usable
51
+ * range. Best-effort; never throws.
52
+ */
53
+ export function findDeclaredRange(parentPkg, cwd) {
54
+ let json;
55
+ try {
56
+ json = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ for (const field of DEP_FIELDS) {
62
+ const map = json[field];
63
+ if (!map || typeof map !== 'object')
64
+ continue;
65
+ const range = map[parentPkg];
66
+ if (typeof range === 'string' && isUsableRange(range))
67
+ return range.trim();
68
+ }
69
+ return null;
70
+ }
71
+ /**
72
+ * One-line version-provenance banner that LEADS a docs answer for a package the
73
+ * worker had to auto-install (not yet present in node_modules — every
74
+ * scaffolding task). Surfaces the version the answer is grounded in directly in
75
+ * the prose the impl model reads, rather than burying it in tool `details`.
76
+ * Empty string when there was no auto-install (already-installed packages need
77
+ * no banner — their version is the project's own).
78
+ */
79
+ export function buildVersionBanner(pin, pkgName, version) {
80
+ if (!pin)
81
+ return '';
82
+ if (pin.source === 'declared-range') {
83
+ return (`[VERSION] "${pkgName}" resolved to this project's declared range `
84
+ + `${pin.range} (installed v${version}); the answer below is pinned to that version.\n\n`);
85
+ }
86
+ return (`[VERSION — verify] "${pkgName}" is not declared in this project's package.json, `
87
+ + `so this answer is based on npm latest (v${version}). Your project may target a `
88
+ + `different MAJOR — confirm the version you intend to install and treat any API that `
89
+ + `differs across majors as unverified until you check it against that version.\n\n`);
90
+ }
27
91
  export function getDocsModulesDir() {
28
92
  const base = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), '.cache');
29
93
  return path.join(base, 'pi-worker', 'docs-modules');
@@ -35,12 +99,15 @@ export function ensureDocsModulesDir(dir) {
35
99
  fs.writeFileSync(pkgPath, '{"name":"pi-worker-docs-modules","private":true}\n', 'utf8');
36
100
  }
37
101
  }
38
- export async function runAutoInstall(spawn, packageName, signal) {
102
+ export async function runAutoInstall(spawn, packageName, signal, versionRange) {
39
103
  const installDir = getDocsModulesDir();
40
104
  ensureDocsModulesDir(installDir);
105
+ // `shell: false` in runChild, so a `^`/`~`/space in the range stays a single
106
+ // literal arg — no glob/expansion risk from `<pkg>@<range>`.
107
+ const target = versionRange ? `${packageName}@${versionRange}` : packageName;
41
108
  const result = await runChild(spawn, {
42
109
  command: 'npm',
43
- args: ['install', '--no-audit', '--no-fund', '--loglevel=error', packageName]
110
+ args: ['install', '--no-audit', '--no-fund', '--loglevel=error', target]
44
111
  }, installDir, signal, { mode: 'text', discardStdout: true });
45
112
  return { success: result.exitCode === 0 && !result.aborted, installDir, stderr: result.stderr };
46
113
  }
@@ -111,20 +178,30 @@ export async function docsRaw(input) {
111
178
  // Step 1: resolve package
112
179
  let pkg;
113
180
  let autoInstalled = false;
181
+ let autoInstallPin;
114
182
  try {
115
183
  pkg = resolvePackage(requested, input.cwd);
116
184
  }
117
185
  catch (firstErr) {
118
186
  if (firstErr instanceof ResolveError && firstErr.kind === 'not_installed') {
119
- // auto-install
187
+ // auto-install — but FIRST honour the version the project intends.
188
+ // If the dep is declared in the project's package.json, install that
189
+ // range so a scaffolding answer is grounded in the project's major,
190
+ // not whatever npm currently tags `latest`.
120
191
  const parentPkg = extractParentPackage(requested);
121
- const installResult = await runAutoInstall(spawn, parentPkg, undefined);
192
+ const declaredRange = findDeclaredRange(parentPkg, input.cwd);
193
+ autoInstallPin =
194
+ declaredRange ?
195
+ { source: 'declared-range', range: declaredRange }
196
+ : { source: 'npm-latest' };
197
+ const installResult = await runAutoInstall(spawn, parentPkg, undefined, declaredRange ?? undefined);
122
198
  if (!installResult.success) {
123
199
  return {
124
200
  kind: 'error',
125
201
  message: `Package "${parentPkg}" is not installed and auto-install failed.\n${installResult.stderr}`,
126
202
  resolveError: 'not_installed',
127
203
  installError: installResult.stderr,
204
+ autoInstallPin,
128
205
  npmVersion: await npmVersionPromise
129
206
  };
130
207
  }
@@ -139,6 +216,7 @@ export async function docsRaw(input) {
139
216
  message: retryErr.message,
140
217
  resolveError: retryErr.kind,
141
218
  autoInstalled,
219
+ autoInstallPin,
142
220
  npmVersion: await npmVersionPromise
143
221
  };
144
222
  }
@@ -146,6 +224,7 @@ export async function docsRaw(input) {
146
224
  kind: 'error',
147
225
  message: `Could not resolve "${input.pkg}" after install: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
148
226
  autoInstalled,
227
+ autoInstallPin,
149
228
  npmVersion: await npmVersionPromise
150
229
  };
151
230
  }
@@ -186,6 +265,8 @@ export async function docsRaw(input) {
186
265
  docsRawCached(cache, pkg, input.query, ensureIndexed, retrieveChunks, autoInstalled)
187
266
  : docsRawUncached(pkg, cacheError ?? 'unknown cache error', autoInstalled);
188
267
  result.npmVersion = await npmVersionPromise;
268
+ if (autoInstallPin && result.kind !== 'not_installed')
269
+ result.autoInstallPin = autoInstallPin;
189
270
  return result;
190
271
  }
191
272
  function docsRawCached(cache, pkg, query, ensureIndexed, retrieveChunks, autoInstalled) {
@@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox';
2
2
  import { Text } from '@earendil-works/pi-tui';
3
3
  import { openCache as defaultOpenCache } from './docs-cache.js';
4
4
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
5
- import { docsRaw, formatResultText, buildPrompt } from './docs-core.js';
5
+ import { docsRaw, formatResultText, buildPrompt, buildVersionBanner } from './docs-core.js';
6
6
  import { formatNpmVersionSection } from './npm-version.js';
7
7
  import { runChild, CHILD_BASE_ARGS } from '../shared/child-process.js';
8
8
  import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
@@ -19,6 +19,9 @@ const Params = Type.Object({
19
19
  description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
20
20
  })
21
21
  });
22
+ function pinDetails(pin) {
23
+ return pin ? { versionSource: pin.source, declaredRange: pin.range } : {};
24
+ }
22
25
  export function registerPiWorkerDocs(pi, internals = {}) {
23
26
  makeWorkerTool(pi, {
24
27
  name: 'pi-worker-docs',
@@ -172,8 +175,10 @@ export function registerPiWorkerDocs(pi, internals = {}) {
172
175
  };
173
176
  }
174
177
  if (rawResult.kind === 'no_chunks') {
178
+ const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version);
175
179
  return {
176
- text: npmHeader
180
+ text: banner
181
+ + npmHeader
177
182
  + `Package ${rawResult.pkg.name}@${rawResult.pkg.version} has no .d.ts files or README. Use pi-worker to read source directly.`,
178
183
  details: {
179
184
  version: rawResult.pkg.version,
@@ -181,12 +186,14 @@ export function registerPiWorkerDocs(pi, internals = {}) {
181
186
  indexedFiles: rawResult.indexedFiles ?? 0,
182
187
  cacheError: rawResult.cacheError,
183
188
  autoInstalled: rawResult.autoInstalled,
189
+ ...pinDetails(rawResult.autoInstallPin),
184
190
  ...npmDetails
185
191
  }
186
192
  };
187
193
  }
188
194
  // kind === 'ok'
189
195
  const { pkg, chunks, hitCache, indexingMs, cacheError, autoInstalled } = rawResult;
196
+ const versionBanner = buildVersionBanner(rawResult.autoInstallPin, pkg.name, pkg.version);
190
197
  const baseDetails = {
191
198
  version: pkg.version,
192
199
  hitCache,
@@ -194,6 +201,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
194
201
  indexingMs,
195
202
  cacheError,
196
203
  autoInstalled,
204
+ ...pinDetails(rawResult.autoInstallPin),
197
205
  ...npmDetails
198
206
  };
199
207
  const concatenated = chunks.map(c => c.content).join('\n\n');
@@ -203,7 +211,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
203
211
  const failure = formatChildFailure(child, 'Docs lookup aborted.');
204
212
  if (failure !== null) {
205
213
  return {
206
- text: npmHeader + failure,
214
+ text: versionBanner + npmHeader + failure,
207
215
  details: {
208
216
  ...baseDetails,
209
217
  ...(child.aborted ? { aborted: true } : {}),
@@ -213,7 +221,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
213
221
  }
214
222
  const parsed = parseChildOutput(child.stdout);
215
223
  const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
216
- const text = npmHeader + formatResultText(pkg, parsed, verified);
224
+ const text = versionBanner + npmHeader + formatResultText(pkg, parsed, verified);
217
225
  return {
218
226
  text,
219
227
  details: {
@@ -17,8 +17,15 @@ export function registerPiWorkerFetch(pi, internals = {}) {
17
17
  description: 'Fetch a web page or text resource (HTML, markdown, plain text, JSON, '
18
18
  + 'XML/feeds), clean HTML to markdown, and hand it to an isolated child '
19
19
  + 'Pi session that extracts ONLY content answering `query`. Returns the '
20
- + 'focused answer. Use after `pi-worker-search` (or with a known URL) to '
21
- + 'avoid stuffing raw content into the main context.',
20
+ + 'focused answer.\n'
21
+ + 'REACH FOR THIS when you need to know how an external library, tool, '
22
+ + 'plugin, framework, or service is CONFIGURED, WIRED, or INTEGRATED and '
23
+ + 'the installed-package docs (pi-worker-docs) do not cover it: fetch its '
24
+ + 'README or official documentation page and extract the setup/wiring you '
25
+ + 'need, instead of guessing. Do NOT guess integration or configuration '
26
+ + 'details from memory — fetch the authoritative page. Also use it to read '
27
+ + 'any known URL, or after `pi-worker-search` to read a result, without '
28
+ + 'stuffing raw content into the main context.',
22
29
  parameters: Params,
23
30
  async run(params, signal, ctx) {
24
31
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
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",