@mjasnikovs/pi-task 0.17.6 → 0.17.8

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.
@@ -3,7 +3,14 @@ import type { EventEmitter } from 'node:events';
3
3
  export declare const KILL_GRACE_MS = 5000;
4
4
  /** Base flags shared by all child pi invocations. */
5
5
  export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-extensions", "--no-prompt-templates", "--no-context-files", "--no-session"];
6
+ /** The subset of a child's stdin we use: write the prompt, then close it. */
7
+ export interface WritableLike {
8
+ write(chunk: string): boolean;
9
+ end(): void;
10
+ }
6
11
  export interface ProcLike extends EventEmitter {
12
+ /** Present only when the child was spawned with stdin 'pipe' (prompt delivery). */
13
+ stdin: WritableLike | null;
7
14
  stdout: EventEmitter | null;
8
15
  stderr: EventEmitter | null;
9
16
  killed: boolean;
@@ -12,7 +19,7 @@ export interface ProcLike extends EventEmitter {
12
19
  export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
13
20
  cwd: string;
14
21
  shell: boolean;
15
- stdio: ['ignore', 'pipe', 'pipe'];
22
+ stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
16
23
  }) => ProcLike;
17
24
  export interface ChildResult {
18
25
  stdout: string;
@@ -105,6 +112,7 @@ export declare class JsonEventSink {
105
112
  export declare function runChild(spawn: SpawnFn, invocation: {
106
113
  command: string;
107
114
  args: ReadonlyArray<string>;
115
+ stdin?: string;
108
116
  }, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions): Promise<ChildResult>;
109
117
  export declare function runChildDefault(invocation: {
110
118
  command: string;
@@ -169,11 +169,22 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
169
169
  let stderr = '';
170
170
  let aborted = false;
171
171
  const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
172
+ // Deliver the prompt on stdin, not argv: a large prompt (e.g. an inlined
173
+ // design doc) blows past the OS command-line limit — Windows CreateProcessW
174
+ // caps it at 32767 chars and Node throws `spawn ENAMETOOLONG`. When a
175
+ // prompt is present we open stdin as a pipe; otherwise keep it 'ignore'
176
+ // (git and other arg-only spawns are unaffected). See GitHub issue #1.
177
+ const usesStdin = invocation.stdin !== undefined;
172
178
  const proc = spawn(invocation.command, invocation.args, {
173
179
  cwd,
174
180
  shell: false,
175
- stdio: ['ignore', 'pipe', 'pipe']
181
+ stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
176
182
  });
183
+ if (usesStdin) {
184
+ // pi reads the prompt from stdin and waits for EOF, so write then end.
185
+ proc.stdin?.write(invocation.stdin);
186
+ proc.stdin?.end();
187
+ }
177
188
  // One kill path, shared by user-abort and loop-kill: SIGTERM, then
178
189
  // SIGKILL after a grace period if the child ignored the term.
179
190
  const killProc = () => {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Text-file reads with line endings normalized to LF.
3
+ *
4
+ * Every task-file parser in pi-task assumes `\n` line endings: the front-matter
5
+ * regex (`^---\n`), the body-delimiter strip, `sectionRegex` (`## h[ \t]*\n`),
6
+ * the checkbox/VERIFY-token line splits. On Windows a task file can carry CRLF
7
+ * (an editor save, or a `git` checkout with `core.autocrlf=true`), and classic
8
+ * Mac tooling can emit lone CR. Any of those makes `parseFrontMatter` return
9
+ * null ("malformed front matter") and every `extractSection` miss.
10
+ *
11
+ * Rather than teach each parser about `\r`, we normalize ONCE at the read
12
+ * boundary — the single place file bytes enter the task layer — so everything
13
+ * downstream sees LF. See GitHub issue #1.
14
+ */
15
+ /** Collapse CRLF and lone CR to LF. `\r\n?` matches Windows CRLF and classic-Mac CR. */
16
+ export declare function normalizeNewlines(text: string): string;
17
+ /** Read a UTF-8 text file with line endings normalized to LF. */
18
+ export declare function readTextFile(filePath: string): Promise<string>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Text-file reads with line endings normalized to LF.
3
+ *
4
+ * Every task-file parser in pi-task assumes `\n` line endings: the front-matter
5
+ * regex (`^---\n`), the body-delimiter strip, `sectionRegex` (`## h[ \t]*\n`),
6
+ * the checkbox/VERIFY-token line splits. On Windows a task file can carry CRLF
7
+ * (an editor save, or a `git` checkout with `core.autocrlf=true`), and classic
8
+ * Mac tooling can emit lone CR. Any of those makes `parseFrontMatter` return
9
+ * null ("malformed front matter") and every `extractSection` miss.
10
+ *
11
+ * Rather than teach each parser about `\r`, we normalize ONCE at the read
12
+ * boundary — the single place file bytes enter the task layer — so everything
13
+ * downstream sees LF. See GitHub issue #1.
14
+ */
15
+ import * as fsp from 'node:fs/promises';
16
+ /** Collapse CRLF and lone CR to LF. `\r\n?` matches Windows CRLF and classic-Mac CR. */
17
+ export function normalizeNewlines(text) {
18
+ return text.replace(/\r\n?/g, '\n');
19
+ }
20
+ /** Read a UTF-8 text file with line endings normalized to LF. */
21
+ export async function readTextFile(filePath) {
22
+ return normalizeNewlines(await fsp.readFile(filePath, 'utf8'));
23
+ }
@@ -1,7 +1,13 @@
1
1
  /** Pick the right way to re-invoke pi: prefer the current pi script under the
2
2
  * current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
3
- * pattern from pi-coding-agent's official subagent example. */
4
- export declare function getPiInvocation(args: string[]): {
3
+ * pattern from pi-coding-agent's official subagent example.
4
+ *
5
+ * `stdin`, when given, is the prompt to feed the child over stdin instead of as
6
+ * an argv element — pi reads its prompt from stdin when no positional message
7
+ * is passed, which keeps a large prompt off the length-limited command line
8
+ * (see runChild / GitHub issue #1). It is threaded through unchanged. */
9
+ export declare function getPiInvocation(args: string[], stdin?: string): {
5
10
  command: string;
6
11
  args: string[];
12
+ stdin?: string;
7
13
  };
@@ -2,23 +2,28 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  /** Pick the right way to re-invoke pi: prefer the current pi script under the
4
4
  * current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
5
- * pattern from pi-coding-agent's official subagent example. */
6
- export function getPiInvocation(args) {
5
+ * pattern from pi-coding-agent's official subagent example.
6
+ *
7
+ * `stdin`, when given, is the prompt to feed the child over stdin instead of as
8
+ * an argv element — pi reads its prompt from stdin when no positional message
9
+ * is passed, which keeps a large prompt off the length-limited command line
10
+ * (see runChild / GitHub issue #1). It is threaded through unchanged. */
11
+ export function getPiInvocation(args, stdin) {
7
12
  // Test/dev override: point at a specific pi binary directly. Bypasses the
8
13
  // re-invoke-current-script heuristic, which goes wrong under `bun test`
9
14
  // (currentScript is the .test.ts file, not a pi entrypoint).
10
15
  if (process.env.PI_BIN) {
11
- return { command: process.env.PI_BIN, args };
16
+ return { command: process.env.PI_BIN, args, stdin };
12
17
  }
13
18
  const currentScript = process.argv[1];
14
19
  const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/');
15
20
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
16
- return { command: process.execPath, args: [currentScript, ...args] };
21
+ return { command: process.execPath, args: [currentScript, ...args], stdin };
17
22
  }
18
23
  const execName = path.basename(process.execPath).toLowerCase();
19
24
  const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
20
25
  if (!isGenericRuntime) {
21
- return { command: process.execPath, args };
26
+ return { command: process.execPath, args, stdin };
22
27
  }
23
- return { command: 'pi', args };
28
+ return { command: 'pi', args, stdin };
24
29
  }
@@ -9,6 +9,7 @@ import * as fsp from 'node:fs/promises';
9
9
  import * as path from 'node:path';
10
10
  import { tasksDir, ensureTasksDir, readTaskFile, setTaskSection } from './task-io.js';
11
11
  import { extractSection, parseFrontMatter } from './task-parsers.js';
12
+ import { readTextFile } from '../shared/fs-text.js';
12
13
  import { RESUMABLE_STATES } from './task-types.js';
13
14
  const AUTO_FILE_RE = /^(TASK_AUTO_\d{4,})\.md$/;
14
15
  const MAX_TASKS = 30;
@@ -117,7 +118,7 @@ export async function findResumableAuto(cwd) {
117
118
  if (!m)
118
119
  continue;
119
120
  try {
120
- const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
121
+ const raw = await readTextFile(path.join(tasksDir(cwd), f));
121
122
  const fm = parseFrontMatter(raw);
122
123
  if (!fm)
123
124
  continue;
@@ -15,6 +15,7 @@ import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
15
15
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
16
16
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
17
17
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
18
+ import { readTextFile } from '../shared/fs-text.js';
18
19
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
19
20
  import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
20
21
  import { refineExistingFilesBlock } from './phases.js';
@@ -134,7 +135,9 @@ export async function expandFeatureMentions(cwd, feature) {
134
135
  continue;
135
136
  seen.add(rel);
136
137
  try {
137
- const body = await fsp.readFile(path.resolve(cwd, rel), 'utf8');
138
+ // Normalize CRLF/CR so an @-mentioned design doc saved on Windows
139
+ // inlines with LF endings the downstream phase parsers expect.
140
+ const body = await readTextFile(path.resolve(cwd, rel));
138
141
  if (body.trim().length > 0) {
139
142
  blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
140
143
  }
@@ -23,7 +23,7 @@ export interface PhaseRunResult {
23
23
  /** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
24
24
  modelError?: string;
25
25
  }
26
- export declare function childArgs(tools: string, prompt: string): string[];
26
+ export declare function childArgs(tools: string): string[];
27
27
  export declare const USER_CANCELLED = "__user_cancelled__";
28
28
  /**
29
29
  * Run a child pi process with JSON event-stream output, loop detection, and
@@ -44,7 +44,7 @@ export function connectionRetryBackoffMs(attempt) {
44
44
  }
45
45
  const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
46
46
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
47
- export function childArgs(tools, prompt) {
47
+ export function childArgs(tools) {
48
48
  // `--mode json` puts the child into the structured event stream the
49
49
  // unified runner parses in `mode: 'json-events'`. Without it the child
50
50
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -55,8 +55,12 @@ export function childArgs(tools, prompt) {
55
55
  // instead of `--tools ''` (which pi would reject). Used by pure-judgment
56
56
  // phases like critique-triage that should reason only over the text we
57
57
  // hand them, never spend time reading the repo.
58
+ //
59
+ // The prompt is NOT an argv element: it goes to the child over stdin (see
60
+ // runChild below / getPiInvocation), so a large inlined-design prompt can't
61
+ // overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
58
62
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
59
- return [...CHILD_BASE_ARGS, '--mode', 'json', ...toolFlags, prompt];
63
+ return [...CHILD_BASE_ARGS, '--mode', 'json', ...toolFlags];
60
64
  }
61
65
  // Sentinel error thrown when the user dismisses a grill-me dialog.
62
66
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
@@ -68,7 +72,7 @@ export const USER_CANCELLED = '__user_cancelled__';
68
72
  * phase-level code.
69
73
  */
70
74
  export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn) {
71
- const invocation = getPiInvocation(childArgs(tools, prompt));
75
+ const invocation = getPiInvocation(childArgs(tools), prompt);
72
76
  let loopHit;
73
77
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
74
78
  mode: 'json-events',
@@ -3,7 +3,14 @@
3
3
  * orchestrator can fan out docs/URL lookups before the research phase.
4
4
  */
5
5
  export declare function extractEnrichTargets(text: string): {
6
+ /** Packages that get a (heavy) docs fetch — capped at ENRICH_CAP. */
6
7
  packages: string[];
8
+ /**
9
+ * Every named package, up to ENRICH_VERSION_CAP, for a cheap live npm version
10
+ * lookup. A superset of `packages`; the extras get a version block only (no
11
+ * docs body). Order-preserving and deduped, same as `packages`.
12
+ */
13
+ versionPackages: string[];
7
14
  urls: string[];
8
15
  services: Array<{
9
16
  name: string;
@@ -21,6 +21,13 @@ const ENRICH_DENYLIST = new Set([
21
21
  'rm'
22
22
  ]);
23
23
  const ENRICH_CAP = 3;
24
+ // Heavy docs/url fetches stay capped at ENRICH_CAP (each is a worker spawn +
25
+ // page fetch). A live npm VERSION lookup is just one cheap registry GET, so it
26
+ // can cover far more packages — every dependency a task explicitly names should
27
+ // get a grounded latest-version block, not just the first ENRICH_CAP of them. A
28
+ // task that named 6 runtime deps used to leave the 4th–6th (e.g. tailwindcss)
29
+ // with NO live version, so a version question fell back to stale training data.
30
+ const ENRICH_VERSION_CAP = 12;
24
31
  const ENRICH_SERVICE_HEADER = 'EXTERNAL-DEPENDENCIES';
25
32
  const ENRICH_HEADER_LINE_RE = /^[A-Z][A-Z0-9 -]+$/;
26
33
  const ENRICH_SERVICE_BULLET_RE = /^\s*-\s+(.+?)\s{2,}(.+?)\s*$/;
@@ -61,13 +68,15 @@ function parseServices(text) {
61
68
  return out;
62
69
  }
63
70
  export function extractEnrichTargets(text) {
64
- const pkgs = new Set();
71
+ const pkgs = [];
72
+ const seen = new Set();
65
73
  for (const m of text.matchAll(ENRICH_PKG_RE)) {
66
74
  const t = m[1];
67
- if (ENRICH_DENYLIST.has(t))
75
+ if (ENRICH_DENYLIST.has(t) || seen.has(t))
68
76
  continue;
69
- pkgs.add(t);
70
- if (pkgs.size >= ENRICH_CAP)
77
+ seen.add(t);
78
+ pkgs.push(t);
79
+ if (pkgs.length >= ENRICH_VERSION_CAP)
71
80
  break;
72
81
  }
73
82
  const urls = new Set();
@@ -78,5 +87,5 @@ export function extractEnrichTargets(text) {
78
87
  break;
79
88
  }
80
89
  const services = parseServices(text);
81
- return { packages: [...pkgs], urls: [...urls], services };
90
+ return { packages: pkgs.slice(0, ENRICH_CAP), versionPackages: pkgs, urls: [...urls], services };
82
91
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { docsRaw } from '../workers/docs-core.js';
12
12
  import { fetchRaw } from '../workers/fetch-core.js';
13
+ import { npmVersionLookup } from '../workers/npm-version.js';
13
14
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
14
15
  import type { PhaseDeps } from './child-runner.js';
15
16
  /** Injectable workers so enrichment is testable without spawning real lookups. */
@@ -17,6 +18,7 @@ export interface ExternalContextDeps {
17
18
  docsRaw?: typeof docsRaw;
18
19
  fetchRaw?: typeof fetchRaw;
19
20
  searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
21
+ npmVersionLookup?: typeof npmVersionLookup;
20
22
  }
21
23
  type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
22
24
  /**
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { docsRaw } from '../workers/docs-core.js';
12
12
  import { fetchRaw } from '../workers/fetch-core.js';
13
- import { formatNpmVersionSection } from '../workers/npm-version.js';
13
+ import { formatNpmVersionSection, npmVersionLookup } from '../workers/npm-version.js';
14
14
  import { search as defaultSearch } from '../workers/search-core.js';
15
15
  import { extractEnrichTargets } from './enrichment.js';
16
16
  import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
@@ -22,7 +22,15 @@ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
22
22
  const docsRawFn = researchDeps.docsRaw ?? docsRaw;
23
23
  const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
24
24
  const searchFn = researchDeps.searchFn ?? defaultSearch;
25
+ const npmVersionFn = researchDeps.npmVersionLookup ?? npmVersionLookup;
25
26
  const enrichTargets = extractEnrichTargets(refined);
27
+ // Every named dep past the heavy-docs cap still gets a cheap live version
28
+ // lookup, so a version block exists for ALL of them (the docs-fetched ones
29
+ // emit their version below from the bundled lookup). Without this, deps 4..N
30
+ // had no live version and a "which version?" question fell back to the model's
31
+ // stale training data — how tailwindcss got pinned to an old major.
32
+ const docsPkgs = new Set(enrichTargets.packages);
33
+ const extraVersionPkgs = enrichTargets.versionPackages.filter(p => !docsPkgs.has(p));
26
34
  if (enrichTargets.packages.length === 0
27
35
  && enrichTargets.urls.length === 0
28
36
  && enrichTargets.services.length === 0) {
@@ -30,7 +38,7 @@ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
30
38
  }
31
39
  const enrichSections = [];
32
40
  const tEnrichStart = Date.now();
33
- const [docsResults, fetchResults, serviceResults] = await Promise.all([
41
+ const [docsResults, fetchResults, serviceResults, extraVersionResults] = await Promise.all([
34
42
  Promise.all(enrichTargets.packages.map(pkg => docsRawFn({
35
43
  pkg,
36
44
  query: refined.split('\n').find(l => l.trim()) ?? refined,
@@ -42,16 +50,22 @@ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
42
50
  query: `${s.name} ${s.query}`,
43
51
  count: 3,
44
52
  signal: deps.signal
45
- }).catch(() => null)))
53
+ }).catch(() => null))),
54
+ Promise.all(extraVersionPkgs.map(pkg => npmVersionFn(pkg, { signal: deps.signal }).catch(() => null)))
46
55
  ]);
47
- // npm version blocks come from docsRaw's bundled lookup and lead the
48
- // section so the model anchors on live version data before reading
49
- // the docs body.
56
+ // npm version blocks lead the section so the model anchors on live version
57
+ // data before reading any docs body. The docs-fetched packages carry their
58
+ // version in docsRaw's bundled lookup; the remaining named deps get theirs
59
+ // from the cheap standalone lookup above. Together they cover EVERY named dep.
50
60
  for (let i = 0; i < enrichTargets.packages.length; i++) {
51
61
  const v = docsResults[i]?.npmVersion;
52
62
  if (v)
53
63
  enrichSections.push(formatNpmVersionSection(v));
54
64
  }
65
+ for (const v of extraVersionResults) {
66
+ if (v)
67
+ enrichSections.push(formatNpmVersionSection(v));
68
+ }
55
69
  for (let i = 0; i < enrichTargets.packages.length; i++) {
56
70
  const r = docsResults[i];
57
71
  if (r?.kind === 'ok' && r.chunks.length > 0) {
@@ -21,6 +21,7 @@ import { PHASES, postCommitPhase } from './phases.js';
21
21
  import { handleFailure } from './failure-classifier.js';
22
22
  import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
23
23
  import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
24
+ import { readTextFile } from '../shared/fs-text.js';
24
25
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
25
26
  import { startWidget } from './widget.js';
26
27
  import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
@@ -705,7 +706,7 @@ async function handleTaskList(_args, ctx) {
705
706
  const rows = [];
706
707
  for (const f of taskFiles) {
707
708
  try {
708
- const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
709
+ const raw = await readTextFile(path.join(tasksDir(cwd), f));
709
710
  const fm = parseFrontMatter(raw);
710
711
  if (!fm)
711
712
  continue;
@@ -221,6 +221,7 @@ LIVE-DATA RULE:
221
221
  - "### service: <name>" blocks are LIVE web data; authoritative over training data for that service.
222
222
  - "### freshness-check skipped" → tag UNKNOWN and say current state needs verification.
223
223
  - No npm block + question is about latest/current version → tag UNKNOWN (training data goes stale).
224
+ - VERSION-PIN questions ("pin to X.y vs latest", "which major version") are costly-to-reverse build-shaping choices: unless the spec or an "### npm:" block already settles it (then ANSWER that value), tag UNKNOWN and surface it. NEVER auto-answer a downgrade to an OLDER major "to avoid breaking changes" from memory — that reasoning is exactly the stale-training-data trap. If an "### npm:" block shows a newer major than your instinct, that block is the live latest; do not silently pin an older major the live data and spec never asked for.
224
225
 
225
226
  TRIAGE — run these checks IN ORDER first. The REVERSIBILITY TEST below applies ONLY to a question that survives all checks as a genuine preference.
226
227
 
@@ -8,6 +8,7 @@ import * as fsp from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
9
  import { TASKS_DIR_NAME } from './task-types.js';
10
10
  import { emitFrontMatter, parseFrontMatter, sectionRegex } from './task-parsers.js';
11
+ import { readTextFile } from '../shared/fs-text.js';
11
12
  // ─── Directory & path helpers ────────────────────────────────────────────────
12
13
  export function tasksDir(cwd) {
13
14
  return path.join(cwd, TASKS_DIR_NAME);
@@ -58,7 +59,10 @@ export async function allocateTaskId(cwd) {
58
59
  }
59
60
  // ─── File read/write ─────────────────────────────────────────────────────────
60
61
  export async function readTaskFile(cwd, id) {
61
- const raw = await fsp.readFile(taskFilePath(cwd, id), 'utf8');
62
+ // Normalize CRLF/CR LF at the read boundary: every parser below (front
63
+ // matter, body strip, sectionRegex) assumes '\n'. A Windows/autocrlf file
64
+ // would otherwise fail as "malformed front matter". See shared/fs-text.ts.
65
+ const raw = await readTextFile(taskFilePath(cwd, id));
62
66
  const fm = parseFrontMatter(raw);
63
67
  if (!fm)
64
68
  throw new Error(`malformed front matter in ${id}.md`);
@@ -423,7 +423,7 @@ export async function docsFocused(input) {
423
423
  const { pkg, chunks, hitCache, indexingMs } = rawResult;
424
424
  const concatenated = chunks.map(c => c.content).join('\n\n');
425
425
  const prompt = buildPrompt(pkg, input.query, concatenated);
426
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
426
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
427
427
  const child = await runChild(spawn, invocation, input.cwd, input.signal);
428
428
  const parsed = parseChildOutput(child.stdout);
429
429
  const excerptVerified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
@@ -24,7 +24,7 @@ export async function fetchFocused(input) {
24
24
  title: cleaned.title,
25
25
  content: truncated
26
26
  });
27
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
27
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
28
28
  const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
29
29
  if (childResult.aborted) {
30
30
  return {
@@ -72,7 +72,7 @@ export async function runWorker(input) {
72
72
  let leakRetries = 0;
73
73
  for (;;) {
74
74
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
75
- const invocation = getPiInvocation([...baseArgs, prompt]);
75
+ const invocation = getPiInvocation([...baseArgs], prompt);
76
76
  const tStart = Date.now();
77
77
  let tFirstByte = null;
78
78
  // loop === false turns the guard off entirely (detector is null and no
@@ -104,7 +104,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
104
104
  };
105
105
  const concatenated = chunks.map(c => c.content).join('\n\n');
106
106
  const prompt = buildProjectPrompt(projectName, params.query, concatenated);
107
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
107
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
108
108
  const child = await runChild(spawn, invocation, ctx.cwd, signal);
109
109
  const failure = formatChildFailure(child, 'Project docs lookup aborted.');
110
110
  if (failure !== null) {
@@ -206,7 +206,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
206
206
  };
207
207
  const concatenated = chunks.map(c => c.content).join('\n\n');
208
208
  const prompt = buildPrompt(pkg, params.query, concatenated);
209
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
209
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
210
210
  const child = await runChild(spawn, invocation, ctx.cwd, signal);
211
211
  const failure = formatChildFailure(child, 'Docs lookup aborted.');
212
212
  if (failure !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
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",