diff --git a/packages/tbd/src/cli/commands/list.ts b/packages/tbd/src/cli/commands/list.ts index 2f89d5fd..622cd754 100644 --- a/packages/tbd/src/cli/commands/list.ts +++ b/packages/tbd/src/cli/commands/list.ts @@ -23,6 +23,7 @@ import { formatNoSpecGroupHeader, type IssueForDisplay, } from '../lib/issue-format.js'; +import { parseDateOption } from '../lib/issue-input-validation.js'; import { parsePriority } from '../../lib/priority.js'; import { selectIssues } from '../../lib/issue-query.js'; import type { IssueQuery, IssueSort } from '../../lib/issue-query.js'; @@ -215,6 +216,10 @@ class ListHandler extends BaseCommand { // An empty --spec means "no filter", as before. spec: options.spec === '' ? null : (options.spec ?? null), deferred: options.deferred ?? false, + deferBefore: + options.deferBefore === undefined + ? null + : parseDateOption(options.deferBefore, '--defer-before'), ready: false, sort: (options.sort ?? 'priority') as IssueSort, limit: null, @@ -240,7 +245,7 @@ export const listCommand = new Command('list') 'Filter by spec path (matches full path, partial path suffix, or filename)', ) .option('--deferred', 'Show only deferred issues') - .option('--defer-before ', 'Deferred before date') + .option('--defer-before ', 'Only issues whose deferred_until falls before this date') .option('--sort ', 'Sort by: priority, created, updated', 'priority') .option('--limit ', 'Limit results') .option('--count', 'Output only the count of matching issues') diff --git a/packages/tbd/src/cli/commands/update.ts b/packages/tbd/src/cli/commands/update.ts index 1015bb83..78e6e911 100644 --- a/packages/tbd/src/cli/commands/update.ts +++ b/packages/tbd/src/cli/commands/update.ts @@ -23,7 +23,7 @@ import type { import { now } from '../../utils/time-utils.js'; import { resolveToInternalId, type IdMapping } from '../../file/id-mapping.js'; import { resolveSpecArg, getPathErrorMessage } from '../../lib/project-paths.js'; -import { validateIssueTitle } from '../lib/issue-input-validation.js'; +import { parseDateOption, validateIssueTitle } from '../lib/issue-input-validation.js'; import { checkParentAssignment, describeHierarchyProblem } from '../../lib/issue-hierarchy.js'; import { withDataSyncContext } from '../lib/data-context.js'; import { @@ -68,14 +68,6 @@ interface UpdateOptions { * anything it cannot is refused by name, rather than reaching the schema and coming back * as "Invalid datetime". */ -function parseDateOption(value: string, flag: string): string { - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) { - throw new ValidationError(`Invalid ${flag} value: ${value}. Expected a date or timestamp.`); - } - return new Date(parsed).toISOString(); -} - class UpdateHandler extends BaseCommand { async run(ids: string[], options: UpdateOptions): Promise { if (ids.length === 1) { diff --git a/packages/tbd/src/cli/lib/integration-runner.ts b/packages/tbd/src/cli/lib/integration-runner.ts index 6dcf93f9..a85f9ef4 100644 --- a/packages/tbd/src/cli/lib/integration-runner.ts +++ b/packages/tbd/src/cli/lib/integration-runner.ts @@ -402,6 +402,7 @@ export async function runEnabledIntegrationPushes( const plan = planMirror({ provider: entry.provider, allIssues, + readyAt: Date.now(), selected, displayId, mirrorLabels: resolveProviderSettings(config.integrations?.linear ?? {}).mirrorLabels, diff --git a/packages/tbd/src/cli/lib/issue-input-validation.ts b/packages/tbd/src/cli/lib/issue-input-validation.ts index 8dc14707..9cccde58 100644 --- a/packages/tbd/src/cli/lib/issue-input-validation.ts +++ b/packages/tbd/src/cli/lib/issue-input-validation.ts @@ -35,3 +35,18 @@ export function validateIssueTitle(title: string, options: IssueTitleValidationO return title; } + +/** + * Parse a user-supplied date flag into a normalized ISO timestamp. + * + * Shared by every date-valued flag so an unusable value fails loudly at the CLI + * boundary. A filter that silently accepts garbage and then matches everything is + * indistinguishable to the caller from a filter that did not run. + */ +export function parseDateOption(value: string, flag: string): string { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + throw new ValidationError(`Invalid ${flag} value: ${value}. Expected a date or timestamp.`); + } + return new Date(parsed).toISOString(); +} diff --git a/packages/tbd/src/file/doc-cache.ts b/packages/tbd/src/file/doc-cache.ts index fbc4e67e..2a117701 100644 --- a/packages/tbd/src/file/doc-cache.ts +++ b/packages/tbd/src/file/doc-cache.ts @@ -421,7 +421,12 @@ const SHORTCUT_DIRECTORY_END = ''; interface GuidelineGroup { heading: string; note?: string; - match: (name: string) => boolean; + /** + * `category` is the doc's declared frontmatter category. Prefer it over the name: + * doc-categories.ts calls name inference retired, and a prefix test silently + * misfiles a sibling whose name does not share the prefix. + */ + match: (name: string, category?: string) => boolean; } /** @@ -452,6 +457,7 @@ const CROSS_CUTTING_NAMES = new Set([ 'general-testing-rules', 'golden-testing-guidelines', 'release-engineering-rules', + 'release-notes-guidelines', 'supply-chain-hardening', ]); @@ -486,8 +492,7 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [ { heading: 'TypeScript & JS ecosystem', note: 'Select the documents that match the TypeScript or JavaScript surface; do not load this whole group by default.', - match: (n) => - n.startsWith('typescript-') || n.endsWith('monorepo-patterns') || n.startsWith('electron-'), + match: (n) => n.startsWith('typescript-') || n.endsWith('monorepo-patterns'), }, { heading: 'Python', @@ -504,6 +509,14 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [ note: 'Select the documents that match the Convex surface; do not load this whole group by default.', match: (n) => n.startsWith('convex-'), }, + { + heading: 'Desktop app frameworks', + note: 'Select the document for the framework in use; do not load this whole group by default.', + // Matched on the declared category, not the name. `electron-`, `electrobun-`, and + // `tauri-` share no prefix, which is exactly how two of the three used to land in + // the catch-all while the third was filed under TypeScript. + match: (_n, category) => category === 'desktop', + }, { // Catch-all, must stay last. heading: 'Docs, process & tooling', @@ -518,8 +531,8 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [ * the only other place this logic surfaces, and a misfiled guideline there is * easy to miss. */ -export function guidelineGroupFor(name: string): string { - const group = GUIDELINE_GROUPS.find((g) => g.match(name)); +export function guidelineGroupFor(name: string, category?: string): string { + const group = GUIDELINE_GROUPS.find((g) => g.match(name, category)); // The catch-all matches everything, so this fallback is unreachable in practice. return group?.heading ?? GUIDELINE_GROUPS[GUIDELINE_GROUPS.length - 1]!.heading; } @@ -612,7 +625,7 @@ export function generateShortcutDirectory( const grouped = GUIDELINE_GROUPS.map((group) => ({ group, docs: [] as CachedDoc[] })); const catchAll = grouped[grouped.length - 1]; for (const doc of guidelines) { - const heading = guidelineGroupFor(doc.name); + const heading = guidelineGroupFor(doc.name, doc.frontmatter?.category); const entry = grouped.find((g) => g.group.heading === heading) ?? catchAll; entry?.docs.push(doc); } diff --git a/packages/tbd/src/integrations/core/mirror.ts b/packages/tbd/src/integrations/core/mirror.ts index bffd6339..850ff260 100644 --- a/packages/tbd/src/integrations/core/mirror.ts +++ b/packages/tbd/src/integrations/core/mirror.ts @@ -42,6 +42,12 @@ export interface MirrorContext { provider: ProviderNameType; /** Every bead, needed for child counts and readiness. */ allIssues: readonly Issue[]; + /** + * The instant readiness is evaluated at, since a `deferred_until` in the future + * makes a bead not-ready. Supplied by callers so one mirror run cannot straddle + * two clock reads, and so tests can pin it; defaults to now. + */ + readyAt?: number; /** The beads to mirror. */ selected: readonly Issue[]; /** Renders an internal id the way a user sees it. */ @@ -195,12 +201,15 @@ export function attachmentsFor( } /** - * Compute what a mirror run would do. Pure. + * Compute what a mirror run would do. + * + * Pure given `context.readyAt`; without it the readiness cutoff is read from the + * clock once, here, rather than per bead. */ export function planMirror(context: MirrorContext): MirrorPlan { const byId = new Map(context.allIssues.map((issue) => [issue.id, issue])); const selectedIds = new Set(context.selected.map((issue) => issue.id)); - const readyIds = readyIssueIds(context.allIssues); + const readyIds = readyIssueIds(context.allIssues, context.readyAt ?? Date.now()); const childrenOf = new Map(); for (const issue of context.allIssues) { diff --git a/packages/tbd/src/lib/issue-changes.ts b/packages/tbd/src/lib/issue-changes.ts index c7c8ea3e..51f18911 100644 --- a/packages/tbd/src/lib/issue-changes.ts +++ b/packages/tbd/src/lib/issue-changes.ts @@ -442,8 +442,16 @@ export function createIssueChanges(options: CreateIssueChangesOptions): IssueCha const idsToCompare = explicitIds ?? candidateIds; const needsReadySets = options.selection.kind === 'filter' && options.selection.ready; const emptySet: ReadonlySet = new Set(); - const readyBefore = needsReadySets ? readyIssueIds(options.before.issues.values()) : emptySet; - const readyAfter = needsReadySets ? readyIssueIds(options.after.issues.values()) : emptySet; + // One instant for both snapshots: readiness depends on `deferred_until`, so two + // clock reads could report a deferral that merely elapsed between them as a ready + // transition that no edit caused. + const readyAt = Date.now(); + const readyBefore = needsReadySets + ? readyIssueIds(options.before.issues.values(), readyAt) + : emptySet; + const readyAfter = needsReadySets + ? readyIssueIds(options.after.issues.values(), readyAt) + : emptySet; const changes: IssueChange[] = []; for (const internalId of Array.from(idsToCompare).sort((left, right) => diff --git a/packages/tbd/src/lib/issue-query.ts b/packages/tbd/src/lib/issue-query.ts index 1848c714..639de3e4 100644 --- a/packages/tbd/src/lib/issue-query.ts +++ b/packages/tbd/src/lib/issue-query.ts @@ -38,6 +38,13 @@ export interface IssueQuery { spec: string | null; /** `--deferred` */ deferred: boolean; + /** + * `--defer-before `, already parsed to an ISO timestamp by the caller (as + * `priority` is): keep only beads whose `deferred_until` falls before this instant. + * Null means no filter. A bead with no `deferred_until` is not deferred before + * anything and is excluded whenever this is set. + */ + deferBefore: string | null; /** `tbd ready` semantics: open, unassigned, and unblocked per `readyIssueIds`. */ ready: boolean; /** `--sort` */ @@ -61,6 +68,7 @@ export function defaultIssueQuery(): IssueQuery { parentId: null, spec: null, deferred: false, + deferBefore: null, ready: false, sort: 'priority', limit: null, @@ -82,6 +90,22 @@ export function selectIssues(issues: readonly Issue[], query: IssueQuery): Issue return query.limit === null ? sorted : sorted.slice(0, query.limit); } +/** + * Whether a bead carries a `deferred_until` strictly before `cutoff`. + * + * A bead with no deferral is not "deferred before" any date, so it is excluded rather + * than treated as deferred since the beginning of time. `cutoff` is already-parsed + * per the field contract, so an unusable value is rejected at the CLI boundary rather + * than degrading into a filter that matches everything. + */ +function deferredBefore(issue: Issue, cutoff: string): boolean { + if (issue.deferred_until == null) { + return false; + } + const until = Date.parse(issue.deferred_until); + return Number.isFinite(until) && until < Date.parse(cutoff); +} + /** Apply shared CLI query predicates without paying for ordering when only facets need rows. */ export function filterIssues(issues: readonly Issue[], query: IssueQuery): Issue[] { const readyIds = query.ready ? readyIssueIds(issues) : null; @@ -115,6 +139,9 @@ export function filterIssues(issues: readonly Issue[], query: IssueQuery): Issue if (query.deferred && issue.status !== 'deferred') { return false; } + if (query.deferBefore !== null && !deferredBefore(issue, query.deferBefore)) { + return false; + } if (readyIds !== null && !readyIds.has(issue.id)) { return false; } diff --git a/packages/tbd/src/lib/issue-selection.ts b/packages/tbd/src/lib/issue-selection.ts index d1cf05aa..587119a6 100644 --- a/packages/tbd/src/lib/issue-selection.ts +++ b/packages/tbd/src/lib/issue-selection.ts @@ -34,6 +34,21 @@ export function issueMatchesSharedFilters(issue: Issue, filters: SharedIssueFilt return true; } +/** + * Whether a bead is still waiting out a `deferred_until` at `now`. + * + * A deferral exactly at `now` has arrived, so the work is available. An unparseable + * timestamp fails open — the schema validates the field, and hiding work because a + * date could not be read is the worse failure of the two. + */ +function deferralPending(issue: Issue, now: number): boolean { + if (issue.deferred_until == null) { + return false; + } + const until = Date.parse(issue.deferred_until); + return Number.isFinite(until) && until > now; +} + /** * Compute ready issue IDs from one complete issue snapshot. * @@ -50,8 +65,21 @@ export function issueMatchesSharedFilters(issue: Issue, filters: SharedIssueFilt * A held bead is never ready regardless of its dependencies: `blocked` means it is * waiting on something and `paused` means it was deliberately set down, and offering * either to an agent looking for work is how a hold gets quietly ignored. + * + * A `deferred_until` still in the future is the same kind of hold, written as a date + * instead of a flag. It used to be recorded and then ignored here, so a bead deferred + * to next year was offered as available work today and the field read as scheduling + * while scheduling nothing. + * + * `now` is a parameter rather than a `Date.now()` read inside the filter because + * `issue-changes.ts` computes this set twice to diff two snapshots: if each call took + * its own clock, a bead whose deferral elapsed between them would surface as a ready + * transition that no edit caused. */ -export function readyIssueIds(issues: Iterable): ReadonlySet { +export function readyIssueIds( + issues: Iterable, + now: number = Date.now(), +): ReadonlySet { const allIssues = Array.from(issues); const issueById = new Map(allIssues.map((issue) => [issue.id, issue])); const blockerIdsByTarget = new Map(); @@ -73,6 +101,9 @@ export function readyIssueIds(issues: Iterable): ReadonlySet { if (issue.status !== 'open' || issue.delegate || issue.hold) { return false; } + if (deferralPending(issue, now)) { + return false; + } const blockerIds = blockerIdsByTarget.get(issue.id) ?? []; return !blockerIds.some((blockerId) => issueById.get(blockerId)?.status !== 'closed'); }) diff --git a/packages/tbd/tests/deferred-until.test.ts b/packages/tbd/tests/deferred-until.test.ts new file mode 100644 index 00000000..12cfc557 --- /dev/null +++ b/packages/tbd/tests/deferred-until.test.ts @@ -0,0 +1,125 @@ +/** + * `deferred_until` is a scheduling field, and two surfaces ignored it. + * + * `tbd ready` offered a bead deferred to 2027 as available work today, so the field + * read as scheduling while changing nothing about what was surfaced. And + * `tbd list --defer-before ` was declared in the option table and help text but + * never read by any filter, so it returned the same rows as no flag at all — a silent + * no-op is worse than a missing flag, because the caller believes the filter applied. + */ + +import { describe, expect, it } from 'vitest'; + +import { ValidationError } from '../src/cli/lib/errors.js'; +import { parseDateOption } from '../src/cli/lib/issue-input-validation.js'; +import { defaultIssueQuery, selectIssues } from '../src/lib/issue-query.js'; +import { readyIssueIds } from '../src/lib/issue-selection.js'; +import type { Issue } from '../src/lib/types.js'; + +const NOW = Date.parse('2026-06-01T00:00:00.000Z'); + +function issue(id: string, overrides: Partial = {}): Issue { + return { + type: 'is', + id: `is-01HZZZZZZZZZZZZZZZZZ${id.padStart(6, '0')}`, + version: 1, + title: `Issue ${id}`, + kind: 'task', + status: 'open', + priority: 2, + labels: [], + dependencies: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } as unknown as Issue; +} + +describe('deferred_until and tbd ready', () => { + it('does not offer a bead deferred into the future', () => { + const future = issue('1', { deferred_until: '2027-01-01T00:00:00.000Z' } as Partial); + expect(readyIssueIds([future], NOW).has(future.id)).toBe(false); + }); + + it('offers a bead whose deferral has elapsed', () => { + const past = issue('2', { deferred_until: '2026-01-15T00:00:00.000Z' } as Partial); + expect(readyIssueIds([past], NOW).has(past.id)).toBe(true); + }); + + it('offers a bead with no deferral', () => { + const plain = issue('3'); + expect(readyIssueIds([plain], NOW).has(plain.id)).toBe(true); + }); + + it('treats a deferral exactly at now as elapsed', () => { + // The boundary has to fall on one side deliberately: a deferral "until now" has + // arrived, so the work is available. + const boundary = issue('4', { deferred_until: '2026-06-01T00:00:00.000Z' } as Partial); + expect(readyIssueIds([boundary], NOW).has(boundary.id)).toBe(true); + }); + + it('keeps the existing holds independent of deferral', () => { + const closed = issue('5', { status: 'closed' }); + const deferredAndClosed = issue('6', { + status: 'closed', + deferred_until: '2026-01-15T00:00:00.000Z', + } as Partial); + const ready = readyIssueIds([closed, deferredAndClosed], NOW); + expect(ready.has(closed.id)).toBe(false); + expect(ready.has(deferredAndClosed.id)).toBe(false); + }); + + it('uses one caller-supplied instant so before/after snapshots cannot disagree', () => { + // issue-changes.ts computes the ready set twice to diff two snapshots. If each + // call read its own clock, a bead whose deferral elapsed between them would show + // as a ready transition that no edit caused. + const bead = issue('7', { deferred_until: '2026-06-01T00:00:01.000Z' } as Partial); + expect(readyIssueIds([bead], NOW).has(bead.id)).toBe(false); + expect(readyIssueIds([bead], NOW + 2000).has(bead.id)).toBe(true); + }); +}); + +describe('tbd list --defer-before', () => { + const deferred2027 = issue('1', { deferred_until: '2027-01-01T00:00:00.000Z' } as Partial); + const deferred2026 = issue('2', { deferred_until: '2026-03-01T00:00:00.000Z' } as Partial); + const notDeferred = issue('3'); + const corpus = [deferred2027, deferred2026, notDeferred]; + + function idsFor(deferBefore: string | null): string[] { + return selectIssues(corpus, { ...defaultIssueQuery(), deferBefore }).map((i) => i.id); + } + + it('is off by default', () => { + expect(idsFor(null)).toHaveLength(3); + }); + + it('keeps only beads deferred before the given date', () => { + expect(idsFor('2026-06-01T00:00:00.000Z')).toEqual([deferred2026.id]); + }); + + it('excludes a bead with no deferral, which is not deferred before anything', () => { + // Sorted: this asserts membership, not the ULID tiebreak that orders the rows. + expect(idsFor('2028-01-01T00:00:00.000Z').sort()).toEqual( + [deferred2026.id, deferred2027.id].sort(), + ); + }); + + it('is exclusive at the boundary', () => { + expect(idsFor('2026-03-01T00:00:00.000Z')).toEqual([]); + }); + + it('rejects an unusable date at the CLI boundary rather than matching everything', () => { + // The failure mode being designed out: a filter that cannot read its argument and + // then returns every row is indistinguishable, to the caller, from one that never + // ran. `--defer-before` reaches the query module already parsed, so the rejection + // happens where the user can see it. + expect(() => parseDateOption('not-a-date', '--defer-before')).toThrow(ValidationError); + expect(parseDateOption('2027-01-01', '--defer-before')).toBe('2027-01-01T00:00:00.000Z'); + }); + + it('is not a silent no-op: a filter that matches nothing returns nothing', () => { + // The defect this pins: the flag parsed, was stored on the options object, and + // then returned every row. An empty result proves the predicate actually ran. + expect(idsFor('2020-01-01T00:00:00.000Z')).toEqual([]); + }); +}); diff --git a/packages/tbd/tests/guideline-groups.test.ts b/packages/tbd/tests/guideline-groups.test.ts index 95d781b1..334105c1 100644 --- a/packages/tbd/tests/guideline-groups.test.ts +++ b/packages/tbd/tests/guideline-groups.test.ts @@ -102,6 +102,53 @@ describe('guidelineGroupFor', () => { } }); + it('routes a declared category to its own group rather than the catch-all', async () => { + // The grouping matched on name prefixes while every bundled guideline already + // declares a category, and doc-categories.ts calls name inference retired. The + // two disagreed: `electron-app-development-patterns` matched the TypeScript + // prefix, while its siblings `electrobun-` and `tauri-` matched nothing and fell + // into "Docs, process & tooling", where an agent looking for desktop guidance + // will not find them. Assert on the declared field, for every category at once, + // so the next category added cannot land in the catch-all unnoticed. + const declared = new Map(); + for (const name of await bundledGuidelineNames()) { + const content = await readFile(join(GUIDELINES_DIR, `${name}.md`), 'utf8'); + const category = /^category:\s*(\S+)/m.exec(content)?.[1]; + if (category == null || category === 'general') { + continue; // `general` is the default and is routed by explicit membership. + } + declared.set(category, [...(declared.get(category) ?? []), name]); + } + expect(declared.size).toBeGreaterThan(0); + + for (const [category, names] of declared) { + const headings = new Set(names.map((n) => guidelineGroupFor(n, category))); + expect(headings.size, `${category} split across headings: ${[...headings].join(', ')}`).toBe( + 1, + ); + const [heading] = headings; + expect(heading, `${category} (${names.join(', ')}) fell into the catch-all`).not.toBe( + 'Docs, process & tooling', + ); + } + }); + + it('keeps the desktop frameworks together under their own heading', () => { + for (const name of [ + 'electron-app-development-patterns', + 'electrobun-app-development-patterns', + 'tauri-app-development-patterns', + ]) { + expect(guidelineGroupFor(name, 'desktop'), name).toBe('Desktop app frameworks'); + } + }); + + it('files release-notes-guidelines beside the release engineering rules', () => { + // publishing.md and release-engineering-rules both tell the reader to load these + // together; the catch-all heading hid one half of that pair. + expect(guidelineGroupFor('release-notes-guidelines')).toBe('Cross-cutting engineering topics'); + }); + it('files every bundled guideline in a group whose heading is non-empty', async () => { const grouped = new Map(); for (const name of await bundledGuidelineNames()) {