@mjasnikovs/pi-task 0.18.31 → 0.18.33

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.
@@ -32,7 +32,7 @@ import { describeDebt } from './accept-debt.js';
32
32
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
33
33
  import { getConfig } from '../config/config.js';
34
34
  import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
35
- import { configureResearchRun } from '../workers/research-cache.js';
35
+ import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
36
36
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
37
37
  import { reconcileTitleSources } from './decompose-fidelity.js';
38
38
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
@@ -1396,9 +1396,14 @@ async function handleTaskAutoResume(_args, ctx) {
1396
1396
  ctx.ui.notify(`Resuming ${id}…`, 'info');
1397
1397
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1398
1398
  autoRunning = true;
1399
- // Fresh per-run research-cache id for the resumed run (F10); a resume re-fetches
1400
- // rather than reusing the interrupted run's digest — safe, only slightly less reuse.
1401
- configureResearchRun(getConfig().researchCache);
1399
+ // Reuse the interrupted run's research-cache id when the cache proves it still
1400
+ // describes the same dependency surface (F10). mx5 run 13 resumed three times and
1401
+ // each resume's fresh id discarded a working 201-entry cache; anything inconclusive
1402
+ // still falls back to a fresh id and a re-fetch. See resumeResearchRun.
1403
+ const research = await resumeResearchRun(cwd, getConfig().researchCache);
1404
+ if (research.reused) {
1405
+ logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies)`);
1406
+ }
1402
1407
  const abort = new AbortController();
1403
1408
  // Resume only runs the loop (runTask); no planning children, so the loader
1404
1409
  // title is unused here — pass the id for clarity if that ever changes.
@@ -3,11 +3,23 @@ const DEFAULT_BUDGET = 24_000;
3
3
  const MIN_TOKEN_LEN = 2;
4
4
  const FALLBACK_DTS_CHARS = 12_000;
5
5
  const FALLBACK_README_CHARS = 4_000;
6
+ /**
7
+ * Split on any run of non-identifier characters — NOT on whitespace alone.
8
+ *
9
+ * Splitting on /\s+/ and then stripping punctuation INSIDE each token silently welds
10
+ * multi-part identifiers into one string that occurs nowhere in the corpus:
11
+ * "src/server/routes/auth.ts" -> "srcserverroutesauthts"
12
+ * "Bun.password.hash" -> "Bunpasswordhash"
13
+ * Because buildFtsQuery ORs the tokens, such a token contributes no MATCH at all, so the
14
+ * single most informative term in the query — the path, or the dotted API symbol — was
15
+ * dropped and ranking fell to the surrounding prose. Measured on the 141 real project
16
+ * queries of mx5 run 13, that cost 18% of them any chunk from the file they named
17
+ * (82% -> 99% retrieved once split on punctuation); on the 44 gradable npm queries,
18
+ * discriminative-symbol recall 89.5% -> 96.4%.
19
+ * See scripts/live-project-docs-retrieval-ab.ts and scripts/live-npm-tokenizer-regression.ts.
20
+ */
6
21
  function tokenize(query) {
7
- return query
8
- .split(/\s+/)
9
- .map(t => t.replace(/[^a-zA-Z0-9_]/g, ''))
10
- .filter(t => t.length >= MIN_TOKEN_LEN);
22
+ return query.split(/[^a-zA-Z0-9_]+/).filter(t => t.length >= MIN_TOKEN_LEN);
11
23
  }
12
24
  function buildFtsQuery(tokens) {
13
25
  return tokens.map(t => `"${t}"`).join(' OR ');
@@ -22,6 +22,33 @@ export declare function configureResearchRun(enabled: boolean): string | undefin
22
22
  * of asking the same thing resolve to the same (correct) result.
23
23
  */
24
24
  export declare function normalizeQuery(s: string): string;
25
+ /**
26
+ * A stable fingerprint of the project's declared dependency surface — the only input a
27
+ * cached docs digest actually depends on (a digest summarises a package's INSTALLED
28
+ * types/README at a pinned version). Built from package.json's dependency blocks with
29
+ * keys sorted, so formatting churn or an unrelated field edit does not invalidate it,
30
+ * while any add/remove/version-bump does.
31
+ *
32
+ * Returns undefined when the manifest is missing or unparseable — the caller then has
33
+ * NO positive evidence of freshness and must not reuse. Deliberately NOT the lockfile's
34
+ * hash or mtime: a lockfile is rewritten by installs that do not change any resolved
35
+ * version, which would defeat reuse for no correctness gain.
36
+ */
37
+ export declare function depsFingerprint(cwd: string): Promise<string | undefined>;
38
+ /**
39
+ * Resume hook: reuse the interrupted run's cache id when — and only when — the file
40
+ * proves it describes the same dependency surface. Returns the id now stamped into the
41
+ * environment (reused or fresh), or undefined when caching is off.
42
+ *
43
+ * Every uncertain path falls through to a fresh id, which merely re-fetches:
44
+ * caching disabled, no/corrupt cache file, a file with no fingerprint (written before
45
+ * this shipped), an unreadable manifest, or a fingerprint that no longer matches.
46
+ */
47
+ export declare function resumeResearchRun(cwd: string, enabled: boolean): Promise<{
48
+ runId: string | undefined;
49
+ reused: boolean;
50
+ entries: number;
51
+ }>;
25
52
  /**
26
53
  * Look up a cached result for `key` in the current run. Returns undefined on a miss,
27
54
  * a stale-run file (different id ⇒ another run's digest, ignored), or any failure.
@@ -25,11 +25,29 @@
25
25
  * a run started with the feature flag OFF (no id in the environment) does not cache
26
26
  * at all — the cache is inert unless the orchestrator turned it on for this run.
27
27
  *
28
+ * RESUME REUSE (mx5 run 13, measured from the file's own git history — the cache is
29
+ * committed with every task, so the whole run is recoverable): a 32-task run built the
30
+ * cache to 201 entries over 20 tasks under one run id, then three /task-auto-resume
31
+ * invocations each stamped a fresh id and the first store of each dropped everything —
32
+ * 201 → 11 → 3 → 5 → 8. The audit read the 8-entry tail and concluded the cache was
33
+ * near-useless; it was in fact working, and the resume threw the work away. The old
34
+ * comment called a resume re-fetch "only slightly less reuse", which holds for a
35
+ * 5-task run and fails badly for a 32-task one.
36
+ *
37
+ * So a resume now REUSES the interrupted run's id — but only on POSITIVE evidence that
38
+ * the digests still describe the same dependency surface. The staleness that matters is
39
+ * a package version moving under a cached answer (a docs digest summarises the
40
+ * INSTALLED types of a pinned package), so the file records a fingerprint of the
41
+ * manifest's dependency block and a resume reuses the id only when that fingerprint is
42
+ * unchanged. Missing, unparseable, or fingerprint-less file ⇒ fresh id and a re-fetch:
43
+ * every inconclusive path costs time, never correctness.
44
+ *
28
45
  * Stored under `.pi-tasks/` (sibling of env-notes.md / contracts.md), which the
29
46
  * git-state guard and discardEdits both exclude. Best-effort throughout: any I/O or
30
47
  * parse failure falls back to a live fetch — the cache only ever saves time, it can
31
48
  * never change an answer or block a worker.
32
49
  */
50
+ import { createHash } from 'node:crypto';
33
51
  import * as fsp from 'node:fs/promises';
34
52
  import * as path from 'node:path';
35
53
  import { tasksDir } from '../task/task-io.js';
@@ -81,6 +99,72 @@ export function configureResearchRun(enabled) {
81
99
  export function normalizeQuery(s) {
82
100
  return s.replace(/\s+/g, ' ').trim().toLowerCase();
83
101
  }
102
+ /**
103
+ * A stable fingerprint of the project's declared dependency surface — the only input a
104
+ * cached docs digest actually depends on (a digest summarises a package's INSTALLED
105
+ * types/README at a pinned version). Built from package.json's dependency blocks with
106
+ * keys sorted, so formatting churn or an unrelated field edit does not invalidate it,
107
+ * while any add/remove/version-bump does.
108
+ *
109
+ * Returns undefined when the manifest is missing or unparseable — the caller then has
110
+ * NO positive evidence of freshness and must not reuse. Deliberately NOT the lockfile's
111
+ * hash or mtime: a lockfile is rewritten by installs that do not change any resolved
112
+ * version, which would defeat reuse for no correctness gain.
113
+ */
114
+ export async function depsFingerprint(cwd) {
115
+ try {
116
+ const raw = await fsp.readFile(path.join(cwd, 'package.json'), 'utf8');
117
+ const pkg = JSON.parse(raw);
118
+ const blocks = [
119
+ 'dependencies',
120
+ 'devDependencies',
121
+ 'peerDependencies',
122
+ 'optionalDependencies'
123
+ ];
124
+ const parts = [];
125
+ for (const block of blocks) {
126
+ const deps = pkg[block];
127
+ if (!deps || typeof deps !== 'object')
128
+ continue;
129
+ const entries = Object.entries(deps)
130
+ .filter(([, v]) => typeof v === 'string')
131
+ .sort(([a], [b]) => a < b ? -1
132
+ : a > b ? 1
133
+ : 0)
134
+ .map(([k, v]) => `${k}@${String(v)}`);
135
+ if (entries.length > 0)
136
+ parts.push(`${block}:${entries.join(',')}`);
137
+ }
138
+ // No dependency block at all is a real, stable state (a dependency-free repo) —
139
+ // fingerprint it as such rather than failing, so reuse still works there.
140
+ return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 32);
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ /**
147
+ * Resume hook: reuse the interrupted run's cache id when — and only when — the file
148
+ * proves it describes the same dependency surface. Returns the id now stamped into the
149
+ * environment (reused or fresh), or undefined when caching is off.
150
+ *
151
+ * Every uncertain path falls through to a fresh id, which merely re-fetches:
152
+ * caching disabled, no/corrupt cache file, a file with no fingerprint (written before
153
+ * this shipped), an unreadable manifest, or a fingerprint that no longer matches.
154
+ */
155
+ export async function resumeResearchRun(cwd, enabled) {
156
+ if (!enabled) {
157
+ delete process.env[RESEARCH_RUN_ID_ENV];
158
+ return { runId: undefined, reused: false, entries: 0 };
159
+ }
160
+ const file = await readCacheFile(cwd);
161
+ const current = await depsFingerprint(cwd);
162
+ if (file && file.deps !== undefined && current !== undefined && file.deps === current) {
163
+ process.env[RESEARCH_RUN_ID_ENV] = file.runId;
164
+ return { runId: file.runId, reused: true, entries: Object.keys(file.entries).length };
165
+ }
166
+ return { runId: configureResearchRun(true), reused: false, entries: 0 };
167
+ }
84
168
  async function readCacheFile(cwd) {
85
169
  try {
86
170
  const raw = await fsp.readFile(researchCacheFile(cwd), 'utf8');
@@ -127,7 +211,16 @@ export async function storeResearch(cwd, runId, key, text, details) {
127
211
  for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
128
212
  delete entries[k];
129
213
  }
130
- const out = { runId, entries };
214
+ // Stamp the dependency surface these entries were produced against, so a later
215
+ // resume can prove they are still fresh. Unreadable manifest ⇒ field omitted,
216
+ // which reads as "cannot prove freshness" and simply denies reuse.
217
+ //
218
+ // FROZEN at the run's first write, deliberately: if a task installs a package
219
+ // mid-run and we re-fingerprinted here, the new fingerprint would bless digests
220
+ // taken BEFORE the install as current. Keeping the original means a mid-run
221
+ // install makes a later resume mismatch and re-fetch — the safe direction.
222
+ const deps = existing && existing.runId === runId ? existing.deps : await depsFingerprint(cwd);
223
+ const out = deps === undefined ? { runId, entries } : { runId, entries, deps };
131
224
  await fsp.mkdir(tasksDir(cwd), { recursive: true });
132
225
  // Atomic-ish write so a concurrent reader never sees a half-written file.
133
226
  const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.31",
3
+ "version": "0.18.33",
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",