@mjasnikovs/pi-task 0.37.4 → 0.37.6

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.
@@ -52,6 +52,7 @@ import * as path from 'node:path';
52
52
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
53
53
  import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
54
54
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
55
+ import { readLaunchManifest, inertLaunchContractNote } from './launch-manifest.js';
55
56
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
56
57
  import { runRenderCheck } from './render-check.js';
57
58
  import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
@@ -1378,11 +1379,28 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1378
1379
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
1379
1380
  // plan-time-extracted declared scripts against the manifest; a missing one is a
1380
1381
  // launch-surface defect. FP-safe: empty declared list (nothing grounded) → no check.
1382
+ //
1383
+ // THE DIFF IS INERT WITHOUT A MANIFEST (nexttask 16A). It used to diff against
1384
+ // `Object.keys(packageScripts(cwd))`, whose catch returns {} — so a project with
1385
+ // NO package.json was indistinguishable from one with no scripts, and every
1386
+ // declared script was reported missing in wording naming a file the project was
1387
+ // never meant to have. Nothing upstream is npm-shaped (the extractor scrapes any
1388
+ // design that says "script"), and this text seeds the autofix child's prompt, so
1389
+ // on a CMake/cargo project the likely repair was to write a package.json.
1390
+ // readLaunchManifest resolves package.json, else a Makefile's targets, else
1391
+ // nothing — and nothing means no failure plus a note, never a silent pass.
1381
1392
  const declared = await readDeclaredScripts(cwd);
1393
+ const contractNotes = [];
1382
1394
  if (declared.length > 0) {
1383
- const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
1384
- if (missing.length > 0) {
1385
- fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1395
+ const manifest = readLaunchManifest(cwd);
1396
+ if (manifest.kind === 'none') {
1397
+ contractNotes.push(inertLaunchContractNote(declared, manifest));
1398
+ }
1399
+ else {
1400
+ const missing = missingDeclaredScripts(declared, manifest.names);
1401
+ if (missing.length > 0) {
1402
+ fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1403
+ }
1386
1404
  }
1387
1405
  }
1388
1406
  // Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
@@ -1416,7 +1434,12 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1416
1434
  // harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
1417
1435
  // failure.
1418
1436
  if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
1419
- const note = unobservedVerdict({ discovered: 0, observed: 0 }) ?? '';
1437
+ // The inert-contract note rides here too: a non-npm project carrying a launch
1438
+ // contract usually discovers no command either, and that is exactly the run
1439
+ // whose silence must not read as "the contract was checked and was fine".
1440
+ const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
1441
+ .filter(n => n !== '')
1442
+ .join(' ');
1420
1443
  return withDebts({ ok: true, unobserved: note, reason: note });
1421
1444
  }
1422
1445
  const ran = [];
@@ -1471,6 +1494,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1471
1494
  // A declared script the manifest doesn't expose is already a launch-contract
1472
1495
  // failure above; executing it too would double-report (pre-aggregation the
1473
1496
  // contract diff early-returned, so this loop could assume presence).
1497
+ // EXECUTION STAYS npm-ONLY. The diff above now also speaks Makefile (16A),
1498
+ // but the runner below is literally `bun run <name>`; on a Makefile project
1499
+ // `present` is empty, so every declared target is skipped rather than run
1500
+ // through the wrong tool. Widening the RUNNER is a separate lever with its
1501
+ // own A/B, not a free rider on an inertness fix.
1474
1502
  const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
1475
1503
  const scripts = packageScripts(cwd);
1476
1504
  // CONFIG-GAP INPUTS (mx5 run 20), read once: the tracked file list and the
@@ -1704,7 +1732,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1704
1732
  const unobserved = [
1705
1733
  bootUnobserved,
1706
1734
  unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
1707
- ...configGapNotes
1735
+ ...configGapNotes,
1736
+ ...contractNotes
1708
1737
  ]
1709
1738
  .filter(n => n !== null)
1710
1739
  .join(' ');
@@ -0,0 +1,37 @@
1
+ /** Which manifest kind the diff resolved for a tree. `none` ⇒ the diff is INERT. */
2
+ export type LaunchManifestKind = 'npm' | 'make' | 'none';
3
+ export interface LaunchManifest {
4
+ kind: LaunchManifestKind;
5
+ /** The manifest the diff is taken against, for the failure text. '' when none. */
6
+ file: string;
7
+ /** Entrypoint names the manifest exposes: npm script keys / Makefile targets. */
8
+ names: string[];
9
+ /** Why nothing is checkable (kind === 'none' only). */
10
+ why?: string;
11
+ }
12
+ /**
13
+ * The explicit targets a Makefile declares. Deliberately conservative — this list
14
+ * only ever has to answer "does the project expose `migrate`", so a missed exotic
15
+ * target costs a false FAIL and is the one error worth avoiding:
16
+ * • recipe lines (leading TAB) are skipped — they are shell, not targets;
17
+ * • `:=` / `::=` / `?=` / `+=` assignments are skipped;
18
+ * • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
19
+ * which is also why the names listed AFTER `.PHONY:` are ignored here: they are
20
+ * prerequisites, and every one of them that is real is declared by its own rule.
21
+ * Multiple targets on one line (`build test:`) all count.
22
+ */
23
+ export declare function makeTargets(src: string): string[];
24
+ /**
25
+ * Resolve the manifest the launch contract may be diffed against.
26
+ *
27
+ * A package.json that exists but does not parse resolves to `none`, not to "zero
28
+ * scripts": diffing against a manifest you could not read is exactly the mistake
29
+ * this module exists to stop, and a broken manifest is the static checks' business.
30
+ */
31
+ export declare function readLaunchManifest(cwd: string): LaunchManifest;
32
+ /**
33
+ * The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
34
+ * channel rather than in `warnings` for the reason nexttask 16A gives: a check that
35
+ * silently did nothing must not read later as a check that passed.
36
+ */
37
+ export declare function inertLaunchContractNote(declared: string[], manifest: LaunchManifest): string;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * launch-manifest — WHICH manifest the launch-contract diff is entitled to diff
3
+ * against, and whether there is one at all (nexttask 16A).
4
+ *
5
+ * The defect this closes (unobserved, proven by construction). The extraction end
6
+ * of the launch contract has no ecosystem test anywhere in it:
7
+ * `enumerateScriptCandidates` scrapes backticked, script-name-shaped tokens out of
8
+ * any design paragraph that says "script", and `keepGroundedScripts` can only DROP
9
+ * a candidate. So a CMake / cargo / poetry / Makefile design saying *"the Makefile
10
+ * must expose `build`, `test`, `migrate`"* records exactly the same artifact an npm
11
+ * design does. The diff end hardcoded the ecosystem anyway:
12
+ *
13
+ * missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)))
14
+ * …where packageScripts' catch returns {} — so "this project has no
15
+ * package.json" was indistinguishable from "its package.json declares no
16
+ * scripts", and every declared script was reported missing, at rank 1, in
17
+ * wording that NAMES a file the project was never meant to have.
18
+ *
19
+ * That text seeds the autofix child's prompt (final-gate.ts's FinalGateOutcome.reason),
20
+ * so the most likely repair on a CMake project was to write a package.json.
21
+ *
22
+ * IAR1 is how close this got: `plan-debug.log:10` records "launch-contract
23
+ * extraction: 0 grounded script(s) kept from 3 emitted" — a non-npm project that
24
+ * reached extraction and emitted three candidates, saved only by its design not
25
+ * backticking them in a "script" paragraph.
26
+ *
27
+ * THE RULE, and it is deliberately two ecosystems wide, not three:
28
+ * • package.json present and parseable ⇒ diff against its `scripts` keys, exactly
29
+ * as before (byte-identical failure text on every npm tree — mx5's missing
30
+ * `build`/`seed` is a TRUE positive and must keep failing).
31
+ * • no package.json but a Makefile ⇒ diff against its TARGETS. The capability was
32
+ * already in final-gate.ts (`makeHasTarget`); the diff simply never called it.
33
+ * • neither ⇒ the check is INERT. No failure, and a note saying the contract was
34
+ * recorded but is not checkable here, so the silence is not read later as a pass
35
+ * (the repo-health-check / env-template-closure discipline: ENOENT = pass, and a
36
+ * check must never invent a file the project chose not to have).
37
+ *
38
+ * cargo/poetry/gradle are NOT here on purpose. The corpus on this box contains one
39
+ * project that ever recorded a launch contract (mx5, npm) and one non-npm project
40
+ * that reached extraction (IAR1, CMake). A third ecosystem would be a guess.
41
+ */
42
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
43
+ import * as path from 'node:path';
44
+ /** GNU make's own lookup order, and nothing beyond it — this is one ecosystem. */
45
+ const MAKEFILE_NAMES = ['GNUmakefile', 'makefile', 'Makefile'];
46
+ /** A target we are willing to name in a failure: an ordinary word-shaped target. */
47
+ const TARGET_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i;
48
+ /**
49
+ * A variable assignment, in every form make accepts (`=`, `:=`, `::=`, `?=`, `+=`,
50
+ * `!=`, with an optional `override`/`export`). Matched BEFORE the target rule
51
+ * because `FLAGS ::= -O2` otherwise reads as a target named `FLAGS`.
52
+ */
53
+ const ASSIGN_RE = /^(?:override\s+|export\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*(?:::|:|\?|\+|!)?=/;
54
+ /**
55
+ * The explicit targets a Makefile declares. Deliberately conservative — this list
56
+ * only ever has to answer "does the project expose `migrate`", so a missed exotic
57
+ * target costs a false FAIL and is the one error worth avoiding:
58
+ * • recipe lines (leading TAB) are skipped — they are shell, not targets;
59
+ * • `:=` / `::=` / `?=` / `+=` assignments are skipped;
60
+ * • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
61
+ * which is also why the names listed AFTER `.PHONY:` are ignored here: they are
62
+ * prerequisites, and every one of them that is real is declared by its own rule.
63
+ * Multiple targets on one line (`build test:`) all count.
64
+ */
65
+ export function makeTargets(src) {
66
+ const out = [];
67
+ const seen = new Set();
68
+ for (const raw of src.replace(/\r\n?/g, '\n').split('\n')) {
69
+ if (raw.startsWith('\t') || raw.trim().length === 0 || raw.trimStart().startsWith('#'))
70
+ continue;
71
+ if (ASSIGN_RE.test(raw))
72
+ continue;
73
+ const m = /^([^:#=]+):(?![=])/.exec(raw);
74
+ if (!m)
75
+ continue;
76
+ for (const tok of m[1].trim().split(/\s+/)) {
77
+ if (!TARGET_NAME_RE.test(tok))
78
+ continue;
79
+ const key = tok.toLowerCase();
80
+ if (seen.has(key))
81
+ continue;
82
+ seen.add(key);
83
+ out.push(tok);
84
+ }
85
+ }
86
+ return out;
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
+ }
110
+ /**
111
+ * Resolve the manifest the launch contract may be diffed against.
112
+ *
113
+ * A package.json that exists but does not parse resolves to `none`, not to "zero
114
+ * scripts": diffing against a manifest you could not read is exactly the mistake
115
+ * this module exists to stop, and a broken manifest is the static checks' business.
116
+ */
117
+ export function readLaunchManifest(cwd) {
118
+ const pkg = path.join(cwd, 'package.json');
119
+ if (existsSync(pkg)) {
120
+ try {
121
+ const j = JSON.parse(readFileSync(pkg, 'utf8'));
122
+ return { kind: 'npm', file: 'package.json', names: Object.keys(j.scripts ?? {}) };
123
+ }
124
+ catch {
125
+ return { kind: 'none', file: '', names: [], why: 'its package.json could not be parsed' };
126
+ }
127
+ }
128
+ for (const name of makefilesOnDisk(cwd)) {
129
+ try {
130
+ return {
131
+ kind: 'make',
132
+ file: name,
133
+ names: makeTargets(readFileSync(path.join(cwd, name), 'utf8'))
134
+ };
135
+ }
136
+ catch {
137
+ break;
138
+ }
139
+ }
140
+ return { kind: 'none', file: '', names: [], why: 'it has no npm manifest and no Makefile' };
141
+ }
142
+ /**
143
+ * The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
144
+ * channel rather than in `warnings` for the reason nexttask 16A gives: a check that
145
+ * silently did nothing must not read later as a check that passed.
146
+ */
147
+ export function inertLaunchContractNote(declared, manifest) {
148
+ return (`launch contract: ${declared.length} declared script(s) (${declared.join(', ')}) were recorded, `
149
+ + `but this project could not be diffed against a manifest — ${manifest.why ?? 'no manifest'}. `
150
+ + 'The contract was NOT checked here.');
151
+ }
@@ -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.4",
3
+ "version": "0.37.6",
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",