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
27 changes: 27 additions & 0 deletions .agents/pm/history/pm-i02t.jsonl

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions .agents/pm/issues/pm-i02t.toon
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
id: pm-i02t
title: Accept --status all for duplicate-safe search and list filters
description: "Agent guidance and duplicate-check workflows need pm search --status all to mean no status restriction across open, closed, canceled, and custom statuses. Current search rejects all as an invalid configured status, blocking all-status duplicate discovery. Implement the all sentinel in shared status-filter parsing and keep docs/contracts/tests aligned."
type: Issue
status: closed
priority: 2
tags[4]: agent-ux,"area:cli",context-management,query
created_at: "2026-06-20T00:12:19.204Z"
updated_at: "2026-06-20T00:50:27.593Z"
author: codex-sdk-cli-continuation
parent: pm-mpbb
resolution: Added a shared all-status sentinel for list/search filters and documented it across CLI/MCP/contracts/docs.
expected_result: Agents can run pm search --status all and pm list --status all to deliberately scan every lifecycle bucket for duplicate-safe context discovery.
actual_result: Standalone all now resolves to no status set while preserving explicit filter intent; search and list return open and closed items and echo filters.status=all; mixed all with other statuses is rejected.
dependencies[2]{id,kind,created_at,author,source_kind}:
pm-ec4s,related,"2026-06-20T00:12:59.537Z",null,null
pm-j2ig,related,"2026-06-20T00:12:59.537Z",null,null
comments[4]{created_at,author,text}:
"2026-06-20T00:12:27.661Z",codex-sdk-cli-continuation,"Duplicate-check evidence: pm list --parent pm-mpbb --status open showed only pm-srex and pm-e7dn; pm search 'status all duplicate discovery' across default/open and closed results found historical closed lineage pm-j2ig and pm-ec4s but no active item for the current --status all rejection. Live reproduction: pm search '<query>' --status all exits with Invalid --status value all."
"2026-06-20T00:30:47.457Z",codex-sdk-cli-continuation,"Evidence: implemented standalone --status all as explicit no-op lifecycle filter for search and list. Search now returns all lifecycle buckets and echoes filters.status=all; list now disables active-only terminal exclusion when --status all is supplied and echoes filters.status=all. Focused tests passed (149 tests); full coverage passed 4699 tests with Statements/Branches/Functions/Lines all 100%; contracts:check and quality:static passed; manual temp-dir smoke with sandboxed PM_PATH/PM_GLOBAL_PATH passed for init/create/close/search/list."
"2026-06-20T00:30:54.970Z",codex-sdk-cli-continuation,"GitHub/security/dependency evidence: gh pr list and gh issue list returned no open items; Dependabot, secret-scanning, and security-advisory API queries returned no open alerts; pnpm audit found no vulnerabilities; pnpm security:scan found no credential-like secrets in tracked files; pm-changelog latest is 2026.6.14 and changelog:pm:check passed before final closeout. Dependency note: commander@15 requires Node >=22.12.0 while this repo declares Node >=20 and CI/Docs/Auto Release still run Node 20, so commander remains on 14.0.3."
"2026-06-20T00:50:27.593Z",codex-sdk-cli-continuation,"Review follow-up evidence: addressed CodeRabbit feedback by avoiding redundant re-tokenization inside parseStatusFilterCsv and normalizing pm search filter echoes so status=ALL/whitespace serializes as filters.status=all like list. Added compact search regression for the normalized all echo. Verification passed: focused status/search/list tests (149), full coverage 4699 tests with 100/100/100/100."
files[9]{path,scope,note}:
CHANGELOG.md,project,Package-owned changelog regenerated after closing pm-i02t.
src/cli/commands/list.ts,project,List --status all disables active-only terminal exclusion and echoes all.
src/cli/register-list-query.ts,project,CLI help text documents all status sentinel for list/search filters.
src/core/item/status-filter.ts,project,Shared status filter parser accepts standalone all as no lifecycle restriction.
src/mcp/tool-definitions.ts,project,MCP tool descriptions document all status sentinel for agents.
tests/fixtures/contracts/full.json,project,Regenerated contract snapshot after status filter help/MCP description changes.
tests/unit/commands/list-command.spec.ts,project,List regression for --status all overriding active-only default filtering.
tests/unit/commands/search-command.spec.ts,project,Command-level regression proving search --status all keeps all statuses.
tests/unit/core/item/status-normalization.spec.ts,project,Parser regressions for standalone all and mixed all rejection.
tests[5]{command,path,scope,timeout_seconds,pm_context_mode,env_set,env_clear,shared_host_safe,assert_stdout_contains,assert_stdout_regex,assert_stderr_contains,assert_stderr_regex,assert_stdout_min_lines,assert_json_field_equals,assert_json_field_gte,note}:
node scripts/run-tests.mjs coverage,null,project,700,null,null,null,null,null,null,null,null,null,null,null,Full repo coverage proof after list/search --status all fixes.
node scripts/run-tests.mjs test -- tests/unit/core/item/status-normalization.spec.ts tests/unit/commands/search-command.spec.ts,null,project,240,null,null,null,null,null,null,null,null,null,null,null,Focused parser and search regressions for --status all.
node scripts/run-tests.mjs test -- tests/unit/core/item/status-normalization.spec.ts tests/unit/commands/search-command.spec.ts tests/unit/commands/list-command.spec.ts,null,project,300,null,null,null,null,null,null,null,null,null,null,null,Focused parser/search/list regressions for --status all.
"pnpm changelog:pm:check",null,project,180,null,null,null,null,null,null,null,null,null,null,null,Package-owned changelog freshness check with pm-changelog.
"pnpm contracts:check && pnpm quality:static",null,project,120,null,null,null,null,null,null,null,null,null,null,null,Contract snapshot and static/docstring quality gate.
docs[1]{path,scope,note}:
docs/COMMANDS.md,project,Command reference documents duplicate-check --status all usage.
close_reason: "Implemented and verified --status all lifecycle-wide search/list behavior with docs, contracts, focused tests, full 100% coverage, and temp-dir smoke evidence."
body: ""
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

### Fixed

- Accept --status all for duplicate-safe search and list filters ([pm-i02t](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-i02t.toon))
- pm health 2.3-3.1s in dev repo \(~2x the 1.3s baseline\): drift-scan cache hit still reads + content-hashes every history stream ([pm-c90s](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-c90s.toon))
- Daily auto-release was silently blocked 06-14..16 by a single stale, unresolved Sentry error \(sentry-telemetry-gate is:unresolved has no time window\) ([pm-nb08](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-nb08.toon))
- appendLineAtomic is not truly atomic: concurrent appends to history JSONL can interleave ([pm-xy9n](https://github.com/unbraind/pm-cli/blob/main/.agents/pm/issues/pm-xy9n.toon))
Expand Down
9 changes: 6 additions & 3 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,14 @@ pm list-all --updated-after 2026-06-04T15:18:32Z --brief
pm list-open --updated-after=-2h --brief
pm list-all --created-after=-7d --status open

# Search scoped to open work only (drops closed-history noise); statuses are
# open/closed/canceled aliases or configured ids, comma-separated, with a
# did-you-mean hint on typos.
# Search scoped to open work only (drops closed-history noise); statuses accept
# all (no lifecycle restriction), open/closed/canceled aliases, or configured
# ids, comma-separated, with a did-you-mean hint on typos.
pm search "reminder validation" --status open --limit 10

# Duplicate checks can deliberately scan every lifecycle bucket with one flag.
pm search "reminder validation" --status all --limit 10

# Full filter parity with list — scope retrieval before ranking.
pm search "calendar" --type Task --assignee alice --updated-after=-7d --parent pm-abcd
```
Expand Down
9 changes: 6 additions & 3 deletions src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pathExists } from "../../core/fs/fs-utils.js";
import { getActiveExtensionRegistrations } from "../../core/extensions/index.js";
import { toItemRecord } from "../../core/item/item-record.js";
import { isTerminalStatus } from "../../core/item/status.js";
import { parseStatusFilterCsv } from "../../core/item/status-filter.js";
import { isStatusAllFilterInput, parseStatusFilterCsv } from "../../core/item/status-filter.js";
import { resolveItemTypeRegistry, type ItemTypeRegistry } from "../../core/item/type-registry.js";
import { parseIntegerLimit, parsePriority, parseType } from "../shared-parsers.js";
import { collectRuntimeFilterValues, matchesRuntimeFilters } from "../../core/schema/runtime-field-filters.js";
Expand Down Expand Up @@ -944,10 +944,13 @@ export async function runList(status: ItemStatus | undefined, options: ListOptio
}
const explicitStatus = resolveStatusFilter(options.status as ItemStatus | undefined, statusRegistry);
const resolvedStatus = explicitStatus ?? resolveStatusFilter(status, statusRegistry);
const effectiveOptions = explicitStatus ? { ...options, excludeTerminal: false } : options;
const explicitAllStatuses = isStatusAllFilterInput(options.status);
const effectiveOptions = explicitStatus || explicitAllStatuses ? { ...options, excludeTerminal: false } : options;
const filtered = applyFilters(items, resolvedStatus, effectiveOptions, typeRegistry, statusRegistry, runtimeFieldFilters);
const filtersStatus =
resolvedStatus === undefined
explicitAllStatuses
? "all"
: resolvedStatus === undefined
? null
: resolvedStatus.length === 1
? resolvedStatus[0]
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { pathExists } from "../../core/fs/fs-utils.js";
import { parseItemDocument } from "../../core/item/item-format.js";
import { toItemRecord } from "../../core/item/item-record.js";
import { isTerminalStatus } from "../../core/item/status.js";
import { parseStatusFilterCsv } from "../../core/item/status-filter.js";
import { isStatusAllFilterInput, parseStatusFilterCsv } from "../../core/item/status-filter.js";
import { collectRuntimeFilterValues, matchesRuntimeFilters } from "../../core/schema/runtime-field-filters.js";
import {
hasMissingMetadataFilter,
Expand Down Expand Up @@ -365,7 +365,7 @@ function buildCompactSearchFilterSummary(params: {
} = params;
const filters: Record<string, unknown> = {};
if (options.status !== undefined) {
filters.status = options.status;
filters.status = isStatusAllFilterInput(options.status) ? "all" : options.status;
}
if (options.type !== undefined) {
filters.type = options.type;
Expand Down Expand Up @@ -466,7 +466,7 @@ function buildVerboseSearchFilters(params: {
return {
mode: effectiveMode,
match_mode: matchMode,
status: options.status ?? null,
status: isStatusAllFilterInput(options.status) ? "all" : options.status ?? null,
type: options.type ?? null,
tag: options.tag ?? null,
priority: options.priority ?? null,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/register-list-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function registerListQueryCommands(program: Command, options?: RegisterLi
): void {
const command = program.command(name).description(description);
if (allowStatusFilter) {
command.option("--status <value>", "Filter by status");
command.option("--status <value>", "Filter by status (use all for no status restriction)");
}
command
.option("--type <value>", "Filter by item type")
Expand Down Expand Up @@ -389,7 +389,7 @@ export function registerListQueryCommands(program: Command, options?: RegisterLi
.option("--title-exact", "Require exact normalized title match for the full query string")
.option("--phrase-exact", "Require exact normalized phrase match in searchable text")
.option("--highlight", "Emit per-field matched-text snippets (wrapped in «…») on each hit; off by default")
.option("--status <value>", "Filter by status before query (open/closed/canceled aliases or configured status id; CSV)")
.option("--status <value>", "Filter by status before query (all, open/closed/canceled aliases, or configured status id; CSV)")
.option("--type <value>", "Filter by item type")
.option("--tag <value>", "Filter by tag")
.option("--priority <value>", "Filter by priority")
Expand Down
32 changes: 29 additions & 3 deletions src/core/item/status-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ const STATUS_GROUP_ALIASES: Readonly<Record<string, "open_status" | "close_statu
cancelled: "canceled_status",
};

const STATUS_ALL_SENTINEL = "all";

/** Return true when a status-filter input is the standalone all-status sentinel. */
export function isStatusAllFilterInput(raw: unknown): boolean {
if (typeof raw !== "string") {
return false;
}
const tokens = raw
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0);
return tokens.length === 1 && tokens[0] === STATUS_ALL_SENTINEL;
}

/**
* Resolve a single status-filter token to a concrete runtime status id.
*
Expand Down Expand Up @@ -89,9 +103,11 @@ export interface ParseStatusFilterOptions {

/**
* Parse a comma-separated status filter into a de-duplicated list of concrete
* status ids. Shared by `pm list` (lenient) and `pm search` (strict, with a
* did-you-mean hint on typos) so both surfaces resolve the open/closed/canceled
* workflow-group aliases identically.
* status ids. A standalone `all` sentinel intentionally resolves to no filter
* so duplicate-discovery loops can search every lifecycle bucket with the same
* `--status` flag shape. Shared by `pm list` (lenient) and `pm search`
* (strict, with a did-you-mean hint on typos) so both surfaces resolve the
* open/closed/canceled workflow-group aliases identically.
*/
export function parseStatusFilterCsv(
raw: unknown,
Expand All @@ -110,6 +126,16 @@ export function parseStatusFilterCsv(
if (tokens.length === 0) {
return undefined;
}
const allTokenCount = tokens.filter((token) => token.trim().toLowerCase() === STATUS_ALL_SENTINEL).length;
if (allTokenCount > 0) {
if (tokens.length === 1) {
return undefined;
}
throw new PmCliError(
`Invalid ${flagLabel} value "all". Use it by itself, or omit ${flagLabel} to search every status.`,
EXIT_CODE.USAGE,
);
}
const resolved: ItemStatus[] = [];
for (const token of tokens) {
const status = resolveStatusFilterToken(token, registry);
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const TOOLS: ToolDefinition[] = [
"Search pm items with keyword, semantic, or hybrid search. " +
"Defaults to a compact projection for token efficiency. " +
"Pass options.mode=keyword|semantic|hybrid, options.limit=N to cap hits, " +
"options.status='all' for no lifecycle restriction, " +
"options.fields='id,title,score' for a custom projection, or options.full=true for full item bodies (can be large). " +
"The result echoes the applied filters and projection mode in query_summary.",
inputSchema: objectSchema({ query: { type: "string" }, options: { type: "object" }, ...SEARCH_TOP_LEVEL_OPTION_PROPERTIES }, ["query"]),
Expand All @@ -141,7 +142,7 @@ export const TOOLS: ToolDefinition[] = [
name: "pm_list",
description:
"List pm items with status/type/tag/priority filters. Defaults to compact projection for token efficiency. " +
"options.status accepts CSV (open,in_progress). " +
"options.status accepts CSV (all,open,in_progress). " +
"Pass options.compact=false or options.includeBody=true for full bodies/comments. " +
"Pass options.brief=true for ultra-terse (id/status/type/title only). " +
"Pass options.fields='id,title,priority' for custom projection. " +
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/contracts/full.json
Original file line number Diff line number Diff line change
Expand Up @@ -8959,7 +8959,7 @@
]
},
{
"description": "List pm items with status/type/tag/priority filters. Defaults to compact projection for token efficiency. options.status accepts CSV (open,in_progress). Pass options.compact=false or options.includeBody=true for full bodies/comments. Pass options.brief=true for ultra-terse (id/status/type/title only). Pass options.fields='id,title,priority' for custom projection. Pass options.limit=N to cap row count. The result echoes the applied filters and projection mode in query_summary.",
"description": "List pm items with status/type/tag/priority filters. Defaults to compact projection for token efficiency. options.status accepts CSV (all,open,in_progress). Pass options.compact=false or options.includeBody=true for full bodies/comments. Pass options.brief=true for ultra-terse (id/status/type/title only). Pass options.fields='id,title,priority' for custom projection. Pass options.limit=N to cap row count. The result echoes the applied filters and projection mode in query_summary.",
"input_schema": {
"additionalProperties": true,
"properties": {
Expand Down Expand Up @@ -9353,7 +9353,7 @@
]
},
{
"description": "Search pm items with keyword, semantic, or hybrid search. Defaults to a compact projection for token efficiency. Pass options.mode=keyword|semantic|hybrid, options.limit=N to cap hits, options.fields='id,title,score' for a custom projection, or options.full=true for full item bodies (can be large). The result echoes the applied filters and projection mode in query_summary.",
"description": "Search pm items with keyword, semantic, or hybrid search. Defaults to a compact projection for token efficiency. Pass options.mode=keyword|semantic|hybrid, options.limit=N to cap hits, options.status='all' for no lifecycle restriction, options.fields='id,title,score' for a custom projection, or options.full=true for full item bodies (can be large). The result echoes the applied filters and projection mode in query_summary.",
"input_schema": {
"additionalProperties": true,
"properties": {
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/commands/list-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,12 @@ describe("runList", () => {
const closedViaStatusOption = await runList(undefined, { status: "closed", excludeTerminal: true }, { path: context.pmPath });
expect(closedViaStatusOption.count).toBe(1);
expect(closedViaStatusOption.items[0].status).toBe("closed");

// --status all is also explicit: keep every lifecycle bucket while echoing
// the caller intent instead of falling back to the active-only default.
const allViaStatusOption = await runList(undefined, { status: "all", excludeTerminal: true }, { path: context.pmPath });
expect(allViaStatusOption.count).toBe(3);
expect(allViaStatusOption.filters.status).toBe("all");
});
});

Expand Down
21 changes: 21 additions & 0 deletions tests/unit/commands/search-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2694,6 +2694,20 @@ describe("runSearch", () => {
const noStatus = await runSearch("statustoken", { mode: "keyword" }, { path: "/tmp/pm-search" });
expect(noStatus.count).toBe(2);
expect(noStatus.filters.status).toBeNull();

// --status all is an explicit no-op status filter for duplicate discovery
// across open/closed/canceled/custom lifecycle buckets.
const allStatus = await runSearch("statustoken", { mode: "keyword", status: "all" }, { path: "/tmp/pm-search" });
expect(allStatus.count).toBe(2);
expect(allStatus.items.map((hit) => hit.item.id).sort()).toEqual([
"pm-status-closed",
"pm-status-open",
]);
expect(allStatus.filters.status).toBe("all");

const upperAllStatus = await runSearch("statustoken", { mode: "keyword", status: " ALL " }, { path: "/tmp/pm-search" });
expect(upperAllStatus.count).toBe(2);
expect(upperAllStatus.filters.status).toBe("all");
});

it("rejects an unrecognized --status token strictly with a did-you-mean hint", async () => {
Expand Down Expand Up @@ -3233,6 +3247,13 @@ describe("classifyImplicitSemanticFallbackReason", () => {
{ path: "/tmp/pm-search" },
);
expect(compact.filters).toMatchObject({ match_mode: "and", assignee: "alice", sprint: "S1" });

const compactAllStatus = await runSearch(
"alpha",
{ mode: "keyword", compact: true, status: " ALL " },
{ path: "/tmp/pm-search" },
);
expect(compactAllStatus.filters.status).toBe("all");
});
});
});
Expand Down
Loading