@mjasnikovs/pi-task 0.18.30 → 0.18.32

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.
@@ -140,7 +140,14 @@ export function extractSpecForVerification(taskBody) {
140
140
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
141
141
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
142
142
  */
143
- export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings) {
143
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings,
144
+ /**
145
+ * The mx5 run-13 (PROMPT 4) probes, grouped rather than appended as three more
146
+ * positional parameters — this signature was already at its limit. Each key is
147
+ * an independent finding list; absent/empty emits no block.
148
+ */
149
+ projectSurface = {}) {
150
+ const { foreignPaths: foreignPathFindings, scriptEscapes: scriptEscapeFindings, runnerGlobs: runnerGlobFindings } = projectSurface;
144
151
  const probeBlock = probeFindings && probeFindings.length > 0 ?
145
152
  [
146
153
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -216,6 +223,57 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
216
223
  ''
217
224
  ]
218
225
  : [];
226
+ const foreignPathBlock = foreignPathFindings && foreignPathFindings.length > 0 ?
227
+ [
228
+ 'SANDBOX PATH LEAK NOTICE (deterministic, computed by the orchestrator by',
229
+ "resolving every absolute path in the task's diff against THIS machine): this",
230
+ 'task committed absolute paths that do not exist here, while the real file they',
231
+ 'name sits inside this repo:',
232
+ ...foreignPathFindings.map(f => `- ${f}`),
233
+ "These are paths from the authoring agent's OWN environment, baked into a file",
234
+ 'that ships. The command that reads such a path does not fail a check — it fails',
235
+ 'to BUILD, so the checks that would have caught it never run and report nothing',
236
+ '(mx5 run 13: a leaked `/workspace` vite alias made `test:ct` collect 63 tests',
237
+ 'and run 0, and the suite stayed dead for the rest of the run). A green or',
238
+ 'EMPTY result from any command that reads these files is therefore NOT evidence.',
239
+ 'Run the affected command yourself and confirm it actually EXECUTES work — count',
240
+ 'the tests/steps that ran, not the exit code. Unless the leaked path resolves on',
241
+ 'this machine, the verdict is FAIL naming the file and the path (rule 4e).',
242
+ ''
243
+ ]
244
+ : [];
245
+ const scriptEscapeBlock = scriptEscapeFindings && scriptEscapeFindings.length > 0 ?
246
+ [
247
+ 'NEUTERED CHECK SCRIPT NOTICE (deterministic, computed by the orchestrator from',
248
+ 'the manifest THIS task changed): these check scripts cannot report failure —',
249
+ 'their exit status is 0 no matter what the checker finds:',
250
+ ...scriptEscapeFindings.map(f => `- ${f}`),
251
+ 'You CANNOT discover this by running the script: it passes, which is the whole',
252
+ 'defect (mx5 run 13 shipped a `lint` whose typecheck was disarmed by an inverted',
253
+ 'grep and a `|| true` tail; every gate that ran it reported success without',
254
+ 'measuring anything). A green result from one of these scripts is NOT evidence',
255
+ 'for any acceptance criterion. To judge the area it claims to cover, run the',
256
+ 'underlying checker DIRECTLY and unmodified (e.g. `tsc --noEmit` rather than',
257
+ '`npm run lint`) and judge THAT output. Unless the task spec explicitly requires',
258
+ 'the script to tolerate failure, the verdict is FAIL naming the script (rule 4f).',
259
+ ''
260
+ ]
261
+ : [];
262
+ const runnerGlobBlock = runnerGlobFindings && runnerGlobFindings.length > 0 ?
263
+ [
264
+ 'TEST-RUNNER GLOB COLLISION NOTICE (deterministic, computed by the orchestrator',
265
+ "from the manifest's declared runners and their config): two test runners claim",
266
+ 'the same files:',
267
+ ...runnerGlobFindings.map(f => `- ${f}`),
268
+ "The scanning runner will import the other's spec files and abort on a module",
269
+ 'loaded outside its own runner — so the suite dies WHOLESALE rather than',
270
+ 'reporting failures (this is the THIRD occurrence: mx5 runs 7 and 13). Run both',
271
+ 'test commands yourself and confirm each collects and runs its own files and only',
272
+ 'its own. A run that errors during collection has verified nothing, whatever its',
273
+ 'exit code says (rule 4g).',
274
+ ''
275
+ ]
276
+ : [];
219
277
  const testAssemblyBlock = testAssemblyFindings && testAssemblyFindings.length > 0 ?
220
278
  [
221
279
  'TEST-ASSEMBLY NOTICE (deterministic, computed by the orchestrator from pure',
@@ -255,6 +313,9 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
255
313
  ...crossTaskDeletionBlock,
256
314
  ...probeGamingBlock,
257
315
  ...skipEscapeBlock,
316
+ ...foreignPathBlock,
317
+ ...scriptEscapeBlock,
318
+ ...runnerGlobBlock,
258
319
  ...testAssemblyBlock,
259
320
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
260
321
  'checkout (or CI run) would experience it:',
@@ -388,6 +449,32 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
388
449
  ' tree). Otherwise the verdict is FAIL naming the deleted file and the task that',
389
450
  ' owns it.',
390
451
  '',
452
+ '4e. AN ABSOLUTE PATH TO PROJECT FILES IS A DEFECT — a committed path like',
453
+ " `/workspace/src/shared` or `/home/<someone>/proj/src` names the authoring agent's",
454
+ ' OWN machine, not this one. Its distinctive damage is that it breaks the run BEFORE',
455
+ ' any check reports: a bad alias/config path makes the tool fail to RESOLVE or BUILD,',
456
+ ' so a suite "passes" having executed nothing. Treat a command that reports no',
457
+ ' failures but also no WORK — 0 tests run, 0 files emitted, an empty report — as',
458
+ ' unverified, never as green. Confirm the count of things that actually ran. A path',
459
+ ' to project-internal files must be relative to the file that carries it, or computed',
460
+ ' at runtime; if one does not resolve here, the verdict is FAIL naming file and path.',
461
+ '',
462
+ '4f. A CHECK THAT CANNOT FAIL PROVES NOTHING — before you cite a check script',
463
+ " (`npm run lint`, `bun run test`) as evidence, read its DEFINITION in the project's",
464
+ ' manifest. A script ending in `|| true`, `; exit 0`, or piping a checker into an',
465
+ ' inverted grep exits 0 unconditionally: its green result is a constant, not a',
466
+ ' measurement, and running it again only reproduces the constant. When a script is',
467
+ ' built that way, run the underlying checker directly and judge its real output; and',
468
+ ' unless the spec required that tolerance, the script itself is a defect — the',
469
+ ' verdict is FAIL naming it.',
470
+ '',
471
+ '4g. TWO RUNNERS, ONE FILE SET, NO RESULTS — when a project declares more than one',
472
+ ' test runner, check that each collects only its own files. A runner that scans for',
473
+ " `*.test.*` / `*.spec.*` project-wide will import another runner's specs and abort",
474
+ ' during COLLECTION. That failure mode looks nothing like a test failure: you get an',
475
+ ' import error, or a suite that reports zero tests. Always read how many tests each',
476
+ ' command actually COLLECTED and RAN; zero collected is never a pass.',
477
+ '',
391
478
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
392
479
  ' service or network resource (a database server, an API host) that the project',
393
480
  ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
@@ -572,6 +659,39 @@ export async function runWorkVerification(deps) {
572
659
  crossDeletions = [];
573
660
  }
574
661
  }
662
+ // Sandbox-path-leak findings the deterministic repair could NOT fix, injected
663
+ // under rule 4e. A probe failure must never block verification.
664
+ let foreignPaths = [];
665
+ if (deps.foreignPathProbe) {
666
+ try {
667
+ foreignPaths = await deps.foreignPathProbe();
668
+ }
669
+ catch {
670
+ foreignPaths = [];
671
+ }
672
+ }
673
+ // Neutered check scripts in a manifest this task changed, injected under rule
674
+ // 4f. A probe failure must never block verification.
675
+ let scriptEscapes = [];
676
+ if (deps.scriptEscapeProbe) {
677
+ try {
678
+ scriptEscapes = await deps.scriptEscapeProbe();
679
+ }
680
+ catch {
681
+ scriptEscapes = [];
682
+ }
683
+ }
684
+ // Colliding test-runner globs, injected under rule 4g. A probe failure must
685
+ // never block verification.
686
+ let runnerGlobs = [];
687
+ if (deps.runnerGlobProbe) {
688
+ try {
689
+ runnerGlobs = await deps.runnerGlobProbe();
690
+ }
691
+ catch {
692
+ runnerGlobs = [];
693
+ }
694
+ }
575
695
  // Environment facts from earlier gate children (best-effort; a cache failure
576
696
  // must never block verification).
577
697
  let envNotes = '';
@@ -607,7 +727,11 @@ export async function runWorkVerification(deps) {
607
727
  for (let attempt = 1;; attempt++) {
608
728
  let text;
609
729
  try {
610
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions)), deps.signal);
730
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions), {
731
+ foreignPaths,
732
+ scriptEscapes,
733
+ runnerGlobs
734
+ }), deps.signal);
611
735
  }
612
736
  catch (err) {
613
737
  if (err instanceof Error && err.message === USER_CANCELLED)
@@ -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.30",
3
+ "version": "0.18.32",
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",