Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/pm/history/pm-starter-y8hb.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"ts":"2026-07-24T20:24:56.684Z","author":"claude-opus-5","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-starter-y8hb"},{"op":"add","path":"/metadata/title","value":"pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis"},{"op":"add","path":"/metadata/description","value":"readPmItems (the documented safe-read demo pattern) spawns the pm CLI to read items but passes no maxBuffer, so Node's 1 MiB spawnSync default applies. Measured against a real 443-item pm workspace, this package's exact argv returns 1054002 bytes (ENOBUFS today). Past the cap the child is killed with status null, signal SIGTERM, error.code ENOBUFS and EMPTY stderr, so the failure surfaces with nothing to diagnose; at larger sizes stdout is genuinely truncated mid-document (verified: a 100 KiB cap yields 'Unterminated string in JSON at position 130829'). Sibling packages pm-changelog, pm-context, pm-brief, pm-graph and pm-linear already cap explicitly; this package was a straggler. Fix: cap at 64 MiB and name an ENOBUFS overrun explicitly instead of reporting an unexplained failure (or, for the never-throw demo/search paths, an empty result set)."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["agent-ux","maxbuffer","production-readiness","real-data"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-24T20:24:56.684Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-24T20:24:56.684Z"},{"op":"add","path":"/metadata/author","value":"claude-opus-5"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"5b53633a1ae80cf075422bbb371368b814f717911fdfa8eaccc87e083abf0c9f","message":"File the 1 MiB pm-read ceiling found by a fleet-wide audit against a real 443-item workspace"}
{"ts":"2026-07-24T20:24:57.125Z","author":"claude-opus-5","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-24T20:24:57.125Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-24T20:24:57.124Z"},{"op":"add","path":"/metadata/resolution","value":"Added PM_JSON_MAX_BUFFER (64 MiB) and passed it to the pm read; ENOBUFS is now reported as a buffer overrun naming the limit and the remedy."},{"op":"add","path":"/metadata/expected_result","value":"Reads of a mature tracker succeed, and any future overrun reports its real cause instead of an unexplained failure or a silent empty result."},{"op":"add","path":"/metadata/actual_result","value":"Verified with the package's exact argv against the real 443-item workspace: default cap -> status null / ENOBUFS; 64 MiB cap -> status 0 with the complete payload. Typecheck and the full test suite pass."},{"op":"add","path":"/metadata/close_reason","value":"Capped the pm read at 64 MiB and made an ENOBUFS overrun self-describing"}],"before_hash":"5b53633a1ae80cf075422bbb371368b814f717911fdfa8eaccc87e083abf0c9f","after_hash":"249ea1d37a1bf3de8899a814294893ccb95edb37de6ee238bc4fd5c2001b0f76","message":"Close after verifying the capped read against the real workspace"}
16 changes: 16 additions & 0 deletions .agents/pm/issues/pm-starter-y8hb.toon
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
id: pm-starter-y8hb
title: "pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis"
description: "readPmItems (the documented safe-read demo pattern) spawns the pm CLI to read items but passes no maxBuffer, so Node's 1 MiB spawnSync default applies. Measured against a real 443-item pm workspace, this package's exact argv returns 1054002 bytes (ENOBUFS today). Past the cap the child is killed with status null, signal SIGTERM, error.code ENOBUFS and EMPTY stderr, so the failure surfaces with nothing to diagnose; at larger sizes stdout is genuinely truncated mid-document (verified: a 100 KiB cap yields 'Unterminated string in JSON at position 130829'). Sibling packages pm-changelog, pm-context, pm-brief, pm-graph and pm-linear already cap explicitly; this package was a straggler. Fix: cap at 64 MiB and name an ENOBUFS overrun explicitly instead of reporting an unexplained failure (or, for the never-throw demo/search paths, an empty result set)."
type: Issue
status: closed
priority: 1
tags[4]: agent-ux,maxbuffer,production-readiness,real-data
created_at: "2026-07-24T20:24:56.684Z"
updated_at: "2026-07-24T20:24:57.125Z"
closed_at: "2026-07-24T20:24:57.124Z"
author: claude-opus-5
resolution: Added PM_JSON_MAX_BUFFER (64 MiB) and passed it to the pm read; ENOBUFS is now reported as a buffer overrun naming the limit and the remedy.
expected_result: "Reads of a mature tracker succeed, and any future overrun reports its real cause instead of an unexplained failure or a silent empty result."
actual_result: "Verified with the package's exact argv against the real 443-item workspace: default cap -> status null / ENOBUFS; 64 MiB cap -> status 0 with the complete payload. Typecheck and the full test suite pass."
close_reason: Capped the pm read at 64 MiB and made an ENOBUFS overrun self-describing
body: ""
8 changes: 8 additions & 0 deletions .hound.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Hound CI configuration.
# `dist/` is generated ES2022 output (committed deliberately so the package can
# be installed straight from the repo as a pm extension). Hound lints it with
# jshint's default ES5 profile, which reports every `const` and template literal
# in the generated file as an error — pure noise that buries real findings.
# Same rationale as pm-web/.hound.yml.
jshint:
enabled: false
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixed

- pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis ([pm-starter-y8hb](https://github.com/unbraind/pm-starter/blob/main/.agents/pm/issues/pm-starter-y8hb.toon))

## 2026.7.23 - 2026-07-23

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion dist/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 43 additions & 2 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

50 changes: 48 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,64 @@ function isObject(value: unknown): value is Record<string, any> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

// Node's spawnSync defaults to a 1 MiB stdout cap, which a mature tracker's JSON
// dump passes at a few hundred items. Past that the child is killed with ENOBUFS,
// status null and EMPTY stderr, so the failure surfaces with nothing to diagnose
// (and at larger sizes stdout is genuinely truncated mid-document).
// 64 MiB matches the cap the sibling pm packages settled on.
/** Read-buffer cap for `pm` output, in bytes. 64 MiB by default; override with the
* `PM_JSON_MAX_BUFFER` env var. Resolved per call so the override takes effect
* without an import-order dependency. Invalid or non-positive values fall back to
* the default rather than silently disabling the guard. */
function pmJsonMaxBuffer(): number {
// Number(), not parseInt(): parseInt("64MiB") silently yields 64, which would
// impose a 64-BYTE cap and break every ordinary read while appearing to honor
// the documented invalid-value fallback. Number() rejects the whole string.
const raw = Number(process.env.PM_JSON_MAX_BUFFER);
return Number.isSafeInteger(raw) && raw > 0 ? raw : 64 * 1024 * 1024;
}

/** Name the real cause of a failed `pm` read. A stdout overrun kills the child
* with `status: null` and EMPTY stderr, so without this the failure surfaces as
* an unexplained error (or, worse, as an empty result set). */
function describePmReadFailure(error: Error, limitBytes: number): string {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOBUFS") {
// This read is always the full workspace, so "narrow the operation" would be a
// dead instruction here — name only the lever the reader actually has.
return `pm output exceeded the ${limitBytes} byte read buffer. `
+ "Raise PM_JSON_MAX_BUFFER (in bytes) to increase the read limit for this workspace.";
}
return `pm read failed: ${error.message}`;
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
/**
* Safely read all items from the workspace by shelling out to `pm`. Returns an
* empty array on any failure so demos never throw at activation/read time.
* This is the SAFE read pattern every demo reuses.
*/
export function readPmItems(pmRoot: string): Array<Record<string, any>> {
const maxBuffer = pmJsonMaxBuffer();
const result = spawnSync(
"pm",
["--path", pmRoot, "list-all", "--json", "--include-body"],
{ encoding: "utf-8" },
{ encoding: "utf-8", maxBuffer },
);
if (result.status !== 0) return [];
// Keep the never-throw contract, but do not let a read failure masquerade as an
// empty workspace — a silent [] here reads as "no items" to every demo.
if (result.error) {
console.error(`pm-starter: could not read pm items — ${describePmReadFailure(result.error, maxBuffer)}`);
return [];
}
// Same reasoning for a genuine nonzero exit: an empty array is indistinguishable
// from an empty workspace, so report stderr rather than discarding it.
if (result.status !== 0) {
console.error(
`pm-starter: could not read pm items (pm exited ${result.status}) — `
+ (result.stderr?.trim() || "no stderr output"),
);
return [];
}
try {
const parsed = JSON.parse(result.stdout);
if (Array.isArray(parsed)) return parsed;
Expand Down
85 changes: 85 additions & 0 deletions test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,88 @@ test("positive integer options honor numeric and string SDK values", () => {
assert.strictEqual(optionPositiveInteger({ limit: "1.5" }, 10, "limit"), 10);
assert.strictEqual(optionPositiveInteger({ limit: "invalid" }, 10, "limit"), 10);
});

// --- pm read buffer -----------------------------------------------------------
// The 64 MiB cap and its ENOBUFS diagnostic are a failure *contract*: a read that
// outgrows the buffer must not be reported as an empty workspace. The cap is env
// configurable, which makes the branch testable for real — no spawnSync mocking.

test("readPmItems honors PM_JSON_MAX_BUFFER and reports an overrun instead of returning a silent empty array", async (t) => {
const { mkdtempSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { execFileSync } = await import("node:child_process");
const { readPmItems } = await import("../dist/index.js");

const dir = mkdtempSync(join(tmpdir(), "pm-starter-buffer-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));

const pmRoot = join(dir, ".agents", "pm");
try {
execFileSync("pm", ["init", "--pm-path", pmRoot], { cwd: dir, stdio: "ignore" });
execFileSync("pm", ["create", "--pm-path", pmRoot, "--type", "issue", "--title", "Buffer probe item", "--author", "test"], { cwd: dir, stdio: "ignore" });
} catch {
t.skip("pm CLI unavailable");
return;
}

// Sanity: the default cap reads the workspace.
assert.ok(readPmItems(pmRoot).length >= 1, "default cap should read the item");

const messages: string[] = [];
const originalError = console.error;
const originalCap = process.env.PM_JSON_MAX_BUFFER;
console.error = (...values: unknown[]) => messages.push(values.join(" "));
// 64 bytes cannot hold any item payload, so the read overruns deterministically.
process.env.PM_JSON_MAX_BUFFER = "64";
try {
const items = readPmItems(pmRoot);
assert.deepEqual(items, [], "the never-throw contract must still hold");
assert.ok(
messages.some((message) => /read buffer/.test(message)),
`overrun must be reported, not silent; saw: ${messages.join(" | ")}`
);
} finally {
console.error = originalError;
if (originalCap === undefined) delete process.env.PM_JSON_MAX_BUFFER;
else process.env.PM_JSON_MAX_BUFFER = originalCap;
}
});

test("a malformed PM_JSON_MAX_BUFFER falls back to the default instead of imposing a tiny cap", async (t) => {
// parseInt("64MiB") === 64 would have imposed a 64-BYTE cap and broken every
// ordinary read while appearing to honor the documented fallback.
const { mkdtempSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { execFileSync } = await import("node:child_process");
const { readPmItems } = await import("../dist/index.js");

const dir = mkdtempSync(join(tmpdir(), "pm-starter-badcap-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const pmRoot = join(dir, ".agents", "pm");
try {
execFileSync("pm", ["init", "--pm-path", pmRoot], { cwd: dir, stdio: "ignore" });
execFileSync("pm", ["create", "--pm-path", pmRoot, "--type", "issue", "--title", "Fallback probe item", "--author", "test"], { cwd: dir, stdio: "ignore" });
} catch {
t.skip("pm CLI unavailable");
return;
}

const originalCap = process.env.PM_JSON_MAX_BUFFER;
try {
for (const malformed of ["64MiB", "64 MB", "abc", "-1", "0", "6.5", ""]) {
process.env.PM_JSON_MAX_BUFFER = malformed;
assert.ok(
readPmItems(pmRoot).length >= 1,
`PM_JSON_MAX_BUFFER=${JSON.stringify(malformed)} must fall back to the default, not cap the read`
);
}
// A valid explicit value is still honored.
process.env.PM_JSON_MAX_BUFFER = String(32 * 1024 * 1024);
assert.ok(readPmItems(pmRoot).length >= 1, "a valid explicit cap should still read the workspace");
} finally {
if (originalCap === undefined) delete process.env.PM_JSON_MAX_BUFFER;
else process.env.PM_JSON_MAX_BUFFER = originalCap;
}
});