* feat(#441): add /gsd-capture --list-seeds for seed listing and audit Seeds (.planning/seeds/SEED-NNN-slug.md) could only be created (--seed), enriched (--enrich), or auto-surfaced at /gsd-new-milestone. There was no way to browse or audit parked seeds on demand. This adds a read-only listing, following the established --list → workflow pattern (per the approved scope on - gsd-tools `list-seeds [status]` (cmdListSeeds in src/commands.cts): scans the seeds dir, returns { count, seeds[], summary } JSON with each seed's id, slug, status, scope, trigger_when, planted, title. Optional case-insensitive status filter. User-controlled content is sanitized (sanitizeForDisplay) and every path validated (requireSafePath); read-only. Independent of audit.scanSeeds, which only returns unimplemented seeds for the milestone surface. - /gsd-capture --list-seeds routes to a new read-only list-seeds workflow that renders the seed table. Closes #441 * chore(#441): point changeset fragment at PR #722 * test(#441): allowlist list-seeds test in prompt-injection scan The test asserts that list-seeds neutralizes injection payloads (<system>, [INST]) embedded in seed content, so the fixtures legitimately contain those patterns — same as the sibling security tests already on the allowlist. * fix(#441): use canonical /gsd:capture colon form in list-seeds workflow Claude-facing source (commands/, agents/, gsd-core/workflows/, ...) must use the /gsd:<cmd> colon form per ADR/CONTEXT.md; the hyphen /gsd-<cmd> form is retired there (enforced by bug-2543-gsd-slash-namespace.test.cjs). The new list-seeds workflow used the hyphen form. * docs(#441): sync help full.md + INVENTORY for --list-seeds Adds the --list-seeds entry to the help reference (help/modes/full.md, per bug-2954 argument-hint↔help parity) and registers the new list-seeds workflow in docs/INVENTORY.md (88→89) and the generated INVENTORY-MANIFEST.json. * docs(#441): add --list-seeds how-to + drop phantom statuses Addresses CHANGES_REQUESTED on PR #722 (two documentation blockers): - USER-GUIDE.md Seeds section (how-to): extend the task to cover auditing parked seeds on demand via --list-seeds, including the status filter — kept task-oriented per Diataxis how-to mode. - CLI-TOOLS.md (reference): drop phantom statuses implemented|rejected from the list-seeds filter vocabulary; the system only produces dormant|active|triggered (src/audit.cts scanSeeds). Reference must be factually accurate and complete. * fix(#441): guard non-scalar status frontmatter in cmdListSeeds A seed with a bare `status:` line (extractFrontmatter yields {}) or a `status: [a, b]` value (yields an array) crashed the whole audit list: `(fm.status || 'dormant').toLowerCase()` throws a TypeError on a non-string. Coerce every frontmatter read through a `fmStr` helper (mirrors the existing `typeof fm.id === 'string'` guard), so a non-scalar status falls back to dormant and non-scalar scope/trigger_when/title can no longer leak a raw array/object into the JSON contract. Title is now capped symmetrically. Adds regression coverage for empty and array `status:` and non-scalar fields. Refs #441 * docs(#441): align list-seeds workflow status vocabulary The load_seeds step listed `implemented` as an example status filter, but the real seed vocabulary is dormant|active|triggered (src/audit.cts scanSeeds); `implemented` has no producer. Matches the earlier CLI-TOOLS.md correction. Refs #441 * refactor(#441): extract pure deriveSeedIdentity; match raw status in list-seeds Pull the seed_id/slug derivation out of cmdListSeeds into a pure, exported deriveSeedIdentity(stem, rawFmId) so the parsing contract can be property-tested in-process (review minor #1). No behavior change. Filter comparison now matches the raw lowercased status (both sides already normalized) instead of sanitizeForDisplay(status); sanitization is for output, not matching (review nit #3). * test(#441): add fast-check property coverage and count=1 boundary for list-seeds Adds tests/list-seeds.property.test.cjs with four fast-check properties over deriveSeedIdentity (never-throws, string-only contract, canonical id->seed_id/slug invariant, filename-prefix fallback) per RULESET.TESTS.property-based-testing (review minor #1). Adds an N==1 status-filter boundary case to list-seeds.test.cjs (review minor #2). * chore(#441): sync runtime launcher snippet into list-seeds workflow Propagate the current _runtime-launcher.snippet.sh (with non-Claude runtime home probes) into the new list-seeds.md workflow via scripts/sync-runtime-launcher.cjs, satisfying bug-891 (E) propagation. * test(#441): record list-seeds.md in workflow size baseline (#1074) --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
117
src/commands.cts
117
src/commands.cts
@@ -9,6 +9,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -195,6 +196,120 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
output(result, raw, count.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* List captured seeds from .planning/seeds/SEED-*.md for browsing/audit (#441).
|
||||
*
|
||||
* Unlike audit.scanSeeds (which returns only *unimplemented* seeds for the
|
||||
* milestone surface), this lists seeds of every status with the richer fields a
|
||||
* human audit needs (scope, trigger, planted date). An optional case-insensitive
|
||||
* status filter narrows the set. Seed content is user-controlled, so every
|
||||
* displayed field is passed through sanitizeForDisplay and each file path is
|
||||
* validated with requireSafePath before reading. Read-only — never mutates.
|
||||
*/
|
||||
/**
|
||||
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
|
||||
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
|
||||
*
|
||||
* seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
|
||||
* of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
|
||||
* remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
|
||||
* `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
|
||||
*/
|
||||
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
|
||||
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
||||
let seedId: string;
|
||||
if (/^SEED-\d+$/i.test(fmId)) {
|
||||
seedId = fmId;
|
||||
} else {
|
||||
const numMatch = stem.match(/^(SEED-\d+)/i);
|
||||
seedId = numMatch ? numMatch[1] : stem;
|
||||
}
|
||||
const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
|
||||
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
|
||||
return { seed_id: seedId, slug };
|
||||
}
|
||||
|
||||
function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolean): void {
|
||||
const planDir = planningDir(cwd);
|
||||
const seedsDir = path.join(planDir, 'seeds');
|
||||
const wantStatus = statusFilter ? statusFilter.trim().toLowerCase() : null;
|
||||
|
||||
const seeds: Array<{
|
||||
seed_id: string; slug: string; status: string; scope: string;
|
||||
trigger_when: string; planted: string; title: string; path: string;
|
||||
}> = [];
|
||||
const summary: Record<string, number> = {};
|
||||
|
||||
// Frontmatter values are not guaranteed to be scalars: extractFrontmatter
|
||||
// yields {} for a bare `key:` line and an array for `key: [a, b]`. Coerce every
|
||||
// read to a string so one malformed seed cannot crash the whole audit list
|
||||
// (`.toLowerCase()` on a non-string throws) or leak a raw object/array into the
|
||||
// JSON contract. Mirrors the existing `typeof fm.id === 'string'` guard below.
|
||||
const fmStr = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// No seeds dir (or unreadable) — an empty, non-error result. The seed dir is
|
||||
// created lazily by the first plant-seed, so absence is the normal zero case.
|
||||
output({ count: 0, seeds: [], summary: {} }, raw, '0');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(path.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
|
||||
|
||||
// Match on the raw lowercased status (both sides already normalized);
|
||||
// sanitizeForDisplay is for output, not comparison.
|
||||
if (wantStatus && status !== wantStatus) continue;
|
||||
|
||||
// Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
|
||||
// back to the numeric prefix of the filename, then to the whole stem. The
|
||||
// descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
|
||||
const stem = path.basename(entry.name, '.md');
|
||||
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
||||
|
||||
let title = sanitizeForDisplay(fmStr(fm.title).slice(0, 100));
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*(.+)$/m);
|
||||
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
||||
}
|
||||
|
||||
const safeStatus = sanitizeForDisplay(status);
|
||||
summary[safeStatus] = (summary[safeStatus] || 0) + 1;
|
||||
|
||||
seeds.push({
|
||||
seed_id: sanitizeForDisplay(seedId),
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: safeStatus,
|
||||
scope: sanitizeForDisplay(fmStr(fm.scope) || 'unknown'),
|
||||
trigger_when: sanitizeForDisplay(fmStr(fm.trigger_when)),
|
||||
planted: sanitizeForDisplay(fmStr(fm.planted)),
|
||||
title,
|
||||
path: toPosixPath(path.relative(cwd, safeFilePath)),
|
||||
});
|
||||
}
|
||||
|
||||
// Stable order: by seed_id so output is deterministic across filesystems.
|
||||
seeds.sort((a, b) => a.seed_id.localeCompare(b.seed_id));
|
||||
|
||||
output({ count: seeds.length, seeds, summary }, raw, seeds.length.toString());
|
||||
}
|
||||
|
||||
function cmdVerifyPathExists(cwd: string, targetPath: string | undefined, raw: boolean): void {
|
||||
if (!targetPath) {
|
||||
error('path required for verification');
|
||||
@@ -1578,6 +1693,8 @@ export = {
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
cmdListTodos,
|
||||
cmdListSeeds,
|
||||
deriveSeedIdentity,
|
||||
cmdVerifyPathExists,
|
||||
cmdHistoryDigest,
|
||||
cmdResolveModel,
|
||||
|
||||
Reference in New Issue
Block a user