From 2ab492ae195adb6738126c9f17240854735a86d1 Mon Sep 17 00:00:00 2001 From: pi Date: Sat, 8 Aug 2026 21:10:20 +0100 Subject: [PATCH 01/40] WL-0MSKSZBEL0025CN3: regenerate src/version.ts (1.0.8 -> 1.0.9) package.json was bumped to 1.0.9 (release commit 6543cb38) but src/version.ts was not regenerated, so wl init semaphore version (1.0.8) mismatched the package version, failing tests/cli/init.test.ts. Regenerate via the documented scripts/generate-version.cjs process (same as dfcc1152 for the previous bump). --- src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index d0f04f93..831d85f9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.8'; +export const WORKLOG_VERSION = '1.0.9'; From 2004f41ca221d6181397305446ed5e31bafb79c9 Mon Sep 17 00:00:00 2001 From: pi Date: Sat, 8 Aug 2026 21:32:29 +0100 Subject: [PATCH 02/40] WL-0MSGJDSMJ004128E: Implementation complete --- packages/herdr/README.md | 2 +- packages/herdr/src/worklist.test.ts | 147 ++++++++++++++++++++++++++++ packages/herdr/src/worklist.ts | 16 ++- 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 7d3fb2c9..dd21a8d9 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -174,7 +174,7 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in - `autoSync` — Enable periodic background `wl sync` before auto-refreshes (default: `true`). Background syncs use a single-flight in-process guard and pass `wl sync --if-idle`, so overlapping syncs (from this pane or other panes/TUI instances) are skipped instead of piling up — preventing wl sync lock storms (WL-0MSAB7ZUC004SK7E). - `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync) - `browseItemCount` — Max number of non-mandatory items to show in the list (default: `20`, range `1`–`50`; critical and completed/in_review items are always shown regardless) -- `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`); changes apply on the next render without a plugin restart +- `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`). When `false`, **all** shortcut hint lines are hidden — including the chord-in-progress footer (`chord: _ `) — consistent with the pi browse widget (WL-0MSGJDSMJ004128E). Chord key *handling* still works while hints are hidden; only rendering is affected. Changes apply on the next render without a plugin restart - `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart ### Downtime worker (local-LLM idle dispatch) diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 9d817c5d..3603a95c 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -34,6 +34,7 @@ import { formatChordHintsForHelp, resolvePodcastTarget, } from './worklist.js'; +import type { ChordState } from './worklist.js'; import type { DowntimeWorker } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; import { loadShortcutConfig, ShortcutRegistry } from './shortcut-config.js'; @@ -1116,6 +1117,152 @@ describe('createListRenderer — code freeze banner', () => { }); }); +// ── Chord-mode footer gating (WL-0MSGJDSMJ004128E) ───────────────────── +// The chord-in-progress footer (`chord: _ `) must be gated by +// `showHelpText` like the normal shortcut hint line, so `showHelpText: false` +// hides ALL shortcut hint lines (consistent with the pi browse widget). The +// gating must only affect rendering — chord state accumulation is untouched. + +describe('createListRenderer — chord-mode footer gating', () => { + const renderer = createListRenderer(); + + function chordStateWithPending(keys: string[], hints = 'update ...'): ChordState { + const state = createChordState(); + state.pendingKeys = keys; + state.hints = hints; + return state; + } + + it('suppresses the chord footer when showHelpText is false', () => { + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + false, // showHelpText + ); + expect(output).not.toContain('chord:'); + expect(output).not.toContain('update ...'); + }); + + it('renders the chord footer when showHelpText is true', () => { + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + true, // showHelpText + ); + expect(output).toContain('chord:'); + expect(output).toContain('u _'); + expect(output).toContain('update ...'); + }); + + it('renders the chord footer when showHelpText is unset (default true)', () => { + // Backwards compatibility: existing positional callers that do not pass + // the trailing showHelpText argument keep the chord footer visible. + const output = renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordStateWithPending(['u']), + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + ); + expect(output).toContain('chord:'); + expect(output).toContain('u _'); + }); + + it('does not mutate chord state while rendering with showHelpText false', () => { + // The fix gates rendering only; the chord key handling state machine must + // keep accumulating even when the footer is hidden (WL-0MSGJDSMJ004128E). + const chordState = chordStateWithPending(['u', 'c']); + renderer( + [makeItem('A')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + chordState, + 0, + false, + undefined, + undefined, + 0, + false, + false, + 0, + undefined, + undefined, + undefined, + 0, + true, + 0, + false, // showHelpText + ); + expect(chordState.pendingKeys).toEqual(['u', 'c']); + expect(chordState.hints).toBe('update ...'); + }); +}); + // ── Code Freeze: dialog rendering ──────────────────────────────────────── describe('formatCodeFreezeDialog', () => { diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 3fe78df5..45579454 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -1797,6 +1797,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( detailToCIndex?: number, detailToCFocus?: boolean, detailRenderedIndex?: number, + showHelpText?: boolean, ) => string { // Default to icons enabled when no getter is supplied (backwards // compatible — callers/tests that render without options keep icons). @@ -1825,6 +1826,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( detailToCIndex?: number, detailToCFocus?: boolean, detailRenderedIndex?: number, + showHelpText?: boolean, ): string => { const { rows, cols } = termSize; // Icons are gated by the getter for the whole frame (list lines, detail @@ -1968,9 +1970,15 @@ export function createListRenderer(getShowIcons?: () => boolean): ( output.push(''); } - // Footer with keyboard hints (dynamic — includes chord hints if available) + // Footer with keyboard hints (dynamic — includes chord hints if available). + // Both the normal hint line and the chord-in-progress line are gated by + // `showHelpText` (default true), so `showHelpText: false` hides ALL shortcut + // hints, consistent with the pi browse widget's showHelpText handling + // (WL-0MSGJDSMJ004128E). Note: gating only affects rendering — chord key + // handling/accumulation in chordState continues regardless. + const helpEnabled = showHelpText ?? true; const isChordActive = chordState && chordState.pendingKeys.length > 0; - if (isChordActive) { + if (isChordActive && helpEnabled) { const pendingStr = chordState!.pendingKeys.join(' '); const hintStr = chordState!.hints ? ` ${ANSI.dim}${chordState!.hints}${ANSI.reset}` @@ -3211,6 +3219,10 @@ export async function runWorklistTui( state.detailToCIndex, state.detailToCFocus, state.detailRenderedIndex, + // Gate the chord-in-progress footer behind showHelpText so `false` hides + // ALL shortcut hint lines (normal and chord-mode), matching the pi browse + // widget (WL-0MSGJDSMJ004128E). Chord key handling is unaffected. + opts.getShowHelpText(), ); // Notifications are surfaced via Herdr toasts (showToast), never as a From 1f9fc65acafdd8755350892123847a87e1f88c15 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <> Date: Sat, 8 Aug 2026 22:57:30 +0100 Subject: [PATCH 03/40] OSL-0MSKVB5K6008XFOQ: w chord split (w-r/w-s/w-b) with review/both marker resolution - resolvePodcastTarget resolves (--review) and (--review-rewrite) to the raw first .podcast.md Key File, with a belt-and-braces error when no script exists - both dispatch sites resolve the new markers before the generic modal-form check so they never fall through to the input form - new tests: resolvePodcastTarget review/both cases (resolution + error paths), w chord-leader hints (collapsed footer, expanded r/s/b at script-bearing stages, s-only at intake_complete, none at idea), per-sub-chord stage gating in the project-local shortcuts contract - herdr README: marker contract table extended with / , w chord leader description updated --- packages/herdr/README.md | 25 +++--- packages/herdr/src/shortcut-config.test.ts | 89 ++++++++++++++++++++ packages/herdr/src/worklist.test.ts | 97 +++++++++++++++++++++- packages/herdr/src/worklist.ts | 61 ++++++++++---- 4 files changed, 247 insertions(+), 25 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index dd21a8d9..6bb41593 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -635,28 +635,33 @@ Semantics: `docs`). All other bundled shortcuts (audit `a-*`, producer review `r`, housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are available on all types. Consumer projects can add their own type-gated - chords (e.g. `w` → `wiki-podcast-script` for `podcast` items) via the - project-local `shortcuts.json` mechanism above. + chords (e.g. a `w` chord leader → `wiki-podcast-script` for `podcast` + items) via the project-local `shortcuts.json` mechanism above. -### Podcast-progression dispatch markers (OSL-0MSKFXM380098LFL) +### Podcast-progression dispatch markers (OSL-0MSKFXM380098LFL, +OSL-0MSKVB5K6008XFOQ) Consumer projects that produce podcasts via Worklog episode items (issue `podcast`) can define progression chords in their project-local -`shortcuts.json` that dispatch the podcast pipeline skills. Two markers are +`shortcuts.json` that dispatch the podcast pipeline skills. Four markers are resolved by the worklist **at dispatch time** from the selected item's `Key Files:` section and lifecycle context — they never fall through to the modal input form: | Marker | Resolution | Typical command | |---|---|---| -| `` | `w` write-script chord: stage `intake_complete` (sourced) → `--doc --force-single`; otherwise with open editor-note children → `--rewrite `; otherwise a belt-and-braces error is shown and nothing dispatches (never authors a duplicate) | `/skill:wiki-podcast-script ` | +| `` | `w s` write-script sub-chord: stage `intake_complete` (sourced) → `--doc --force-single`; otherwise with open editor-note children → `--rewrite `; otherwise a belt-and-braces error is shown and nothing dispatches (never authors a duplicate) | `/skill:wiki-podcast-script ` | +| `` | `w r` write-review sub-chord: first `.podcast.md` Key File in raw form (runs the 6 reviews with `--review`; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review ` | +| `` | `w b` write-both sub-chord: first `.podcast.md` Key File in raw form (runs reviews + rewrite in one pass with `--review-rewrite`, 7 LLM calls; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review-rewrite ` | | `` | `t` TTS chord: first `.podcast.md` Key File, normalized to the wiki-dir-relative `podcast/...` path the TTS skill expects (errors when no script exists yet) | `/skill:wiki-tts-generate --podcast-file ` | -Both markers require the chord entry to carry `work_item_types: ["podcast"]` -so it is only visible on podcast-typed items (see [Shortcut filtering by -work-item type](#shortcut-filtering-by-work-item-type)); the `w` chord should -additionally be stage-limited to the podcast lifecycle (`intake_complete`, -`plan_complete`, `in_review`, `done`). +All markers require the chord entry to carry `work_item_types: ["podcast"]` +so they are only visible on podcast-typed items (see [Shortcut filtering by +work-item type](#shortcut-filtering-by-work-item-type)); the `w` sub-chords +should additionally be stage-limited to the podcast lifecycle +(`w s`: `intake_complete`, `plan_complete`, `in_review`, `done`; `w r` / +`w b`: `plan_complete`, `in_review`, `done` — the stages where a script +exists). ### Shortcut filtering during a freeze diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index cde3b48a..26dec021 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -513,3 +513,92 @@ describe('loadShortcutConfig — project-local shortcuts.json overrides', () => expect(JSON.stringify(a.getEntries())).toBe(JSON.stringify(b.getEntries())); }); }); + +// ── w chord split: per-sub-chord stage gating (OSL-0MSKVB5K6008XFOQ) ── +// The single-key `w` write-script chord becomes a chord leader with three +// sub-chords: w-r (write review), w-s (write script), w-b (write both). +// w-r/w-b require an existing script, so they are gated to script-bearing +// stages (plan_complete / in_review / done); w-s keeps today's four-stage +// gate. All three stay gated to podcast-typed items. + +describe('loadShortcutConfig — w chord split stage gating', () => { + let tempRoots: string[] = []; + + function makeLocalRoot(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-w-chord-')); + tempRoots.push(dir); + for (const [rel, content] of Object.entries(files)) { + writeFileSync(join(dir, rel), content); + } + return dir; + } + + afterEach(() => { + for (const dir of tempRoots) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempRoots = []; + vi.restoreAllMocks(); + }); + + const wEntries = [ + { chord: ['w', 'r'], command: '/skill:wiki-podcast-script --review ', view: 'both', label: 'write review', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 's'], command: '/skill:wiki-podcast-script ', view: 'both', label: 'write script', stages: ['intake_complete', 'plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 'b'], command: '/skill:wiki-podcast-script --review-rewrite ', view: 'both', label: 'write both', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + ]; + + it('loads all three w sub-chords and no single-key w', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + expect(registry.lookupChordEntry(['w'], 'list')).toBeUndefined(); + expect(registry.lookupChordEntry(['w', 'r'], 'list')).toBeDefined(); + expect(registry.lookupChordEntry(['w', 's'], 'list')).toBeDefined(); + expect(registry.lookupChordEntry(['w', 'b'], 'list')).toBeDefined(); + }); + + it('w-r and w-b are visible only at script-bearing stages (plan_complete, in_review, done)', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const chord of [['w', 'r'], ['w', 'b']]) { + for (const stage of ['plan_complete', 'in_review', 'done']) { + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', stage)).toBeDefined(); + } + for (const stage of ['idea', 'intake_complete', 'in_progress']) { + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', stage)).toBeUndefined(); + } + } + }); + + it('w-s keeps the four-stage gate (intake_complete through done)', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const stage of ['intake_complete', 'plan_complete', 'in_review', 'done']) { + expect(registry.lookupChordEntry(['w', 's'], 'list', stage)).toBeDefined(); + } + for (const stage of ['idea', 'in_progress']) { + expect(registry.lookupChordEntry(['w', 's'], 'list', stage)).toBeUndefined(); + } + }); + + it('all three sub-chords resolve at the script-bearing stages', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + expect(registry.lookupChordEntry(['w', 'r'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script --review '); + expect(registry.lookupChordEntry(['w', 's'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script '); + expect(registry.lookupChordEntry(['w', 'b'], 'list', 'plan_complete')?.command) + .toBe('/skill:wiki-podcast-script --review-rewrite '); + }); + + it('all three w sub-chords stay gated to podcast-typed items', () => { + const root = makeLocalRoot({ 'shortcuts.json': JSON.stringify(wEntries) }); + const registry = loadShortcutConfig(root); + for (const chord of [['w', 'r'], ['w', 's'], ['w', 'b']]) { + const entry = registry.lookupChordEntry(chord as ['w', 'r'], 'list'); + expect(entry?.workItemTypes).toEqual(['podcast']); + // Hidden on non-podcast types. + expect(registry.lookupChordEntry(chord as ['w', 'r'], 'list', undefined, false, 'bug')).toBeUndefined(); + } + }); +}); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 3603a95c..a84c604c 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -37,7 +37,7 @@ import { import type { ChordState } from './worklist.js'; import type { DowntimeWorker } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; -import { loadShortcutConfig, ShortcutRegistry } from './shortcut-config.js'; +import { loadShortcutConfig, ShortcutRegistry, type ShortcutEntry } from './shortcut-config.js'; import { regroupWorkItems, extractFilePaths } from './grouping.js'; import { setWorklogDir, resetWorklogDir, setExecFileAsync, resetExecFileAsync, type WorkItem } from './fetcher.js'; @@ -1052,6 +1052,101 @@ describe('resolvePodcastTarget — podcast-progression dispatch', () => { const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file ', synthItem); expect(result.error).toMatch(/no podcast script/i); }); + + it('resolves to the raw script path (w-r write-review chord)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review foo/foo.podcast.md' }); + }); + + it('belt-and-braces: errors on when no script is in Key Files', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', sourcedItem); + expect(result.error).toMatch(/no podcast script/i); + expect(result.command).toBeUndefined(); + }); + + it('resolves to the raw script path (w-b write-both chord)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review-rewrite ', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review-rewrite foo/foo.podcast.md' }); + }); + + it('belt-and-braces: errors on when no script is in Key Files', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review-rewrite ', sourcedItem); + expect(result.error).toMatch(/no podcast script/i); + expect(result.command).toBeUndefined(); + }); + + it('resolves review markers on wiki-dir-relative script paths as-is (raw form)', async () => { + const wikiItem: WorkItem = { ...draftedItem, description: '## Key Files:\n- wiki/podcast/foo/foo.podcast.md\n' }; + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review ', wikiItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review wiki/podcast/foo/foo.podcast.md' }); + }); + + it('returns the command unchanged for a command with no podcast markers (review marker absent)', async () => { + const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review foo/foo.podcast.md', draftedItem); + expect(result).toEqual({ command: '/skill:wiki-podcast-script --review foo/foo.podcast.md' }); + }); +}); + +// ── w chord leader: sub-chord hints ────────────────────────────────────── +// The `w` single-key write-script chord is split into w-r/w-s/w-b sub-chords +// (OSL-0MSKVB5K6008XFOQ). Pressing `w` must collapse to `w:write...` and +// expand to the per-sub-chord hints via the existing formatChordHintsForHelp +// machinery, respecting per-stage visibility. + +describe('w chord leader — sub-chord hints and stage gating', () => { + const wChords: ShortcutEntry[] = [ + { + chord: ['w', 'r'], + command: '/skill:wiki-podcast-script --review ', + view: 'both', + label: 'write review', + stages: ['plan_complete', 'in_review', 'done'], + }, + { + chord: ['w', 's'], + command: '/skill:wiki-podcast-script ', + view: 'both', + label: 'write script', + stages: ['intake_complete', 'plan_complete', 'in_review', 'done'], + }, + { + chord: ['w', 'b'], + command: '/skill:wiki-podcast-script --review-rewrite ', + view: 'both', + label: 'write both', + stages: ['plan_complete', 'in_review', 'done'], + }, + ]; + + it('shows w as a chord leader hint in the footer (collapsed)', () => { + const registry = new ShortcutRegistry([...wChords]); + const hints = getChordHelpHints(registry); + expect(hints).toContain('[w] chords'); + }); + + it('expands to r/s/b sub-chord hints at a script-bearing stage', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'plan_complete'); + const hints = formatChordHintsForHelp(nextChords, ['w']); + expect(hints).toContain('r:review'); + expect(hints).toContain('s:script'); + expect(hints).toContain('b:both'); + }); + + it('shows only the s sub-chord at intake_complete (w-r/w-b hidden)', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'intake_complete'); + const hints = formatChordHintsForHelp(nextChords, ['w']); + expect(hints).toContain('s:script'); + expect(hints).not.toContain('r:review'); + expect(hints).not.toContain('b:both'); + }); + + it('hides all w sub-chords at idea (no stage gate matches)', () => { + const registry = new ShortcutRegistry([...wChords]); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'idea'); + expect(nextChords).toHaveLength(0); + }); }); // ── Code Freeze: banner rendering ──────────────────────────────────────── diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 45579454..1543934f 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -2302,10 +2302,11 @@ export interface PodcastTargetResolution { /** * Resolve podcast-progression command markers (``, - * ``) for the selected work item at dispatch time - * (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). + * ``, ``, ``) for the selected + * work item at dispatch time (OSL-0MSKFXM380098LFL, folding in + * OSL-0MSHFQ51L009IUOS and the OSL-0MSKVB5K6008XFOQ w-chord split). * - * The `w` write-script chord command + * The `w s` write-script sub-chord command * (`/skill:wiki-podcast-script `) derives its mode from the * selected item's lifecycle context: * - stage `intake_complete` (sourced): author a new script from the source @@ -2315,6 +2316,15 @@ export interface PodcastTargetResolution { * - otherwise: belt-and-braces guard — returns an error and does NOT * dispatch (never authors a duplicate). * + * The `w r` write-review sub-chord command + * (`/skill:wiki-podcast-script --review `) runs the 6 + * reviews on the existing script, and the `w b` write-both sub-chord + * command (`/skill:wiki-podcast-script --review-rewrite `) + * runs reviews + rewrite in one pass (7 LLM calls). Both resolve + * ``/`` to the first `.podcast.md` Key File + * in raw form (same as the existing `--rewrite` resolution) with a + * belt-and-braces error when no script exists. + * * The `t` TTS chord command * (`/skill:wiki-tts-generate --podcast-file `) resolves * `` to the first `.podcast.md` Key File, normalized to the @@ -2337,7 +2347,9 @@ export async function resolvePodcastTarget( ): Promise { const hasTarget = command.includes(''); const hasScript = command.includes(''); - if (!hasTarget && !hasScript) { + const hasReview = command.includes(''); + const hasBoth = command.includes(''); + if (!hasTarget && !hasScript && !hasReview && !hasBoth) { return { command }; } if (!item) { @@ -2391,6 +2403,23 @@ export async function resolvePodcastTarget( resolved = resolved.replace(//g, podcastFile); } + // `w r` write-review / `w b` write-both sub-chords: both require an + // existing script and resolve the marker to the raw first `.podcast.md` + // Key File (same raw form the `--rewrite` resolution uses). The chords + // are stage-gated to script-bearing stages, but a belt-and-braces guard + // still protects the unfiltered/edge case (OSL-0MSKVB5K6008XFOQ). + if (hasReview || hasBoth) { + if (!script) { + return { error: 'No podcast script found in Key Files: — author the script first (w)' }; + } + if (hasReview) { + resolved = resolved.replace(//g, script); + } + if (hasBoth) { + resolved = resolved.replace(//g, script); + } + } + return { command: resolved }; } @@ -2800,11 +2829,13 @@ export async function runWorklistTui( chordState.resolvedCommand = null; chordState.resolvedModel = null; if (command) { - // Podcast-progression markers (/) - // are resolved from the selected item's context BEFORE the generic - // modal-form check so they never fall through to the input form - // (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). - if (command.includes('') || command.includes('')) { + // Podcast-progression markers (// + // /) are resolved from the selected + // item's context BEFORE the generic modal-form check so they never + // fall through to the input form (OSL-0MSKFXM380098LFL, folding in + // OSL-0MSHFQ51L009IUOS; w-chord split OSL-0MSKVB5K6008XFOQ). + if (command.includes('') || command.includes('') + || command.includes('') || command.includes('')) { const podcast = await resolvePodcastTarget(command, state.getSelectedItem()); if (podcast.error) { showToast('Error', { body: podcast.error }); @@ -2933,11 +2964,13 @@ export async function runWorklistTui( if (singleEntry) { let singleCmd = singleEntry.command; const singleModel = singleEntry.model ?? undefined; - // Podcast-progression markers (/) - // are resolved from the selected item's context BEFORE the generic - // modal-form check so they never fall through to the input form - // (OSL-0MSKFXM380098LFL, folding in OSL-0MSHFQ51L009IUOS). - if (singleCmd.includes('') || singleCmd.includes('')) { + // Podcast-progression markers (// + // /) are resolved from the selected + // item's context BEFORE the generic modal-form check so they never + // fall through to the input form (OSL-0MSKFXM380098LFL, folding in + // OSL-0MSHFQ51L009IUOS; w-chord split OSL-0MSKVB5K6008XFOQ). + if (singleCmd.includes('') || singleCmd.includes('') + || singleCmd.includes('') || singleCmd.includes('')) { const podcast = await resolvePodcastTarget(singleCmd, state.getSelectedItem()); if (podcast.error) { showToast('Error', { body: podcast.error }); From 03d7e672fa513506f570ce13f940d0bd332a1424 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 03:10:53 +0100 Subject: [PATCH 04/40] WL-0MSL2ZQIF006QB4Q: Implementation complete --- packages/herdr/README.md | 12 +++-- packages/herdr/src/downtime-worker.test.ts | 63 ++++++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 34 +++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 6bb41593..68abddf1 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -214,10 +214,14 @@ identity data the worker fails closed to all-slots-free for `0 < N < total`; 60s so the pause cannot be disabled or set trivially small) The worker polls `GET {proxyUrl}/llama/local/status` on the poll interval. -Idle means: llama-server running, no active query, no model switch, no active -local lease, and the required free-slot condition met. Endpoint failures, -timeouts, and ambiguous responses are treated as **busy** (no dispatch) and -never crash the plugin. Each poll is single-flight with a per-poll timeout. +Idle means: llama-server running, no active **local** query (when the proxy +serves `local_active_query` — preferred over the global `active_query`, so +remote-only streams with free local slots do not block dispatch; absent on +pre-fix proxies, the global `active_query` is used as the fallback), no model +switch, no active local lease, and the required free-slot condition met. +Endpoint failures, timeouts, and ambiguous responses are treated as **busy** +(no dispatch) and never crash the plugin. Each poll is single-flight with a +per-poll timeout. **Dispatch behaviour** — once idle has been continuous for the threshold, the worker first runs `wl list --status completed --stage in_review --json` and diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 491d9ae7..b1e70f15 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -114,6 +114,17 @@ describe('idle detection (isIdleStatus)', () => { expect(isIdleStatus({ ...idle, active_query: true }, 0)).toBe(false); }); + it('is idle during remote-only traffic when local_active_query=false (global active_query true)', () => { + // Remote provider streams keep the GLOBAL active_query true while the + // local model is idle with free slots; the proxy's local_active_query is + // the local-only signal (LP-0MSL2ZLLS009RVKR) and must not block dispatch. + expect(isIdleStatus({ ...idle, active_query: true, local_active_query: false }, 0)).toBe(true); + }); + + it('is busy when local_active_query=true (a local query is in flight)', () => { + expect(isIdleStatus({ ...idle, local_active_query: true }, 0)).toBe(false); + }); + it('is busy while a model switch is in progress', () => { expect(isIdleStatus({ ...idle, model_switch_in_progress: true }, 0)).toBe(false); }); @@ -841,6 +852,40 @@ describe('endpoint failures and poller', () => { }); }); +// ── local_active_query parsing (WL-0MSL2ZQIF006QB4Q) ────────────────── + +describe('parseLlamaStatus local_active_query', () => { + const base = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + }; + + it('exposes local_active_query=false when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: false }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(false); + }); + + it('exposes local_active_query=true when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: true }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(true); + }); + + it('leaves local_active_query undefined when absent (pre-fix proxy, backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBeUndefined(); + }); + + it('treats a malformed (non-boolean) local_active_query as ambiguous → busy', () => { + expect(parseLlamaStatus({ ...base, local_active_query: 'yes' })).toBeNull(); + }); +}); + // ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── describe('runtime idle evaluation (evaluateIdle)', () => { @@ -1121,6 +1166,24 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { expect(deps.spawnAgentPane).not.toHaveBeenCalled(); }); + it('treats remote-only traffic as idle and dispatches after the threshold (local_active_query=false)', async () => { + // Integration (AC2): a stub status with global active_query=true but + // local_active_query=false (remote streams in flight, local slots free) + // must be treated as idle and dispatch after the idle threshold. + const { worker, deps, cfg } = makeWorker({ + status: { ...idleAllSlotsFree, active_query: true, local_active_query: false }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { const { worker, deps, cfg } = makeWorker(); const start = 1_000_000; diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index e609ba84..ec7a9ba2 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -117,7 +117,22 @@ export const DEFAULT_DOWNTIME_MODEL = 'plan'; /** Shape of `GET /llama/local/status` as served by the llama-proxy. */ export interface LlamaStatus { llama_server_running: boolean; + /** + * GLOBAL query activity: any request in flight (local AND remote). + * + * Prefer `local_active_query` when present — remote provider streams keep + * this true while the local model is idle with free slots, so treating it + * as the busy signal would block downtime dispatch (RCA + * WL-0MSK9TUCA00206M7). + */ active_query: boolean; + /** + * LOCAL-only query activity (served by proxies exposing the + * `local_active_queries` counter, LP-0MSL2ZLLS009RVKR). Optional: ABSENT on + * pre-fix proxies. When present, `isIdleStatus` prefers it over the global + * `active_query`; `local_active_query=true` implies `active_query=true`. + */ + local_active_query?: boolean; model_switch_in_progress: boolean; local_lease_active: boolean; available_slots: number; @@ -144,7 +159,14 @@ export interface LlamaStatus { */ export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): boolean { if (!status.llama_server_running) return false; - if (status.active_query) return false; + // Prefer the local-only signal when the proxy exposes it (LP-0MSL2ZLLS009RVKR): + // remote streams keep the GLOBAL active_query true while the local model is + // idle with free slots, so they must not block downtime dispatch. Fall back + // to the global active_query for pre-fix proxies that do not serve + // local_active_query (backward compatible). + const queryActive = + status.local_active_query !== undefined ? status.local_active_query : status.active_query; + if (queryActive) return false; if (status.model_switch_in_progress) return false; if (status.local_lease_active) return false; @@ -245,6 +267,15 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { if (typeof o.active_query !== 'boolean') return null; if (typeof o.model_switch_in_progress !== 'boolean') return null; + // Optional local-only signal (LP-0MSL2ZLLS009RVKR): absent on pre-fix + // proxies, in which case isIdleStatus falls back to the global + // active_query. A malformed (non-boolean) value is ambiguous → busy. + let localActiveQuery: boolean | undefined; + if (o.local_active_query !== undefined) { + if (typeof o.local_active_query !== 'boolean') return null; + localActiveQuery = o.local_active_query; + } + const available = o.available_slots; const total = o.total_slots; if (typeof available !== 'number' || !Number.isFinite(available) || available < 0) return null; @@ -265,6 +296,7 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { return { llama_server_running: o.llama_server_running, active_query: o.active_query, + local_active_query: localActiveQuery, model_switch_in_progress: o.model_switch_in_progress, local_lease_active: localLeaseActive, available_slots: available, From f65558d016f27d0f9f2a33db8da31c6f9f906bca Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 14:51:13 +0100 Subject: [PATCH 05/40] WL-0MSKCRX730052IIW: Implementation complete --- packages/herdr/README.md | 21 +++++++++----- packages/herdr/src/fetcher.ts | 27 +++++++++++++++--- packages/herdr/src/worklist.test.ts | 43 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 68abddf1..cb04466b 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -5,7 +5,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Features - **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand. Expanded parents **stay expanded across refreshes**: each auto/manual refresh re-fetches their children in parallel with the top-level list and swaps both in atomically, so the hierarchy never momentarily collapses or flickers (WL-0MSBVBNGH002RDP5). -- **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review, `s`=sprint back to the default view), or type `/wl ` (shorthand alias or canonical stage name, e.g. `/wl intake_complete` or `/wl progress`), to filter items by stage. Stage-filtered views show **every open** root item in the selected stage — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G) +- **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review, `s`=sprint back to the default view), or type `/wl ` (shorthand alias or canonical stage name, e.g. `/wl intake_complete` or `/wl progress`), to filter items by stage. Stage-filtered views show every root item in the selected stage matching the stage's status rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW) - **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp) - **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The detail view metadata section additionally shows the review status (❌ needs review / ✅ reviewed) and the last audit timestamp. - **Chord shortcuts** — Multi-key chord sequences provide quick actions like updating priorities, stage/status, title, closing/deleting items, running workflows, and toggling review status (configurable via `shortcuts.json`) @@ -96,7 +96,7 @@ The plugin pane will then be available via the Herdr plugin system. - Press `f` then `s` (sprint) — Return to the default unfiltered browse list - `/wl ` accepts shorthand aliases (`idea`, `intake`, `plan`, `progress`, `review`) and canonical stage names (`intake_complete`, `plan_complete`, `in_progress`, `in_review`) - `/wl` with no stage argument returns to the default unfiltered browse list - - Filtered views show **every open** item in the selected stage (see [Selection List Behaviour](#selection-list-behaviour)) + - Filtered views show every root item in the selected stage matching the stage's status rule (see [Selection List Behaviour](#selection-list-behaviour)) - Press `Escape` to cancel an incomplete chord 4. Workflow shortcuts (single-key): @@ -366,13 +366,20 @@ first 10 others (15 total). If there were 20 completed/in_review items instead of 3, all 22 mandatory items would be shown (22 > 15). The **stage-filtered** views (press `f` + stage chord, or `/wl `) show -**every open** root item in the selected stage (WL-0MSDT8X1V003206G): +every root item in the selected stage matching the stage's status rule +(WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW): -- All open items (`status=open`) in the stage are listed — no +- Most stages show **every open** item (`status=open`) in the stage — no `browseItemCount` cap and no `wl next` selection omission, so items the - priority algorithm deprioritises are still visible. -- Items with status `blocked`, `in-progress`, or `completed` are excluded - even when their stage matches. + priority algorithm deprioritises are still visible. Items with status + `blocked`, `in-progress`, or `completed` are excluded even when their + stage matches. +- The **in_review** stage (press `f` + `r`, or `/wl review`) is the + exception: it shows items with status `completed`, `in-progress`, or + `open`. Per the project workflow, advancing an item to `in_review` sets + its status to `completed` (or leaves it `in-progress` while being + re-worked after review feedback) — restricting the filter to + `status=open` would empty the review queue (WL-0MSKCRX730052IIW). - Children stay hidden in the top-level list and remain reachable via expand (Tab), exactly as in the unfiltered view. - Results follow the standard list order (sortIndex), matching the diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 5af804d3..52271783 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -498,17 +498,36 @@ export async function fetchNextItems(count?: number): Promise { return regroupWorkItems(selected); } +/** + * Status set used per stage by the stage-filtered worklist fetch. + * + * Most stages show only open items (`status=open`) — the "every open item + * in the stage" rule from WL-0MSDT8X1V003206G. The in_review stage is the + * exception (WL-0MSKCRX730052IIW): per the project workflow, advancing an + * item to in_review sets its status to `completed` (or leaves it + * `in-progress` while it is being re-worked after review feedback), so + * restricting to `status=open` would hide the entire review queue. `open` + * is included for robustness (no in_review item carries it today). + */ +const STAGE_STATUS: Record = { + in_review: 'completed,in-progress,open', +}; + /** * Fetch work items filtered by stage (via `wl list`). - * Open items only (WL-0MSDT8X1V003206G): the stage-filtered worklist shows - * every open root item in the stage — items with status `blocked`, - * `in-progress`, or `completed` are excluded even when their stage matches. + * Status per stage (WL-0MSKCRX730052IIW): the in_review stage fetches items + * with status `completed`, `in-progress`, or `open` — in_review items carry + * `completed`/`in-progress` status per the project workflow. All other + * stages fetch open items only (WL-0MSDT8X1V003206G): items with status + * `blocked`, `in-progress`, or `completed` are excluded even when their + * stage matches. * Root-only (WL-0MS964SIA0057ABR): stage-filtered top-level lists hide * child items; children remain reachable via expand (wl list --parent). * Results are ordered by the standard list order (sortIndex). */ export async function fetchItemsByStage(stage: string): Promise { - const output = await runWl(['list', '--status', 'open', '--stage', stage, '--root-only']); + const status = STAGE_STATUS[stage] ?? 'open'; + const output = await runWl(['list', '--status', status, '--stage', stage, '--root-only']); const payload = extractJson(output); return extractItems(payload); } diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index a84c604c..abcd3958 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -700,6 +700,35 @@ describe('fetchItemsForView — stage-filtered fetch', () => { expect(callArgs).toContain('--root-only'); }); + it('fetches completed/in-progress items for the in_review stage', async () => { + // in_review items carry status completed (submitted for review) or + // in-progress (being re-worked after review feedback) per the project + // workflow — restricting to status=open would empty the review queue + // (WL-0MSKCRX730052IIW). + const stageItems = [ + { ...makeItem('A', 'in_review'), status: 'completed' }, + { ...makeItem('B', 'in_review'), status: 'in-progress' }, + ]; + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: stageItems }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView('in_review', defaultFetcher); + + expect(items.map((i) => i.id)).toEqual(['A', 'B']); + expect(defaultFetcher).not.toHaveBeenCalled(); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('list'); + const statusArg = callArgs[callArgs.indexOf('--status') + 1]; + expect(statusArg).toContain('completed'); + expect(statusArg).toContain('in-progress'); + expect(callArgs[callArgs.indexOf('--stage') + 1]).toBe('in_review'); + expect(callArgs).toContain('--root-only'); + }); + it('uses the default fetcher when no filter is active', async () => { const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); const items = await fetchItemsForView(null, defaultFetcher); @@ -715,6 +744,20 @@ describe('fetchItemsForView — stage-filtered fetch', () => { const items = await fetchItemsForView('idea', defaultFetcher); expect(items.map((i) => i.id)).toEqual(['C']); }); + + it('applies the in_review stage filter client-side regardless of status', () => { + // Client-side filter (_applyFilters) matches on stage only — items are + // already status-filtered at fetch time, so completed/in-progress + // in_review items must survive the client-side pass (WL-0MSKCRX730052IIW). + const items = [ + { ...makeItem('A', 'in_review'), status: 'completed' }, + { ...makeItem('B', 'in_review'), status: 'in-progress' }, + makeItem('C', 'idea'), + ]; + const state = new WorkItemListState(items, TERM_80x24); + state.applyFilter('in_review'); + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['A', 'B']); + }); }); describe('Chord-complete error notification handling', () => { From 9836609a1b0625f2ad2263b9250caf6212d4dccd Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 14:51:40 +0100 Subject: [PATCH 06/40] WL-0MSLUANM5004NNPU: Regression tests for audit-tier re-dispatch exclusion (red phase) Test-first coverage for the dispatched-marker exclusion (parent WL-0MSLIY8ZR004QUSY): - downtime-log.test.ts: readDowntimeLogEntries fail-safe read (missing/ unreadable -> [], malformed lines skipped) + auditDispatchedItemIds audit-tier-only scope guard (plan/intake markers ignored). - downtime-worker.test.ts: selectAuditCandidate dispatched-marker exclusion regression (WL-0MSGTLSUT002NF29), composition with isAuditFresh, empty set no-op. - index.test.ts: getNextAuditCandidate wiring tests (marker exclusion, scope guard, fail-safe missing log + malformed lines) and the e2e single-dispatch case (two consecutive idle windows -> exactly one dispatch). All 11 new tests fail against the current implementation (red phase). --- packages/herdr/src/downtime-log.test.ts | 54 +++++++ packages/herdr/src/downtime-worker.test.ts | 60 ++++++++ packages/herdr/src/index.test.ts | 170 ++++++++++++++++++++- 3 files changed, 276 insertions(+), 8 deletions(-) diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index d6d4a46d..a1fb966a 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -13,6 +13,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { appendDowntimeLogEntry, + auditDispatchedItemIds, + readDowntimeLogEntries, DOWNTIME_LOG_FILE, DOWNTIME_LOG_MAX_ENTRIES, } from './downtime-log.js'; @@ -77,3 +79,55 @@ describe('downtime rolling log', () => { expect(JSON.parse(lines[lines.length - 1])).toEqual({ n: total - 1 }); }); }); + +describe('readDowntimeLogEntries (fail-safe lookup)', () => { + it('returns [] when the log file does not exist', async () => { + const cwd = makeTempCwd(); + expect(await readDowntimeLogEntries(cwd)).toEqual([]); + }); + + it('returns [] when the log file is unreadable', async () => { + const cwd = makeTempCwd(); + // A directory at the log path cannot be read as a file (EISDIR). + mkdirSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), { recursive: true }); + expect(await readDowntimeLogEntries(cwd)).toEqual([]); + }); + + it('skips malformed JSONL lines and parses the valid ones', async () => { + const cwd = makeTempCwd(); + mkdirSync(join(cwd, '.worklog')); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + '{"itemId":"WL-A","kind":"audit"}\nnot-json\n{"itemId":"WL-B","kind":"plan"}\n\n', + 'utf8', + ); + expect(await readDowntimeLogEntries(cwd)).toEqual([ + { itemId: 'WL-A', kind: 'audit' }, + { itemId: 'WL-B', kind: 'plan' }, + ]); + }); + + it('round-trips entries written by appendDowntimeLogEntry', async () => { + const cwd = makeTempCwd(); + const entry = { itemId: 'WL-A', kind: 'audit', dispatchedAt: '2026-01-01T00:00:00.000Z', cwd }; + await appendDowntimeLogEntry(cwd, JSON.stringify(entry)); + expect(await readDowntimeLogEntries(cwd)).toEqual([entry]); + }); +}); + +describe('auditDispatchedItemIds (audit-tier-only scope guard)', () => { + it('collects only audit-kind entries that carry an itemId', () => { + const ids = auditDispatchedItemIds([ + { itemId: 'WL-A', kind: 'audit' }, + { itemId: 'WL-B', kind: 'plan' }, + { itemId: 'WL-C', kind: 'intake' }, + { kind: 'audit' }, // error-style entry without itemId → ignored + { itemId: 'WL-D', kind: 'audit' }, + ]); + expect([...ids].sort()).toEqual(['WL-A', 'WL-D']); + }); + + it('returns an empty set for empty input', () => { + expect([...auditDispatchedItemIds([])]).toEqual([]); + }); +}); diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index b1e70f15..ccb2c3d4 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -541,6 +541,66 @@ describe('audit selection 7-day recency (selectAuditCandidate)', () => { }); }); +describe('audit selection dispatched-marker exclusion (selectAuditCandidate)', () => { + // Regression (WL-0MSGTLSUT002NF29): an item already dispatched for audit + // by the downtime worker must never be re-selected while it still lacks a + // fresh audit — even though it is completed/in_review (the dispatched run + // reverts the status without recording a fresh audit). + const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); + const dispatchedUnaudited: AuditCandidate = { + id: 'WL-DUP', + title: 'already dispatched, no audit', + sortIndex: 100, + }; + const otherUnaudited: AuditCandidate = { + id: 'WL-OTHER', + title: 'not dispatched', + sortIndex: 200, + }; + + it('excludes a candidate present in the dispatched set with no fresh audit (regression)', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited], NOW, dispatched)).toBeNull(); + }); + + it('selects another candidate when the first is excluded', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited, otherUnaudited], NOW, dispatched)?.id).toBe( + 'WL-OTHER', + ); + }); + + it('composes with audit freshness: a fresh audit still excludes the item (unchanged)', () => { + const freshDispatched: AuditCandidate = { + id: 'WL-FRESH', + title: 'fresh audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:30.000Z', + sortIndex: 100, + }; + const dispatched = new Set(['WL-FRESH']); + // isAuditFresh governs: fresh → not a candidate, even without the marker. + expect(selectAuditCandidate([freshDispatched], NOW)).toBeNull(); + // Marker + fresh audit → still not a candidate (unchanged). + expect(selectAuditCandidate([freshDispatched], NOW, dispatched)).toBeNull(); + }); + + it('a stale/absent audit plus an audit marker is excluded', () => { + const staleDispatched: AuditCandidate = { + id: 'WL-STALE', + title: 'stale audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale + sortIndex: 100, + }; + expect(selectAuditCandidate([staleDispatched], NOW, new Set(['WL-STALE']))).toBeNull(); + }); + + it('an item without a marker is selected as before (empty set is a no-op)', () => { + expect(selectAuditCandidate([dispatchedUnaudited], NOW, new Set())?.id).toBe('WL-DUP'); + }); +}); + describe('parseAuditCandidatesOutput', () => { it('parses a { workItems: [...] } shape', () => { const stdout = JSON.stringify({ diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 40b8a604..51d183ea 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -16,8 +16,8 @@ import { parsePaneIdFile, CAPTURE_TIMEOUT_MS, } from './index.js'; -import { DOWNTIME_LOG_FILE } from './downtime-log.js'; -import { DOWNTIME_WL_TIMEOUT_MS } from './downtime-worker.js'; +import { DOWNTIME_LOG_FILE, readDowntimeLogEntries } from './downtime-log.js'; +import { DOWNTIME_WL_TIMEOUT_MS, dispatchDowntimeWork } from './downtime-worker.js'; import { fetchItemsByStage, resetExecFileAsync, @@ -627,7 +627,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - const result = await deps.getNextAuditCandidate(); + const result = await deps.getNextAuditCandidate('/repo'); expect(mockExec).toHaveBeenCalledWith( 'wl', @@ -651,13 +651,13 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - expect(await deps.getNextAuditCandidate()).toBeNull(); + expect(await deps.getNextAuditCandidate('/repo')).toBeNull(); }); it('getNextAuditCandidate fails closed (null) when wl errors', async () => { setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - expect(await deps.getNextAuditCandidate()).toBeNull(); + expect(await deps.getNextAuditCandidate('/repo')).toBeNull(); }); it('getNextAuditCandidate passes a bounded timeout so a hung wl fails closed', async () => { @@ -668,7 +668,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - await deps.getNextAuditCandidate(); + await deps.getNextAuditCandidate('/repo'); // AC1: the audit-tier invocation must carry the bounded timeout too. const [, , options] = mockExec.mock.calls[0]; @@ -690,7 +690,7 @@ describe('createDowntimeDeps', () => { setExecFileAsync(hungExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - const resultPromise = deps.getNextAuditCandidate(); + const resultPromise = deps.getNextAuditCandidate('/repo'); await vi.advanceTimersByTimeAsync(DOWNTIME_WL_TIMEOUT_MS); await expect(resultPromise).resolves.toBeNull(); } finally { @@ -703,7 +703,161 @@ describe('createDowntimeDeps', () => { setExecFileAsync(mockExec as never); const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); - expect(await deps.getNextAuditCandidate()).toBeNull(); + expect(await deps.getNextAuditCandidate('/repo')).toBeNull(); + }); + + it('getNextAuditCandidate excludes an item already dispatched for audit (marker in the log, no fresh audit)', async () => { + // Regression (WL-0MSGTLSUT002NF29): a completed/in_review item present in + // the shared dispatch log with kind:audit and no fresh audit is excluded + // from audit-tier selection even though it is otherwise selectable. + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { id: 'WL-DUP', title: 'already dispatched', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + { id: 'WL-OTHER', title: 'fresh slot', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 200 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + // A real dispatch log under a temp worklog root with an audit marker. + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-DUP', kind: 'audit', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-OTHER', title: 'fresh slot', stage: 'audit' }); + }); + + it('getNextAuditCandidate does not exclude plan/intake markers (audit-tier-only scope guard)', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-PLANNED', title: 'planned before', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-PLANNED', kind: 'plan', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-PLANNED', title: 'planned before', stage: 'audit' }); + }); + + it('getNextAuditCandidate treats a missing dispatch log as empty (fail-safe, dispatch still works)', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-FRESHWORKLOG', title: 'fresh worklog', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + // cwd with no .worklog/downtime-dispatches.log at all. + const cwd = makeTempDir(); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESHWORKLOG', title: 'fresh worklog', stage: 'audit' }); + }); + + it('getNextAuditCandidate skips malformed log lines without failing the lookup', async () => { + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-OK', title: 'ok item', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + 'not-json\n{"broken":\n' + JSON.stringify({ itemId: 'WL-DUP', kind: 'audit', dispatchedAt: new Date(now - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate(cwd); + expect(result).toEqual({ id: 'WL-OK', title: 'ok item', stage: 'audit' }); + }); + + it('end-to-end: two consecutive idle windows dispatch a single unaudited candidate exactly once', async () => { + // AC5 (parent): with one unaudited completed/in_review candidate and two + // consecutive idle windows (no audit recorded between), the worker + // dispatches exactly once. The first dispatch writes the durable marker + // (kind:audit) to the shared log; the second window's candidate selection + // reads the marker back and excludes the item, so no second dispatch. + const now = Date.now(); + const candidate = { + id: 'WL-ONCE', + title: 'dispatch me once', + auditedAt: null, + updatedAt: new Date(now - 60_000).toISOString(), + sortIndex: 100, + }; + // wl list always returns the same single candidate; wl next returns no + // candidate so a second (wrong) audit dispatch would be the only path. + const mockExec = vi.fn((_bin: string, args: string[]) => { + if (args[0] === 'list') { + return Promise.resolve({ + stdout: JSON.stringify({ success: true, count: 1, workItems: [candidate] }), + stderr: '', + }); + } + return Promise.resolve({ stdout: JSON.stringify({ success: true, workItem: null }), stderr: '' }); + }); + setExecFileAsync(mockExec as never); + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + const cwd = makeTempDir(); + + // Idle window 1: audit candidate selected and dispatched. + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd }); + expect(first.dispatched).toBe(true); + expect(first.kind).toBe('audit'); + expect(first.candidate?.id).toBe('WL-ONCE'); + + // The durable marker landed in the shared log (kind:audit). + const entries = await readDowntimeLogEntries(cwd); + expect(entries.some((e) => e.itemId === 'WL-ONCE' && e.kind === 'audit')).toBe(true); + + // Idle window 2: the marker excludes the item; nothing else to dispatch. + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd }); + expect(second.dispatched).toBe(false); + expect(second.reason).toBe('no-candidate'); + expect(spawnFn).toHaveBeenCalledTimes(1); }); it('claimItem runs wl update --status in_progress --assignee', async () => { From 228019bdf455bcbc97f9088a6f62a7a1fb56c551 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 15:12:23 +0100 Subject: [PATCH 07/40] WL-0MSLUASNF001LLAT: Durable dispatched-marker exclusion for the audit tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the duplicate-dispatch loop (parent WL-0MSLIY8ZR004QUSY) where a dispatched /skill:audit run reverts an item to completed/in_review without recording a fresh audit, re-exposing it to audit-tier selection. - downtime-log.ts: add readDowntimeLogEntries(cwd) (fail-safe: missing/ unreadable log -> [], malformed lines skipped) and auditDispatchedItemIds (audit-kind-only marker set — plan/intake markers are scoped to their own tiers and never suppress audit selection). - downtime-worker.ts: selectAuditCandidate gains an optional dispatchedItemIds exclusion set, composing with isAuditFresh (a fresh audit still governs); DowntimeWorkerDeps.getNextAuditCandidate now takes cwd so the shared log can be read per worklog root. - index.ts: getNextAuditCandidate(cwd) reads /.worklog/downtime-dispatches.log, builds the marker set and applies it to selection. Fail-safe: missing log -> empty set, audit dispatch still works on a fresh worklog. - README: document the dispatched-marker exclusion in dispatch behaviour and the audit trail. Build (tsc) clean; full test suite 4159 passed / 0 failed. --- packages/herdr/README.md | 18 ++++++- packages/herdr/src/downtime-log.ts | 73 +++++++++++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 37 +++++++++++--- packages/herdr/src/index.ts | 18 +++++-- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index cb04466b..b06b9614 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -229,7 +229,16 @@ selects the first completed/in_review item **without** a valid audit that was **modified within the last 7 days** (`updatedAt` recency window; a candidate with a missing `updatedAt` is still selected — recency cannot be verified — while an unparseable one is skipped), dispatching `/skill:audit ` (pane -named `Downtime audit`). If none, it runs `wl next --stage intake_complete +named `Downtime audit`). The audit tier additionally applies the +**dispatched-marker exclusion** (WL-0MSLIY8ZR004QUSY): an item the downtime +worker has already dispatched for `/skill:audit` (a `kind:audit` entry in +`.worklog/downtime-dispatches.log`) is never re-selected while it still +lacks a fresh audit — closing the loop where a dispatched audit run reverts +the item to completed/in_review without recording a fresh audit. The +exclusion composes with the freshness rule: a *fresh* audit since the +dispatch still governs (fresh → not a candidate). A missing or unreadable +log is treated as empty (fail-safe), so audit dispatch keeps working on a +fresh worklog. If none, it runs `wl next --stage intake_complete --json` and dispatches `/skill:plan `; if no such item it runs `wl next --stage idea --json` and dispatches `/skill:intake `; if all three are empty nothing is dispatched. A `wl`/CLI error on the `intake_complete` lookup @@ -298,7 +307,12 @@ comment on the dispatched item (`wl comment add`, author UTC timestamp — this survives `wl sync` and is the durable trail; and (2) a bounded JSONL entry in `.worklog/downtime-dispatches.log` under the resolved worklog root (rolling — only the most recent 100 entries are -kept). A three-strike CLI-error pause additionally writes a JSONL entry to +kept). The `kind:audit` entries in this log double as the dispatched-marker +exclusion source for the audit tier (WL-0MSLIY8ZR004QUSY): an item the +worker already dispatched for `/skill:audit` is excluded from later audit +tier selection while it still lacks a fresh audit (plan/intake markers are +scoped to their own tiers and never suppress audit selection). A +three-strike CLI-error pause additionally writes a JSONL entry to the same rolling log (with the `at` timestamp and an error message) so the persistent failure is auditable even though nothing was dispatched. The `.worklog` log file is gitignored and local-only; all writes are diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index 2f52149e..f469a6c4 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -8,6 +8,13 @@ * is bounded — only the most recent DOWNTIME_LOG_MAX_ENTRIES entries are * kept — so it rolls instead of growing unbounded over a long-lived plugin * pane. Callers must treat failures as fail-closed (never crash the worker). + * + * The log doubles as the dispatched-marker source for the audit tier + * (WL-0MSLIY8ZR004QUSY): `readDowntimeLogEntries` + `auditDispatchedItemIds` + * let the audit-tier selection exclude items the downtime worker has already + * dispatched for `/skill:audit`, closing the re-selection loop where a + * dispatched run reverts the item to completed/in_review without a fresh + * audit. */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; @@ -19,6 +26,72 @@ export const DOWNTIME_LOG_FILE = 'downtime-dispatches.log'; /** Rolling bound: keep at most this many entries in the log file. */ export const DOWNTIME_LOG_MAX_ENTRIES = 100; +/** + * One parsed line of the downtime dispatch log: either a dispatch event + * (itemId/kind/dispatchedAt/title) or a persistent-error event + * (cwd/at/message, three-strike trail). All fields optional so malformed + * or foreign lines never break parsing. + */ +export interface DowntimeLogEntry { + itemId?: string; + kind?: string; + dispatchedAt?: string; + cwd?: string; + title?: string; + at?: string; + message?: string; +} + +/** + * Read the bounded rolling dispatch log at `/.worklog/downtime-dispatches.log`. + * + * FAIL-SAFE by contract (WL-0MSLIY8ZR004QUSY): a missing or unreadable log + * yields `[]` (a missing log is the normal first-run state — treating it as + * empty keeps audit-tier dispatch working on a fresh worklog, and a + * corrupted log must not silently disable the audit tier either), and + * malformed JSONL lines are skipped without throwing. This function never + * throws. + */ +export async function readDowntimeLogEntries(cwd: string): Promise { + const file = join(cwd, '.worklog', DOWNTIME_LOG_FILE); + let raw: string; + try { + raw = await readFile(file, 'utf8'); + } catch { + return []; // missing or unreadable → empty (fail-safe) + } + const entries: DowntimeLogEntry[] = []; + for (const line of raw.split('\n')) { + if (line.trim() === '') continue; + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed === 'object' && parsed !== null) { + entries.push(parsed as DowntimeLogEntry); + } + } catch { + // malformed line → skip (fail-safe) + } + } + return entries; +} + +/** + * Build the set of itemIds the downtime worker has already dispatched for + * `/skill:audit` (`kind === 'audit'` entries only). Plan/intake markers are + * scoped to their own tiers and must NOT suppress audit-tier selection + * (audit-tier-only scope guard, WL-0MSLIY8ZR004QUSY). Entries without an + * itemId (e.g. persistent-error events) are ignored. + */ +export function auditDispatchedItemIds(entries: DowntimeLogEntry[]): Set { + const ids = new Set(); + for (const e of entries) { + if (e.kind === 'audit' && typeof e.itemId === 'string' && e.itemId.length > 0) { + ids.add(e.itemId); + } + } + return ids; +} + /** * Append one entry (a JSONL line) to `/.worklog/downtime-dispatches.log`, * creating the `.worklog` directory if needed and trimming the file back to diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index ec7a9ba2..d99b0682 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -18,7 +18,12 @@ * items without a valid audit (modified within the last 7 days) → * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then `wl next --stage intake_complete` → * `/skill:plan `, fallback `--stage idea` → `/skill:intake `, - * pre-dispatch claim, per-process single-flight. A tier-2 CLI error does + * pre-dispatch claim, per-process single-flight. The audit tier + * additionally excludes items the downtime worker has already dispatched + * for `/skill:audit` (durable dispatched-marker exclusion, + * WL-0MSLIY8ZR004QUSY) unless a fresh audit exists since, closing the + * re-selection loop where a dispatched run reverts the item to + * completed/in_review without recording a fresh audit. A tier-2 CLI error does * NOT short-circuit: the idea tier is still attempted so a tier-3 * candidate can still dispatch. * `wl next` failures are reported as `{ok:false}` (fail closed to busy) @@ -426,9 +431,13 @@ export interface DowntimeWorkerDeps { /** * Look up the next completed/in_review item WITHOUT a valid audit (the * audit dispatch tier, which runs before the plan/intake tiers). Fail-closed: - * a wl failure yields no candidate (no dispatch). + * a wl failure yields no candidate (no dispatch). `cwd` is the worklog root + * whose `.worklog/downtime-dispatches.log` is consulted for the + * dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): items the downtime + * worker already dispatched for `/skill:audit` are never re-selected while + * they still lack a fresh audit. */ - getNextAuditCandidate(): Promise; + getNextAuditCandidate(cwd: string): Promise; /** Claims the item (`wl update --status in_progress`) before dispatch. */ claimItem(itemId: string): Promise; /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ @@ -485,9 +494,11 @@ export interface DowntimeDispatchOutcome { let dispatchInFlight = false; /** - * Dispatch one downtime work item. Selection priority (audit tier first, - * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit → - * `/skill:audit `; if none, `wl next --stage intake_complete` → + * dispatch one downtime work item. Selection priority (audit tier first, + * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit AND + * NOT already dispatched for audit by this worker → + * `/skill:audit ` (the dispatched-marker exclusion, + * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`); if none, `wl next --stage intake_complete` → * `/skill:plan `; if none, `wl next --stage idea` → `/skill:intake `; * if all three are empty, no dispatch. A CLI error at the intake_complete * tier does NOT short-circuit: the idea tier is still attempted, so a @@ -509,7 +520,7 @@ export async function dispatchDowntimeWork( } dispatchInFlight = true; try { - const auditCandidate = await deps.getNextAuditCandidate(); + const auditCandidate = await deps.getNextAuditCandidate(opts.cwd); if (auditCandidate !== null) { await deps.claimItem(auditCandidate.id); await deps.spawnAgentPane(buildDowntimePrompt('audit', auditCandidate), opts); @@ -948,6 +959,16 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n * unaudited/stale (or the list is empty). Missing auditedAt/updatedAt means * not fresh → selected. * + * Dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): candidates whose id is + * present in `dispatchedItemIds` (itemIds the downtime worker has already + * dispatched for `/skill:audit`) are excluded UNLESS they carry a fresh + * audit — the exclusion composes with `isAuditFresh` above, so an item + * whose most recent audit is fresh is governed by the existing freshness + * logic (fresh → not a candidate, unchanged) and an item with a + * stale/absent audit since its dispatch is never re-selected. This closes + * the re-selection loop where a dispatched audit run reverts the item to + * completed/in_review without recording a fresh audit. + * * 7-day recency filter: a candidate must have been modified within * `DOWNTIME_AUDIT_RECENCY_WINDOW_MS` (7 days) to be dispatched. A MISSING * `updatedAt` is still included (recency cannot be verified → include, per @@ -958,10 +979,12 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n export function selectAuditCandidate( candidates: AuditCandidate[], now: number = Date.now(), + dispatchedItemIds?: ReadonlySet, ): AuditCandidate | null { const recencyCutoff = now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS; const target = candidates .filter((c) => !isAuditFresh(c.auditedAt, c.updatedAt)) + .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) .filter((c) => { if (!c.updatedAt) return true; // missing → include const updated = new Date(c.updatedAt).getTime(); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 93a01d64..a6df6ce0 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -59,7 +59,7 @@ import { buildDowntimeDispatchComment, DOWNTIME_WL_TIMEOUT_MS, } from './downtime-worker.js'; -import { appendDowntimeLogEntry } from './downtime-log.js'; +import { appendDowntimeLogEntry, auditDispatchedItemIds, readDowntimeLogEntries } from './downtime-log.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -358,7 +358,7 @@ export function createDowntimeDeps( return { ok: false }; } }, - async getNextAuditCandidate(): Promise { + async getNextAuditCandidate(cwd: string): Promise { try { // Audit tier (WL-0MSI8H3HP000K0RG): select the first completed / // in_review item WITHOUT a valid audit so the producer-review queue @@ -371,7 +371,19 @@ export function createDowntimeDeps( { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, ); const candidates = parseAuditCandidatesOutput(stdout); - const selected = candidates === null ? null : selectAuditCandidate(candidates); + if (candidates === null) return null; + // Dispatched-marker exclusion (WL-0MSLIY8ZR004QUSY): read the shared + // rolling dispatch log for THIS worklog root (the same that + // recordDispatch writes) and exclude any candidate the downtime + // worker has already dispatched for /skill:audit unless a fresh + // audit exists since (the composition lives in selectAuditCandidate). + // Fail-safe: readDowntimeLogEntries never throws — a missing or + // unreadable log is treated as empty, so audit-tier dispatch still + // works on a fresh worklog (a corrupted log cannot silently disable + // the audit tier). + const entries = await readDowntimeLogEntries(cwd); + const dispatchedAuditIds = auditDispatchedItemIds(entries); + const selected = selectAuditCandidate(candidates, Date.now(), dispatchedAuditIds); return selected === null ? null : toDowntimeCandidate(selected); } catch { // Fail-closed: a wl failure yields no candidate (no dispatch). From cb696e918b4319489c7d2211c9d29ca6a067d80c Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 15:46:41 +0100 Subject: [PATCH 08/40] WL-0MSKUG2WW0058A7W: Implementation complete --- packages/herdr/README.md | 19 ++++ .../docs/downtime-config-audit-2026-08-08.md | 103 ++++++++++++++++++ packages/herdr/src/index.ts | 7 +- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/herdr/docs/downtime-config-audit-2026-08-08.md diff --git a/packages/herdr/README.md b/packages/herdr/README.md index b06b9614..833dc042 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -318,6 +318,25 @@ persistent failure is auditable even though nothing was dispatched. The `.worklog` log file is gitignored and local-only; all writes are fail-closed, so a comment or log failure never blocks or fails a dispatch. +**Failure-path logging** — a complete account of what each dispatch outcome +leaves behind (documented for WL-0MSKUG2WW0058A7W, audit gap AC2): + +| Outcome | Trace in `.worklog/downtime-dispatches.log` | Notes | +|---|---|---| +| Successful dispatch | comment on the item + JSONL entry (`kind`, `itemId`, `dispatchedAt`) | the only fully-visible outcome | +| Genuine empty backlog (no-candidate) | **none** — intentionally silent | full cooldown pause (default 60 min); worker stops polling | +| 1–2 transient wl CLI errors (strikes) | **none** — silent | one strike per `wl-error` outcome; retries on the next idle window | +| 3rd consecutive wl CLI error | `recordError` JSONL entry | three-strike pause; the only failure path that logs | +| Audit-tier wl/parse failure | **none** — silent, and **no strike** | `getNextAuditCandidate` collapses the failure to `null` like an empty tier (index.ts:389-391); worker falls through to the plan tier and looks healthy — known silent path, follow-up WL-0MSLWJ2KP0002SV0 | +| Claim failure (`wl update --status in_progress`) | **none** — result discarded | dispatch proceeds and is still recorded as a success; the claim is the cross-pane serialization guard, so a failed claim leaves the item selectable by another pane — known silent path, follow-up WL-0MSLWJ310000ND0X | +| Pane spawn failure (`send-to-pi.sh`) | **none** — invisible | spawn is detached/`stdio: ignore`/`unref`d with no `error`/`exit` handler; a failed spawn (or a script that exits non-zero) is never observed and the dispatch is still logged as a success — known silent path, follow-up WL-0MSLWJ3I70031Z8U | +| `recordDispatch` / `recordError` write failure | **none** | fail-closed by design: logging must never crash or block the worker | + +Consequence: the log's *absence* of an entry is ambiguous — it cannot +distinguish "no candidate (paused)" from "worker disabled" or from any of +the silent failure paths above; only the three-strike and success paths leave +entries. The follow-up items above close the three code-level silent paths. + The worker runs inside the plugin's single consolidated scheduler loop (one `setInterval`; no independent timers), uses unref'd timers, and is cleaned up when the pane exits. While the pane is open the list header shows the worker diff --git a/packages/herdr/docs/downtime-config-audit-2026-08-08.md b/packages/herdr/docs/downtime-config-audit-2026-08-08.md new file mode 100644 index 00000000..442fe31a --- /dev/null +++ b/packages/herdr/docs/downtime-config-audit-2026-08-08.md @@ -0,0 +1,103 @@ +# Downtime worker configuration audit — 2026-08-08 idle window + +**Date:** 2026-08-08/09 · **Work items:** WL-0MSKUG2WW0058A7W (evaluation), parent RCA WL-0MSK9TUCA00206M7 + +## Purpose + +Document the enabled/disabled/paused/window-rule configuration state of every +dispatch-capable herdr downtime worker pane that observed the idle window of +2026-08-07 21:55Z → 2026-08-08 (RCA: zero dispatches overnight despite +continuous status polling). This answers RCA hypothesis 4 (config/disablement) +and is acceptance-criterion 1 of WL-0MSKUG2WW0058A7W: *"Config state +(enabled/disabled/paused/window rules) documented for every dispatch-capable +pane that observed the idle window."* + +## Method + +1. Settings schema + defaults read from source (`packages/herdr/src/settings.ts`, + `packages/herdr/src/downtime-worker.ts`). +2. Live settings file inspected: `~/.config/herdr/worklog-plugin.json`. +3. Per-project dispatch logs inspected: + `/.worklog/downtime-dispatches.log` (written by every successful + dispatch and by the three-strike `recordError` path). + +## 1. Settings schema — no dispatch-window-rule setting exists + +The `PluginSettings` schema (`packages/herdr/src/settings.ts:43-60`) has +exactly these downtime keys: `downtimeEnabled`, `downtimeIdleThresholdMs`, +`downtimeRequiredFreeSlots`, `downtimePollIntervalMs`, `downtimeProxyUrl`, +`downtimeModel`, `downtimeNoCandidateCooldownMs`. + +There is **no dispatch-window-rule setting** (no start/end time, no +day-of-week gate). The worker dispatches whenever the proxy reports idle +continuously for `downtimeIdleThresholdMs` — there is no calendar/window +constraint to disable. + +The only gating that exists is the pane-visibility rule, and the downtime task +is the **one** scheduler task that is deliberately **NOT** visibility-gated +(`packages/herdr/src/worklist.ts:3378-3385`): unlike refresh/sync it runs +while the worklist pane is open, hidden or not. A hidden pane still dispatches. + +## 2. Defaults in force + +`defaultSettings` (`packages/herdr/src/settings.ts:73-79`) and module constants +(`packages/herdr/src/downtime-worker.ts:80,117`): + +| Setting | Default | Meaning | +|---|---|---| +| `downtimeEnabled` | `true` | worker enabled | +| `downtimeProxyUrl` | `http://192.168.0.199:8000` | llama-proxy endpoint | +| `downtimeNoCandidateCooldownMs` | `3_600_000` (60 min) | pause after genuine empty backlog | +| `downtimeIdleThresholdMs` | `240_000` (4 min) | min continuous idle before dispatch | +| `downtimePollIntervalMs` | `30_000` | proxy poll cadence | +| `downtimeModel` | `plan` | pi model for dispatched panes | + +Missing keys fall back to these defaults (`settings.ts:149-166`). + +## 3. Live config — defaults in force + +`~/.config/herdr/worklog-plugin.json` (inspected 2026-08-09) contains **no** +`downtime*` keys — only `browseItemCount`, `showHelpText`, `autoRefresh`, +`refreshIntervalMs`, `showIcons`, `autoSync`, `syncIntervalMs`. Therefore every +pane loading this settings file runs the worker **enabled** with the defaults +above. + +## 4. Dispatch-capable panes observed in the idle window + +Five worklist panes — one per project worklog root on this host — each with an +active `.worklog/downtime-dispatches.log`: + +| Worklog root | Log entries | Error entries | Last dispatch (UTC) | +|---|---|---|---| +| `/home/rgardler/projects/ContextHub` | 29 | 0 | 2026-08-09T13:49:30Z | +| `/home/rgardler/projects/SorraAgents` | 17 | 0 | 2026-08-09T13:05:09Z | +| `/home/rgardler/projects/Tableau-Card-Engine` | 18 | 0 | 2026-08-09T13:49:30Z | +| `/home/rgardler/projects/dev-scripts` | 15 | 0 | 2026-08-09T10:22:16Z | +| `/home/rgardler/projects/open_source_llm` | 22 | 0 | 2026-08-09T00:46:25Z | + +Evidence notes: + +- **Enabled:** all panes load the shared settings file with no `downtime*` + keys → `downtimeEnabled: true` (defaults in force). +- **Not paused (no-candidate cooldown):** a paused worker stops proxy polling + entirely. The proxy log shows continuous ~16 polls/min status polling + through the idle window (per Map's comment on WL-0MSKUG2WW0058A7W, + 2026-08-08T23:56Z), so no pane was in the 60-min no-candidate pause. +- **No three-strike pause:** `recordError` writes to the same rolling log; all + five logs have **zero error entries**, so no pane ever hit three consecutive + wl CLI errors. +- **Dispatch-capable after the window:** dispatch activity resumed 2026-08-09 + (ContextHub/SorraAgents/Tableau/dev-scripts entries from 05:36Z onward), + corroborating enabled, working workers. + +## Conclusion + +Every dispatch-capable pane that observed the idle window was **enabled with +default settings**: no window rules exist to constrain dispatch, no worker was +in no-candidate cooldown (polling never paused), and no three-strike pause +ever fired (zero error entries). **RCA hypothesis 4 (config/disablement) is +refuted** for these panes. The zero-dispatch night is attributed to the +proxy-side status evaluation (stale dispatch lease / stuck global +`active_query`), captured in follow-ups WL-0MSL2ZQIF006QB4Q (prefer +`local_active_query`) and WL-0MRE6JDT3004OSTF (proactive lease release) — see +the RCA WL-0MSK9TUCA00206M7. diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index a6df6ce0..281b2728 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -391,7 +391,12 @@ export function createDowntimeDeps( } }, async claimItem(itemId: string): Promise { - // claimWorkItem never throws (failures are returned and logged). + // claimWorkItem never throws — failures are returned, but the result is + // deliberately discarded here: a failed claim must not block the + // dispatch. Note this path does NOT log the failure (unlike + // claimItemForAgentCommand), so a claim failure is silent and the + // dispatch is still recorded as a success (known silent path, follow-up + // WL-0MSLWJ310000ND0X; see README "Failure-path logging"). await claimWorkItem(itemId, assignee); }, async spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise { From f4f3c162321c144df800d37511c98b9eb45e676b Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 15:46:35 +0100 Subject: [PATCH 09/40] Restore Architecture Notes for Agents section in AGENTS.md (SA-0MSITKOXI007XD4N) The AGENTS.md deduplication dropped the ContextHub-only 'Architecture Notes for Agents' section (per operator decision Q1(a) it must be retained in ContextHub; dev-scripts and open_source_llm correctly do not carry it). Restore it under Project-specific guidance, linking to docs/ARCHITECTURE.md. --- AGENTS.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index acd97f04..23034524 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,36 @@ Read the global agent instructions at `~/.pi/agent/AGENTS.md` — they define the core principles, the Worklog (wl) work-item workflow, and the coding disciplines that apply to every project. That file is installed from this repository's `AGENTS_GLOBAL.md` by `scripts/install_pi.sh`, which symlinks it into place. -## Project-specific guidance \ No newline at end of file +## Project-specific guidance + +### Architecture Notes for Agents + +Worklog uses **SQLite as the runtime source of truth** with an **ephemeral JSONL pattern** for Git sync: + +- **SQLite** (`.worklog/worklog.db`): All runtime reads/writes happen here +- **JSONL** (`.worklog/worklog-data.jsonl`): Only exists transiently during sync operations +- **Git**: Persistent storage for collaboration + +#### Important Rules for Agents + +1. **Work with SQLite, not JSONL** + - Never manually edit JSONL files + - Use the database API for all data operations + - JSONL is only for Git transport, not for data manipulation + +2. **Migration Complete** + - The old `autoExport` feature has been removed + - No automatic JSONL exports after database writes + - TUI is now responsive regardless of data size + +3. **Sync Behavior** + - `wl sync` exports SQLite → JSONL → pushes to Git → deletes local JSONL + - JSONL only exists during the sync window (seconds) + - Working directory should not have persistent JSONL files + +4. **Legacy JSONL Files** + - If you encounter a persistent JSONL file, it may be from an older version + - Use `wl doctor migrate` to import it into SQLite + - Use `wl doctor migrate --delete` to import and remove the file + +For more information, see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). \ No newline at end of file From db4f1e933d92b65bb6d0463413a5896b3bb13014 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 18:29:31 +0100 Subject: [PATCH 10/40] WL-0MSM1ZRD8004REV2: Implementation complete --- packages/herdr/src/worklist.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 1543934f..2482aaef 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -2187,11 +2187,16 @@ export function formatCodeFreezeDialog(maxCols: number, maxRows: number, reason? /** * Fetch the items for the current view. * - * When a stage filter is active, the worklist shows EVERY open root item in - * that stage (`wl list --status open --stage --root-only`) — not - * just the `browseItemCount`-capped `wl next` subset — so stage-filtered - * views give a complete picture of the stage (WL-0MSDT8X1V003206G). Items - * with status `blocked`, `in-progress`, or `completed` are excluded; child + * When a stage filter is active, the worklist shows every root item in that + * stage matching the stage's status rule (`wl list --status --stage + * --root-only`; see STAGE_STATUS in fetcher.ts) — not just the + * `browseItemCount`-capped `wl next` subset — so stage-filtered views give + * a complete picture of the stage (WL-0MSDT8X1V003206G). Most stages show + * `open`-status items only; the in_review stage additionally includes + * `completed` and `in-progress` items, because per the project workflow + * advancing an item to in_review sets its status to `completed` (or leaves + * it `in-progress` while being re-worked after review feedback) + * (WL-0MSKCRX730052IIW). Items with status `blocked` are excluded; child * items stay hidden and remain reachable via expand exactly as in the * unfiltered view. Results follow the standard list order (sortIndex). * @@ -2918,8 +2923,9 @@ export async function runWorklistTui( showToast('Sent', { body: command.length > 60 ? command.substring(0, 57) + '...' : command }); } // Stage-filter dispatch (/wl or f-chord shortcut): refetch - // so the filtered view shows EVERY open root item in the stage, - // not just the already-loaded subset (WL-0MSDT8X1V003206G). + // so the filtered view shows every root item in the stage matching + // the stage's status rule, not just the already-loaded subset + // (WL-0MSDT8X1V003206G). if (result === 'dispatched' && isWlViewCommand(command)) { await doRefresh(true); } @@ -3039,8 +3045,9 @@ export async function runWorklistTui( showToast('Sent', { body: singleCmd.length > 60 ? singleCmd.substring(0, 57) + '...' : singleCmd }); } // Stage-filter dispatch (/wl or f-chord shortcut): refetch - // so the filtered view shows EVERY open root item in the stage, - // not just the already-loaded subset (WL-0MSDT8X1V003206G). + // so the filtered view shows every root item in the stage matching + // the stage's status rule, not just the already-loaded subset + // (WL-0MSDT8X1V003206G). if (result === 'dispatched' && isWlViewCommand(singleCmd)) { await doRefresh(true); } From be2ceef94fd9fe6f275643675e98ceb4db478eff Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 19:43:45 +0100 Subject: [PATCH 11/40] Swap model and command positions on footer Line 3 (WL-0MSHENVKR002HZT5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the command/prompt preview on the left of the footer model line and the model identifier on the right (after the │ separator), giving the command as much width as possible: - Cap the model part at 30 visible columns (truncateToTerminalWidth appends an ellipsis when longer). - Allocate width − modelWidth − separatorWidth to the command preview (removes the old fixed 38-column reservation). - Keep the final truncateToTerminalWidth(…, width) safety clamp so the line never overflows at very narrow widths. - No-prompt case unchanged (just the model identifier). Tests: updated 'model line in footer' assertions for the new ordering, added explicit measurement of the width split, a model-cap case, and a narrow-width no-overflow case. --- .../extensions/Worklog/session-health.test.ts | 80 +++++++++++++++++-- .../tui/extensions/Worklog/session-health.ts | 25 ++++-- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/packages/tui/extensions/Worklog/session-health.test.ts b/packages/tui/extensions/Worklog/session-health.test.ts index 1502f774..41237ff7 100644 --- a/packages/tui/extensions/Worklog/session-health.test.ts +++ b/packages/tui/extensions/Worklog/session-health.test.ts @@ -1119,20 +1119,86 @@ describe('session-health', () => { ); expect(lines.length).toBe(2); - // Format: " → │ " (no quotes) - expect(lines[1]).toContain('code → openai/gpt-4'); + // Format: " │ → " — command preview on + // the left, model identifier on the right (no quotes). expect(lines[1]).toContain('Fix the bug by adding validation'); + expect(lines[1]).toContain('code → openai/gpt-4'); + expect(lines[1]).toContain('Fix the bug by adding validation │ code → openai/gpt-4'); expect(lines[1]).not.toContain('"'); expect(lines[1]).toContain('[dim'); }); + it('gives the command preview the majority of the line width', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + const longPrompt = + 'Implement the authentication flow with refresh token rotation and session persistence across restarts'; + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + longPrompt, + 500, + ); + + expect(lines.length).toBe(2); + // Command budget = width(500) − model(17) − separator(5) = 478, so the + // prompt fits untruncated and the model sits after the separator at the + // end of the line. + expect(lines[1]).toContain(`${longPrompt} │ code → openai/gpt-4`); + + // Measure the two parts around the │ separator: the command part must + // be (a) the full untruncated prompt and (b) longer than the model part. + // (The mock theme wraps the label as `[dim` + label + `]`.) + const content = lines[1].replace(/^\[dim/, '').replace(/\]$/, ''); + const sepIndex = content.indexOf('│'); + const commandPart = content.slice(0, sepIndex).trim(); + const modelPart = content.slice(sepIndex + 1).trim(); + expect(commandPart).toBe(longPrompt); + expect(modelPart).toBe('code → openai/gpt-4'); + expect(commandPart.length).toBeGreaterThan(modelPart.length); + }); + + it('caps the model identifier width so the command preview gets the space', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + 'Fix the bug', + 500, + ); + + // modelPart = "code → openai/gpt-4-abcdefghijklmnopqrstuvwxyz" (46 cols) + // → capped to 30 visible columns; the rest of the line goes to the prompt. + expect(lines.length).toBe(2); + expect(lines[1]).toContain('Fix the bug │ code → openai/gpt-4-abcdefghij'); + expect(lines[1]).not.toContain('qrstuvwxyz'); + }); + + it('never overflows the line at very narrow widths', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + // Width 20 < model cap (30) + separator (5): the command budget is 0, + // the label overflows, and the final clamp keeps the line at 20 cols. + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + 'Fix the bug by adding validation', + 20, + ); + + expect(lines.length).toBe(2); + expect(lines[1].length).toBeLessThanOrEqual(20); + }); + it('truncates long initial prompt preview', () => { mocks.mockGetResolvedModel.mockReturnValue(null); mocks.mockGetSelectedModel.mockReturnValue('code'); const longPrompt = 'Write a comprehensive test suite for the authentication module including all edge cases like token expiry and refresh'; // Use a narrow terminal width (120) to trigger truncation - // With width=120, reserved=38, available=max(15, 120-38)=82, prompt length=151 > 82 + // With width=120, model="code → (resolving)" (19 cols), separator=5, + // available=max(15, 120-19-5)=96, prompt length=151 > 96 const lines = fabricateFooterLines( { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, longPrompt, @@ -1140,9 +1206,11 @@ describe('session-health', () => { ); expect(lines.length).toBe(2); - // Should show alias + truncated prompt with ... - expect(lines[1]).toContain('code'); - expect(lines[1]).toContain('...'); + // Truncated prompt with ... followed by the model identifier after the + // separator; the final clamp keeps the line at most `width` columns + // (may clip the model tail at very narrow widths — documented edge case). + expect(lines[1]).toContain('... │ code →'); + expect(lines[1].length).toBeLessThanOrEqual(120); expect(lines[1]).toContain('[dim'); }); diff --git a/packages/tui/extensions/Worklog/session-health.ts b/packages/tui/extensions/Worklog/session-health.ts index 133416b1..68bb178b 100644 --- a/packages/tui/extensions/Worklog/session-health.ts +++ b/packages/tui/extensions/Worklog/session-health.ts @@ -621,6 +621,16 @@ export function registerSessionHealth(pi: ExtensionAPI): void { modelPart = '—'; } + // Cap the model identifier at a fixed maximum so the command + // preview gets as much of the line as possible. truncateToTerminalWidth + // appends an ellipsis when the model part exceeds the cap. + const modelPartMaxWidth = 30; + const cappedModelPart = truncateToTerminalWidth(modelPart, modelPartMaxWidth); + + // Separator between the command preview (left) and the model + // identifier (right). + const separator = ' │ '; + // Build initial prompt portion — unquoted preview with generous // space allocation. The line is ultimately truncated to `width` by // truncateToTerminalWidth, so we use a dynamic limit. @@ -629,18 +639,21 @@ export function registerSessionHealth(pi: ExtensionAPI): void { // Truncate work item IDs to compact form (e.g., WL-0MQL0T5TR0060AEH // → WL...68HD) before applying length truncation. const compacted = truncateWorkItemId(initialPrompt); - // Reserve space for model part (up to ~30 chars), separator (4), - // and a small buffer. Then clip at the end. - const reserved = 38; - const maxLen = Math.max(15, width - reserved); + // Reserve only what the (capped) model part and separator need; + // everything else goes to the command preview. The final + // truncateToTerminalWidth(…, width) clamp below still guards + // against overflow at very narrow widths. + const separatorWidth = visibleWidth(separator); + const modelWidth = visibleWidth(cappedModelPart); + const maxLen = Math.max(0, width - modelWidth - separatorWidth); const preview = - compacted.length > maxLen + compacted.length > maxLen && maxLen > 3 ? `${compacted.slice(0, maxLen - 3)}...` : compacted; promptPart = preview; } - const label = promptPart ? `${modelPart} │ ${promptPart}` : modelPart; + const label = promptPart ? `${promptPart}${separator}${cappedModelPart}` : modelPart; lines.push(truncateToTerminalWidth(theme.fg('dim', label), width)); return lines; From 4eec42cc4bafa65acc053c3017d1ac173b47d3e5 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 20:16:34 +0100 Subject: [PATCH 12/40] WL-0MSKIL6P00045UOC: Implementation complete --- packages/herdr/README.md | 2 +- packages/herdr/src/index.test.ts | 23 ++++++++++++++++++++++- packages/herdr/src/shortcuts.json | 11 +++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 833dc042..c76a0a59 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -592,7 +592,7 @@ packages/herdr/ - **Pi agent dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are intercepted by the entry point and routed to a new pi agent pane. The `send-to-pi.sh` script splits the current pane to the right, creates a new pane, runs `pi` with the command as the initial prompt, and renames the pane to "Pi Agent". Agent commands are routed before any prefix handling, so they are unaffected by `!!`/`!` processing. - **Podcast Editing tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-podcast-editor-tab` action wraps the same pane-open command and renames the created tab to "Podcast Editing" via `herdr tab rename` (socket API, not session-state editing), so podcast production is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. - **Model selection per shortcut** — Each LLM-bound shortcut entry in `shortcuts.json` may carry an optional `model` field (a pi model pattern such as `plan`, `code`, or `author`). When the command is dispatched to the agent channel, `--model ` is forwarded to the spawned `pi` CLI (e.g. `pi --model code '/skill:implement '`), so every workflow runs on an appropriately specialised model without manual model switching. Agent-bound entries without a `model` field default to `plan`; shell (`!!`) and `/wl` filter entries never carry a model and never receive a `--model` flag. The default mapping in `src/shortcuts.json`: `/plan`, `/intake`, `/skill:audit`, `/prompt:` → `plan`; `/skill:implement` → `code`. -- **Free-form prompts via `/prompt:`** — Commands starting with `/prompt:` are also routed to the agent pane, but the `/prompt:` routing prefix is stripped before `send-to-pi.sh` runs, so pi receives only the bare prompt text (e.g. `pi "What are the audit gaps reported in the most recent audit for WL-123"`). This lets a chord shortcut open a new pi instance with an arbitrary injected prompt, not just a skill/workflow invocation. The `P-p` chord opens the command input form so you can type any free-form prompt, and `P-a` opens pi with `What are the audit gaps reported in the most recent audit for ` (the selected item's ID is substituted automatically). Edit `src/shortcuts.json` to bind your own prompt text to any free chord. +- **Free-form prompts via `/prompt:`** — Commands starting with `/prompt:` are also routed to the agent pane, but the `/prompt:` routing prefix is stripped before `send-to-pi.sh` runs, so pi receives only the bare prompt text (e.g. `pi "What are the audit gaps reported in the most recent audit for WL-123"`). This lets a chord shortcut open a new pi instance with an arbitrary injected prompt, not just a skill/workflow invocation. The `P-p` chord opens the command input form so you can type any free-form prompt, `P-a` opens pi with `What are the audit gaps reported in the most recent audit for ` (the selected item's ID is substituted automatically), and `P-n` opens a brand-new blank session (`/prompt:` with an empty prompt — no form dialog, no injected text, and no work-item association). Edit `src/shortcuts.json` to bind your own prompt text to any free chord. - **Correct project directory for new panes** — Panes created by `send-to-pi.sh`, `open-pi-agent.sh`, and `run-in-pane.sh` are started in the correct project root. Herdr's `follow` CWD policy would otherwise inherit the source pane's CWD (the plugin directory), so each script resolves a target CWD (`--cwd` arg > `HERDR_RESOLVED_CWD` > `$PWD`) and applies it in both launch modes: `--no-resize` passes it to `herdr pane split --cwd`, and the default resize mode forwards it to `grid.py --cwd` which includes it in the `pane.split` RPC params. The entry point passes the resolved worklog root (`wlRoot`) so skills, `wl` commands, and relative paths operate on the user's project rather than the plugin's installation directory. - **`` placeholder resolution** — Before output, any `` placeholders in the resolved command are replaced with the currently selected work item's ID. If no item is selected and the command requires ``, the command is silently dropped (graceful no-op). - **Parameter input form** — Chord commands containing unknown `` placeholders open a modal input form (`form-dialog.ts`) before dispatch. The dialog renders at 80% of the pane width (40-column minimum, centered), wraps the description and field values at its inner content width, and expands downward as content wraps — bounded by the terminal height. Every content line is padded to exactly the border width so the box borders stay aligned at any pane width (see WL-0MSAKRBOC005T320). diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 51d183ea..cb978746 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -59,6 +59,16 @@ describe('buildSendToPiArgs', () => { ]); }); + it('passes an empty prompt arg for a bare /prompt: command (blank session)', () => { + expect(buildSendToPiArgs('/prompt:', '/project', 'plan')).toEqual([ + '--cwd', + '/project', + '--model', + 'plan', + '', + ]); + }); + it('passes /plan with the plan model', () => { expect(buildSendToPiArgs('/plan ', '/project', 'plan')).toEqual([ '--cwd', @@ -249,7 +259,7 @@ describe('shortcuts.json command routing', () => { } }); - it('binds P-p to the free-form prompt and P-a to the audit-gaps prompt', () => { + it('binds P-p to the free-form prompt, P-a to the audit-gaps prompt, and P-n to a blank session', () => { const freePrompt = entries.find((e) => e.chord.join(' ') === 'P p'); expect(freePrompt).toBeDefined(); expect(freePrompt!.command).toBe('/prompt:'); @@ -265,6 +275,17 @@ describe('shortcuts.json command routing', () => { expect(auditPrompt!.view).toBe('both'); expect(auditPrompt!.model).toBe('plan'); expect(routeCommand(auditPrompt!.command)).toBe('agent'); + + const blankSession = entries.find((e) => e.chord.join(' ') === 'P n'); + expect(blankSession).toBeDefined(); + expect(blankSession!.command).toBe('/prompt:'); + expect(blankSession!.view).toBe('both'); + expect(blankSession!.model).toBe('plan'); + // Empty /prompt: carries no placeholders: no command-input form, no + // work-item claim (AC2/AC3), and it routes to the agent channel. + expect(blankSession!.command).not.toContain(''); + expect(blankSession!.command).not.toContain(''); + expect(routeCommand(blankSession!.command)).toBe('agent'); }); it('keeps the single-key p chord bound to plan (P leader does not shadow it)', () => { diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index bc726d00..f6ac6818 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -218,6 +218,17 @@ "description": "Open a new Pi agent pane asking for the audit gaps from the selected item's most recent audit.", "model": "plan" }, + { + "chord": [ + "P", + "n" + ], + "command": "/prompt:", + "view": "both", + "label": "new session", + "description": "Open a new Pi agent session with a blank prompt.", + "model": "plan" + }, { "chord": [ "f", From 5f880ee3978d362e916c95f929ebb031592b9440 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 22:11:20 +0100 Subject: [PATCH 13/40] WL-0MSM8PLBJ000E3KM: Implementation complete --- packages/herdr/src/worklist.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 2482aaef..a2214f17 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -2730,9 +2730,12 @@ export async function runWorklistTui( * True when the resolved command is a `/wl` view command — a stage filter * (`/wl `, shorthand alias or canonical name) or the clear-filter * `/wl` with no arguments. Used after dispatch to trigger a view refetch: - * filtered views show every open root item in the stage - * (WL-0MSDT8X1V003206G); clearing the filter restores the default view - * (WL-0MSGSE15000746F7). + * filtered views show every root item in that stage matching the stage's + * status rule (`wl list --status --stage --root-only`; + * see STAGE_STATUS in fetcher.ts) — most stages show `open`-status items + * only, while the in_review stage additionally includes `completed` and + * `in-progress` items (WL-0MSKCRX730052IIW); clearing the filter restores + * the default view (WL-0MSGSE15000746F7). */ const isWlViewCommand = (cmd: string): boolean => { if (/^\/wl\s*$/.test(cmd)) return true; From a097115870be0b9bad02d7647ac7aa6c338272d2 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 22:21:18 +0100 Subject: [PATCH 14/40] Demote completed/in_review parent to plan_complete when a child is added (WL-0MSJL00P5004Y0L6) A parent cannot be completed (status) or in_review (stage) while it has uncompleted children. Adding a child via 'wl create --parent ' or reparenting via 'wl update --parent ' now automatically demotes the target parent to open/plan_complete, so the lifecycle invariant is preserved at the source instead of being recovered later by 'wl close'. - packages/shared: add demoteParentOnChildAdded() to WorklogDatabase, mirroring the cascadePriorityDowngrade pattern; add DemotedParent type - src/commands/create.ts: demote parent after creating a child with --parent; report in JSON (demotedParent) and a human summary line - src/commands/update.ts: demote the new parent when reparenting via --parent; report in JSON and human output - tests: database-level coverage (completed/in_review, completed/done, in_review stage, non-eligible no-op, missing parent, ancestor untouched) and CLI-level coverage for create/update JSON + human reporting Full suite: 224 files / 4179 tests passing. --- packages/shared/src/database.ts | 38 +++- packages/shared/src/types.ts | 17 ++ src/commands/create.ts | 28 ++- src/commands/update.ts | 22 ++- src/types.ts | 1 + tests/cli/create-update-demote-parent.test.ts | 176 ++++++++++++++++++ tests/database.test.ts | 72 +++++++ 7 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 tests/cli/create-update-demote-parent.test.ts diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index a12a51db..5fee60fd 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult } from './types.js'; +import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult, DemotedParent } from './types.js'; import { SqlitePersistentStore, FtsSearchResult, PersistentStoreServices, PersistentStoreCacheOptions } from './persistent-store.js'; import { normalizeStatusValue } from './status-stage-rules.js'; @@ -1244,6 +1244,42 @@ export class WorklogDatabase { return downgraded; } + /** + * Demote a parent work item when a child is added to it. + * + * A parent cannot be `completed` (status) or `in_review` (stage) while its + * subtree is not finished. When a new child is attached to such a parent + * (via `wl create --parent` or `wl update --parent`), the parent is moved + * back to `open` / `plan_complete` so its lifecycle state always reflects + * that it has uncompleted children. + * + * Only the direct parent is demoted; ancestors are left untouched. + * + * @param parentId - id of the parent that received the new child + * @returns the demotion details (parent, from status/stage, to status/stage) + * or `null` when the parent is not in an eligible state (or does not exist) + */ + demoteParentOnChildAdded(parentId: string): DemotedParent | null { + const parent = this.get(parentId); + if (!parent) { + return null; + } + const eligible = parent.status === 'completed' || parent.stage === 'in_review'; + if (!eligible) { + return null; + } + const from = { status: parent.status, stage: parent.stage }; + const updated = this.update(parentId, { status: 'open', stage: 'plan_complete' }); + if (!updated) { + return null; + } + return { + parent: updated, + from, + to: { status: updated.status, stage: updated.stage }, + }; + } + /** * Get the number of direct children for each work item. * Returns a Map. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0468f27f..3f024880 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -295,3 +295,20 @@ export interface ShowJsonOutput { // Optional error message used when success is false error?: string; } + +/** + * Result of demoting a parent work item after a child was added to it. + * + * A parent cannot stay `completed` (status) or `in_review` (stage) while it + * has uncompleted children, so adding a child to such a parent reopens it. + * This shape captures the lifecycle transition so callers can report it + * (e.g. `completed`/`in_review` → `open`/`plan_complete`). + */ +export interface DemotedParent { + /** The parent work item after the demotion */ + parent: WorkItem; + /** The parent's status/stage before the demotion */ + from: { status: string; stage: string }; + /** The parent's status/stage after the demotion */ + to: { status: string; stage: string }; +} diff --git a/src/commands/create.ts b/src/commands/create.ts index 8e9e834c..a0d12fff 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { CreateOptions } from '../cli-types.js'; -import type { WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel } from '../types.js'; +import type { WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent } from '../types.js'; import { humanFormatWorkItem, resolveFormat } from './helpers.js'; import { canValidateStatusStage, validateStatusStageCompatibility, validateStatusStageInput } from './status-stage-validation.js'; import { promises as fs } from 'fs'; @@ -151,12 +151,14 @@ export default function register(ctx: PluginContext): void { }; } + const parentId = utils.normalizeCliId(options.parent, options.prefix) || null; + const item = db.createWithNextSortIndex({ title: options.title, description: description, status: normalizedStatus as WorkItemStatus, priority: (options.priority || 'medium') as WorkItemPriority, - parentId: utils.normalizeCliId(options.parent, options.prefix) || null, + parentId, tags: options.tags ? options.tags.split(',').map((t: string) => t.trim()) : [], assignee: options.assignee || '', stage: normalizedStage, @@ -177,6 +179,19 @@ export default function register(ctx: PluginContext): void { db.saveAuditResult(auditResultData); } + // A parent cannot stay `completed`/`in_review` while it gains a new, + // uncompleted child: demote it to `open`/`plan_complete` so its + // lifecycle state reflects that its subtree is not finished. + let demotedParent: DemotedParent | null = null; + if (parentId) { + try { + demotedParent = db.demoteParentOnChildAdded(parentId); + } catch (err) { + // Best-effort: a demotion failure must not abort the create. + console.error(`Warning: failed to demote parent ${parentId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + const refreshed = db.get(item.id) || item; // Include audit data in JSON output when audit was provided @@ -186,10 +201,17 @@ export default function register(ctx: PluginContext): void { } if (utils.isJsonMode()) { - output.json({ success: true, workItem: refreshed }); + output.json({ + success: true, + workItem: refreshed, + ...(demotedParent ? { demotedParent } : {}), + }); } else { const format = resolveFormat(program); console.log(humanFormatWorkItem(refreshed, db, format)); + if (demotedParent) { + console.log(`[Parent ${demotedParent.parent.id} demoted from ${demotedParent.from.status}/${demotedParent.from.stage} to ${demotedParent.to.status}/${demotedParent.to.stage}]`); + } } // Trigger re-sort after create only when the create modified one of the // impactful fields (status, priority, risk, effort, stage). Honor caller diff --git a/src/commands/update.ts b/src/commands/update.ts index e732fc7e..eacf8a53 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { UpdateOptions } from '../cli-types.js'; -import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel } from '../types.js'; +import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent } from '../types.js'; import { promises as fs } from 'fs'; import { humanFormatWorkItem, resolveFormat, extractFilePaths } from './helpers.js'; import { canValidateStatusStage, validateStatusStageCompatibility, validateStatusStageInput } from './status-stage-validation.js'; @@ -403,6 +403,19 @@ export default function register(ctx: PluginContext): void { downgradedChildren = db.cascadePriorityDowngrade(normalizedId, priorityCandidate as WorkItemPriority); } + // Reparenting: a parent cannot stay `completed`/`in_review` while a + // new, uncompleted child is attached to it. Demote the target parent + // to `open`/`plan_complete` so its lifecycle state stays consistent. + let demotedParent: DemotedParent | null = null; + if (updates.parentId) { + try { + demotedParent = db.demoteParentOnChildAdded(updates.parentId); + } catch (err) { + // Best-effort: a demotion failure must not abort the update. + console.error(`Warning: failed to demote parent ${updates.parentId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + // Mark that an impactful change was made if any qualifying fields were // included in this per-id update. if (updates.status || updates.priority || updates.risk || updates.effort || updates.stage) { @@ -432,6 +445,7 @@ export default function register(ctx: PluginContext): void { success: true, workItem: item, ...(downgradedChildren.length > 0 ? { downgradedChildren } : {}), + ...(demotedParent ? { demotedParent } : {}), }); } @@ -448,6 +462,9 @@ export default function register(ctx: PluginContext): void { if (r.downgradedChildren?.length) { jsonOut.downgradedChildren = r.downgradedChildren; } + if (r.demotedParent) { + jsonOut.demotedParent = r.demotedParent; + } output.json(jsonOut); } else { @@ -466,6 +483,9 @@ export default function register(ctx: PluginContext): void { if (r.downgradedChildren?.length) { console.log(`[Downgraded ${r.downgradedChildren.length} child(ren) from critical to high]`); } + if (r.demotedParent) { + console.log(`[Parent ${r.demotedParent.parent.id} demoted from ${r.demotedParent.from.status}/${r.demotedParent.from.stage} to ${r.demotedParent.to.status}/${r.demotedParent.to.stage}]`); + } } else { output.error(r.error, { success: false, error: r.error }); } diff --git a/src/types.ts b/src/types.ts index 9830d420..4ed7dd5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,4 +24,5 @@ export { type ConflictDetail, type NextWorkItemResult, type ShowJsonOutput, + type DemotedParent, } from '@worklog/shared/types'; diff --git a/tests/cli/create-update-demote-parent.test.ts b/tests/cli/create-update-demote-parent.test.ts new file mode 100644 index 00000000..d965dc86 --- /dev/null +++ b/tests/cli/create-update-demote-parent.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for parent demotion when a child is added to a completed/in_review parent. + * + * `wl create --parent ` and `wl update --parent ` demote the + * target parent from `completed`/`in_review` to `open`/`plan_complete`, so a + * completed parent never silently gains uncompleted children. The demotion is + * reported in JSON output (`demotedParent`) and as a human summary line. + * + * Work item: WL-0MSJL00P5004Y0L6 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; + +describe('parent demotion on child add', () => { + let tempState: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + tempState = enterTempDir(); + writeConfig(tempState.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempState.tempDir); + }); + + afterEach(() => { + leaveTempDir(tempState); + }); + + async function createItem(flags = ''): Promise { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Item" ${flags}` + ); + return JSON.parse(stdout).workItem.id; + } + + async function showItem(id: string): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${id}`); + return JSON.parse(stdout).workItem; + } + + // ======================================================================= + // wl create --parent + // ======================================================================= + describe('wl create --parent', () => { + it('should demote a completed/in_review parent and report it in JSON output', async () => { + const parentId = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.parent.id).toBe(parentId); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + // Parent demoted in the DB + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + }); + + it('should demote a completed/done parent', async () => { + const parentId = await createItem('--status completed --stage done'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'done' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + }); + + it('should not demote a parent that is not completed/in_review', async () => { + const parentId = await createItem('--status open --stage plan_complete'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Child" --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeUndefined(); + + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + }); + + it('should print a human-readable summary line on demotion', async () => { + const parentId = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} create -t "Child" --parent ${parentId}` + ); + + expect(stdout).toContain( + `[Parent ${parentId} demoted from completed/in_review to open/plan_complete]` + ); + }); + + it('should not print a summary line when no demotion occurs', async () => { + const parentId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} create -t "Child" --parent ${parentId}` + ); + + expect(stdout).not.toContain('demoted from'); + }); + }); + + // ======================================================================= + // wl update --parent (reparenting) + // ======================================================================= + describe('wl update --parent (reparenting)', () => { + it('should demote the target parent when reparenting under a completed parent', async () => { + const parentId = await createItem('--status completed --stage in_review'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${childId} --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeDefined(); + expect(result.demotedParent.parent.id).toBe(parentId); + expect(result.demotedParent.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.demotedParent.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + // Parent demoted and child attached in the DB + const parent = await showItem(parentId); + expect(parent.status).toBe('open'); + expect(parent.stage).toBe('plan_complete'); + const child = await showItem(childId); + expect(child.parentId).toBe(parentId); + }); + + it('should not demote when reparenting under an open parent', async () => { + const parentId = await createItem('--status open --stage idea'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${childId} --parent ${parentId}` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.demotedParent).toBeUndefined(); + }); + + it('should print a human-readable summary line on reparenting demotion', async () => { + const parentId = await createItem('--status completed --stage done'); + const childId = await createItem('--status open --stage idea'); + + const { stdout } = await execAsync( + `tsx ${cliPath} update ${childId} --parent ${parentId}` + ); + + expect(stdout).toContain( + `[Parent ${parentId} demoted from completed/done to open/plan_complete]` + ); + }); + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index 74e65e93..21244193 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -530,6 +530,78 @@ describe('WorklogDatabase', () => { }); }); + describe('demoteParentOnChildAdded', () => { + it('should demote a completed/in_review parent to open/plan_complete', () => { + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'in_review' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.parent.id).toBe(parent.id); + expect(demoted!.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + // Persisted in the DB + const refreshed = db.get(parent.id); + expect(refreshed?.status).toBe('open'); + expect(refreshed?.stage).toBe('plan_complete'); + }); + + it('should demote a completed/done parent to open/plan_complete', () => { + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'done' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.from).toEqual({ status: 'completed', stage: 'done' }); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + expect(db.get(parent.id)?.status).toBe('open'); + expect(db.get(parent.id)?.stage).toBe('plan_complete'); + }); + + it('should demote a parent in the in_review stage regardless of status', () => { + // in_review normally pairs with completed, but the rule keys on the + // stage axis too, so a parent stuck at stage in_review is demoted. + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'in_review' }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted).not.toBeNull(); + expect(demoted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + }); + + it('should be a no-op for parents that are not completed/in_review', () => { + const openParent = db.create({ title: 'Open parent', status: 'open', stage: 'plan_complete' }); + const inProgressParent = db.create({ title: 'In-progress parent', status: 'in-progress', stage: 'in_progress' }); + const ideaParent = db.create({ title: 'Idea parent', status: 'open', stage: 'idea' }); + + expect(db.demoteParentOnChildAdded(openParent.id)).toBeNull(); + expect(db.demoteParentOnChildAdded(inProgressParent.id)).toBeNull(); + expect(db.demoteParentOnChildAdded(ideaParent.id)).toBeNull(); + // Untouched + expect(db.get(openParent.id)?.status).toBe('open'); + expect(db.get(openParent.id)?.stage).toBe('plan_complete'); + expect(db.get(inProgressParent.id)?.status).toBe('in-progress'); + expect(db.get(ideaParent.id)?.stage).toBe('idea'); + }); + + it('should return null for a missing parent', () => { + expect(db.demoteParentOnChildAdded('TEST-NONEXISTENT')).toBeNull(); + }); + + it('should only demote the direct parent, leaving ancestors untouched', () => { + const grandparent = db.create({ title: 'Grandparent', status: 'completed', stage: 'done' }); + const parent = db.create({ title: 'Parent', status: 'completed', stage: 'done', parentId: grandparent.id }); + + const demoted = db.demoteParentOnChildAdded(parent.id); + + expect(demoted?.parent.id).toBe(parent.id); + expect(db.get(parent.id)?.status).toBe('open'); + // Ancestor propagation is out of scope — grandparent stays completed + expect(db.get(grandparent.id)?.status).toBe('completed'); + expect(db.get(grandparent.id)?.stage).toBe('done'); + }); + }); + describe('comments', () => { let workItemId: string; From c7c457ba1a371d13ec2ebb210ac517f655c25d13 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 23:22:50 +0100 Subject: [PATCH 15/40] =?UTF-8?q?WL-0MSJNJPRM009RM35:=20tab-focus-gated=20?= =?UTF-8?q?worklist=20visibility=20=E2=80=94=20tests=20(WL-0MSKUANPC000OKR?= =?UTF-8?q?2)=20+=20impl=20(WL-0MSKUAT5D00443GA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/pause-when-hidden.md | 39 ++--- packages/herdr/README.md | 39 +++-- packages/herdr/src/index.ts | 2 + packages/herdr/src/visibility.test.ts | 94 ++++++++---- packages/herdr/src/visibility.ts | 48 +++--- .../herdr/src/worklist-visibility.test.ts | 137 +++++++++--------- packages/herdr/src/worklist.ts | 13 +- 7 files changed, 220 insertions(+), 152 deletions(-) diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index 84db5e27..7c90719d 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -21,7 +21,7 @@ Two pollers were fixed: | Poller | File | Cadence | Fix | |--------|------|---------|-----| | Pi extension browse selection widget | `packages/tui/extensions/Worklog/lib/browse.ts` | 5s interval (4–5 wl spawns/tick) | **Idle gating**: pause fetch + `wl sync --if-idle` after 30s without keypresses (`IDLE_PAUSE_MS`); resume immediately on the next keypress. | -| Herdr worklist pane | `packages/herdr/src/worklist.ts` | 30s refresh + 60s sync timers (~4–5 wl spawns/tick per pane) | **Pane-focus gating**: skip timer ticks when the pane is hidden (`HERDR_PANE_ID` → `herdr pane get` → `result.pane.focused === false`); fail-open otherwise. | +| Herdr worklist pane | `packages/herdr/src/worklist.ts` | 30s refresh + 60s sync timers (~4–5 wl spawns/tick per pane) | **Tab-focus gating**: skip timer ticks when the pane's tab is hidden (`HERDR_TAB_ID` → `herdr tab get` → `result.tab.focused === false`); fail-open otherwise. | ## Behavior summary @@ -38,20 +38,25 @@ Two pollers were fixed: - Manual actions (navigation, shortcuts, `r`/`S`-style refreshes) are never gated; the detail-view widget (no interval) is unaffected. -### Herdr worklist pane (pane-focus gating) - -- Visibility signal: `HERDR_PANE_ID` → `herdr pane get ` → - `result.pane.focused`. A hidden (non-focused) tab reports `focused: false`. -- When hidden, the auto-refresh and auto-sync timer ticks are skipped → - **zero fetcher / `wl sync --if-idle` spawns**. -- Fail-open: no `HERDR_PANE_ID`, herdr CLI missing/erroring, or unparseable +### Herdr worklist pane (tab-focus gating) + +- Visibility signal: `HERDR_TAB_ID` → `herdr tab get ` → + `result.tab.focused`. A hidden (non-focused) tab reports `focused: false`. + Tab focus is the visibility signal — a pane is visible when its TAB is + focused, regardless of which pane in the tab holds keyboard focus (multi- + pane split fix, WL-0MSJNJPRM009RM35). There is no pane-focus fallback; a + pane zoomed-over within a focused tab is treated as visible (documented + limitation). +- When the tab is hidden, the auto-refresh and auto-sync timer ticks are + skipped → **zero fetcher / `wl sync --if-idle` spawns**. +- Fail-open: no `HERDR_TAB_ID`, herdr CLI missing/erroring, or unparseable output → the pane is treated as visible and polling continues as before. Standalone runs (outside Herdr) are unaffected. -- Visible-pane cadence unchanged (30s refresh / 60s sync defaults). +- Visible-tab cadence unchanged (30s refresh / 60s sync defaults). - The list header shows `[paused — hidden]` while gating is active. - Manual `S` (sync), navigation, chords, and the initial data load are never gated. -- `PollGate` (TTL ~2s) shares one `herdr pane get` call across refresh+sync +- `PollGate` (TTL ~2s) shares one `herdr tab get` call across refresh+sync ticks in a cycle (≤1 visibility exec per cycle). ## Verification procedure @@ -69,7 +74,7 @@ ps -eo args | grep -E 'wl (next|list|sync)' | grep -v grep | wc -l Expected while **active/visible**: - Pi browse widget actively browsed: ~4–5 `wl` spawns per 5s tick (transient). -- Herdr worklist pane focused: ~4–5 `wl` spawns per 30s tick (transient). +- Herdr worklist pane tab focused: ~4–5 `wl` spawns per 30s tick (transient). Expected while **hidden/idle**: @@ -91,14 +96,14 @@ Expected while **hidden/idle**: ### 3. Per-pane measurement (herdr worklist) -1. Open the worklist pane (`prefix+l` or the plugin action). Note its pane id: - `echo $HERDR_PANE_ID` inside the pane, or `herdr pane current`. -2. Focus the pane and confirm auto-refresh spawns: +1. Open the worklist pane (`prefix+l` or the plugin action). Note its tab id: + `echo $HERDR_TAB_ID` inside the pane, or `herdr tab current`. +2. Focus the tab and confirm auto-refresh spawns: `ps -eo args | grep -E 'wl (next|list|sync)' | wc -l` shows transient spawns every 30s, and the header shows no `[paused — hidden]` marker. -3. Switch to another tab (hiding the worklist pane). Within ~2s the header - shows `[paused — hidden]` and the `wl` process count from that pane drops - to zero. +3. Switch to another tab (hiding the worklist pane's tab). Within ~2s the + header shows `[paused — hidden]` and the `wl` process count from that pane + drops to zero. 4. Switch back — the pane resumes its normal cadence. ### 4. System-level idle test diff --git a/packages/herdr/README.md b/packages/herdr/README.md index c76a0a59..9771e737 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -351,29 +351,38 @@ auto-sync timers pause so hidden panes stop spawning `wl` processes (~4–5 per 30s tick per pane). With many open panes this previously caused heavy `wl` process churn and memory pressure (WL-0MSB1N0HB0007N6N). -- **Visibility signal** — Herdr sets `HERDR_PANE_ID` for panes it spawns; a - hidden (non-focused) tab reports `result.pane.focused === false` from - `herdr pane get `. The plugin checks this via `visibility.ts`. -- **Fail-open** — when visibility cannot be determined (no `HERDR_PANE_ID` +- **Visibility signal** — Herdr sets `HERDR_TAB_ID` for panes it spawns; a + hidden (non-focused) tab reports `result.tab.focused === false` from + `herdr tab get `. The plugin checks this via `visibility.ts`. Tab + focus is the visibility signal: a pane is visible when its TAB is focused, + regardless of which pane in the tab holds keyboard focus (so a visible + worklist pane in a multi-pane split keeps refreshing while an adjacent + pane holds focus). +- **Fail-open** — when visibility cannot be determined (no `HERDR_TAB_ID` env, herdr CLI missing/erroring, unparseable output) the pane is treated as visible and polling proceeds exactly as before. Standalone runs (outside Herdr) are unaffected. -- **Cadence unchanged when visible** — while the pane is focused (or +- **Cadence unchanged when visible** — while the pane's tab is focused (or fail-open), auto-refresh/auto-sync keep their existing intervals (30s / 60s defaults). -- **Header indicator** — while the pane is hidden the list header shows - `[paused — hidden]` so operators can tell gating is active; the indicator - clears as soon as the list refreshes after the pane becomes visible. -- **Immediate refresh on resume** — while the pane is hidden a lightweight - resume poll (2s interval, `herdr pane get` only — never `wl`) watches for - the hidden → visible transition; the moment the tab regains focus the list - re-fetches immediately (with a "Refreshed" notification) instead of waiting - for the next 30s tick, then the normal cadence resumes. +- **Header indicator** — while the pane's tab is hidden the list header + shows `[paused — hidden]` so operators can tell gating is active; the + indicator clears as soon as the list refreshes after the tab regains + focus. +- **Immediate refresh on resume** — while the pane's tab is hidden a + lightweight resume poll (2s interval, `herdr tab get` only — never `wl`) + watches for the hidden → visible transition; the moment the tab regains + focus the list re-fetches immediately (with a "Refreshed" notification) + instead of waiting for the next 30s tick, then the normal cadence resumes. - **Never gated** — manual actions (navigation, `S` manual sync, shortcut - chords, the initial data load) work regardless of pane visibility. + chords, the initial data load) work regardless of tab visibility. - **Shared visibility check** — the `PollGate` TTL memoizer (~2s) makes the - refresh and sync ticks in one cycle share a single `herdr pane get` call + refresh and sync ticks in one cycle share a single `herdr tab get` call (≤1 visibility exec per cycle). +- **Zoom-over limitation** — a pane zoomed-over within a focused tab is + treated as visible (tab focus is the sole signal; there is no pane-focus + fallback) and keeps refreshing. Zoom-over detection is out of scope for + the visibility gate (approved plan, WL-0MSJNJPRM009RM35). - **No settings toggle** — pause-when-hidden is always on. ### Selection List Behaviour diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 281b2728..f35b15bb 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -11,6 +11,8 @@ * * Environment: * HERDR_PANE_ID - Set by Herdr when running in a pane (optional) + * HERDR_TAB_ID - Set by Herdr when running in a pane; the tab-focus + * visibility signal for pause-when-hidden (optional) * WL_COUNT - Number of items to fetch (default: 20, now superseded by browseItemCount setting) * * Exit codes: diff --git a/packages/herdr/src/visibility.test.ts b/packages/herdr/src/visibility.test.ts index 4b57e4b5..85d0806b 100644 --- a/packages/herdr/src/visibility.test.ts +++ b/packages/herdr/src/visibility.test.ts @@ -2,6 +2,11 @@ * Unit tests for packages/herdr/src/visibility.ts — pane visibility * detection (isPaneVisible + PollGate). * + * Visibility signal: the pane is visible when its TAB is focused + * (`HERDR_TAB_ID` → `herdr tab get ` → `result.tab.focused`), + * regardless of which pane in the tab holds keyboard focus (multi-pane + * split bug, WL-0MSJNJPRM009RM35). No pane-focus fallback. + * * Run: npx vitest run packages/herdr/src/visibility.test.ts */ @@ -13,13 +18,13 @@ import { import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; /** - * Build a herdr-style pane-get envelope. Hidden (non-focused) tabs report - * `result.pane.focused === false`. + * Build a herdr-style tab-get envelope. A hidden (non-focused) tab reports + * `result.tab.focused === false`. */ -function paneGetEnvelope(focused: boolean): string { +function tabGetEnvelope(focused: boolean): string { return JSON.stringify({ - id: 'cli:pane:get', - result: { pane: { focused } }, + id: 'cli:tab:get', + result: { tab: { focused } }, }); } @@ -28,28 +33,30 @@ describe('isPaneVisible', () => { beforeEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; delete process.env.HERDR_BIN_PATH; }); afterEach(() => { resetExecFileAsync(); + process.env.HERDR_TAB_ID = originalEnv.HERDR_TAB_ID; process.env.HERDR_PANE_ID = originalEnv.HERDR_PANE_ID; process.env.HERDR_BIN_PATH = originalEnv.HERDR_BIN_PATH; }); - it('returns true when HERDR_PANE_ID is not set (fail-open, standalone mode)', async () => { - delete process.env.HERDR_PANE_ID; + it('returns true when HERDR_TAB_ID is not set (fail-open, standalone mode)', async () => { + delete process.env.HERDR_TAB_ID; const mockFn = vi.fn(); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); - // No herdr exec at all when there is no pane context. + // No herdr exec at all when there is no tab context. expect(mockFn).not.toHaveBeenCalled(); }); it('returns true when the herdr CLI is missing (ENOENT)', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockRejectedValue({ code: 'ENOENT' }); setExecFileAsync(mockFn as any); @@ -57,7 +64,7 @@ describe('isPaneVisible', () => { }); it('returns true when the herdr CLI exits non-zero', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockRejectedValue(new Error('herdr: exit status 1')); setExecFileAsync(mockFn as any); @@ -65,42 +72,77 @@ describe('isPaneVisible', () => { }); it('returns true when the CLI returns unparseable output (fail-open)', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ stdout: 'not json at all', stderr: '' }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); }); - it('returns false when herdr pane get reports result.pane.focused === false', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('returns false when herdr tab get reports result.tab.focused === false (tab not focused)', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(false), + stdout: tabGetEnvelope(false), stderr: '', }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(false); - // The pane id is passed to the CLI. + // The tab id is passed to the CLI. + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); + }); + + it('returns true when herdr tab get reports result.tab.focused === true (tab focused)', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(true), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + expect(await isPaneVisible()).toBe(true); + }); + + it('multi-pane bug case: tab focused + pane unfocused → visible', async () => { + // The bug (WL-0MSJNJPRM009RM35): in a split tab the worklist pane is + // NOT the keyboard-focused pane, so `herdr pane get` reports + // `focused: false` — yet the pane is visible on screen. The tab-focus + // signal (HERDR_TAB_ID + tab get) must decide visibility, so the pane + // keeps refreshing. Both env vars present mirrors the real environment; + // the pane id is set but must NOT be consulted (no pane-focus fallback). + process.env.HERDR_TAB_ID = 'w1:t11'; + process.env.HERDR_PANE_ID = 'w1:p41'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(true), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + expect(await isPaneVisible()).toBe(true); + // Only the tab-get path is exercised — never a pane-get. const callArgs = mockFn.mock.calls[0][1] as string[]; - expect(callArgs).toEqual(['pane', 'get', 'w1:pCM']); + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); }); - it('returns true when herdr pane get reports result.pane.focused === true', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('multi-pane bug case: tab focused + pane focused → visible', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; + process.env.HERDR_PANE_ID = 'w1:p41'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(true), + stdout: tabGetEnvelope(true), stderr: '', }); setExecFileAsync(mockFn as any); expect(await isPaneVisible()).toBe(true); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toEqual(['tab', 'get', 'w1:t11']); }); it('resolves the herdr binary from HERDR_BIN_PATH when set, else "herdr"', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(true), + stdout: tabGetEnvelope(true), stderr: '', }); setExecFileAsync(mockFn as any); @@ -120,11 +162,13 @@ describe('isPaneVisible', () => { describe('PollGate', () => { beforeEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; }); afterEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; }); @@ -171,10 +215,10 @@ describe('PollGate', () => { expect(check).toHaveBeenCalledTimes(1); }); - it('defaults to isPaneVisible and shares one herdr exec within the TTL', async () => { - process.env.HERDR_PANE_ID = 'w1:pCM'; + it('defaults to isPaneVisible and shares one tab-get exec within the TTL', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = vi.fn().mockResolvedValue({ - stdout: paneGetEnvelope(false), + stdout: tabGetEnvelope(false), stderr: '', }); setExecFileAsync(mockFn as any); @@ -182,7 +226,7 @@ describe('PollGate', () => { const gate = new PollGate(); expect(await gate.visible()).toBe(false); expect(await gate.visible()).toBe(false); - // refresh + sync ticks in one cycle share a single herdr pane get call. + // refresh + sync ticks in one cycle share a single herdr tab get call. expect(mockFn).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/herdr/src/visibility.ts b/packages/herdr/src/visibility.ts index 0473f392..8e2c5b82 100644 --- a/packages/herdr/src/visibility.ts +++ b/packages/herdr/src/visibility.ts @@ -1,18 +1,22 @@ /** * packages/herdr/src/visibility.ts — Pane visibility detection * - * Determines whether the current herdr pane is visible (focused) so the - * worklist TUI can pause auto-refresh/auto-sync when its tab is hidden. + * Determines whether the current herdr pane is visible so the worklist TUI + * can pause auto-refresh/auto-sync when its TAB is hidden. * - * Visibility signal: herdr sets `HERDR_PANE_ID` (plus `HERDR_TAB_ID` / + * Visibility signal: herdr sets `HERDR_TAB_ID` (plus `HERDR_PANE_ID` / * `HERDR_WORKSPACE_ID` / `HERDR_BIN_PATH`) for panes it spawns; a hidden - * (non-focused) tab reports `result.pane.focused === false` from - * `herdr pane get `. + * (non-focused) tab reports `result.tab.focused === false` from + * `herdr tab get `. Tab focus is the visibility signal — a pane is + * visible when its TAB is focused, regardless of which pane in the tab + * holds keyboard focus (multi-pane split fix, WL-0MSJNJPRM009RM35). There + * is NO pane-focus fallback (approved scope); a pane zoomed-over within a + * focused tab is therefore treated as visible (documented limitation). * - * Fail-open design: when visibility cannot be determined (no `HERDR_PANE_ID`, - * CLI missing/erroring, unparseable output) the pane is treated as visible so - * polling proceeds exactly as today. Standalone runs without herdr context - * are unaffected. + * Fail-open design: when visibility cannot be determined (no `HERDR_TAB_ID`, + * CLI missing/erroring, unparseable output) the pane is treated as visible + * so polling proceeds exactly as today. Standalone runs without herdr + * context are unaffected. */ import { getExecFileAsync } from './fetcher.js'; @@ -20,36 +24,36 @@ import { getExecFileAsync } from './fetcher.js'; // ── Constants ───────────────────────────────────────────────────────── /** Default TTL for the PollGate memoizer (ms). Refresh + sync ticks in one - * cycle share a single `herdr pane get` call. */ + * cycle share a single `herdr tab get` call. */ export const DEFAULT_POLL_GATE_TTL_MS = 2000; // ── isPaneVisible ───────────────────────────────────────────────────── /** - * Check whether the current herdr pane is visible (focused). + * Check whether the current herdr pane's tab is visible (focused). * * Returns true (fail-open) when: - * - `HERDR_PANE_ID` is not set (standalone mode — polling as today) + * - `HERDR_TAB_ID` is not set (standalone mode — polling as today) * - The herdr CLI is missing, exits non-zero, or returns unparseable output * - * Returns false only when `herdr pane get ` definitively reports - * `result.pane.focused === false`. + * Returns false only when `herdr tab get ` definitively reports + * `result.tab.focused === false`. * * Uses the injectable exec seam (setExecFileAsync) from fetcher.ts — no * real process spawns in tests. */ export async function isPaneVisible(): Promise { - const paneId = process.env.HERDR_PANE_ID; - if (!paneId) return true; + const tabId = process.env.HERDR_TAB_ID; + if (!tabId) return true; const bin = process.env.HERDR_BIN_PATH ?? 'herdr'; try { const execFileAsync = getExecFileAsync(); - const result = await execFileAsync(bin, ['pane', 'get', paneId], { + const result = await execFileAsync(bin, ['tab', 'get', tabId], { maxBuffer: 1024 * 1024, }); - const payload = extractPaneGetPayload(result.stdout); - const focused = payload?.result?.pane?.focused; + const payload = extractTabGetPayload(result.stdout); + const focused = payload?.result?.tab?.focused; if (typeof focused === 'boolean') return focused; return true; // unparseable / unexpected shape → fail open } catch { @@ -61,11 +65,11 @@ export async function isPaneVisible(): Promise { * Extract the JSON object from herdr CLI output. The CLI may prefix the * envelope with log lines, so scan for the first `{` like fetcher.ts. */ -function extractPaneGetPayload(raw: string): { result?: { pane?: { focused?: unknown } } } | null { +function extractTabGetPayload(raw: string): { result?: { tab?: { focused?: unknown } } } | null { const start = raw.indexOf('{'); if (start < 0) return null; try { - return JSON.parse(raw.slice(start)) as { result?: { pane?: { focused?: unknown } } }; + return JSON.parse(raw.slice(start)) as { result?: { tab?: { focused?: unknown } } }; } catch { return null; } @@ -77,7 +81,7 @@ function extractPaneGetPayload(raw: string): { result?: { pane?: { focused?: unk * A small TTL memoizer around a visibility check. * * Multiple auto-refresh/auto-sync ticks in one cycle share a single - * underlying check (e.g. one `herdr pane get` exec within the TTL). + * underlying check (e.g. one `herdr tab get` exec within the TTL). * Fails open: if the underlying check throws, `visible()` returns true * (polling continues) so transient herdr CLI errors never stall the TUI. */ diff --git a/packages/herdr/src/worklist-visibility.test.ts b/packages/herdr/src/worklist-visibility.test.ts index d566c9ca..f002c4a0 100644 --- a/packages/herdr/src/worklist-visibility.test.ts +++ b/packages/herdr/src/worklist-visibility.test.ts @@ -3,10 +3,12 @@ * visibility-gated auto-refresh/auto-sync in the worklist TUI * (WL-0MSB446N4009FFT5). * - * Verifies that when the herdr pane is hidden (not focused), the + * Verifies that when the herdr pane's TAB is hidden (not focused), the * auto-refresh and auto-sync timers skip their ticks (zero fetcher / * `wl sync` invocations), while visible panes (and the fail-open path) * keep the existing cadence. Manual actions are never gated. + * Visibility signal: tab focus (HERDR_TAB_ID + `herdr tab get`), no + * pane-focus fallback (WL-0MSJNJPRM009RM35). * * Run: npx vitest run packages/herdr/src/worklist-visibility.test.ts */ @@ -85,6 +87,7 @@ beforeEach(() => { afterEach(() => { resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; delete process.env.HERDR_PANE_ID; delete process.env.HERDR_BIN_PATH; vi.useRealTimers(); @@ -97,30 +100,30 @@ function tick(): Promise { } // --------------------------------------------------------------------------- -// Exec-mock helper: dispatch herdr pane-get vs wl CLI calls. +// Exec-mock helper: dispatch herdr tab-get vs wl CLI calls. // --------------------------------------------------------------------------- /** - * Build an execFileAsync mock that returns a herdr pane-get envelope for - * `herdr pane get` calls and a benign wl response for everything else. + * Build an execFileAsync mock that returns a herdr tab-get envelope for + * `herdr tab get` calls and a benign wl response for everything else. * - * @param paneFocused - The focused flag for `herdr pane get` responses. - * @param paneGetCalls - Optional array collecting pane-get call counts. + * @param tabFocused - The focused flag for `herdr tab get` responses. + * @param tabGetCalls - Optional array collecting tab-get call counts. */ function makeExecMock( - paneFocused: boolean | undefined, - paneGetCalls?: { count: number }, + tabFocused: boolean | undefined, + tabGetCalls?: { count: number }, ): Mock { return vi.fn(async (bin: string, args: string[]) => { - if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { - if (paneGetCalls) paneGetCalls.count += 1; - if (paneFocused === undefined) { - throw new Error('herdr: pane not found'); + if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { + if (tabGetCalls) tabGetCalls.count += 1; + if (tabFocused === undefined) { + throw new Error('herdr: tab not found'); } return { stdout: JSON.stringify({ - id: 'cli:pane:get', - result: { pane: { focused: paneFocused } }, + id: 'cli:tab:get', + result: { tab: { focused: tabFocused } }, }), stderr: '', }; @@ -133,10 +136,10 @@ function makeExecMock( }); } -/** Count how many pane-get execs were made through the mock. */ -function countPaneGetCalls(mockFn: Mock): number { +/** Count how many tab-get execs were made through the mock. */ +function countTabGetCalls(mockFn: Mock): number { return mockFn.mock.calls.filter( - (c) => c[0] === 'herdr' && c[1]?.[0] === 'pane' && c[1]?.[1] === 'get', + (c) => c[0] === 'herdr' && c[1]?.[0] === 'tab' && c[1]?.[1] === 'get', ).length; } @@ -153,7 +156,7 @@ async function quit(p: Promise): Promise { describe('worklist TUI visibility gating', () => { it('hidden pane: refreshTimer ticks do NOT invoke the fetcher (zero wl spawns)', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -175,7 +178,7 @@ describe('worklist TUI visibility gating', () => { it('hidden pane: syncTimer ticks do NOT invoke runSync or the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -199,7 +202,7 @@ describe('worklist TUI visibility gating', () => { it('visible pane: refreshTimer and syncTimer ticks invoke the fetcher / runSync as today', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(true) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -226,9 +229,9 @@ describe('worklist TUI visibility gating', () => { await quit(p); }); - it('fail-open: no HERDR_PANE_ID env → ticks invoke the fetcher as today (standalone runs unaffected)', async () => { + it('fail-open: no HERDR_TAB_ID env → ticks invoke the fetcher as today (standalone runs unaffected)', async () => { vi.useFakeTimers(); - delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_TAB_ID; setExecFileAsync(makeExecMock(undefined) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -249,7 +252,7 @@ describe('worklist TUI visibility gating', () => { it('fail-open: herdr CLI error → pane treated as visible, polling continues', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(undefined) as any); // CLI throws const fetcher = vi.fn().mockResolvedValue([]); @@ -270,7 +273,7 @@ describe('worklist TUI visibility gating', () => { it('manual S (sync) is never gated — works even when the pane is hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -296,7 +299,7 @@ describe('worklist TUI visibility gating', () => { it('manual actions are never gated — a fresh TUI load fetches items even while hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); // Initial data load (which happens regardless of visibility) still works @@ -313,11 +316,11 @@ describe('worklist TUI visibility gating', () => { await quit(p2); }); - it('PollGate TTL: refresh+sync ticks in one cycle share a single herdr pane get call', async () => { + it('PollGate TTL: refresh+sync ticks in one cycle share a single herdr tab get call', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - const paneGetCalls = { count: 0 }; - setExecFileAsync(makeExecMock(true, paneGetCalls) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + const tabGetCalls = { count: 0 }; + setExecFileAsync(makeExecMock(true, tabGetCalls) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -329,25 +332,25 @@ describe('worklist TUI visibility gating', () => { }); await vi.advanceTimersByTimeAsync(0); - const execsAfterStart = paneGetCalls.count; + const execsAfterStart = tabGetCalls.count; - // One refresh cycle (t=30s): exactly one pane-get for the refresh tick. + // One refresh cycle (t=30s): exactly one tab-get for the refresh tick. await vi.advanceTimersByTimeAsync(30_000); - const execsAfterRefresh = paneGetCalls.count; + const execsAfterRefresh = tabGetCalls.count; expect(execsAfterRefresh - execsAfterStart).toBe(1); // One sync cycle boundary (t=60s): refresh + sync fire together; the // sync tick reuses the refresh tick's cached result (TTL) → no extra exec. await vi.advanceTimersByTimeAsync(30_000); - expect(paneGetCalls.count - execsAfterRefresh).toBe(1); - expect(paneGetCalls.count - execsAfterStart).toBeLessThanOrEqual(3); + expect(tabGetCalls.count - execsAfterRefresh).toBe(1); + expect(tabGetCalls.count - execsAfterStart).toBeLessThanOrEqual(3); await quit(p); }); it('header shows the [paused — hidden] indicator when the pane is hidden', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; setExecFileAsync(makeExecMock(false) as any); const fetcher = vi.fn().mockResolvedValue([]); @@ -376,7 +379,7 @@ describe('worklist TUI visibility gating', () => { describe('worklist TUI visibility gating — getExecFileAsync seam', () => { it('visibility checks go through the same injectable exec seam as the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; const mockFn = makeExecMock(false); setExecFileAsync(mockFn as any); @@ -388,12 +391,12 @@ describe('worklist TUI visibility gating — getExecFileAsync seam', () => { showHelpText: false, }); await vi.advanceTimersByTimeAsync(0); - // Fire the refresh tick so the gate runs its pane-get check. + // Fire the refresh tick so the gate runs its tab-get check. await vi.advanceTimersByTimeAsync(30_000); - // The gate's pane-get check must have gone through getExecFileAsync(). + // The gate's tab-get check must have gone through getExecFileAsync(). expect(getExecFileAsync).toBeDefined(); - expect(countPaneGetCalls(mockFn)).toBeGreaterThanOrEqual(1); + expect(countTabGetCalls(mockFn)).toBeGreaterThanOrEqual(1); await quit(p); }); @@ -401,19 +404,19 @@ describe('worklist TUI visibility gating — getExecFileAsync seam', () => { describe('worklist TUI visibility gating — hidden → visible transition (WL-0MSBVS4AS006ZQEZ)', () => { /** - * Exec mock with a dynamic focused flag (read per pane-get call), so a - * test can flip the pane from hidden to visible mid-run. + * Exec mock with a dynamic focused flag (read per tab-get call), so a + * test can flip the tab from hidden to visible mid-run. */ - function makeDynamicExecMock(getFocused: () => boolean | undefined, paneGetCalls?: { count: number }): Mock { + function makeDynamicExecMock(getFocused: () => boolean | undefined, tabGetCalls?: { count: number }): Mock { return vi.fn(async (bin: string, args: string[]) => { - if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { - if (paneGetCalls) paneGetCalls.count += 1; + if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { + if (tabGetCalls) tabGetCalls.count += 1; const focused = getFocused(); if (focused === undefined) { - throw new Error('herdr: pane not found'); + throw new Error('herdr: tab not found'); } return { - stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused } } }), + stdout: JSON.stringify({ id: 'cli:tab:get', result: { tab: { focused } } }), stderr: '', }; } @@ -426,9 +429,9 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 it('hidden → visible transition triggers an immediate fetch outside the normal tick', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -440,24 +443,24 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 await vi.advanceTimersByTimeAsync(0); fetcher.mockClear(); - // First refresh tick while hidden: skipped, pane pauses. + // First refresh tick while hidden: skipped, tab pauses. await vi.advanceTimersByTimeAsync(30_000); expect(fetcher).not.toHaveBeenCalled(); - // Pane regains focus → the resume poll catches the transition and the + // Tab regains focus → the resume poll catches the transition and the // list re-fetches immediately (t=32s, outside the 30s tick at t=60s). - paneFocused = true; + tabFocused = true; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); await quit(p); }); - it('while hidden the resume poll spawns only herdr pane get — never the fetcher', async () => { + it('while hidden the resume poll spawns only herdr tab get — never the fetcher', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - const paneGetCalls = { count: 0 }; - setExecFileAsync(makeDynamicExecMock(() => false, paneGetCalls) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + const tabGetCalls = { count: 0 }; + setExecFileAsync(makeDynamicExecMock(() => false, tabGetCalls) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -471,20 +474,20 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 // Hidden tick starts the resume poll; run several polls while still hidden. await vi.advanceTimersByTimeAsync(30_000); - const execsAfterFirstTick = paneGetCalls.count; + const execsAfterFirstTick = tabGetCalls.count; await vi.advanceTimersByTimeAsync(10_000); expect(fetcher).not.toHaveBeenCalled(); - expect(paneGetCalls.count).toBeGreaterThan(execsAfterFirstTick); // pane-get polls only + expect(tabGetCalls.count).toBeGreaterThan(execsAfterFirstTick); // tab-get polls only await quit(p); }); it('after the immediate transition refresh, refreshes follow the normal refreshIntervalMs cadence', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -502,7 +505,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 expect(fetcher).not.toHaveBeenCalled(); // Become visible: immediate fetch via the resume poll (t=62s). - paneFocused = true; + tabFocused = true; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); @@ -519,9 +522,9 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 it('the [paused — hidden] header indicator clears once the transition refresh completes', async () => { vi.useFakeTimers(); - process.env.HERDR_PANE_ID = 'w1:pCM'; - let paneFocused = false; - setExecFileAsync(makeDynamicExecMock(() => paneFocused) as any); + process.env.HERDR_TAB_ID = 'w1:t11'; + let tabFocused = false; + setExecFileAsync(makeDynamicExecMock(() => tabFocused) as any); const fetcher = vi.fn().mockResolvedValue([]); const p = runWorklistTui(fetcher, [], undefined, { @@ -532,7 +535,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 }); await vi.advanceTimersByTimeAsync(0); - // Hidden tick pauses the pane; a navigation render shows the indicator. + // Hidden tick pauses the tab; a navigation render shows the indicator. await vi.advanceTimersByTimeAsync(30_000); writes.length = 0; dataHandler?.(Buffer.from('j')); @@ -540,7 +543,7 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 expect(writes.join('')).toContain('[paused — hidden]'); // Regain focus: the immediate transition refresh re-renders without it. - paneFocused = true; + tabFocused = true; writes.length = 0; await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(1); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index a2214f17..f8b9abb4 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -2585,9 +2585,10 @@ export async function runWorklistTui( // Pane-visibility gating (pause-when-hidden). When the pane's tab is not // focused, auto-refresh/auto-sync timer ticks are skipped so hidden panes // stop spawning wl processes. Fail-open: when visibility can't be - // determined (no HERDR_PANE_ID / CLI error) the pane is treated as visible - // and polling proceeds as today. PollGate memoizes the pane-get exec within - // a TTL so refresh+sync ticks in one cycle share a single `herdr pane get`. + // determined (no HERDR_TAB_ID / CLI error) the pane is treated as visible + // and polling proceeds as today. Tab focus is the signal (herdr tab get + // -> result.tab.focused); PollGate memoizes the tab-get exec within a + // TTL so refresh+sync ticks in one cycle share a single `herdr tab get`. const paneGate = new PollGate(isPaneVisible, DEFAULT_POLL_GATE_TTL_MS); // Whether the pane is currently hidden (drives the header indicator). // Updated by the gate check on each timer tick; fail-open defaults to false. @@ -3367,10 +3368,10 @@ export async function runWorklistTui( }); } - // Visibility resume-poll — runs only while the pane is hidden. Polls - // pane visibility on a short interval so the hidden → visible transition + // Visibility resume-poll — runs only while the pane's tab is hidden. Polls + // tab visibility on a short interval so the hidden → visible transition // triggers an immediate doRefresh instead of waiting for the next - // refreshIntervalMs tick. The poll uses only herdr pane get (via the + // refreshIntervalMs tick. The poll uses only herdr tab get (via the // PollGate memoizer, TTL aligned with the poll interval) — never wl — // preserving the zero-wl-when-hidden guarantee. scheduler.addTask({ From e27bca8057f81a0c1fc7e2c0deaad55e545e0573 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 9 Aug 2026 23:59:32 +0100 Subject: [PATCH 16/40] WL-0MSMAIP5F003WAGG: Implementation complete --- CLI.md | 23 +++ packages/herdr/README.md | 13 +- packages/shared/src/database.ts | 139 ++++++++++++-- src/commands/next.ts | 28 ++- tests/cli/next-risk-effort-cli.test.ts | 96 ++++++++++ tests/next-risk-effort-filter.test.ts | 249 +++++++++++++++++++++++++ 6 files changed, 532 insertions(+), 16 deletions(-) create mode 100644 tests/cli/next-risk-effort-cli.test.ts create mode 100644 tests/next-risk-effort-filter.test.ts diff --git a/CLI.md b/CLI.md index 1060e480..8e571e58 100644 --- a/CLI.md +++ b/CLI.md @@ -461,6 +461,27 @@ In blocker-surfacing and critical-escalation paths, child blockers are never ret In batch mode (`-n `), children of returned parents are also excluded from subsequent results, ensuring the batch never contains items from the same subtree. +#### Risk / effort filters + +`--risk ` and `--effort ` restrict candidates with **at-most** ordinal +semantics (WL-0MSMAIP5F003WAGG): + +- Risk: `low` < `medium` < `high` < `severe` (alias `critical`). `--risk low` matches only + Low-risk items; `--risk medium` matches Low and Medium; and so on. +- Effort: `extra-small`/`xs` < `small`/`s` < `medium`/`m` < `large`/`l` < + `extra-large`/`xl`. `--effort small` matches Extra Small and Small items; + both the short (`XS`, `S`, …) and long-form (`Extra Small`, `Small`, …) + spellings are recognized, case-insensitively. +- **Fail-closed:** items with unset/empty risk or effort never match — an + absent estimate is not "≤ low/small". +- Filters compose with `--stage`, priority selection, and the existing + dependency-blocked exclusion, and apply to surfaced blockers too. + +```sh +wl next --stage plan_complete --risk low --effort small +wl next --stage plan_complete --risk low --effort small -n 10 +``` + #### Automatic re-sort By default, `wl next` re-sorts all active items by score before selecting candidates. This ensures that recently created or re-prioritized items are immediately reflected in the selection order without requiring a manual `wl re-sort`. The re-sort uses the same scoring logic as `wl re-sort` (priority weight, age, and optional recency policy). @@ -563,6 +584,8 @@ wl next -n 10 -g 4 wl next -a alice --search "bug" wl next --stage idea wl next --stage in_progress +wl next --risk low --effort small +wl next --stage plan_complete --risk low --effort small -n 10 wl next --include-blocked wl next --no-re-sort wl next --recency-policy prefer diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 9771e737..f1fe0b9b 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -238,7 +238,18 @@ the item to completed/in_review without recording a fresh audit. The exclusion composes with the freshness rule: a *fresh* audit since the dispatch still governs (fresh → not a candidate). A missing or unreadable log is treated as empty (fail-safe), so audit dispatch keeps working on a -fresh worklog. If none, it runs `wl next --stage intake_complete +fresh worklog. + +> **Audit-tier selection note (WL-0MSMAIP5F003WAGG):** the audit tier keeps +> its `wl list --status completed --stage in_review --json` selection rather +> than converting to `wl next --stage in_review`. `wl next` is strictly +> root-only, so converting would silently drop completed child items from +> audit dispatch (32 children in the completed/in_review queue as of the +> decision); the audit tier must audit the full completed/in_review set, +> including children. The conversion was scoped to the implement tier only +> (AC5 escape hatch — decision recorded). + +If none, it runs `wl next --stage intake_complete --json` and dispatches `/skill:plan `; if no such item it runs `wl next --stage idea --json` and dispatches `/skill:intake `; if all three are empty nothing is dispatched. A `wl`/CLI error on the `intake_complete` lookup diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index 5fee60fd..adf93643 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -1355,6 +1355,67 @@ export class WorklogDatabase { return depth; } + /** + * Ordinal rank of a risk level on the canonical scale + * (Low < Medium < High < Severe/Critical). Both the type-level spelling + * ('Severe') and the icon-scale spelling ('critical') map to the top + * rank. Unset/unknown values map to null (fail-closed: they are never + * matched by an at-most risk filter). + */ + private riskOrdinal(risk: string | undefined | null): number | null { + switch ((risk ?? '').trim().toLowerCase()) { + case 'low': return 1; + case 'medium': return 2; + case 'high': return 3; + case 'severe': + case 'critical': return 4; + default: return null; + } + } + + /** + * Ordinal rank of an effort level on the canonical scale + * (Extra Small < Small < Medium < Large < Extra Large). Accepts both the + * short CLI spellings (XS/S/M/L/XL) and the long-form effort-and-risk + * skill spellings (extra small/small/medium/large/extra large), + * normalized case-insensitively. Unset/unknown values map to null + * (fail-closed: they are never matched by an at-most effort filter). + */ + private effortOrdinal(effort: string | undefined | null): number | null { + switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + case 'xs': + case 'extrasmall': return 1; + case 's': + case 'small': return 2; + case 'm': + case 'medium': return 3; + case 'l': + case 'large': return 4; + case 'xl': + case 'extralarge': return 5; + default: return null; + } + } + + /** + * True when an item satisfies the optional at-most risk/effort filters. + * Fail-closed: an unset/unknown risk or effort on the item (or an invalid + * filter level) never matches. + */ + private matchesRiskEffort(item: WorkItem, risk?: string, effort?: string): boolean { + if (risk !== undefined && risk !== '') { + const level = this.riskOrdinal(risk); + const ord = this.riskOrdinal(item.risk); + if (level === null || ord === null || ord > level) return false; + } + if (effort !== undefined && effort !== '') { + const level = this.effortOrdinal(effort); + const ord = this.effortOrdinal(item.effort); + if (level === null || ord === null || ord > level) return false; + } + return true; + } + /** * Get numeric priority value for comparisons */ @@ -1502,6 +1563,8 @@ export class WorklogDatabase { options: { assignee?: string; searchTerm?: string; + risk?: string; + effort?: string; excluded?: Set; debugPrefix?: string; includeInProgress?: boolean; @@ -1512,6 +1575,8 @@ export class WorklogDatabase { const { assignee, searchTerm, + risk, + effort, excluded, debugPrefix = '[critical]', includeInProgress = false, @@ -1550,8 +1615,9 @@ export class WorklogDatabase { if (unblockedCriticals.length > 0) { // Apply assignee/search to unblocked criticals — only return items - // that match the caller's filters. - let selectable = this.applyFilters(unblockedCriticals, assignee, searchTerm); + // that match the caller's filters (including risk/effort). + let selectable = this.applyFilters(unblockedCriticals, assignee, searchTerm) + .filter(item => this.matchesRiskEffort(item, risk, effort)); if (excluded && excluded.size > 0) { selectable = selectable.filter(item => !excluded.has(item.id)); } @@ -1624,7 +1690,7 @@ export class WorklogDatabase { // Apply assignee/search filters to the blockers only const filteredBlockingPairs = blockingPairs.filter(pair => this.applyFilters([pair.blocking], assignee, searchTerm).length > 0 - ); + ).filter(pair => this.matchesRiskEffort(pair.blocking, risk, effort)); this.debug(`${debugPrefix} blocking candidates=${blockingPairs.length} after filters=${filteredBlockingPairs.length}`); // Strict root-only (WL-0MS964SIA0057ABR): never surface child blockers. @@ -1634,7 +1700,7 @@ export class WorklogDatabase { const rootBlockingPairs: { blocking: WorkItem; critical: WorkItem }[] = []; for (const pair of filteredBlockingPairs) { const resolved = this.resolveBlockerToRoot(pair.blocking, allItems, assignee, searchTerm, excluded); - if (resolved) { + if (resolved && this.matchesRiskEffort(resolved, risk, effort)) { rootBlockingPairs.push({ blocking: resolved, critical: pair.critical }); } } @@ -1652,7 +1718,8 @@ export class WorklogDatabase { // No actionable blocker found — return the blocked critical itself as a // last resort so the user is aware of the stuck critical item. - let selectableBlocked = this.applyFilters(blockedCriticals, assignee, searchTerm); + let selectableBlocked = this.applyFilters(blockedCriticals, assignee, searchTerm) + .filter(item => this.matchesRiskEffort(item, risk, effort)); if (excluded && excluded.size > 0) { selectableBlocked = selectableBlocked.filter(item => !excluded.has(item.id)); } @@ -1871,6 +1938,8 @@ export class WorklogDatabase { assignee?: string; searchTerm?: string; stage?: string; + risk?: string; + effort?: string; excluded?: Set; includeBlocked?: boolean; includeInProgress?: boolean; @@ -1882,6 +1951,8 @@ export class WorklogDatabase { assignee, searchTerm, stage, + risk, + effort, excluded, includeBlocked = false, includeInProgress = false, @@ -1936,6 +2007,38 @@ export class WorklogDatabase { pool = this.applyFilters(pool, assignee, searchTerm); this.debug(`${debugPrefix} filter: after assignee/search=${pool.length}`); + // 7b. Apply risk/effort at-most filters (ordinal, fail-closed on unset). + // An item is eligible only when its risk ≤ the filter level (Low < Medium + // < High < Severe) and its effort ≤ the filter level (Extra Small < Small + // < Medium < Large < Extra Large). Items with unset/empty risk or effort + // are NEVER matched — an absent estimate is not "≤ low/small" (AC2). + if (risk !== undefined && risk !== '') { + const riskLevel = this.riskOrdinal(risk); + if (riskLevel === null) { + // Invalid filter level → fail-closed: match nothing. + pool = []; + } else { + pool = pool.filter(item => { + const ord = this.riskOrdinal(item.risk); + return ord !== null && ord <= riskLevel; + }); + } + this.debug(`${debugPrefix} filter: after risk=${risk}=${pool.length}`); + } + if (effort !== undefined && effort !== '') { + const effortLevel = this.effortOrdinal(effort); + if (effortLevel === null) { + // Invalid filter level → fail-closed: match nothing. + pool = []; + } else { + pool = pool.filter(item => { + const ord = this.effortOrdinal(item.effort); + return ord !== null && ord <= effortLevel; + }); + } + this.debug(`${debugPrefix} filter: after effort=${effort}=${pool.length}`); + } + // Snapshot for critical-path escalation (before dep-blocker removal) const criticalPool = pool; @@ -2042,9 +2145,11 @@ export class WorklogDatabase { includeBlocked: boolean = false, stage?: string, includeInProgress: boolean = false, - edgeCache?: EdgeCache + edgeCache?: EdgeCache, + risk?: string, + effort?: string ): NextWorkItemResult { - this.debug(`${debugPrefix} assignee=${assignee || ''} search=${searchTerm || ''} stage=${stage || ''} excluded=${excluded?.size || 0}`); + this.debug(`${debugPrefix} assignee=${assignee || ''} search=${searchTerm || ''} stage=${stage || ''} excluded=${excluded?.size || 0} risk=${risk || ''} effort=${effort || ''}`); // Build the sort-order cache once from the pre-loaded items array. // This avoids an extra full-table scan of all work items from the database. @@ -2059,6 +2164,8 @@ export class WorklogDatabase { assignee, searchTerm, stage, + risk, + effort, excluded, includeBlocked, includeInProgress, @@ -2078,6 +2185,8 @@ export class WorklogDatabase { searchTerm, excluded, includeInProgress, + risk, + effort, debugPrefix: `${debugPrefix} [critical]`, edgeCache, sortOrderCache, @@ -2148,7 +2257,7 @@ export class WorklogDatabase { // Apply assignee/search filters to blockers let filteredBlockers = blockingPairs.filter(pair => this.applyFilters([pair.blocking], assignee, searchTerm).length > 0 - ); + ).filter(pair => this.matchesRiskEffort(pair.blocking, risk, effort)); // Strict root-only (WL-0MS964SIA0057ABR): child blockers are never // surfaced by wl next. @@ -2275,11 +2384,13 @@ export class WorklogDatabase { searchTerm?: string, includeBlocked: boolean = false, stage?: string, - includeInProgress: boolean = false + includeInProgress: boolean = false, + risk?: string, + effort?: string ): NextWorkItemResult { const items = this.store.getAllWorkItems(); const edgeCache = this.buildEdgeCache(items); - return this.findNextWorkItemFromItems(items, assignee, searchTerm, undefined, '[next]', includeBlocked, stage, includeInProgress, edgeCache); + return this.findNextWorkItemFromItems(items, assignee, searchTerm, undefined, '[next]', includeBlocked, stage, includeInProgress, edgeCache, risk, effort); } /** @@ -2292,7 +2403,9 @@ export class WorklogDatabase { searchTerm?: string, includeBlocked: boolean = false, stage?: string, - includeInProgress: boolean = false + includeInProgress: boolean = false, + risk?: string, + effort?: string ): NextWorkItemResult[] { const results: NextWorkItemResult[] = []; const excluded = new Set(); @@ -2312,7 +2425,9 @@ export class WorklogDatabase { includeBlocked, stage, includeInProgress, - edgeCache + edgeCache, + risk, + effort ); results.push(result); diff --git a/src/commands/next.ts b/src/commands/next.ts index f5c0c579..e90d12cf 100644 --- a/src/commands/next.ts +++ b/src/commands/next.ts @@ -21,6 +21,8 @@ export default function register(ctx: PluginContext): void { .description('Find the next work item to work on based on priority and status (excludes dependency-blocked items by default)') .option('-a, --assignee ', 'Filter by assignee') .option('--stage ', 'Filter by stage (idea, intake_complete, plan_complete, in_progress, in_review, done)') + .option('--risk ', 'Filter by risk level, at-most semantics (low, medium, high, severe). Items with unset risk never match.') + .option('--effort ', 'Filter by effort level, at-most semantics (xs/extra-small, s/small, m/medium, l/large, xl/extra-large). Items with unset effort never match.') .option('--search ', 'Search term for fuzzy matching against title, description, and comments') .option('-n, --number ', 'Number of items to return (default: 1)', '1') .option('--prefix ', 'Override the default prefix') @@ -32,7 +34,7 @@ export default function register(ctx: PluginContext): void { .option('-g, --groups ', 'Number of parallel-safe groups to identify (default: 3, only meaningful when -n > 1)', '3') .action(async (...rawArgs: any[]) => { // Normalize incoming args: commander may pass a Command instance - const normalized = normalizeActionArgs(rawArgs, ['assignee', 'stage', 'search', 'number', 'prefix', 'includeBlocked', 'includeInProgress', 'reSort', 'reSortSync', 'recencyPolicy', 'groups']); + const normalized = normalizeActionArgs(rawArgs, ['assignee', 'stage', 'search', 'number', 'prefix', 'includeBlocked', 'includeInProgress', 'reSort', 'reSortSync', 'recencyPolicy', 'groups', 'risk', 'effort']); let options: any = normalized.options || {}; utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -53,6 +55,26 @@ export default function register(ctx: PluginContext): void { options.stage = normalizedStage; } + // Validate risk/effort levels (at-most ordinal filters). Invalid levels + // fail closed at the CLI boundary — the shared filterCandidates pipeline + // also fails closed (matches nothing) as a belt-and-suspenders guard. + const VALID_RISK_LEVELS = new Set(['low', 'medium', 'high', 'severe', 'critical']); + const VALID_EFFORT_LEVELS = new Set(['xs', 'extra-small', 'extrasmall', 's', 'small', 'm', 'medium', 'l', 'large', 'xl', 'extra-large', 'extralarge']); + if (options.risk) { + const normalizedRisk = options.risk.toLowerCase().trim(); + if (!VALID_RISK_LEVELS.has(normalizedRisk)) { + output.error(`Invalid risk: "${options.risk}". Valid levels: low, medium, high, severe, critical.`, { success: false, error: `Invalid risk: "${options.risk}"` }); + process.exit(1); + } + } + if (options.effort) { + const normalizedEffort = options.effort.toLowerCase().trim().replace(/\s+/g, '-'); + if (!VALID_EFFORT_LEVELS.has(normalizedEffort)) { + output.error(`Invalid effort: "${options.effort}". Valid levels: xs/extra-small, s/small, m/medium, l/large, xl/extra-large.`, { success: false, error: `Invalid effort: "${options.effort}"` }); + process.exit(1); + } + } + // Auto re-sort unless --no-re-sort is passed. Commander exposes // the flag as `reSort: false` (for --no-re-sort) in some contexts // and some callers/tools may surface `noReSort` instead. Accept @@ -89,8 +111,8 @@ export default function register(ctx: PluginContext): void { } const results = (db as any).findNextWorkItems - ? (db as any).findNextWorkItems(count, options.assignee, options.search, includeBlocked, options.stage, includeInProgress) - : [db.findNextWorkItem(options.assignee, options.search, includeBlocked, options.stage, includeInProgress)]; + ? (db as any).findNextWorkItems(count, options.assignee, options.search, includeBlocked, options.stage, includeInProgress, options.risk, options.effort) + : [db.findNextWorkItem(options.assignee, options.search, includeBlocked, options.stage, includeInProgress, options.risk, options.effort)]; const availableResults = results.filter((result: any) => Boolean(result.workItem)); const missingCount = Math.max(0, count - availableResults.length); diff --git a/tests/cli/next-risk-effort-cli.test.ts b/tests/cli/next-risk-effort-cli.test.ts new file mode 100644 index 00000000..091c9539 --- /dev/null +++ b/tests/cli/next-risk-effort-cli.test.ts @@ -0,0 +1,96 @@ +/** + * CLI-level tests for `wl next --risk/--effort` at-most filters + * (WL-0MSMAIP5F003WAGG). Verifies the exact invocation the herdr + * downtime implement tier uses: + * `wl next --stage plan_complete --risk low --effort small -n N --json` + * and the fail-closed behavior for invalid filter levels. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore, execAsync } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); + +describe('wl next --risk/--effort CLI (WL-0MSMAIP5F003WAGG)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempDir); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + async function createItem(args: string[]): Promise { + const { stderr } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + if (stderr && stderr.includes('Error')) { + throw new Error(`create failed: ${stderr}`); + } + } + + async function runNext(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const res = await execAsync(`tsx ${cliPath} --json next ${args.join(' ')}`, { cwd: tempDir }); + return { stdout: res.stdout, stderr: res.stderr, code: 0 }; + } catch (e: any) { + return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', code: e.exitCode ?? 1 }; + } + } + + it('--risk low --effort small selects only matching plan_complete items', async () => { + await createItem(['-t', '"Eligible"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await createItem(['-t', '"MediumRisk"', '--stage', 'plan_complete', '--risk', 'Medium', '--effort', 'Small', '--no-re-sort']); + await createItem(['-t', '"MediumEffort"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Medium', '--no-re-sort']); + await createItem(['-t', '"WrongStage"', '--stage', 'idea', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids.length).toBe(1); + const item = parsed.workItems[0].workItem; + expect(item.title).toBe('Eligible'); + expect(item.risk).toBe('Low'); + expect(item.effort).toBe('Small'); + }); + + it('accepts XS/extra-small effort spellings', async () => { + await createItem(['-t', '"XS item"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'XS', '--no-re-sort']); + await createItem(['-t', '"S item"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'S', '--no-re-sort']); + + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'xs', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids.length).toBe(1); + expect(parsed.workItems[0].workItem.title).toBe('XS item'); + }); + + it('invalid risk level fails closed with an error', async () => { + const { stderr, code } = await runNext(['--risk', 'bogus']); + expect(code).toBe(1); + expect(stderr).toContain('Invalid risk'); + }); + + it('invalid effort level fails closed with an error', async () => { + const { stderr, code } = await runNext(['--effort', 'bogus']); + expect(code).toBe(1); + expect(stderr).toContain('Invalid effort'); + }); + + it('no flags behavior is unchanged', async () => { + await createItem(['-t', '"A"', '-p', 'high', '--no-re-sort']); + await createItem(['-t', '"B"', '-p', 'medium', '--no-re-sort']); + const { stdout } = await runNext([]); + const parsed = JSON.parse(stdout); + expect(parsed.workItem?.title).toBe('A'); + }); +}); diff --git a/tests/next-risk-effort-filter.test.ts b/tests/next-risk-effort-filter.test.ts new file mode 100644 index 00000000..f5c4eb11 --- /dev/null +++ b/tests/next-risk-effort-filter.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for `wl next` risk/effort at-most filters (WL-0MSMAIP5F003WAGG). + * + * Semantics under test (AC1/AC2): + * - risk ≤ level (low < medium < high < severe); effort ≤ level + * (extra small < small < medium < large < extra large). + * - Items with unset/empty risk or effort NEVER match (fail-closed). + * - Effort matching recognizes both "Small"/"Extra Small" and the short + * CLI spellings (XS/S/M/L/XL), normalized case-insensitively. + * - Dependency-blocked items remain excluded under the new filters. + * - Batch mode (`-n`) returns multiple eligible candidates. + * - Callers that do not use the new flags see unchanged behavior. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import { WorklogDatabase } from '../src/database.js'; +import { + createTempDir, + cleanupTempDir, + createTempJsonlPath, + createTempDbPath, +} from './test-utils.js'; +import type { WorkItemRiskLevel, WorkItemEffortLevel } from '../src/types.js'; + +/** Cast a raw DB value (e.g. lowercase or long-form) to the strict type. */ +const risk = (v: string) => v as unknown as WorkItemRiskLevel; +const effort = (v: string) => v as unknown as WorkItemEffortLevel; + +describe('wl next risk/effort filters (WL-0MSMAIP5F003WAGG)', () => { + let tempDir: string; + let dbPath: string; + let jsonlPath: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = createTempDir(); + dbPath = createTempDbPath(tempDir); + jsonlPath = createTempJsonlPath(tempDir); + if (fs.existsSync(jsonlPath)) { + fs.unlinkSync(jsonlPath); + } + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true, true); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + // ───────────────────────────────────────────────────────────────────── + // Risk filter boundaries (AC1: at-most ordinal semantics) + // ───────────────────────────────────────────────────────────────────── + describe('risk filter (at-most ordinal)', () => { + it('risk=low matches only Low-risk items', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + db.create({ title: 'Medium risk', risk: 'Medium', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'low'); + expect(result.workItem?.id).toBe(low.id); + }); + + it('risk=medium matches Low and Medium, excludes High/Severe', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + const medium = db.create({ title: 'Medium risk', risk: 'Medium', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, 'medium'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(low.id); + expect(ids).toContain(medium.id); + expect(ids).not.toContain( + results.find(r => r.workItem?.title === 'High risk')?.workItem?.id + ); + expect(ids.length).toBe(2); + }); + + it('risk=severe matches all canonical levels', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + const severe = db.create({ title: 'Severe risk', risk: 'Severe', status: 'open' }); + db.create({ title: 'Unset risk', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, 'severe'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(low.id); + expect(ids).toContain(severe.id); + // Unset risk never matches (fail-closed) + expect(ids.length).toBe(2); + }); + + it('risk matching is case-insensitive (data may store "low")', () => { + const lower = db.create({ title: 'Lowercase risk', risk: risk('low'), status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'low'); + expect(result.workItem?.id).toBe(lower.id); + }); + + it('filter value itself is case-insensitive ("Low" matches Low-risk items)', () => { + const low = db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + db.create({ title: 'High risk', risk: 'High', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'Low'); + expect(result.workItem?.id).toBe(low.id); + }); + + it('invalid risk level fails closed at the DB layer (matches nothing)', () => { + db.create({ title: 'Low risk', risk: 'Low', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, 'bogus'); + expect(result.workItem).toBeNull(); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Effort filter boundaries (AC1: XS/S matching, fail-closed on unset) + // ───────────────────────────────────────────────────────────────────── + describe('effort filter (at-most ordinal)', () => { + it('effort=small matches Small and Extra Small, excludes Medium+', () => { + const xs = db.create({ title: 'XS effort', effort: 'XS', status: 'open' }); + const small = db.create({ title: 'Small effort', effort: 'Small', status: 'open' }); + db.create({ title: 'Medium effort', effort: 'Medium', status: 'open' }); + db.create({ title: 'Large effort', effort: 'Large', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'small'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(xs.id); + expect(ids).toContain(small.id); + expect(ids.length).toBe(2); + }); + + it('effort=xs matches only Extra Small (XS)', () => { + const xs = db.create({ title: 'XS effort', effort: 'XS', status: 'open' }); + db.create({ title: 'Small effort', effort: 'Small', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'xs'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toEqual([xs.id]); + }); + + it('effort recognizes long-form spellings ("Extra Small", "extra small")', () => { + const longXs = db.create({ title: 'Long XS', effort: effort('Extra Small'), status: 'open' }); + const lowerLongXs = db.create({ title: 'Lower long XS', effort: effort('extra small'), status: 'open' }); + db.create({ title: 'Medium effort', effort: 'Medium', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'xs'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(longXs.id); + expect(ids).toContain(lowerLongXs.id); + expect(ids.length).toBe(2); + }); + + it('effort=medium matches XS/S/M', () => { + const xs = db.create({ title: 'XS', effort: 'XS', status: 'open' }); + const s = db.create({ title: 'S', effort: 'S', status: 'open' }); + const m = db.create({ title: 'M', effort: 'M', status: 'open' }); + db.create({ title: 'L', effort: 'L', status: 'open' }); + db.create({ title: 'XL', effort: 'XL', status: 'open' }); + + const results = db.findNextWorkItems(5, undefined, undefined, false, undefined, false, undefined, 'medium'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(xs.id); + expect(ids).toContain(s.id); + expect(ids).toContain(m.id); + expect(ids.length).toBe(3); + }); + + it('unset/empty effort never matches (fail-closed)', () => { + db.create({ title: 'Unset effort', status: 'open' }); + db.create({ title: 'Empty effort', effort: '' as const, status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, undefined, 'small'); + expect(result.workItem).toBeNull(); + }); + + it('filter value "extra small" (with space) matches XS items', () => { + const xs = db.create({ title: 'XS', effort: 'XS', status: 'open' }); + db.create({ title: 'Small', effort: 'Small', status: 'open' }); + + const result = db.findNextWorkItem(undefined, undefined, false, undefined, false, undefined, 'extra small'); + expect(result.workItem?.id).toBe(xs.id); + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Combined risk + effort + stage (AC2: implement-tier selection shape) + // ───────────────────────────────────────────────────────────────────── + describe('combined risk/effort/stage selection', () => { + it('stage=plan_complete + risk=low + effort=small returns only matching open items', () => { + const target = db.create({ + title: 'Eligible plan_complete Low/Small', + stage: 'plan_complete', + risk: 'Low', + effort: 'Small', + status: 'open', + }); + db.create({ title: 'Medium risk', stage: 'plan_complete', risk: 'Medium', effort: 'Small', status: 'open' }); + db.create({ title: 'Medium effort', stage: 'plan_complete', risk: 'Low', effort: 'Medium', status: 'open' }); + db.create({ title: 'Unset risk', stage: 'plan_complete', effort: 'Small', status: 'open' }); + db.create({ title: 'Wrong stage', stage: 'idea', risk: 'Low', effort: 'Small', status: 'open' }); + db.create({ title: 'Completed item kept by stage filter', stage: 'plan_complete', risk: 'Low', effort: 'Small', status: 'completed' }); + + const results = db.findNextWorkItems(10, undefined, undefined, false, 'plan_complete', false, 'low', 'small'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids).toContain(target.id); + // Non-matching candidates are never returned + for (const r of results) { + const wi = r.workItem; + if (!wi) continue; + expect(wi.stage).toBe('plan_complete'); + expect(['Low', 'low']).toContain(wi.risk); + expect(['Small', 'XS', 'S', 'small', 'xs', 'Extra Small']).toContain(wi.effort); + } + }); + + it('dependency-blocked items stay excluded under the new filters', () => { + const blocker = db.create({ title: 'Blocker', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'Small' }); + const blocked = db.create({ title: 'Blocked', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'Small' }); + db.addDependencyEdge(blocked.id, blocker.id); // blocked depends on blocker → excluded + + const result = db.findNextWorkItem(undefined, undefined, false, 'plan_complete', false, 'low', 'small'); + expect(result.workItem?.id).toBe(blocker.id); + expect(result.workItem?.id).not.toBe(blocked.id); + }); + + it('no-flag callers see unchanged behavior', () => { + const a = db.create({ title: 'Open A', priority: 'high', status: 'open' }); + db.create({ title: 'Open B', priority: 'medium', status: 'open' }); + + const result = db.findNextWorkItem(); + expect(result.workItem?.id).toBe(a.id); + const batch = db.findNextWorkItems(5); + expect(batch.filter(r => r.workItem).length).toBe(2); + }); + + it('batch -n returns multiple eligible candidates and excludes unset estimates', () => { + db.create({ title: 'Low1', risk: 'Low', status: 'open' }); + db.create({ title: 'Low2', risk: 'Low', status: 'open' }); + db.create({ title: 'Unset', status: 'open' }); + + const results = db.findNextWorkItems(2, undefined, undefined, false, undefined, false, 'low'); + const ids = results.map(r => r.workItem?.id).filter(Boolean); + expect(ids.length).toBe(2); + }); + }); +}); From de063ccad5dd83dd1991faca2c1397ed77ffdac4 Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 01:52:16 +0100 Subject: [PATCH 17/40] WL-0MSMAYIKX005LLO4: Implement-tier dispatch tests (red phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-first matrix for the herdr downtime implement tier (WL-0MSMAYPQP001FLR6). All 36 new tests fail against the current implementation (red phase): - downtime-worker.test.ts: dispatch priority audit → implement → plan → intake; fallback order; no-candidate handling; parseImplementCandidatesOutput (wl next --stage plan_complete --risk low --effort small -n N --json shape); selectImplementCandidate (status=open client-side filter AC2, risk/effort threshold boundaries AC1, dispatched-marker exclusion AC6); implement prompt/pane/comment helpers. - index.test.ts: getNextImplementCandidate dep — wl next invocation with risk/effort flags + --worklog-dir, client-side status=open selection, fail-closed on wl error/malformed output/hung timeout, kind-implement dispatched-marker exclusion, missing-log fail-safe. - downtime-log.test.ts: implementDispatchedItemIds (kind implement only, ignores audit/plan/intake markers). Coverage map: AC1 risk/effort thresholds, AC2 status=open, AC4 dispatch priority, AC5 fallback + CLI-error non-short-circuit, AC6 no-candidate + dispatched-marker exclusion, AC7 kind-implement dispatch logging. Verified red: 36 failed | 224 passed (zero regression). --- packages/herdr/src/downtime-log.test.ts | 26 ++ packages/herdr/src/downtime-worker.test.ts | 322 +++++++++++++++++++++ packages/herdr/src/index.test.ts | 183 ++++++++++++ 3 files changed, 531 insertions(+) diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index a1fb966a..0c4bfef3 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { appendDowntimeLogEntry, auditDispatchedItemIds, + implementDispatchedItemIds, readDowntimeLogEntries, DOWNTIME_LOG_FILE, DOWNTIME_LOG_MAX_ENTRIES, @@ -131,3 +132,28 @@ describe('auditDispatchedItemIds (audit-tier-only scope guard)', () => { expect([...auditDispatchedItemIds([])]).toEqual([]); }); }); + +describe('implementDispatchedItemIds (implement-tier-only scope guard)', () => { + it('collects only implement-kind entries that carry an itemId', () => { + const ids = implementDispatchedItemIds([ + { itemId: 'WL-A', kind: 'implement' }, + { itemId: 'WL-B', kind: 'plan' }, + { itemId: 'WL-C', kind: 'audit' }, + { kind: 'implement' }, // error-style entry without itemId → ignored + { itemId: 'WL-D', kind: 'implement' }, + ]); + expect([...ids].sort()).toEqual(['WL-A', 'WL-D']); + }); + + it('does not collect audit markers (implement tier is scoped to kind implement only)', () => { + const ids = implementDispatchedItemIds([ + { itemId: 'WL-AUD', kind: 'audit' }, + { itemId: 'WL-IMP', kind: 'implement' }, + ]); + expect([...ids]).toEqual(['WL-IMP']); + }); + + it('returns an empty set for empty input', () => { + expect([...implementDispatchedItemIds([])]).toEqual([]); + }); +}); diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index ccb2c3d4..b3f02755 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -11,6 +11,13 @@ * and fail-closed parsing from WL-0MSG80254005ZNE9, and the idle tracker, * dispatch orchestration, pane spawn and worker orchestrator from * WL-0MSG80AG700429M8). + * + * Implement-tier tests (WL-0MSMAYIKX005LLO4): test-first matrix for the + * implement dispatch tier — dispatch priority (audit → implement → plan → + * intake), status=open client-side filter, dispatched-marker exclusion, + * parseImplementCandidatesOutput / selectImplementCandidate, and + * implement prompt/pane helpers. Red phase: these fail until the implement + * tier lands (WL-0MSMAYPQP001FLR6). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -29,6 +36,8 @@ import { buildDowntimeSpawnOptions, BLOCKED_QUESTIONS_INSTRUCTION, parseNextItemOutput, + parseImplementCandidatesOutput, + selectImplementCandidate, parseAuditCandidatesOutput, selectAuditCandidate, toDowntimeCandidate, @@ -49,6 +58,7 @@ import { type DowntimeWorkerDeps, type DowntimeSpawn, type AuditCandidate, + type ImplementCandidate, } from './downtime-worker.js'; import { statusFixtures, @@ -66,6 +76,7 @@ function makeDeps(overrides: Partial = {}): DowntimeWorkerDe return { getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), claimItem: vi.fn().mockResolvedValue(undefined), spawnAgentPane: vi.fn().mockResolvedValue(undefined), recordDispatch: vi.fn().mockResolvedValue(undefined), @@ -415,6 +426,317 @@ describe('dispatch audit tier', () => { }); }); +// ── Implement dispatch tier (WL-0MSMAYIKX005LLO4) ──────────────────── + +describe('dispatch implement tier', () => { + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + it('dispatches /skill:implement on the implement candidate after the audit tier', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledWith('/repo'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-IMP'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-IMP'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('records the implement dispatch with kind implement', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-IMP'); + expect(event.kind).toBe('implement'); + expect(event.cwd).toBe('/repo'); + }); + + it('dispatch priority is audit → implement → plan → intake (audit stays first)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ id: 'WL-AUD', title: 'Audit me', stage: 'audit' }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Audit wins — the implement tier is never consulted when an audit + // candidate exists (audit is the release gate). + expect(outcome.kind).toBe('audit'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to the implement tier when no audit candidate exists, and audit runs first', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to plan (intake_complete) when no implement candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + }); + + it('falls back to intake (idea) when no implement or plan candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + }); + + it('a wl next CLI error at the implement tier does NOT short-circuit the plan/intake fallback', async () => { + // Mirrors the tier-2 (intake_complete) error handling: getNextImplementCandidate + // is fail-closed at the deps boundary (a wl failure yields null), so the + // plan/intake tiers still run. The implement tier itself never signals a + // hard error — null IS the fail-closed shape (AC6). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue(null), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), // wl error → null + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextItem).toHaveBeenCalled(); + }); + + it('no-candidate when all tiers (audit, implement, plan, intake) are empty', async () => { + const deps = makeDeps(); // all four tiers empty + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(2); + }); +}); + +// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── + +describe('implement selection (selectImplementCandidate)', () => { + const open: ImplementCandidate = { + id: 'WL-OPEN', + title: 'open item', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 100, + }; + const completed: ImplementCandidate = { + id: 'WL-DONE', + title: 'completed item', + status: 'completed', + risk: 'low', + effort: 'small', + sortIndex: 200, + }; + + it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { + expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); + }); + + it('excludes completed candidates (status=open client-side filter, AC2)', () => { + expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); + }); + + it('returns null when no open candidate exists', () => { + expect(selectImplementCandidate([completed])).toBeNull(); + }); + + it('returns null on an empty list', () => { + expect(selectImplementCandidate([])).toBeNull(); + }); + + it('sorts ascending by sortIndex (wl next priority order preserved)', () => { + const low = { ...open, id: 'WL-LOW', sortIndex: 50 }; + const high = { ...open, id: 'WL-HIGH', sortIndex: 900 }; + expect(selectImplementCandidate([high, low])?.id).toBe('WL-LOW'); + }); + + it('excludes candidates present in the dispatched marker set (kind implement)', () => { + const dispatched = new Set(['WL-DUP']); + const dup = { ...open, id: 'WL-DUP', sortIndex: 10 }; + const fresh = { ...open, id: 'WL-FRESH', sortIndex: 20 }; + expect(selectImplementCandidate([dup, fresh], dispatched)?.id).toBe('WL-FRESH'); + }); + + it('an empty dispatched set is a no-op', () => { + expect(selectImplementCandidate([open], new Set())?.id).toBe('WL-OPEN'); + }); + + // ── Risk/effort threshold boundaries (AC1) ────────────────────────── + // Belt-and-suspenders client-side guard: even though `wl next --risk low + // --effort small` filters server-side, the herdr tier verifies the + // thresholds again so a malformed/absent server filter can never dispatch + // a Medium+/Large+ item (fail-closed). + + it('only risk exactly Low is eligible (Medium/High/Critical excluded)', () => { + const medium = { ...open, id: 'WL-MED', risk: 'medium' as const }; + const high = { ...open, id: 'WL-HIGH-R', risk: 'high' as const }; + const critical = { ...open, id: 'WL-CRIT', risk: 'critical' as const }; + expect(selectImplementCandidate([medium, high, critical, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([medium])).toBeNull(); + expect(selectImplementCandidate([high])).toBeNull(); + expect(selectImplementCandidate([critical])).toBeNull(); + }); + + it('effort Small and Extra Small eligible (Medium/Large/Extra Large excluded)', () => { + const xs = { ...open, id: 'WL-XS', effort: 'xs' as const }; + const medium = { ...open, id: 'WL-MED-E', effort: 'medium' as const }; + const large = { ...open, id: 'WL-LARGE', effort: 'large' as const }; + const xl = { ...open, id: 'WL-XL', effort: 'xl' as const }; + expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([xs])?.id).toBe('WL-XS'); + expect(selectImplementCandidate([medium])).toBeNull(); + expect(selectImplementCandidate([large])).toBeNull(); + expect(selectImplementCandidate([xl])).toBeNull(); + }); + + it('unset/empty risk or effort items are excluded (fail-closed)', () => { + const noRisk = { ...open, id: 'WL-NORISK', risk: undefined }; + const noEffort = { ...open, id: 'WL-NOEFFORT', effort: undefined }; + expect(selectImplementCandidate([noRisk, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noEffort, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noRisk])).toBeNull(); + expect(selectImplementCandidate([noEffort])).toBeNull(); + }); + + it('recognizes long-form effort spellings (Small / Extra Small)', () => { + const small = { ...open, id: 'WL-S', effort: 'Small' as const }; + const extraSmall = { ...open, id: 'WL-ES', effort: 'Extra Small' as const }; + expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([extraSmall])?.id).toBe('WL-ES'); + }); +}); + +describe('parseImplementCandidatesOutput', () => { + it('parses the wl next -n N { workItems: [{ workItem }] } shape', () => { + const stdout = JSON.stringify({ + success: true, + count: 2, + requested: 10, + results: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + workItems: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed).toHaveLength(2); + expect(parsed?.[0]).toMatchObject({ id: 'WL-A', status: 'open', risk: 'low', effort: 'small' }); + expect(parsed?.[1]).toMatchObject({ id: 'WL-B', status: 'open', effort: 'xs' }); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseImplementCandidatesOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true }))).toBeNull(); + }); + + it('returns an empty array for an empty workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true, count: 0, workItems: [] }))).toEqual([]); + }); + + it('skips entries without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ + success: true, + workItems: [{ workItem: { title: 'no id' } }, { workItem: { id: 'WL-OK', title: 'ok' } }], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed?.length).toBe(1); + expect(parsed?.[0].id).toBe('WL-OK'); + }); +}); + +describe('implement prompt & pane helpers', () => { + const candidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; + + it('builds an /skill:implement prompt', () => { + const prompt = buildDowntimePrompt('implement', candidate); + expect(prompt).toContain('/skill:implement WL-IMP'); + }); + + it('builds a Downtime implement pane name', () => { + const args = buildDowntimePaneArgs('implement', 'Run /skill:implement WL-IMP — Implement me.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toContain('Downtime implement'); + expect(args).toContain('--no-focus'); + }); + + it('skillKindFromPrompt detects an implement prompt', () => { + expect(skillKindFromPrompt('Run /skill:implement WL-IMP — x.')).toBe('implement'); + }); + + it('buildDowntimeDispatchComment renders /skill:implement', () => { + const comment = buildDowntimeDispatchComment('WL-IMP', 'implement', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:implement WL-IMP'); + }); +}); + describe('audit selection (selectAuditCandidate)', () => { // Fixed clock so the 7-day recency filter is deterministic (all fixture // dates fall within the window). diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index cb978746..6066530f 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -834,6 +834,189 @@ describe('createDowntimeDeps', () => { expect(result).toEqual({ id: 'WL-OK', title: 'ok item', stage: 'audit' }); }); + it('getNextImplementCandidate runs wl next plan_complete risk/effort batch and selects the first open item', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + requested: 10, + results: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + workItems: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate('/repo'); + + // The invocation must carry the risk/effort filters and a generous batch + // count (-n) so completed items can be filtered out client-side without + // starving selection (WL-0MSMAYIKX005LLO4 AC2/AC3). + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['next', '--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', expect.any(String), '--json'], + expect.anything(), + ); + // wl next keeps completed items with a stage filter; the client-side + // status=open filter drops the completed epic and selects WL-OPEN. + expect(result).toEqual({ id: 'WL-OPEN', title: 'Open low-risk', stage: 'implement' }); + }); + + it('getNextImplementCandidate returns null when no open candidate exists (all completed)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-DONE', title: 'Completed epic', status: 'completed', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate fails closed (null) when wl errors', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate fails closed (null) on malformed wl output', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: 'not json', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextImplementCandidate('/repo')).toBeNull(); + }); + + it('getNextImplementCandidate passes a bounded timeout so a hung wl fails closed', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextImplementCandidate('/repo'); + + const [, , options] = mockExec.mock.calls[0]; + expect(options).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + }); + + it('getNextImplementCandidate excludes an item already dispatched for implement (kind implement marker)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { workItem: { id: 'WL-DUP', title: 'already dispatched', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-FRESH', title: 'fresh slot', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-DUP', kind: 'implement', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESH', title: 'fresh slot', stage: 'implement' }); + }); + + it('getNextImplementCandidate does not exclude audit/plan/intake markers (implement-tier-only scope guard)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-IMP', title: 'implementable', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-IMP', kind: 'audit', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-IMP', title: 'implementable', stage: 'implement' }); + }); + + it('getNextImplementCandidate treats a missing dispatch log as empty (fail-safe, dispatch still works)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-FRESHWORKLOG', title: 'fresh worklog', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); // no .worklog/downtime-dispatches.log + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextImplementCandidate(cwd); + expect(result).toEqual({ id: 'WL-FRESHWORKLOG', title: 'fresh worklog', stage: 'implement' }); + }); + + it('getNextImplementCandidate applies --worklog-dir when the tab resolved a worklog root', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + setWorklogDir('/home/user/projects/SorraAgents/.worklog'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextImplementCandidate('/repo'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + '--worklog-dir', + '/home/user/projects/SorraAgents/.worklog', + 'next', + '--stage', + 'plan_complete', + '--risk', + 'low', + '--effort', + 'small', + '-n', + expect.any(String), + '--json', + ], + expect.anything(), + ); + }); + it('end-to-end: two consecutive idle windows dispatch a single unaudited candidate exactly once', async () => { // AC5 (parent): with one unaudited completed/in_review candidate and two // consecutive idle windows (no audit recorded between), the worker From 01101b32c58734f7b6054da263ef753a801e633e Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 02:17:40 +0100 Subject: [PATCH 18/40] WL-0MSMAYPQP001FLR6: Implementation complete --- packages/herdr/README.md | 33 ++++- packages/herdr/src/downtime-log.ts | 18 +++ packages/herdr/src/downtime-worker.ts | 198 ++++++++++++++++++++++++-- packages/herdr/src/index.ts | 55 ++++++- 4 files changed, 288 insertions(+), 16 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index f1fe0b9b..99cd3932 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -182,9 +182,12 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in When the local LLM (llama-server behind the llama-proxy) is idle, the plugin can use that compute to advance the worklog backlog automatically: after the proxy reports idle continuously for the configured threshold, it opens a -visible (non-focus-stealing) pi agent pane. Dispatch priority (WL-0MSI8H3HP000K0RG): +visible (non-focus-stealing) pi agent pane. Dispatch priority +(WL-0MSI8H3HP000K0RG, WL-0MSMAYPQP001FLR6): first a completed/in_review item **without a valid audit** → `/skill:audit -`; else `/skill:plan` on the next `intake_complete` item; else falls back +`; else the highest-priority open `plan_complete` item with risk `Low` +and effort `Small`/`Extra Small` → `/skill:implement `; else +`/skill:plan` on the next `intake_complete` item; else falls back to `/skill:intake` on the next `idea` item (parent WL-0MSF49FMW009M06K). A "valid" audit is defined by the review-icon freshness rule: the audit is @@ -251,19 +254,35 @@ fresh worklog. If none, it runs `wl next --stage intake_complete --json` and dispatches `/skill:plan `; if no such item it runs `wl next ---stage idea --json` and dispatches `/skill:intake `; if all three are +--stage idea --json` and dispatches `/skill:intake `; if all four are empty nothing is dispatched. A `wl`/CLI error on the `intake_complete` lookup does **not** skip the `idea` lookup — a tier-3 candidate can still dispatch. The item is claimed (`wl update --status in_progress`) *before* the pane spawns, so it appears in-progress immediately and a second pane's `wl next` -cannot select it. Panes are named `Downtime audit` / `Downtime plan` / +cannot select it. Panes are named `Downtime audit` / `Downtime implement` / +`Downtime plan` / `Downtime intake`, opened with `--no-focus` (visible, never steals focus), `--cwd ` and `--model `. -**Empty-backlog cooldown** — when **both** `wl next` lookups genuinely return -no candidate (the tab's project has nothing to dispatch), the worker enters a -full **pause** for `downtimeNoCandidateCooldownMs` (default 60 minutes): no +**Implement tier (WL-0MSMAYPQP001FLR6)** — after the audit tier, the worker +runs `wl next --stage plan_complete --risk low --effort small -n 10 --json` +and selects the first candidate that is `status: open` (wl next keeps +completed epics under a stage filter — the implement tier filters them out +client-side), re-verifying the risk/effort thresholds fail-closed (only risk +exactly `Low`; effort `Small`/`Extra Small`/`XS`; unset values never match), +and excluding items already dispatched for `/skill:implement` (a `kind: +implement` entry in `.worklog/downtime-dispatches.log` — AC6 dispatched- +marker exclusion, same pattern as the audit tier). Dependency-blocked items +are excluded by `wl next` itself. Dispatch is `/skill:implement ` (pane +named `Downtime implement`). A `wl`/CLI error or empty result at the +implement tier is fail-closed (never a candidate) and does **not** +short-circuit the plan/intake fallback (AC5/AC6). + +**Empty-backlog cooldown** — when the implement, plan, and intake `wl next` +lookups genuinely return no candidate (the tab's project has nothing to +dispatch), the worker enters a full **pause** for `downtimeNoCandidateCooldownMs` +(default 60 minutes): no proxy polling, no idle tracking, and no dispatch until the pause expires — so it stops burning cycles (proxy polling + `wl` spawns) during empty periods. Only a *genuine* empty backlog triggers the pause: transient `wl`/CLI errors diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index f469a6c4..b4faa1f3 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -92,6 +92,24 @@ export function auditDispatchedItemIds(entries: DowntimeLogEntry[]): Set return ids; } +/** + * Build the set of itemIds the downtime worker has already dispatched for + * `/skill:implement` (`kind === 'implement'` entries only). Audit/plan/ + * intake markers are scoped to their own tiers and must NOT suppress + * implement-tier selection (implement-tier-only scope guard, + * WL-0MSMAYPQP001FLR6 AC6). Entries without an itemId (e.g. persistent-error + * events) are ignored. + */ +export function implementDispatchedItemIds(entries: DowntimeLogEntry[]): Set { + const ids = new Set(); + for (const e of entries) { + if (e.kind === 'implement' && typeof e.itemId === 'string' && e.itemId.length > 0) { + ids.add(e.itemId); + } + } + return ids; +} + /** * Append one entry (a JSONL line) to `/.worklog/downtime-dispatches.log`, * creating the `.worklog` directory if needed and trimming the file back to diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index d99b0682..3c251ac9 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -16,7 +16,10 @@ * threshold). * - `dispatchDowntimeWork` — dispatch orchestration: completed/in_review * items without a valid audit (modified within the last 7 days) → - * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then `wl next --stage intake_complete` → + * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then the + * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open + * plan_complete item with risk Low / effort Small|XS → + * `/skill:implement `, then `wl next --stage intake_complete` → * `/skill:plan `, fallback `--stage idea` → `/skill:intake `, * pre-dispatch claim, per-process single-flight. The audit tier * additionally excludes items the downtime worker has already dispatched @@ -389,8 +392,8 @@ export function createDowntimePoller( // ── Dispatch (implemented — F3) ─────────────────────────────────────── -export type DowntimeStage = 'intake_complete' | 'idea' | 'audit'; -export type DowntimeSkillKind = 'plan' | 'intake' | 'audit'; +export type DowntimeStage = 'intake_complete' | 'idea' | 'audit' | 'implement'; +export type DowntimeSkillKind = 'plan' | 'intake' | 'audit' | 'implement'; export interface DowntimeCandidate { id: string; @@ -398,6 +401,23 @@ export interface DowntimeCandidate { stage: DowntimeStage; } +/** + * A candidate for the implement tier, parsed from + * `wl next --stage plan_complete --risk low --effort small -n N --json`. + * The `status` field distinguishes open items from completed epics that + * `wl next` keeps under a stage filter (the implement tier must filter + * client-side to `status === 'open'`, AC2). `sortIndex` preserves wl next's + * priority order for deterministic selection. + */ +export interface ImplementCandidate { + id: string; + title: string; + status: string; + risk?: string; + effort?: string; + sortIndex?: number; +} + /** * A completed/in_review item candidate for the downtime audit tier. * Parsed from `wl list --status completed --stage in_review --json`. @@ -438,6 +458,16 @@ export interface DowntimeWorkerDeps { * they still lack a fresh audit. */ getNextAuditCandidate(cwd: string): Promise; + /** + * Look up the next implement-tier candidate (WL-0MSMAYPQP001FLR6): the + * highest-priority open plan_complete item with risk Low / effort Small|XS, + * excluding dependency-blocked items (wl next default) and items already + * dispatched for `/skill:implement` (kind `implement` dispatched markers, + * AC6). Fail-closed: a wl failure yields null (no dispatch) — the + * plan/intake fallback still runs. `cwd` is the worklog root whose + * `.worklog/downtime-dispatches.log` is consulted for the marker set. + */ + getNextImplementCandidate(cwd: string): Promise; /** Claims the item (`wl update --status in_progress`) before dispatch. */ claimItem(itemId: string): Promise; /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ @@ -498,9 +528,13 @@ let dispatchInFlight = false; * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit AND * NOT already dispatched for audit by this worker → * `/skill:audit ` (the dispatched-marker exclusion, - * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`); if none, `wl next --stage intake_complete` → + * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`); then the + * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open + * plan_complete item with risk Low / effort Small|XS → `/skill:implement ` + * (fail-closed null on wl error or no candidate — never short-circuits the + * fallback, AC5/AC6); if none, `wl next --stage intake_complete` → * `/skill:plan `; if none, `wl next --stage idea` → `/skill:intake `; - * if all three are empty, no dispatch. A CLI error at the intake_complete + * if all four are empty, no dispatch. A CLI error at the intake_complete * tier does NOT short-circuit: the idea tier is still attempted, so a * tier-3 candidate can still dispatch. A `wl-error` outcome (any CLI * failure) is never a candidate and never a `no-candidate`; the caller's @@ -527,6 +561,19 @@ export async function dispatchDowntimeWork( await recordDispatchAudit(deps, 'audit', auditCandidate, opts.cwd); return { dispatched: true, candidate: auditCandidate, kind: 'audit' }; } + // Implement tier (WL-0MSMAYPQP001FLR6): after the audit gate, dispatch + // /skill:implement for the highest-priority open plan_complete item with + // risk Low / effort Small|XS. getNextImplementCandidate is fail-closed + // (null on wl failure or no candidate), so a null here means the tier is + // exhausted and the plan/intake tiers below still run (AC5/AC6 — a wl + // error at the implement tier does NOT short-circuit the fallback). + const implementCandidate = await deps.getNextImplementCandidate(opts.cwd); + if (implementCandidate !== null) { + await deps.claimItem(implementCandidate.id); + await deps.spawnAgentPane(buildDowntimePrompt('implement', implementCandidate), opts); + await recordDispatchAudit(deps, 'implement', implementCandidate, opts.cwd); + return { dispatched: true, candidate: implementCandidate, kind: 'implement' }; + } // Tier 2 (intake_complete → /skill:plan). A CLI error here does NOT // short-circuit: tier 3 is still attempted so a tier-3 candidate can // still dispatch (operator refinement). @@ -855,7 +902,11 @@ export const BLOCKED_QUESTIONS_INSTRUCTION = /** Build the prompt dispatched to a pi agent pane for the given candidate. */ export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: DowntimeCandidate): string { - const skill = kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : '/skill:intake'; + const skill = + kind === 'plan' ? '/skill:plan' + : kind === 'audit' ? '/skill:audit' + : kind === 'implement' ? '/skill:implement' + : '/skill:intake'; return [ `Run ${skill} ${candidate.id} — ${candidate.title}.`, BLOCKED_QUESTIONS_INSTRUCTION, @@ -874,7 +925,11 @@ export function buildDowntimeDispatchComment( dispatchedAt: string, title?: string, ): string { - const skill = kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : '/skill:intake'; + const skill = + kind === 'plan' ? '/skill:plan' + : kind === 'audit' ? '/skill:audit' + : kind === 'implement' ? '/skill:implement' + : '/skill:intake'; const suffix = title ? ` (${title.replace(/[\r\n]+/g, ' ')})` : ''; return `Auto-dispatched by the herdr downtime worker at ${dispatchedAt} — running ${skill} ${itemId}${suffix}.`; } @@ -906,10 +961,12 @@ export function parseNextItemOutput(stdout: string, stage: DowntimeStage): Downt /** * Derive the dispatch kind from a prompt built by `buildDowntimePrompt` - * (the pane name is `Downtime plan` / `Downtime intake` / `Downtime audit`). + * (the pane name is `Downtime plan` / `Downtime intake` / `Downtime audit` / + * `Downtime implement`). */ export function skillKindFromPrompt(prompt: string): DowntimeSkillKind { if (prompt.includes('/skill:audit ')) return 'audit'; + if (prompt.includes('/skill:implement ')) return 'implement'; return prompt.includes('/skill:plan ') ? 'plan' : 'intake'; } @@ -1003,6 +1060,131 @@ export function toDowntimeCandidate(candidate: AuditCandidate): DowntimeCandidat return { id: candidate.id, title: candidate.title, stage: 'audit' }; } +// ── Implement-tier selection (WL-0MSMAYPQP001FLR6) ─────────────────── + +/** + * Ordinal rank of a risk level on the canonical scale + * (Low < Medium < High < Severe/Critical), mirroring the wl next DB filter + * semantics (packages/shared riskOrdinal) for the belt-and-suspenders + * client-side guard. Unset/unknown values map to null (fail-closed). + */ +function riskOrdinal(risk: string | undefined | null): number | null { + switch ((risk ?? '').trim().toLowerCase()) { + case 'low': return 1; + case 'medium': return 2; + case 'high': return 3; + case 'severe': + case 'critical': return 4; + default: return null; + } +} + +/** + * Ordinal rank of an effort level on the canonical scale + * (Extra Small < Small < Medium < Large < Extra Large), mirroring the wl + * next DB filter semantics (packages/shared effortOrdinal). Accepts both + * the short CLI spellings (XS/S/M/L/XL) and long-form spellings, + * normalized case-insensitively. Unset/unknown values map to null + * (fail-closed). + */ +function effortOrdinal(effort: string | undefined | null): number | null { + switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + case 'xs': + case 'extrasmall': return 1; + case 's': + case 'small': return 2; + case 'm': + case 'medium': return 3; + case 'l': + case 'large': return 4; + case 'xl': + case 'extralarge': return 5; + default: return null; + } +} + +/** + * Parse the stdout of `wl next --stage plan_complete --risk low --effort + * small -n N --json` into typed implement candidates. Accepts the + * `{ workItems: [{ workItem: {...} }] }` shape the CLI emits for a batch + * (`-n N`); entries without an id are skipped. Malformed JSON or output + * without a workItems list yields null (fail-closed); an empty list yields + * `[]` (a genuine empty backlog). + */ +export function parseImplementCandidatesOutput(stdout: string): ImplementCandidate[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const items = + parsed !== null && + typeof parsed === 'object' && + Array.isArray((parsed as { workItems?: unknown }).workItems) + ? (parsed as { workItems: unknown[] }).workItems + : null; + if (items === null) return null; + + const candidates: ImplementCandidate[] = []; + for (const raw of items) { + if (typeof raw !== 'object' || raw === null) continue; + const entry = raw as { workItem?: Record }; + const o = entry.workItem; + if (typeof o !== 'object' || o === null) continue; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + candidates.push({ + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + status: typeof o.status === 'string' ? o.status : '', + risk: typeof o.risk === 'string' ? o.risk : undefined, + effort: typeof o.effort === 'string' ? o.effort : undefined, + sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + }); + } + return candidates; +} + +/** + * Select the next implement candidate from parsed wl next output: the + * first candidate that is open (`status === 'open'`, AC2), carries risk + * exactly Low and effort Small/Extra Small (AC1 threshold boundaries, + * fail-closed on unset/unknown), is not in the dispatched-marker set + * (kind `implement`, AC6), sorted ascending by `sortIndex` (wl next + * priority order preserved). Returns null when no candidate qualifies + * (or the list is empty). + * + * Belt-and-suspenders client-side guard (AC1): even though `wl next + * --risk low --effort small` filters server-side, the herdr tier verifies + * the thresholds again so a malformed/absent server filter can never + * dispatch a Medium+/Large+ item. + */ +export function selectImplementCandidate( + candidates: ImplementCandidate[], + dispatchedItemIds?: ReadonlySet, +): ImplementCandidate | null { + const target = candidates + .filter((c) => c.status === 'open') + .filter((c) => { + const risk = riskOrdinal(c.risk); + if (risk === null || risk !== 1) return false; // only risk exactly Low + const effort = effortOrdinal(c.effort); + if (effort === null || effort > 2) return false; // Small (2) + Extra Small (1) + return true; + }) + .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + return target[0] ?? null; +} + +/** + * Turn a parsed implement candidate into a dispatachable `DowntimeCandidate` + * (stage `implement`) for the implement tier. + */ +export function toImplementCandidate(candidate: ImplementCandidate): DowntimeCandidate { + return { id: candidate.id, title: candidate.title, stage: 'implement' }; +} + // ── Settings clamps (implemented — wired into settings.ts by F2) ────── /** diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index f35b15bb..62373271 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -46,8 +46,11 @@ import { spawnDowntimePane, parseNextItemOutput, parseAuditCandidatesOutput, + parseImplementCandidatesOutput, selectAuditCandidate, + selectImplementCandidate, toDowntimeCandidate, + toImplementCandidate, skillKindFromPrompt, type DowntimeWorker, type DowntimeWorkerDeps, @@ -61,7 +64,12 @@ import { buildDowntimeDispatchComment, DOWNTIME_WL_TIMEOUT_MS, } from './downtime-worker.js'; -import { appendDowntimeLogEntry, auditDispatchedItemIds, readDowntimeLogEntries } from './downtime-log.js'; +import { + appendDowntimeLogEntry, + auditDispatchedItemIds, + implementDispatchedItemIds, + readDowntimeLogEntries, +} from './downtime-log.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -392,6 +400,51 @@ export function createDowntimeDeps( return null; } }, + async getNextImplementCandidate(cwd: string): Promise { + try { + // Implement tier (WL-0MSMAYPQP001FLR6): select the highest-priority + // open plan_complete item with risk Low / effort Small|XS. The wl + // next server-side at-most filters (--risk low --effort small, + // delivered by WL-0MSMAIP5F003WAGG) do the heavy lifting; a + // generous batch (-n 10) is fetched so completed epics (which wl + // next keeps under a stage filter) can be filtered out client-side + // without starving selection. The bounded timeout + // (WL-0MSJIPHD0001L1J9) kills a hung wl child. Fail-closed: a wl + // failure yields no candidate (no dispatch) and never short-circuits + // the plan/intake fallback (AC6). + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs([ + 'next', + '--stage', + 'plan_complete', + '--risk', + 'low', + '--effort', + 'small', + '-n', + '10', + '--json', + ]), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const candidates = parseImplementCandidatesOutput(stdout); + if (candidates === null) return null; + // Dispatched-marker exclusion (AC6): read the shared rolling + // dispatch log for THIS worklog root and exclude any candidate the + // downtime worker has already dispatched for /skill:implement + // (kind implement markers). Fail-safe: readDowntimeLogEntries never + // throws — a missing or unreadable log is treated as empty, so + // implement-tier dispatch still works on a fresh worklog. + const entries = await readDowntimeLogEntries(cwd); + const dispatchedImplementIds = implementDispatchedItemIds(entries); + const selected = selectImplementCandidate(candidates, dispatchedImplementIds); + return selected === null ? null : toImplementCandidate(selected); + } catch { + // Fail-closed: a wl failure yields no candidate (no dispatch). + return null; + } + }, async claimItem(itemId: string): Promise { // claimWorkItem never throws — failures are returned, but the result is // deliberately discarded here: a failed claim must not block the From 19bcc7fa27bba653ec40a5ab34e34c81ec90374c Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 09:28:13 +0100 Subject: [PATCH 19/40] WL-0MSMYP1CB003XVTZ: Implement-tier dependency-blocked exclusion test coverage --- packages/herdr/src/index.test.ts | 23 +++++++++++++++++++++++ tests/cli/next-risk-effort-cli.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 6066530f..305f288a 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -869,6 +869,29 @@ describe('createDowntimeDeps', () => { expect(result).toEqual({ id: 'WL-OPEN', title: 'Open low-risk', stage: 'implement' }); }); + it('getNextImplementCandidate relies on wl next default dependency-blocked exclusion (no --include-blocked)', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { workItem: { id: 'WL-OPEN', title: 'Open low-risk', status: 'open', risk: 'low', effort: 'small' } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextImplementCandidate('/repo'); + + const [, args] = mockExec.mock.calls[0]; + // Dependency-blocked exclusion is wl next's default (includeBlocked=false). + // The implement tier must never opt into --include-blocked, or blocked + // candidates would reach the dispatch layer (WL-0MSMAYIKX005LLO4 AC3). + expect(args).not.toContain('--include-blocked'); + }); + it('getNextImplementCandidate returns null when no open candidate exists (all completed)', async () => { const mockExec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ diff --git a/tests/cli/next-risk-effort-cli.test.ts b/tests/cli/next-risk-effort-cli.test.ts index 091c9539..cedaf79a 100644 --- a/tests/cli/next-risk-effort-cli.test.ts +++ b/tests/cli/next-risk-effort-cli.test.ts @@ -38,6 +38,12 @@ describe('wl next --risk/--effort CLI (WL-0MSMAIP5F003WAGG)', () => { } } + async function createItemGetId(args: string[]): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + const parsed = JSON.parse(stdout); + return parsed.workItem.id; + } + async function runNext(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { try { const res = await execAsync(`tsx ${cliPath} --json next ${args.join(' ')}`, { cwd: tempDir }); @@ -93,4 +99,20 @@ describe('wl next --risk/--effort CLI (WL-0MSMAIP5F003WAGG)', () => { const parsed = JSON.parse(stdout); expect(parsed.workItem?.title).toBe('A'); }); + + it('dependency-blocked plan_complete Low/Small items are excluded by the implement-tier invocation', async () => { + // Fixture: an eligible blocker and an eligible item that depends on it. + // wl next excludes dependency-blocked items by default (includeBlocked=false), + // so the blocked item must never appear in the implement tier's candidate list. + const blockerId = await createItemGetId(['-t', '"Blocker"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + const blockedId = await createItemGetId(['-t', '"Blocked"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerId}`, { cwd: tempDir }); + + // Exact implement-tier invocation (packages/herdr/src/index.ts getNextImplementCandidate). + const { stdout } = await runNext(['--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', '10']); + const parsed = JSON.parse(stdout); + const ids = parsed.workItems?.map((r: any) => r.workItem?.id) ?? []; + expect(ids).toContain(blockerId); // the blocker is itself eligible + expect(ids).not.toContain(blockedId); // blocked candidates never reach the dispatch layer + }); }); From 949371a021e66ed33facc601b4ab068a2ca4f4ff Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 11:28:52 +0100 Subject: [PATCH 20/40] WL-0MSMYP1CB003XVTZ: Implement-tier blocked-exclusion fixture test (end-to-end) --- .../implement-tier-blocked-exclusion.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/herdr/src/implement-tier-blocked-exclusion.test.ts diff --git a/packages/herdr/src/implement-tier-blocked-exclusion.test.ts b/packages/herdr/src/implement-tier-blocked-exclusion.test.ts new file mode 100644 index 00000000..7916355b --- /dev/null +++ b/packages/herdr/src/implement-tier-blocked-exclusion.test.ts @@ -0,0 +1,71 @@ +/** + * End-to-end fixture test for the implement tier's dependency-blocked + * exclusion (WL-0MSMYP1CB003XVTZ, closes the parent AC8 coverage gap): + * creates a REAL blocked fixture in a temp worklog and proves the exact + * implement-tier invocation — `wl next --stage plan_complete --risk low + * --effort small -n 10 --json` (packages/herdr/src/index.ts + * getNextImplementCandidate) — never yields the dependency-blocked item. + * Feeding the real CLI output through the implement tier's own parser + * (`parseImplementCandidatesOutput`) and selector + * (`selectImplementCandidate`) proves a blocked candidate can never be + * dispatched. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, writeConfig, writeInitSemaphore, cliPath } from '../../../tests/cli/cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../../../tests/test-utils.js'; +import { parseImplementCandidatesOutput, selectImplementCandidate } from './downtime-worker.js'; + +describe('implement tier dependency-blocked exclusion (fixture, WL-0MSMYP1CB003XVTZ)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempDir); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + async function createItemGetId(args: string[]): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json create ${args.join(' ')}`, { cwd: tempDir }); + const parsed = JSON.parse(stdout); + return parsed.workItem.id; + } + + it( + 'the implement-tier invocation never yields a dependency-blocked candidate, so dispatch can never select one', + async () => { + // Fixture: a high-priority eligible blocker and an eligible item that + // depends on it. While the blocker is open, the dependent is + // dependency-blocked and must be excluded by wl next's default + // (includeBlocked=false) — the same exclusion the implement tier + // relies on (never passing --include-blocked). + const blockerId = await createItemGetId(['-t', '"Blocker"', '-p', 'high', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + const blockedId = await createItemGetId(['-t', '"Blocked"', '--stage', 'plan_complete', '--risk', 'Low', '--effort', 'Small', '--no-re-sort']); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerId}`, { cwd: tempDir }); + + // The exact implement-tier invocation (packages/herdr/src/index.ts). + const { stdout } = await execAsync( + `tsx ${cliPath} --json next --stage plan_complete --risk low --effort small -n 10`, + { cwd: tempDir }, + ); + + // Feed the real CLI output through the implement tier's own parser + // and selector — the blocked candidate must never reach dispatch. + const candidates = parseImplementCandidatesOutput(stdout); + expect(candidates).not.toBeNull(); + const ids = (candidates ?? []).map((c) => c.id); + expect(ids).toContain(blockerId); + expect(ids).not.toContain(blockedId); + + const selected = selectImplementCandidate(candidates ?? [], new Set()); + expect(selected).not.toBeNull(); + expect(selected?.id).toBe(blockerId); + expect(selected?.id).not.toBe(blockedId); + }, + 60_000, + ); +}); From aa656f021320c3a9b2b8588b7dc792852b0f990d Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 20:33:00 +0100 Subject: [PATCH 21/40] WL-0MSNMPCHZ002SIQJ: Add docs to work_item_types allowlist for n/p/i shortcuts docs-typed work items (e.g. SA-0MSKZNHEQ003NW5J) are now eligible for the intake/plan/implement shortcuts. Updates shortcuts.json, README, and tests (all 771 herdr tests pass). --- packages/herdr/README.md | 10 +++++----- packages/herdr/src/shortcut-config.test.ts | 11 +++++------ packages/herdr/src/shortcuts.json | 6 +++--- packages/herdr/src/worklist.test.ts | 4 ++-- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 99cd3932..82ed548e 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -695,7 +695,7 @@ limiting the shortcut to work items whose issue type is listed | `work_item_types` value | Behaviour | |---|---| | `["podcast"]` | Shortcut visible only on `podcast`-typed work items | -| `["bug","feature","task","chore","epic"]` | Shortcut visible only on code work item types | +| `["bug","docs","feature","task","chore","epic"]` | Shortcut visible only on code and docs work item types | | omitted | Always shown (backward compatible) | Semantics: @@ -713,10 +713,10 @@ Semantics: whose allowlist misses it, so footer hints, chord hints, and dispatch lookups all respect the gating automatically. - **Bundled restriction:** the code-workflow chords `n` (intake), `p` (plan), - and `i` (implement) carry `work_item_types: ["bug","feature","task", - "chore","epic"]`, so they are hidden on non-code types (e.g. `podcast`, - `docs`). All other bundled shortcuts (audit `a-*`, producer review `r`, - housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are + and `i` (implement) carry `work_item_types: ["bug","docs","feature", + "task","chore","epic"]`, so they are hidden on non-code and non-docs types + (e.g. `podcast`). All other bundled shortcuts (audit `a-*`, producer review + `r`, housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are available on all types. Consumer projects can add their own type-gated chords (e.g. a `w` chord leader → `wiki-podcast-script` for `podcast` items) via the project-local `shortcuts.json` mechanism above. diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index 26dec021..30c28e4c 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -118,9 +118,9 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(entry?.command).toBe('/skill:implement '); }); - it('restricts the bundled code-workflow chords n/p/i to code issue types', () => { + it('restricts the bundled code-workflow chords n/p/i to code and docs issue types', () => { const registry = loadShortcutConfig(); - const codeTypes = ['bug', 'feature', 'task', 'chore', 'epic']; + const codeTypes = ['bug', 'docs', 'feature', 'task', 'chore', 'epic']; for (const chord of ['n', 'p', 'i']) { const entry = registry.lookupChordEntry([chord], 'list'); expect(entry?.workItemTypes).toEqual(codeTypes); @@ -332,7 +332,7 @@ describe('ShortcutRegistry — issue-type filtering', () => { command: '/skill:implement ', view: 'both', label: 'implement', - workItemTypes: ['bug', 'feature', 'task', 'chore', 'epic'], + workItemTypes: ['bug', 'docs', 'feature', 'task', 'chore', 'epic'], }; const untypedEntry: ShortcutEntry = { @@ -354,12 +354,11 @@ describe('ShortcutRegistry — issue-type filtering', () => { expect(registry.lookupChord(['w'], 'list', undefined, false, 'docs')).toBeUndefined(); }); - it('resolves a code-gated chord only for code item types', () => { - for (const t of ['bug', 'feature', 'task', 'chore', 'epic']) { + it('resolves a code-gated chord only for code and docs item types', () => { + for (const t of ['bug', 'docs', 'feature', 'task', 'chore', 'epic']) { expect(registry.lookupChord(['i'], 'list', undefined, false, t)).toBe('/skill:implement '); } expect(registry.lookupChord(['i'], 'list', undefined, false, 'podcast')).toBeUndefined(); - expect(registry.lookupChord(['i'], 'list', undefined, false, 'docs')).toBeUndefined(); }); it('keeps untyped chords available on every type', () => { diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index f6ac6818..264af6be 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -21,7 +21,7 @@ "label": "intake", "description": "Ensure that the selected item is reasonably well defined in terms of objectives.", "model": "plan", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -35,7 +35,7 @@ "label": "plan", "description": "Run the plan workflow on the selected work item", "model": "plan", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ @@ -52,7 +52,7 @@ "description": "Run the implement workflow on the selected work item", "model": "code", "code_freeze": "block", - "work_item_types": ["bug", "feature", "task", "chore", "epic"] + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] }, { "chord": [ diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index abcd3958..3a9e761a 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -920,10 +920,10 @@ describe('issue-type shortcut filtering — worklist integration', () => { registry = loadShortcutConfig(); }); - it('treats i as a chord leader only for code item types', () => { + it('treats i as a chord leader only for code and docs item types', () => { expect(isChordLeader('i', registry, false, 'feature')).toBe(true); + expect(isChordLeader('i', registry, false, 'docs')).toBe(true); expect(isChordLeader('i', registry, false, 'podcast')).toBe(false); - expect(isChordLeader('i', registry, false, 'docs')).toBe(false); }); it('treats n and p as chord leaders only for code item types', () => { From fef02f146bd7de745c84697f1603e48f0db73ffc Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 10 Aug 2026 23:57:35 +0100 Subject: [PATCH 22/40] WL-0MSKFFJWD002BQJ5: render markdown with marked (GFM) in detail description and md viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled line parser in packages/herdr with the marked v18.x lexer + a custom ANSI token walker: - md-viewer.ts: full GFM rendering — heading hierarchy (h1=██/h2=█/h3+=▌), ordered + nested bullet lists, blockquotes, aligned GFM tables, fenced + inline code, bold/italic/strikethrough, links (underlined + dim href), horizontal rules, word-wrapped paragraphs; NOTE markers still render as ↗ (renderNoteLinks applied pre-lex); YAML frontmatter skipped in the viewer; fail-open on parse errors. - worklist.ts: description section now renders via renderMarkdown (GFM) instead of raw line-splitting + wrap. - package.json: add marked ^18.0.5 dependency (installed into packages/herdr/node_modules). - md-viewer.test.ts: +9 GFM tests (headings, lists, blockquote, tables, inline styling, links, hr, NOTE-in-GFM, description GFM); updated h1 glyph assertion. - README.md: Markdown viewer section documents supported GFM constructs, description rendering, and the marked dependency. Full repo suite green (4254 passed, 5 skipped); root tsc clean. --- packages/herdr/README.md | 19 +- packages/herdr/package.json | 3 +- packages/herdr/src/md-viewer.test.ts | 126 +++++++- packages/herdr/src/md-viewer.ts | 421 ++++++++++++++++++++++----- packages/herdr/src/worklist.ts | 27 +- 5 files changed, 506 insertions(+), 90 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 82ed548e..d2fc3a2a 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -21,7 +21,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **Metadata panel** — The bottom portion of the list view (roughly 20–40% of the pane height, responsive to terminal size) is reserved for the selected item's metadata: ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). - **Command log** — Every plugin-dispatched command that targets a work item (via `` substitution or an explicit item ID) is recorded to a local JSON log. For `in_progress` items the panel shows the **last command** at the bottom, so you can see exactly what was last dispatched against the item. See [Command log](#command-log). - **Stage grouping** — Work items are grouped by their Worklog stage (standard lifecycle stages only: `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done` — no custom stage values). Podcast episode items group exactly as their frontmatter stages map 1:1 (PRD §7.2). See [Stage grouping](#stage-grouping). -- **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, headings/lists/code shown) as a preview. A persistent **Related Docs** table of contents at the top of the detail view lists every `.md` Key File (`↑↓/j:k` to navigate, `Enter` to open in the viewer), and the metadata panel shows a display-only `Related Docs` row. See [Markdown viewer](#markdown-viewer). +- **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, full GFM rendering: headings, lists, tables, blockquotes, code, links) as a preview. The description section is rendered with the same markdown renderer. A persistent **Related Docs** table of contents at the top of the detail view lists every `.md` Key File (`↑↓/j:k` to navigate, `Enter` to open in the viewer), and the metadata panel shows a display-only `Related Docs` row. See [Markdown viewer](#markdown-viewer). - **Inline note links** — Inline `[NOTE : ...]` markers (PRD §7.1) render as clickable links to the note work items: the marker is displayed as `↗`, and the note text is never shown in the viewer. See [Inline note links](#inline-note-links). - **Code Freeze awareness** — While a ship-it release is in progress the project is in *Code Freeze*: the worklist shows a prominent banner and blocks all implement commands (`/skill:implement*`) with a notice dialog until the release finishes. See [Code Freeze](#code-freeze). @@ -513,10 +513,25 @@ that file with a generic markdown viewer instead of showing only the raw description. The viewer: - skips the YAML frontmatter block; -- renders ATX headings, bullet lists, fenced code blocks, and paragraphs; +- renders the full GFM construct set used in `.podcast.md` episode files and + work-item descriptions: ATX heading hierarchy (`#`…`######` with distinct + glyphs per level), ordered and nested bullet lists, blockquotes, GFM + tables (aligned columns), fenced and inline code, bold/italic/ + strikethrough, links, horizontal rules, and paragraphs (word-wrapped to + the terminal width); +- renders inline `[NOTE : ...]` markers as `↗` links (see + [Inline note links](#inline-note-links)); - is preview-only (no notes editor); - falls back to the raw description when the file is missing/unreadable. +The **description section** of the detail view is rendered with the same +markdown renderer, so GFM-heavy descriptions (tables, bold/italic, inline +code, links, lists) display properly instead of as raw text. Markdown is +parsed with the [`marked`](https://github.com/markedjs/marked) library (a +declared dependency of `packages/herdr`), which is also present in the pi +agent dependency tree (`@earendil-works/pi-tui`), so the compatibility is +proven. + Key Files paths are resolved against the **worklog root** (the directory containing `.worklog/`, from `HERDR_RESOLVED_CWD` / `configureWorklogTarget`) — never the plugin pane's process CWD, which is the plugin source dir — with diff --git a/packages/herdr/package.json b/packages/herdr/package.json index e536275a..4824f63a 100644 --- a/packages/herdr/package.json +++ b/packages/herdr/package.json @@ -9,6 +9,7 @@ "dev": "tsx src/index.ts" }, "dependencies": { - "@worklog/shared": "file:../../packages/shared" + "@worklog/shared": "file:../../packages/shared", + "marked": "^18.0.5" } } diff --git a/packages/herdr/src/md-viewer.test.ts b/packages/herdr/src/md-viewer.test.ts index d5e8d08f..9e4b9928 100644 --- a/packages/herdr/src/md-viewer.test.ts +++ b/packages/herdr/src/md-viewer.test.ts @@ -98,8 +98,8 @@ describe('renderMarkdownViewer', () => { // Frontmatter is not rendered. expect(joined).not.toContain('pipeline_stage'); expect(joined).not.toContain('version:'); - // Heading rendered. - expect(joined).toContain('█ Episode One'); + // Heading rendered (h1 glyph; bold ANSI wraps the heading text). + expect(visibleOf(lines)).toContain('██ Episode One'); // Dialogue paragraphs rendered. expect(joined).toContain('Nova:'); expect(joined).toContain('Sorra:'); @@ -127,6 +127,101 @@ describe('renderMarkdownViewer', () => { }); }); +// ── GFM rendering (WL-0MSKFFJWD002BQJ5) ─────────────────────────────── + +/** Strip ANSI SGR codes so tests assert on visible text. */ +function visibleOf(lines: string[]): string { + return lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); +} + +describe('renderMarkdownViewer — GFM constructs', () => { + it('renders heading hierarchy with distinct glyphs per depth', () => { + const joined = visibleOf(renderMarkdownViewer('# One\n\n## Two\n\n### Three\n\n###### Six', 80)); + expect(joined).toContain('██ One'); + expect(joined).toContain('█ Two'); + expect(joined).toContain('▌ Three'); + expect(joined).toContain('▌ Six'); + }); + + it('renders ordered and nested bullet lists', () => { + const md = [ + '1. first', + '2. second', + '', + '- top', + ' - nested one', + ' - deep two', + '- bottom', + ].join('\n'); + const joined = visibleOf(renderMarkdownViewer(md, 80)); + expect(joined).toContain('1. first'); + expect(joined).toContain('2. second'); + expect(joined).toContain('• top'); + expect(joined).toContain(' • nested one'); + expect(joined).toContain(' • deep two'); + expect(joined).toContain('• bottom'); + }); + + it('renders blockquotes indented with a quote glyph', () => { + const joined = visibleOf(renderMarkdownViewer('> quoted line one\n> quoted line two', 80)); + expect(joined).toContain('▌ quoted line one'); + expect(joined).toContain('▌ quoted line two'); + }); + + it('renders GFM tables as aligned columns with a header row', () => { + const md = [ + '| Left | Center | Right |', + '|:-----|:------:|------:|', + '| a | b | c |', + '| long | mid | x |', + ].join('\n'); + const visible = visibleOf(renderMarkdownViewer(md, 80)); + // Header row and alignment (center column padded both sides, right + // column right-aligned) render with column separators. + expect(visible).toContain('│ Left │'); + expect(visible).toContain('│ a │ b │ c │'); + expect(visible).toContain('│ long │ mid │ x │'); + // Separator row between header and body. + expect(visible).toMatch(/├.*┼.*┤/); + }); + + it('renders bold, italic, strikethrough, and inline code with styling', () => { + const md = 'Para **bold** *italic* ~~struck~~ `inline code`.'; + const joined = renderMarkdownViewer(md, 80).join('\n'); + // Visible text survives styling. + expect(joined).toContain('bold'); + expect(joined).toContain('italic'); + expect(joined).toContain('struck'); + // Styles applied: ANSI bold around bold text, italic around italic, + // strikethrough around struck, cyan inside backticks for code. + expect(joined).toContain(`\x1b[1mbold\x1b[0m`); + expect(joined).toContain(`\x1b[3mitalic\x1b[0m`); + expect(joined).toContain(`\x1b[9mstruck\x1b[0m`); + expect(joined).toContain('`\x1b[36minline code\x1b[0m`'); + }); + + it('renders links underlined/colored with the href shown dimmed', () => { + const joined = renderMarkdownViewer('See [the docs](https://example.com/guide).', 80).join('\n'); + expect(joined).toContain(`\x1b[4m\x1b[34mthe docs\x1b[0m`); + expect(joined).toContain(`\x1b[2m (https://example.com/guide)\x1b[0m`); + }); + + it('renders horizontal rules as a line of dashes', () => { + const joined = visibleOf(renderMarkdownViewer('before\n\n---\n\nafter', 80)); + expect(joined).toContain('before'); + expect(joined).toContain('after'); + expect(joined).toMatch(/\n─{3,}\n/); + }); + + it('renders NOTE markers as links inside GFM content', () => { + const md = '# H\n\nA line with [NOTE OSL-CCC: hidden note text] inside.'; + const joined = renderMarkdownViewer(md, 120).join('\n'); + expect(joined).toContain('OSL-CCC↗'); + expect(joined).not.toContain('[NOTE'); + expect(joined).not.toContain('hidden note text'); + }); +}); + // ── Podcast item stage grouping (standard stages only) ──────────────── /** Build a minimal WorkItem with a stage (podcast items use standard stages). */ @@ -248,6 +343,33 @@ describe('detail-view wiring', () => { expect(joined).not.toContain('[NOTE'); }); + it('formatDetailContent renders the description as GFM (table, bold, italic, code, links, lists)', () => { + const item = makeEpisodeItem( + [ + '**Bold** and *italic* and `code` and [a link](https://example.com).', + '', + '| A | B |', + '|---|---|', + '| 1 | 2 |', + '', + '- one', + '- two', + ].join('\n'), + ); + const visible = formatDetailContent(item, 120).join('\n').replace(/\x1b\[[0-9;]*m/g, ''); + // Bold/italic/code/link text renders. + expect(visible).toContain('Bold'); + expect(visible).toContain('italic'); + expect(visible).toContain('`code`'); + expect(visible).toContain('a link'); + // Table renders with aligned columns. + expect(visible).toContain('│ A │ B │'); + expect(visible).toContain('│ 1 │ 2 │'); + // Lists render as bullets. + expect(visible).toContain('• one'); + expect(visible).toContain('• two'); + }); + it('formatDetailContent embeds the md viewer section when readFile is provided', () => { const item = makeEpisodeItem( '**Key Files:**\n- `podcast/episode-one.podcast.md`\n\nSome description.', diff --git a/packages/herdr/src/md-viewer.ts b/packages/herdr/src/md-viewer.ts index e652feb0..fcecf898 100644 --- a/packages/herdr/src/md-viewer.ts +++ b/packages/herdr/src/md-viewer.ts @@ -2,20 +2,38 @@ * packages/herdr/src/md-viewer.ts — Generic markdown document viewer helpers * for the Herdr worklist-selection-list plugin. * - * Pure, dependency-free rendering helpers used to preview an episode file - * (opened from a work item's `Key Files:`) in a viewer pane, and to render - * inline `[NOTE : ...]` markers (PRD §7.1) as clickable links to the - * note work items (operator decision: marker-as-link rendering is required). + * Pure, dependency-injectable rendering helpers used to preview an episode + * file (opened from a work item's `Key Files:`) in a viewer pane, and to + * render inline `[NOTE : ...]` markers (PRD §7.1) as clickable links to + * the note work items (operator decision: marker-as-link rendering is + * required). * - * The functions here are intentionally minimal and side-effect free so they - * can be unit-tested without a terminal or pane harness: + * Markdown is parsed with the `marked` library (declared dependency of + * `packages/herdr`) and rendered to ANSI-styled terminal lines via a custom + * token walker that emits the constructs used in `.podcast.md` episode + * files and work-item descriptions: + * + * - ATX heading hierarchy (`#`…`######`, distinct glyphs per level), + * - ordered and nested bullet lists, + * - blockquotes, + * - GFM tables (aligned columns), + * - fenced code blocks and inline code spans, + * - bold / italic / strikethrough / links, + * - horizontal rules, + * - paragraphs (word-wrapped to the terminal width). + * + * The functions here are intentionally side-effect free so they can be + * unit-tested without a terminal or pane harness: + * - `renderMarkdown()` — core renderer (NOTE markers always rendered as + * links; optional YAML frontmatter skip for file bodies). * - `renderMarkdownViewer()` — formats a markdown file body as terminal - * lines (paragraphs, headings, lists, fenced code blocks) with NOTE - * markers rendered as links. + * lines with YAML frontmatter skipped. * - `renderNoteLinks()` — rewrites inline `[NOTE : ...]` markers into a * link representation (id + "↗" indicator) and returns the note ids. */ +import { lexer, type Token, type Tokens } from 'marked'; + // ── NOTE-marker handling ────────────────────────────────────────────── /** @@ -57,90 +75,357 @@ export function renderNoteLinks(text: string): string { return text.replace(NOTE_MARKER_RE, (_marker, id: string) => `${id}↗`); } -// ── Markdown viewer rendering ───────────────────────────────────────── +// ── ANSI styling and width helpers ──────────────────────────────────── + +/** ANSI escape sequences used for terminal markdown rendering. */ +const STYLE = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + italic: '\x1b[3m', + underline: '\x1b[4m', + strike: '\x1b[9m', + cyan: '\x1b[36m', + blue: '\x1b[34m', +} as const; /** ANSI dim/glyph helpers for viewer output (kept minimal). */ const VIEWER_GLYPHS = { + headingH1: '██', heading: '█', + headingDeep: '▌', bullet: '•', code: '│', + quote: '▌', + table: '│', + hr: '─', } as const; +/** Strip ANSI escape sequences (SGR codes) from a styled string. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + /** - * Render a markdown document body as terminal viewer lines. - * - * Supports the constructs used in `.podcast.md` episode files and work-item - * descriptions: ATX headings (`#`…`######`), bullet lists, fenced code - * blocks, blank-line-separated paragraphs, and inline `[NOTE : ...]` - * markers (rendered as links, see `renderNoteLinks`). YAML frontmatter - * (`---`-delimited) is skipped. - * - * @param mdText - Raw markdown document text. - * @param maxCols - Terminal width to wrap/truncate lines to. - * @returns Viewer lines (no trailing padding; callers pad to viewport). + * Truncate a styled line to at most `maxCols` visible characters, copying + * ANSI escape sequences through and closing any open styling at the end. */ -export function renderMarkdownViewer(mdText: string, maxCols: number): string[] { - if (!mdText) return []; - - const rawLines = mdText.split('\n'); - const lines: string[] = []; - let inFrontmatter = false; - let inCodeBlock = false; - let sawFrontmatterClose = false; - - for (const raw of rawLines) { - const line = raw.trimEnd(); - - // YAML frontmatter: skip everything between a leading `---` and its close. - if (!inFrontmatter && !sawFrontmatterClose && line.trim() === '---') { - inFrontmatter = true; - continue; - } - if (inFrontmatter) { - if (line.trim() === '---') { - inFrontmatter = false; - sawFrontmatterClose = true; +function truncateAnsi(line: string, maxCols: number): string { + if (stripAnsi(line).length <= maxCols) return line; + let result = ''; + let vis = 0; + let i = 0; + while (vis < maxCols && i < line.length) { + if (line[i] === '\x1b' && line[i + 1] === '[') { + const end = line.indexOf('m', i); + if (end >= 0) { + result += line.slice(i, end + 1); + i = end + 1; + continue; } - continue; } + result += line[i]; + vis += 1; + i += 1; + } + return `${result}${STYLE.reset}`; +} - // Fenced code blocks. - if (line.trim().startsWith('```')) { - inCodeBlock = !inCodeBlock; - lines.push(''); - continue; +/** + * Word-wrap a styled string to at most `maxCols` visible characters per + * line, honouring ANSI sequences for width. Words longer than `maxCols` + * are truncated (with open styling closed) so a single line never exceeds + * the terminal width. + */ +function wrapText(text: string, maxCols: number): string[] { + if (maxCols <= 0) return [text]; + const out: string[] = []; + let cur = ''; + let curPlain = 0; + for (const word of text.split(/\s+/)) { + if (word === '') continue; + const wordPlain = stripAnsi(word).length; + if (cur && curPlain + 1 + wordPlain > maxCols) { + out.push(cur); + cur = ''; + curPlain = 0; } - if (inCodeBlock) { - lines.push(` ${VIEWER_GLYPHS.code} ${renderNoteLinks(line)}`); + if (wordPlain > maxCols) { + if (cur) { + out.push(cur); + cur = ''; + curPlain = 0; + } + out.push(truncateAnsi(word, maxCols)); continue; } + if (cur) { + cur += ' '; + curPlain += 1; + } + cur += word; + curPlain += wordPlain; + } + if (cur) out.push(cur); + return out.length > 0 ? out : ['']; +} - const trimmed = line.trim(); +// ── Inline token rendering ──────────────────────────────────────────── - // ATX headings. - const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed); - if (heading) { - lines.push(`${VIEWER_GLYPHS.heading} ${renderNoteLinks(heading[2])}`); - continue; +/** + * Render a list of inline tokens to an ANSI-styled string. + * + * @param tokens - Inline tokens (from a paragraph/heading/table cell/...). + * @returns Styled text; embedded `\n` (hard line breaks) are preserved. + */ +function renderInline(tokens: Token[]): string { + let out = ''; + for (const tok of tokens) { + switch (tok.type) { + case 'text': + case 'escape': + out += tok.text; + break; + case 'strong': + out += `${STYLE.bold}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'em': + out += `${STYLE.italic}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'del': + out += `${STYLE.strike}${renderInline(tok.tokens ?? [])}${STYLE.reset}`; + break; + case 'codespan': + out += `\`${STYLE.cyan}${tok.text}${STYLE.reset}\``; + break; + case 'link': { + const label = renderInline(tok.tokens ?? []); + out += `${STYLE.underline}${STYLE.blue}${label}${STYLE.reset}`; + if (tok.href && tok.href !== label) { + out += `${STYLE.dim} (${tok.href})${STYLE.reset}`; + } + break; + } + case 'image': + out += `${STYLE.dim}[image: ${tok.text}]${STYLE.reset}`; + break; + case 'checkbox': + out += tok.checked ? '[x]' : '[ ]'; + break; + case 'br': + out += '\n'; + break; + case 'html': + // Raw inline HTML tags are not rendered; strip them so the text + // content (if any) still shows. + out += tok.text.replace(/<[^>]*>/g, ''); + break; + default: + break; } + } + return out; +} - // Bullet lists. - const bullet = /^[-*+]\s+(.*)$/.exec(trimmed); - if (bullet) { - lines.push(` ${VIEWER_GLYPHS.bullet} ${renderNoteLinks(bullet[1])}`); - continue; +/** + * Inline children of a token, or the token itself as a single inline + * token when it carries no children (e.g. block-level `text` tokens from + * tight list items keep their inline-parsed children in `tokens`). + */ +function inlineChildren(tok: Token): Token[] { + return 'tokens' in tok && Array.isArray(tok.tokens) && tok.tokens.length > 0 + ? tok.tokens + : [tok]; +} + +// ── Block token rendering ───────────────────────────────────────────── + +/** Heading glyph per ATX depth: h1 = `██`, h2 = `█`, h3+ = `▌`. */ +function headingGlyph(depth: number): string { + if (depth <= 1) return VIEWER_GLYPHS.headingH1; + if (depth === 2) return VIEWER_GLYPHS.heading; + return VIEWER_GLYPHS.headingDeep; +} + +/** Render a GFM table as aligned terminal columns. */ +function renderTable(tok: Tokens.Table, pad: string): string[] { + const renderCell = (cell: Tokens.TableCell, header: boolean): { styled: string; plain: string } => { + const text = renderInline(cell.tokens); + const styled = header ? `${STYLE.bold}${text}${STYLE.reset}` : text; + return { styled, plain: stripAnsi(styled) }; + }; + const header = tok.header.map(c => renderCell(c, true)); + const rows = tok.rows.map(r => r.map(c => renderCell(c, false))); + + // Column widths from the visible (ANSI-stripped) text. + const widths = tok.header.map((_c, col) => { + let w = header[col]?.plain.length ?? 0; + for (const row of rows) w = Math.max(w, row[col]?.plain.length ?? 0); + return w; + }); + + const padCell = (cell: { styled: string; plain: string }, width: number, align: 'center' | 'left' | 'right' | null): string => { + const diff = width - cell.plain.length; + let left = 0; + let right = diff; + if (align === 'center') { + left = Math.floor(diff / 2); + right = Math.ceil(diff / 2); + } else if (align === 'right') { + left = diff; + right = 0; } + return `${' '.repeat(left)}${cell.styled}${' '.repeat(right)}`; + }; - // Blank line → blank viewer line. - if (trimmed === '') { - lines.push(''); - continue; + const renderRow = (cells: { styled: string; plain: string }[]): string => + `${VIEWER_GLYPHS.table}${cells.map((c, i) => ` ${padCell(c, widths[i], tok.align[i] ?? null)} `).join(VIEWER_GLYPHS.table)}${VIEWER_GLYPHS.table}`; + + const lines: string[] = []; + lines.push(pad + renderRow(header)); + lines.push(pad + `├${widths.map(w => `${VIEWER_GLYPHS.hr.repeat(w + 2)}`).join('┼')}┤`); + for (const row of rows) lines.push(pad + renderRow(row)); + return lines; +} + +/** + * Render a block-level token list to terminal lines. + * + * @param tokens - Block tokens (from `marked.lexer`). + * @param pad - Indentation prefix applied to every emitted line. + * @param maxCols - Terminal width used for word-wrapping/truncation. + * @returns Terminal lines (styled, no trailing padding). + */ +function renderBlocks(tokens: Token[], pad: string, maxCols: number): string[] { + const lines: string[] = []; + /** Push inline-rendered text as wrapped, padded lines. */ + const pushWrapped = (text: string): void => { + for (const seg of text.split('\n')) { + for (const l of wrapText(seg, maxCols - pad.length)) lines.push(`${pad}${l}`); + } + }; + for (const tok of tokens) { + switch (tok.type) { + case 'space': + // A blank source line renders as a blank viewer line (deduped). + if (lines.length > 0 && lines[lines.length - 1] !== '') lines.push(''); + break; + case 'heading': { + const t = tok as Tokens.Heading; + const text = renderInline(t.tokens ?? []); + const style = t.depth <= 2 ? STYLE.bold : `${STYLE.bold}${STYLE.dim}`; + lines.push(`${pad}${headingGlyph(t.depth)} ${style}${text}${STYLE.reset}`); + break; + } + case 'paragraph': + pushWrapped(renderInline(tok.tokens ?? [])); + break; + case 'text': + pushWrapped(renderInline(inlineChildren(tok))); + break; + case 'list': { + const list = tok as Tokens.List; + list.items.forEach((item, idx) => { + const marker = list.ordered + ? `${(list.start === '' ? 1 : Number(list.start)) + idx}.` + : VIEWER_GLYPHS.bullet; + const prefix = `${marker} `; + const contentPad = `${pad}${' '.repeat(prefix.length)}`; + const itemLines = renderBlocks(item.tokens, contentPad, maxCols); + if (itemLines.length === 0) { + lines.push(`${pad}${prefix}`); + return; + } + lines.push(`${pad}${prefix}${itemLines[0].slice(contentPad.length)}`); + lines.push(...itemLines.slice(1)); + }); + break; + } + case 'code': + for (const line of tok.text.split('\n')) { + lines.push(`${pad} ${VIEWER_GLYPHS.code} ${line}`); + } + break; + case 'blockquote': + lines.push(...renderBlocks(tok.tokens ?? [], `${pad}${VIEWER_GLYPHS.quote} `, maxCols)); + break; + case 'table': + lines.push(...renderTable(tok as Tokens.Table, pad)); + break; + case 'hr': { + const width = Math.min(30, Math.max(1, maxCols - pad.length)); + lines.push(`${pad}${VIEWER_GLYPHS.hr.repeat(width)}`); + break; + } + case 'html': + lines.push(`${pad}${tok.text.replace(/<[^>]*>/g, '').trim()}`); + break; + case 'def': + break; // definition links are consumed by the linker — nothing to show + default: + break; } + } + return lines; +} + +// ── Markdown rendering entry points ─────────────────────────────────── + +/** Leading YAML frontmatter block (`---` delimited at the start of file). */ +const YAML_FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; + +/** Strip a leading YAML frontmatter block, if present. */ +function stripYamlFrontmatter(text: string): string { + const match = text.match(YAML_FRONTMATTER_RE); + return match ? text.slice(match[0].length) : text; +} + +export interface RenderMarkdownOptions { + /** Skip a leading YAML frontmatter block (`---` delimited). */ + skipFrontmatter?: boolean; +} - // Plain paragraph line. - lines.push(` ${renderNoteLinks(line)}`); +/** + * Render markdown text as ANSI-styled terminal viewer lines. + * + * Inline `[NOTE : ...]` markers are always rendered as `↗` links + * (see `renderNoteLinks`). Renders the full GFM construct set used in + * `.podcast.md` episode files and work-item descriptions: heading + * hierarchy, ordered + nested bullet lists, blockquotes, tables, fenced + * and inline code, bold/italic/strikethrough, links, and horizontal rules. + * + * Fail-open: on a parse error the raw text is returned as plain lines so + * the viewer never crashes on unexpected input. + * + * @param mdText - Raw markdown document text. + * @param maxCols - Terminal width to wrap/truncate lines to. + * @param opts - Rendering options. + * @returns Viewer lines (no trailing padding; callers pad to viewport). + */ +export function renderMarkdown(mdText: string, maxCols: number, opts: RenderMarkdownOptions = {}): string[] { + if (!mdText) return []; + let text = renderNoteLinks(mdText); + if (opts.skipFrontmatter) text = stripYamlFrontmatter(text); + try { + const tokens = lexer(text); + const lines = renderBlocks(tokens, '', maxCols); + return lines.map(l => truncateAnsi(l, maxCols)); + } catch { + // Fail-open: never crash the viewer on malformed markdown. + return text.split('\n').map(l => truncateAnsi(l, maxCols)); } +} - // Wrap/truncate to maxCols. - return lines.map(l => (l.length > maxCols ? l.slice(0, maxCols) : l)); +/** + * Render a markdown document body as terminal viewer lines. + * + * YAML frontmatter (`---`-delimited, leading) is skipped; inline + * `[NOTE : ...]` markers are rendered as `↗` links. + * + * @param mdText - Raw markdown document text. + * @param maxCols - Terminal width to wrap/truncate lines to. + * @returns Viewer lines (no trailing padding; callers pad to viewport). + */ +export function renderMarkdownViewer(mdText: string, maxCols: number): string[] { + return renderMarkdown(mdText, maxCols, { skipFrontmatter: true }); } diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index f8b9abb4..08758546 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -40,7 +40,7 @@ import { substituteIdentifiers, } from './form-dialog.js'; import { extractFilePaths } from './grouping.js'; -import { renderMarkdownViewer, renderNoteLinks } from './md-viewer.js'; +import { renderMarkdown, renderMarkdownViewer } from './md-viewer.js'; // ── Constants ───────────────────────────────────────────────────────── @@ -1092,25 +1092,18 @@ export function formatDetailContent( lines.push(''); lines.push(` ${ANSI.underline}Description${ANSI.reset}`); lines.push(''); - const descLines = item.description.split('\n'); + // Render the description as GFM (tables, bold/italic, inline code, + // links, lists, headings) via the shared markdown renderer; NOTE + // markers render as `↗` links inside the rendered output. Wrap to + // the content width minus the 2-space indent. + const indent = 2; + const wrapWidth = contentWidth - indent - 2; + const descLines = renderMarkdown(item.description, Math.max(wrapWidth, 20)); for (const dl of descLines) { - // Wrap long lines to fit width; NOTE markers render as links. - const indent = 2; - const wrapWidth = contentWidth - indent - 2; - const linked = renderNoteLinks(dl); - if (linked.length > wrapWidth && wrapWidth > 10) { - let remaining = linked; - while (remaining.length > 0) { - const seg = remaining.slice(0, wrapWidth); - remaining = remaining.slice(wrapWidth); - lines.push(` ${seg}`); - } - } else { - lines.push(` ${linked}`); - } + lines.push(` ${dl}`); // Limit total lines if (lines.length > 500) { - lines.push(` ... (truncated, ${descLines.length} total description lines)`); + lines.push(` ... (truncated, ${item.description.split('\n').length} total description lines)`); break; } } From 67d573e5f9d963877efb4dec3ed8206ada7d5479 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 11 Aug 2026 02:07:23 +0100 Subject: [PATCH 23/40] WL-0MSNULGSM00018I4: materialize marked dep for herdr plugin (fix ERR_MODULE_NOT_FOUND crash on prefix+l launch) --- packages/herdr/package-lock.json | 449 +++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 packages/herdr/package-lock.json diff --git a/packages/herdr/package-lock.json b/packages/herdr/package-lock.json new file mode 100644 index 00000000..9e759ec7 --- /dev/null +++ b/packages/herdr/package-lock.json @@ -0,0 +1,449 @@ +{ + "name": "@worklog/herdr-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@worklog/herdr-plugin", + "version": "0.1.0", + "dependencies": { + "@worklog/shared": "file:../../packages/shared", + "marked": "^18.0.5" + } + }, + "node_modules/@worklog/shared": { + "version": "1.0.0", + "resolved": "file:../shared", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.6.2" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/marked": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} From 501c8c4216e768d98b18049edcae739db1d79646 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 11 Aug 2026 11:42:10 +0100 Subject: [PATCH 24/40] WL-0MSND0V9D009222F: Implementation complete --- packages/herdr/src/grouping.test.ts | 327 ++++++++++++++++++---------- 1 file changed, 213 insertions(+), 114 deletions(-) diff --git a/packages/herdr/src/grouping.test.ts b/packages/herdr/src/grouping.test.ts index d6557351..47154f62 100644 --- a/packages/herdr/src/grouping.test.ts +++ b/packages/herdr/src/grouping.test.ts @@ -4,9 +4,21 @@ * * The grouping algorithm is intentionally duplicated per TUI (decision Q2c * in WL-0MS8W5LTW006YZ4B) because Herdr has zero npm dependencies and cannot - * import from src/commands/. This suite mirrors the core grouping-utility - * tests plus the merged-list regression for the duplicate "In Review" - * sections bug (WL-0MSAK8YLB0025EGW). + * import from src/commands/. This suite asserts the priority-first ordering + * introduced by WL-0MSI1LVTJ001M9EY: + * + * - One section per priority bucket, ordered Critical → High → Medium → Low + * (labels are the priority names; no stage sub-headers are rendered). + * - Within a bucket, items sort by stage in workflow order (idea → + * intake_complete → plan_complete → in_progress → in_review → done) then + * by id as a deterministic tie-break. + * - Unknown/empty priority sorts as medium (DEFAULT_PRIORITY convention); + * unknown stages sort after all known stages. + * + * It also covers the merged-list regression for duplicate section headings + * (WL-0MSAK8YLB0025EGW) and the smart-selection merge path guarantee + * (WL-0MS8W5LTW006YZ4B): all critical + completed/in_review items remain + * visible after select → regroup. * * Run: npx vitest run packages/herdr/src/grouping.test.ts */ @@ -19,6 +31,7 @@ import { extractFilePaths, type GroupableItem, } from './grouping.js'; +import { selectWorkItems } from './smart-selection.js'; import type { WorkItem } from './fetcher.js'; /** @@ -36,129 +49,184 @@ function makeItem(id: string, overrides: Partial = {}): WorkItem { }; } -describe('grouping.ts — duplicated algorithm mirrors core spec', () => { - it('produces the canonical group order: Critical Group N → Group N → Idea → Other → In Review', () => { +describe('assignItemGroups — priority-bucket sections (WL-0MSI1LVTJ001M9EY)', () => { + it('produces one section per priority bucket in order Critical → High → Medium → Low', () => { const items: GroupableItem[] = [ - { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/c.ts'], priority: 'critical' }, - { id: 'WL-P1', stage: 'plan_complete', filePaths: ['src/p1.ts'], priority: 'high' }, - { id: 'WL-I1', stage: 'intake_complete', filePaths: ['src/i1.ts'], priority: 'medium' }, - { id: 'WL-IP1', stage: 'in_progress', filePaths: ['src/ip1.ts'], priority: 'high' }, - { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'low' }, - { id: 'WL-other', stage: 'custom', filePaths: [], priority: 'medium' }, - { id: 'WL-R1', stage: 'in_review', filePaths: [], priority: 'medium' }, + { id: 'WL-L', stage: 'in_progress', filePaths: [], priority: 'low' }, + { id: 'WL-H', stage: 'plan_complete', filePaths: [], priority: 'high' }, + { id: 'WL-C', stage: 'idea', filePaths: [], priority: 'critical' }, + { id: 'WL-M', stage: 'in_review', filePaths: [], priority: 'medium' }, ]; - const groups = assignItemGroups(items, 3); - const groupOf = (id: string): number => groups.get(id)!.group; - expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); - // Plan/intake/in_progress items share Group N (no stage prefix in the label). - expect(groups.get('WL-P1')!.groupLabel).toBe('Group 1'); - expect(groups.get('WL-I1')!.groupLabel).toBe('Group 1'); - expect(groups.get('WL-IP1')!.groupLabel).toBe('Group 1'); - expect(groupOf('WL-C1')).toBeLessThan(groupOf('WL-P1')); - expect(groupOf('WL-P1')).toBeLessThan(groupOf('WL-idea')); - expect(groupOf('WL-idea')).toBeLessThan(groupOf('WL-other')); - expect(groupOf('WL-other')).toBeLessThan(groupOf('WL-R1')); - expect(groups.get('WL-R1')!.groupLabel).toBe('In Review'); - }); - - it('partitions critical items into Critical Group N by file-path conflicts', () => { + const groups = assignItemGroups(items); + // One section per priority bucket, labelled with the priority name. + expect(groups.get('WL-C')!.groupLabel).toBe('Critical'); + expect(groups.get('WL-H')!.groupLabel).toBe('High'); + expect(groups.get('WL-M')!.groupLabel).toBe('Medium'); + expect(groups.get('WL-L')!.groupLabel).toBe('Low'); + // No stage sub-headers: the set of labels is exactly the priority names. + expect(new Set([...groups.values()].map(g => g.groupLabel))).toEqual( + new Set(['Critical', 'High', 'Medium', 'Low']), + ); + // Sections are numbered in priority order (Critical first). + expect(groups.get('WL-C')!.group).toBeLessThan(groups.get('WL-H')!.group); + expect(groups.get('WL-H')!.group).toBeLessThan(groups.get('WL-M')!.group); + expect(groups.get('WL-M')!.group).toBeLessThan(groups.get('WL-L')!.group); + expect([...groups.values()].map(g => g.group)).toEqual([1, 2, 3, 4]); + }); + + it('puts a high-priority in_progress item ahead of a medium-priority plan_complete item', () => { + // Regression for the reported bug: priority is the primary sort key, so + // a high-priority in_progress item must precede a medium-priority + // plan_complete item instead of trailing in a lower section. const items: GroupableItem[] = [ - { id: 'WL-C1', stage: 'plan_complete', filePaths: ['src/foo.ts'], priority: 'critical' }, - { id: 'WL-C2', stage: 'plan_complete', filePaths: ['src/foo.ts'], priority: 'critical' }, - { id: 'WL-C3', stage: 'idea', filePaths: [], priority: 'critical' }, + { id: 'WL-HIP', stage: 'in_progress', filePaths: [], priority: 'high' }, + { id: 'WL-MPC', stage: 'plan_complete', filePaths: [], priority: 'medium' }, ]; - const groups = assignItemGroups(items, 3); - expect(groups.get('WL-C1')!.groupLabel).toBe('Critical Group 1'); - expect(groups.get('WL-C2')!.groupLabel).toBe('Critical Group 2'); - expect(groups.get('WL-C3')!.groupLabel).toBe('Critical Group 3'); - for (const [, assignment] of groups) { - expect(assignment.groupLabel).not.toBe('Critical'); + const groups = assignItemGroups(items); + expect(groups.get('WL-HIP')!.groupLabel).toBe('High'); + expect(groups.get('WL-MPC')!.groupLabel).toBe('Medium'); + expect(groups.get('WL-HIP')!.group).toBeLessThan(groups.get('WL-MPC')!.group); + }); + + it('treats unknown/empty priority as medium (DEFAULT_PRIORITY convention)', () => { + const items: GroupableItem[] = [ + { id: 'WL-UNKNOWN', stage: 'in_progress', filePaths: [], priority: 'bogus' }, + { id: 'WL-EMPTY', stage: 'plan_complete', filePaths: [], priority: undefined }, + { id: 'WL-MED', stage: 'idea', filePaths: [], priority: 'medium' }, + ]; + const groups = assignItemGroups(items); + for (const id of ['WL-UNKNOWN', 'WL-EMPTY', 'WL-MED']) { + expect(groups.get(id)!.groupLabel).toBe('Medium'); + expect(groups.get(id)!.group).toBe(1); } }); - it('sorts within a group by stage sub-order then priority', () => { + it('numbers buckets sequentially from the first non-empty priority', () => { + // No critical items → the first section is High (group 1). + const groups = assignItemGroups([ + { id: 'WL-H', stage: 'idea', filePaths: [], priority: 'high' }, + { id: 'WL-L', stage: 'idea', filePaths: [], priority: 'low' }, + ]); + expect(groups.get('WL-H')!.groupLabel).toBe('High'); + expect(groups.get('WL-H')!.group).toBe(1); + expect(groups.get('WL-L')!.groupLabel).toBe('Low'); + expect(groups.get('WL-L')!.group).toBe(2); + }); + + it('assigns every item to exactly one bucket (no item dropped, no duplicate assignments)', () => { const items: GroupableItem[] = [ - { id: 'I-low', stage: 'intake_complete', filePaths: [], priority: 'low' }, - { id: 'P-med', stage: 'plan_complete', filePaths: [], priority: 'medium' }, - { id: 'I-high', stage: 'intake_complete', filePaths: [], priority: 'high' }, - { id: 'P-high', stage: 'plan_complete', filePaths: [], priority: 'high' }, + { id: 'WL-1', stage: 'idea', filePaths: [], priority: 'critical' }, + { id: 'WL-2', stage: 'in_review', filePaths: [], priority: 'high' }, + { id: 'WL-3', stage: 'done', filePaths: [], priority: 'low' }, + { id: 'WL-4', stage: 'custom', filePaths: [], priority: undefined }, + ]; + const groups = assignItemGroups(items); + expect(groups.size).toBe(items.length); + for (const item of items) { + expect(groups.has(item.id)).toBe(true); + } + }); +}); + +describe('compareGroupableItems — priority first, then stage, then id', () => { + it('sorts by priority in descending order regardless of stage', () => { + const items: GroupableItem[] = [ + { id: 'WL-M', stage: 'in_progress', filePaths: [], priority: 'medium' }, + { id: 'WL-C', stage: 'idea', filePaths: [], priority: 'critical' }, + { id: 'WL-H', stage: 'in_review', filePaths: [], priority: 'high' }, + { id: 'WL-L', stage: 'plan_complete', filePaths: [], priority: 'low' }, ]; const sorted = items.slice().sort(compareGroupableItems); - expect(sorted.map(i => i.id)).toEqual(['P-high', 'P-med', 'I-high', 'I-low']); + expect(sorted.map(i => i.id)).toEqual(['WL-C', 'WL-H', 'WL-M', 'WL-L']); }); - it('never places canonical stages in "Other" (in_progress joins Group N)', () => { + it('sorts by stage in workflow order within equal priority', () => { const items: GroupableItem[] = [ - { id: 'WL-idea', stage: 'idea', filePaths: ['src/idea.ts'], priority: 'medium' }, - { id: 'WL-intake', stage: 'intake_complete', filePaths: ['src/intake.ts'], priority: 'medium' }, - { id: 'WL-plan', stage: 'plan_complete', filePaths: ['src/plan.ts'], priority: 'medium' }, - { id: 'WL-progress', stage: 'in_progress', filePaths: ['src/progress.ts'], priority: 'medium' }, - { id: 'WL-review', stage: 'in_review', filePaths: ['src/review.ts'], priority: 'medium' }, + { id: 'WL-D', stage: 'done', filePaths: [], priority: 'high' }, + { id: 'WL-I', stage: 'idea', filePaths: [], priority: 'high' }, + { id: 'WL-R', stage: 'in_review', filePaths: [], priority: 'high' }, + { id: 'WL-P', stage: 'in_progress', filePaths: [], priority: 'high' }, + { id: 'WL-K', stage: 'intake_complete', filePaths: [], priority: 'high' }, + { id: 'WL-PC', stage: 'plan_complete', filePaths: [], priority: 'high' }, ]; - const groups = assignItemGroups(items, 3); - for (const [, assignment] of groups) { - expect(assignment.groupLabel).not.toBe('Other'); - } - // Unknown/custom stages still fall back to "Other" as the safety net. - const unknown = assignItemGroups([{ id: 'WL-x', stage: 'custom', filePaths: [], priority: 'medium' }], 3); - expect(unknown.get('WL-x')!.groupLabel).toBe('Other'); + const sorted = items.slice().sort(compareGroupableItems); + expect(sorted.map(i => i.id)).toEqual([ + 'WL-I', // idea + 'WL-K', // intake_complete + 'WL-PC', // plan_complete + 'WL-P', // in_progress + 'WL-R', // in_review + 'WL-D', // done + ]); }); - it('sorts in_progress items first within a group (stage sub-order)', () => { + it('uses id as the deterministic tie-break for equal priority and stage', () => { const items: GroupableItem[] = [ - { id: 'I-low', stage: 'intake_complete', filePaths: [], priority: 'low' }, - { id: 'IP-high', stage: 'in_progress', filePaths: [], priority: 'high' }, - { id: 'P-med', stage: 'plan_complete', filePaths: [], priority: 'medium' }, - { id: 'IP-low', stage: 'in_progress', filePaths: [], priority: 'low' }, + { id: 'WL-B', stage: 'idea', filePaths: [], priority: 'high' }, + { id: 'WL-A', stage: 'idea', filePaths: [], priority: 'high' }, + { id: 'WL-C', stage: 'idea', filePaths: [], priority: 'high' }, ]; const sorted = items.slice().sort(compareGroupableItems); - expect(sorted.map(i => i.id)).toEqual(['IP-high', 'IP-low', 'P-med', 'I-low']); + expect(sorted.map(i => i.id)).toEqual(['WL-A', 'WL-B', 'WL-C']); }); -}); -describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () => { - it('assigns group metadata to every displayed item (mandatory wl list items included)', () => { - // Simulated merged list: wl next items carry group metadata, mandatory - // wl list items (critical + completed/in_review) do NOT. - const merged: WorkItem[] = [ - // From wl next — already grouped by the CLI. - makeItem('WL-NEXT-1', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), - makeItem('WL-NEXT-2', { stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }), - // From wl list (mandatory subset) — NO group metadata. - makeItem('WL-LIST-CRIT', { stage: 'intake_complete', priority: 'critical' }), - makeItem('WL-LIST-REV', { stage: 'in_review', priority: 'medium', status: 'completed' }), + it('sorts unknown stages after all known stages', () => { + const items: GroupableItem[] = [ + { id: 'WL-custom', stage: 'custom', filePaths: [], priority: 'high' }, + { id: 'WL-done', stage: 'done', filePaths: [], priority: 'high' }, + { id: 'WL-idea', stage: 'idea', filePaths: [], priority: 'high' }, ]; + const sorted = items.slice().sort(compareGroupableItems); + expect(sorted.map(i => i.id)).toEqual(['WL-idea', 'WL-done', 'WL-custom']); + }); - const regrouped = regroupWorkItems(merged, 3); + it('treats unknown priority as medium in comparisons', () => { + const items: GroupableItem[] = [ + { id: 'WL-MED', stage: 'idea', filePaths: [], priority: 'medium' }, + { id: 'WL-UNK', stage: 'idea', filePaths: [], priority: 'bogus' }, + { id: 'WL-UNDEF', stage: 'idea', filePaths: [], priority: undefined }, + ]; + const sorted = items.slice().sort(compareGroupableItems); + // All three are equal on priority (medium) and stage; id is the tie-break. + expect(sorted.map(i => i.id)).toEqual(['WL-MED', 'WL-UNDEF', 'WL-UNK']); + }); +}); - // Every item receives a group assignment. +describe('regroupWorkItems — priority-first list order', () => { + it('orders the regrouped list by priority bucket then stage, stamping every item', () => { + const merged: WorkItem[] = [ + makeItem('WL-LOW-IP', { stage: 'in_progress', priority: 'low' }), + makeItem('WL-CRIT-IDEA', { stage: 'idea', priority: 'critical' }), + makeItem('WL-HIGH-REV', { stage: 'in_review', priority: 'high' }), + makeItem('WL-MED-PLAN', { stage: 'plan_complete', priority: 'medium' }), + makeItem('WL-HIGH-IDEA', { stage: 'idea', priority: 'high' }), + ]; + const regrouped = regroupWorkItems(merged); + expect(regrouped.map(i => i.id)).toEqual([ + 'WL-CRIT-IDEA', // Critical bucket, idea stage + 'WL-HIGH-IDEA', // High bucket, idea before in_review + 'WL-HIGH-REV', // High bucket, in_review stage + 'WL-MED-PLAN', // Medium bucket + 'WL-LOW-IP', // Low bucket + ]); + // Every displayed item receives group metadata. for (const item of regrouped) { expect(item.group).toBeDefined(); expect(item.groupLabel).toBeDefined(); } - - // The merged mandatory in_review items (one non-completed from wl next, - // one completed from wl list) land in the SAME "In Review" group. - const inReviewItems = regrouped.filter(i => i.id === 'WL-NEXT-2' || i.id === 'WL-LIST-REV'); - expect(inReviewItems.every(i => i.groupLabel === 'In Review')).toBe(true); - expect(inReviewItems[0].group).toBe(inReviewItems[1].group); - // Exactly one distinct In Review group number exists. - expect(new Set(regrouped.filter(i => i.groupLabel === 'In Review').map(i => i.group)).size).toBe(1); }); - it('regression: merged list renders exactly one In Review section, positioned after Other', () => { + it('renders exactly one section per priority bucket (no duplicate headings)', () => { + // Merged list with stale group metadata (wl next) + ungrouped mandatory + // subsets (wl list): after regroup there must be exactly one section per + // priority bucket, in priority order. const merged: WorkItem[] = [ - // wl next results (grouped) — including a non-completed in_review item - // that previously fell into the "others" bucket while carrying its - // "In Review" group label, producing a second In Review section. - makeItem('WL-NEXT-PLAN', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), - makeItem('WL-NEXT-REVIEW', { stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }), - makeItem('WL-NEXT-OTHER', { stage: 'custom', priority: 'medium', group: 3, groupLabel: 'Other' }), - // Mandatory wl list subsets (no group metadata). + makeItem('WL-NEXT-H', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), + makeItem('WL-NEXT-M', { stage: 'in_progress', priority: 'medium', status: 'in-progress', group: 3, groupLabel: 'Group 1' }), makeItem('WL-LIST-CRIT', { stage: 'plan_complete', priority: 'critical' }), makeItem('WL-LIST-REV', { stage: 'in_review', priority: 'medium', status: 'completed' }), + makeItem('WL-LIST-LOW', { stage: 'idea', priority: 'low' }), ]; - const regrouped = regroupWorkItems(merged, 3); // Simulate the renderer: separator lines are emitted when the group @@ -174,16 +242,7 @@ describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () } } - // Exactly one "In Review" section. - const inReviewCount = renderedSections.filter(s => s === 'In Review').length; - expect(inReviewCount).toBe(1); - // "In Review" is the LAST section (after Other). - expect(renderedSections[renderedSections.length - 1]).toBe('In Review'); - const otherIndex = renderedSections.indexOf('Other'); - expect(otherIndex).toBeGreaterThan(-1); - expect(renderedSections.indexOf('In Review')).toBeGreaterThan(otherIndex); - // No duplicate section headings at all. - expect(new Set(renderedSections).size).toBe(renderedSections.length); + expect(renderedSections).toEqual(['Critical', 'High', 'Medium', 'Low']); }); it('preserves every item across regroup (no filtering) — mandatory guarantee intact', () => { @@ -191,24 +250,64 @@ describe('regroupWorkItems — merged-list regression (WL-0MSAK8YLB0025EGW)', () makeItem('WL-A', { stage: 'plan_complete', priority: 'high' }), makeItem('WL-B', { stage: 'in_review', priority: 'medium', status: 'completed' }), makeItem('WL-C', { stage: 'idea', priority: 'low' }), + makeItem('WL-D', { stage: 'done', priority: 'critical' }), ]; const regrouped = regroupWorkItems(merged, 3); - expect(regrouped.map(i => i.id).sort()).toEqual(['WL-A', 'WL-B', 'WL-C']); + expect(regrouped.map(i => i.id).sort()).toEqual(['WL-A', 'WL-B', 'WL-C', 'WL-D']); + }); +}); + +describe('smart-selection merge path (WL-0MS8W5LTW006YZ4B / parent AC 5)', () => { + it('keeps all critical and completed/in_review items visible through select → regroup', () => { + const merged: WorkItem[] = [ + // wl next results (carry stale group metadata). + makeItem('WL-NEXT-1', { stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }), + makeItem('WL-NEXT-2', { stage: 'in_progress', priority: 'medium', status: 'in-progress', group: 3, groupLabel: 'Group 1' }), + // Mandatory wl list subsets (no group metadata). + makeItem('WL-CRIT-1', { stage: 'idea', priority: 'critical' }), + makeItem('WL-CRIT-2', { stage: 'plan_complete', priority: 'critical' }), + makeItem('WL-REV-1', { stage: 'in_review', priority: 'medium', status: 'completed' }), + // Non-mandatory "other" item. + makeItem('WL-OTHER-1', { stage: 'idea', priority: 'low' }), + // Excluded from the default list (stage=done, WL-0MS94VAII00054L9). + makeItem('WL-DONE-1', { stage: 'done', priority: 'high' }), + ]; + + const selected = selectWorkItems(merged, 3); // cap 3 → mandatory set only + const regrouped = regroupWorkItems(selected, 3); + + const ids = new Set(regrouped.map(i => i.id)); + // Mandatory set fully visible despite the small browseItemCount. + for (const id of ['WL-CRIT-1', 'WL-CRIT-2', 'WL-REV-1']) { + expect(ids.has(id)).toBe(true); + } + // "Other" items were trimmed by the cap; done items never enter the list. + expect(ids.has('WL-OTHER-1')).toBe(false); + expect(ids.has('WL-DONE-1')).toBe(false); + + // After regroup, the mandatory items sit in their priority buckets. + const byId = new Map(regrouped.map(i => [i.id, i])); + expect(byId.get('WL-CRIT-1')!.groupLabel).toBe('Critical'); + expect(byId.get('WL-CRIT-2')!.groupLabel).toBe('Critical'); + expect(byId.get('WL-REV-1')!.groupLabel).toBe('Medium'); }); - it('orders the regrouped list by group then within-group order', () => { + it('keeps the full mandatory set visible when it exceeds browseItemCount (no hard cap)', () => { const merged: WorkItem[] = [ - makeItem('WL-I-low', { stage: 'intake_complete', priority: 'low' }), - makeItem('WL-P-med', { stage: 'plan_complete', priority: 'medium' }), - makeItem('WL-idea', { stage: 'idea', priority: 'low' }), - makeItem('WL-R', { stage: 'in_review', priority: 'medium' }), + makeItem('WL-CRIT-1', { stage: 'idea', priority: 'critical' }), + makeItem('WL-CRIT-2', { stage: 'idea', priority: 'critical' }), + makeItem('WL-REV-1', { stage: 'in_review', priority: 'medium', status: 'completed' }), + makeItem('WL-REV-2', { stage: 'in_review', priority: 'medium', status: 'completed' }), + makeItem('WL-REV-3', { stage: 'in_review', priority: 'medium', status: 'completed' }), + makeItem('WL-OTHER-1', { stage: 'idea', priority: 'low' }), ]; - const regrouped = regroupWorkItems(merged, 3); - // plan_complete before intake_complete inside Group 1; Idea; In Review last. - const order = regrouped.map(i => i.id); - expect(order.indexOf('WL-P-med')).toBeLessThan(order.indexOf('WL-I-low')); - expect(order.indexOf('WL-I-low')).toBeLessThan(order.indexOf('WL-idea')); - expect(order.indexOf('WL-idea')).toBeLessThan(order.indexOf('WL-R')); + const selected = selectWorkItems(merged, 2); // mandatory set alone exceeds the cap + const regrouped = regroupWorkItems(selected, 3); + const ids = new Set(regrouped.map(i => i.id)); + for (const id of ['WL-CRIT-1', 'WL-CRIT-2', 'WL-REV-1', 'WL-REV-2', 'WL-REV-3']) { + expect(ids.has(id)).toBe(true); + } + expect(ids.has('WL-OTHER-1')).toBe(false); }); }); From 32633cfe0f97f87092b1bc3e0c7ea2a7a7b97cbb Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 11 Aug 2026 11:53:41 +0100 Subject: [PATCH 25/40] WL-0MSND0Y4S004H6WH: Implementation complete --- packages/herdr/src/grouping.ts | 262 ++++++++++----------------------- 1 file changed, 80 insertions(+), 182 deletions(-) diff --git a/packages/herdr/src/grouping.ts b/packages/herdr/src/grouping.ts index 411808f9..8f19327a 100644 --- a/packages/herdr/src/grouping.ts +++ b/packages/herdr/src/grouping.ts @@ -18,6 +18,14 @@ * adjacency. `regroupWorkItems` recomputes group assignments for the final * selected set so every displayed item gets a correct group and no duplicate * section headings render (WL-0MSAK8YLB0025EGW). + * + * Ordering (WL-0MSI1LVTJ001M9EY): the Herdr selection list sorts by priority + * FIRST — one section per priority bucket (Critical → High → Medium → Low) — + * then by stage in workflow order (idea → intake_complete → plan_complete → + * in_progress → in_review → done, unknown stages last) then by id. The old + * file-path conflict partitioning ("Critical Group N" / "Group N" labels) was + * dropped: priority is the primary sort key, so a high-priority in_progress + * item no longer trails a medium-priority plan_complete item. */ // ── File path extraction ────────────────────────────────────────────── @@ -127,162 +135,86 @@ export interface GroupAssignment { // ── Grouping algorithm ──────────────────────────────────────────────── /** - * Greedy first-fit grouping algorithm for file-path-based conflict detection. - * - * Assigns each item to the first group (1-indexed) that contains no item - * sharing any file path with it. Items with empty file paths (unknown) are - * each placed in their own singleton "restricted" group. - * - * Mirrors `groupItemsByFilePaths` in src/commands/grouping.ts. + * Priority rank per the project convention (src/commands/helpers.ts): + * critical=4 > high=3 > medium=2 > low=1. Unknown/empty priorities sort as + * medium (the `DEFAULT_PRIORITY` convention). */ -export function groupItemsByFilePaths( - items: GroupableItem[], - maxGroups: number = 3, -): Map { - const itemGroup = new Map(); - const groupPaths = new Map>(); - const restrictedGroups = new Set(); - let nextGroup = 1; - - for (const item of items) { - const { id, filePaths } = item; - - // Items with unknown file paths → singleton restricted group. - if (filePaths.length === 0) { - itemGroup.set(id, nextGroup); - groupPaths.set(nextGroup, new Set()); - restrictedGroups.add(nextGroup); - nextGroup++; - continue; - } +const PRIORITY_RANK: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, +}; +const DEFAULT_PRIORITY_RANK = PRIORITY_RANK.medium; - // Try to find an existing group this item fits in. - let assigned = false; - const groupsToCheck = Math.min(maxGroups, nextGroup - 1); - for (let g = 1; g <= groupsToCheck; g++) { - const existingPaths = groupPaths.get(g); - if (!existingPaths) continue; - - if (restrictedGroups.has(g)) continue; - - const hasConflict = filePaths.some(fp => existingPaths.has(fp)); - if (!hasConflict) { - for (const fp of filePaths) { - existingPaths.add(fp); - } - itemGroup.set(id, g); - assigned = true; - break; - } - } +/** + * Priority buckets rendered as one section each, most actionable first: + * Critical → High → Medium → Low. Labels are the priority names; no stage + * sub-headers are rendered (within-bucket stage order is implicit). + */ +const PRIORITY_BUCKET_ORDER: ReadonlyArray = ['critical', 'high', 'medium', 'low']; +const BUCKET_LABEL: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', +}; - if (!assigned) { - const newGroup = nextGroup; - groupPaths.set(newGroup, new Set(filePaths)); - itemGroup.set(id, newGroup); - nextGroup++; - } - } +/** + * Stage order within a priority bucket, following the canonical stage order + * recorded in the work item config (src/config.ts defaults): idea → + * intake_complete → plan_complete → in_progress → in_review → done. Unknown + * stages sort after all known ones. + */ +const STAGE_ORDER: Record = { + idea: 0, + intake_complete: 1, + plan_complete: 2, + in_progress: 3, + in_review: 4, + done: 5, +}; +const UNKNOWN_STAGE_ORDER = 6; - return itemGroup; +/** + * Resolve an item's priority rank, treating unknown/empty priority as medium + * (the `DEFAULT_PRIORITY` convention from src/commands/helpers.ts). + */ +function priorityRank(priority: string | undefined): number { + return PRIORITY_RANK[priority ?? ''] ?? DEFAULT_PRIORITY_RANK; } /** - * Assign items to groups based on priority, stage and file-path conflicts. + * Assign items to groups by priority bucket. * * Group display order (most actionable first): - * - **Critical Group N** — critical items partitioned by file-path conflicts - * (per-category label counter, no single all-inclusive "Critical" group). - * - **Group N** — non-critical `in_progress` + `plan_complete` + `intake_complete` - * items partitioned by file-path conflicts (no stage prefix in the label). - * - **Idea** — single group for all `idea` items. - * - **Other** — single group for all remaining non-critical items (safety net - * for unknown/custom stages, `done`, etc.). - * - **In Review** — single group for all `in_review` items, placed last. + * - **Critical** — all critical-priority items. + * - **High** — all high-priority items. + * - **Medium** — all medium-priority items (and unknown/empty priorities, + * per the `DEFAULT_PRIORITY` convention). + * - **Low** — all low-priority items. + * + * Each non-empty bucket becomes exactly one section labelled with its + * priority name; empty buckets produce no section (group numbers start at 1 + * for the first non-empty bucket). Items carry no file-path partition + * labels — the old "Critical Group N" / "Group N" partitioning + * (WL-0MSAKPVR9005B6XJ) was dropped by WL-0MSI1LVTJ001M9EY. * - * Mirrors `assignItemGroups` in src/commands/grouping.ts. + * `maxFilePathGroups` is retained for signature compatibility with the core + * `assignItemGroups` (src/commands/grouping.ts) but is no longer used. */ export function assignItemGroups( items: GroupableItem[], - maxFilePathGroups: number = 3, + _maxFilePathGroups: number = 3, ): Map { const result = new Map(); let nextGroup = 1; - - // Remap file-path group numbers to sequential display group numbers - // starting at `startGroup`; returns the mapping and the count consumed. - const remapToSequential = ( - fileGroups: Map, - startGroup: number, - ): { map: Map; count: number } => { - const unique = [...new Set(fileGroups.values())].sort((a, b) => a - b); - const map = new Map(); - unique.forEach((g, i) => map.set(g, startGroup + i)); - return { map, count: unique.length }; - }; - - // 0. Critical Group N. - const criticalItems = items.filter(item => item.priority === 'critical'); - if (criticalItems.length > 0) { - const criticalGroups = groupItemsByFilePaths(criticalItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(criticalGroups, nextGroup); - const labelNumByFileGroup = buildLabelCounter(criticalGroups); - for (const [id, g] of criticalGroups) { - const groupNum = groupNumMap.get(g)!; - result.set(id, { group: groupNum, groupLabel: `Critical Group ${labelNumByFileGroup.get(g)}` }); - } - nextGroup += count; - } - - // 1. Group N (in_progress + plan_complete + intake_complete). - const groupNItems = items.filter( - item => - item.priority !== 'critical' && - (item.stage === 'in_progress' || item.stage === 'plan_complete' || item.stage === 'intake_complete'), - ); - if (groupNItems.length > 0) { - const groupNGroups = groupItemsByFilePaths(groupNItems, maxFilePathGroups); - const { map: groupNumMap, count } = remapToSequential(groupNGroups, nextGroup); - const labelNumByFileGroup = buildLabelCounter(groupNGroups); - for (const [id, g] of groupNGroups) { - const groupNum = groupNumMap.get(g)!; - result.set(id, { group: groupNum, groupLabel: `Group ${labelNumByFileGroup.get(g)}` }); - } - nextGroup += count; - } - - // 2. Idea. - const ideaItems = items.filter(item => item.priority !== 'critical' && item.stage === 'idea'); - if (ideaItems.length > 0) { - for (const item of ideaItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'Idea' }); - } - nextGroup++; - } - - // 3. Other. - const otherItems = items.filter( - item => - item.priority !== 'critical' && - item.stage !== 'in_review' && - item.stage !== 'in_progress' && - item.stage !== 'plan_complete' && - item.stage !== 'intake_complete' && - item.stage !== 'idea', - ); - if (otherItems.length > 0) { - for (const item of otherItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'Other' }); - } - nextGroup++; - } - - // 4. In Review (last). - const inReviewItems = items.filter(item => item.priority !== 'critical' && item.stage === 'in_review'); - if (inReviewItems.length > 0) { - for (const item of inReviewItems) { - result.set(item.id, { group: nextGroup, groupLabel: 'In Review' }); + for (const bucket of PRIORITY_BUCKET_ORDER) { + const bucketItems = items.filter(item => priorityRank(item.priority) === PRIORITY_RANK[bucket]); + if (bucketItems.length === 0) continue; + for (const item of bucketItems) { + result.set(item.id, { group: nextGroup, groupLabel: BUCKET_LABEL[bucket] }); } nextGroup++; } @@ -290,55 +222,21 @@ export function assignItemGroups( return result; } -/** - * Build a per-category label counter mapping file-path group numbers (from - * groupItemsByFilePaths) to sequential category labels (1, 2, ...). - */ -function buildLabelCounter(fileGroups: Map): Map { - const unique = [...new Set(fileGroups.values())].sort((a, b) => a - b); - const map = new Map(); - unique.forEach((g, i) => map.set(g, i + 1)); - return map; -} - // ── Within-group ordering ───────────────────────────────────────────── /** - * Stage sub-order within a group: in_progress first (actively being worked), - * then plan_complete, then intake_complete, then all remaining stages. - * No headings are rendered between sub-groups. - */ -const WITHIN_GROUP_STAGE_ORDER: Record = { - in_progress: 0, - plan_complete: 1, - intake_complete: 2, -}; -const REMAINING_STAGE_ORDER = 3; - -/** - * Priority order for within-group sorting: high → medium → low. - * Unknown priorities are treated as medium. - */ -const WITHIN_GROUP_PRIORITY_ORDER: Record = { - high: 0, - medium: 1, - low: 2, -}; -const DEFAULT_PRIORITY_ORDER = WITHIN_GROUP_PRIORITY_ORDER.medium; - -/** - * Compare two items for within-group display order: - * stage sub-sort (in_progress → plan_complete → intake_complete → remaining - * stages), then priority (high → medium → low), then id as a deterministic - * tie-break. + * Compare two items for display order: priority first (critical → high → + * medium → low; unknown → medium), then stage in workflow order (idea → + * intake_complete → plan_complete → in_progress → in_review → done; unknown + * stages last), then id as a deterministic tie-break. */ export function compareGroupableItems(a: GroupableItem, b: GroupableItem): number { - const stageA = WITHIN_GROUP_STAGE_ORDER[a.stage ?? ''] ?? REMAINING_STAGE_ORDER; - const stageB = WITHIN_GROUP_STAGE_ORDER[b.stage ?? ''] ?? REMAINING_STAGE_ORDER; + const prioA = priorityRank(a.priority); + const prioB = priorityRank(b.priority); + if (prioA !== prioB) return prioB - prioA; + const stageA = STAGE_ORDER[a.stage ?? ''] ?? UNKNOWN_STAGE_ORDER; + const stageB = STAGE_ORDER[b.stage ?? ''] ?? UNKNOWN_STAGE_ORDER; if (stageA !== stageB) return stageA - stageB; - const prioA = WITHIN_GROUP_PRIORITY_ORDER[a.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; - const prioB = WITHIN_GROUP_PRIORITY_ORDER[b.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; - if (prioA !== prioB) return prioA - prioB; return a.id.localeCompare(b.id); } From 003f91c6dccf2170ca1706c061bd87ef2a38875c Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 11 Aug 2026 12:03:15 +0100 Subject: [PATCH 26/40] WL-0MSND1FGF000L645: Implementation complete --- packages/herdr/src/md-viewer.test.ts | 41 ++++++++++++++++------------ packages/herdr/src/worklist.test.ts | 30 +++++++++++++------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/herdr/src/md-viewer.test.ts b/packages/herdr/src/md-viewer.test.ts index 9e4b9928..0af927ad 100644 --- a/packages/herdr/src/md-viewer.test.ts +++ b/packages/herdr/src/md-viewer.test.ts @@ -246,31 +246,33 @@ function toGroupable(item: WorkItem): GroupableItem { }; } -describe('podcast item stage grouping', () => { - it('groups podcast items by standard lifecycle stages with no custom values', () => { +describe('podcast item priority-bucket grouping', () => { + it('groups podcast items by priority buckets with no custom stage strings', () => { const items: WorkItem[] = [ - makeItem('ep-idea', 'idea'), - makeItem('ep-drafted', 'plan_complete'), - makeItem('ep-written', 'in_review'), - makeItem('ep-produced', 'done'), + makeItem('ep-idea', 'idea', 'high'), + makeItem('ep-drafted', 'plan_complete', 'high'), + makeItem('ep-written', 'in_review', 'low'), + makeItem('ep-produced', 'done', 'low'), ]; const groups = assignItemGroups(items.map(toGroupable)); - // Group labels must come from the standard stage mapping; no custom + // Group labels must come from the priority-bucket mapping; no custom // stage strings appear in any label. const labels = [...groups.values()].map(g => g.groupLabel); const allLabels = labels.join(' '); expect(allLabels).not.toMatch(/sourced|drafted|written|produced/); - // Idea → 'Idea', plan_complete → 'Group N', in_review → 'In Review'. - expect(groups.get('ep-idea')?.groupLabel).toBe('Idea'); - expect(groups.get('ep-drafted')?.groupLabel).toMatch(/^Group \d+$/); - expect(groups.get('ep-written')?.groupLabel).toBe('In Review'); - // done is not one of the known buckets → 'Other'. - expect(groups.get('ep-produced')?.groupLabel).toBe('Other'); + // high → 'High', low → 'Low' (priority-first ordering, + // WL-0MSI1LVTJ001M9EY); stage no longer drives the section label. + expect(groups.get('ep-idea')?.groupLabel).toBe('High'); + expect(groups.get('ep-drafted')?.groupLabel).toBe('High'); + expect(groups.get('ep-written')?.groupLabel).toBe('Low'); + expect(groups.get('ep-produced')?.groupLabel).toBe('Low'); + // High bucket precedes Low bucket. + expect(groups.get('ep-idea')!.group).toBeLessThan(groups.get('ep-written')!.group); }); - it('regroupWorkItems keeps podcast stage groups stable after reorder', () => { + it('regroupWorkItems keeps podcast items stable after reorder (priority buckets)', () => { const items: WorkItem[] = [ makeItem('ep-idea', 'idea'), makeItem('ep-drafted', 'plan_complete'), @@ -281,10 +283,13 @@ describe('podcast item stage grouping', () => { const regrouped = regroupWorkItems(shuffled); const byId = new Map(regrouped.map(i => [i.id, i])); - // In Review must come last. - expect(byId.get('ep-written')?.groupLabel).toBe('In Review'); - expect(byId.get('ep-idea')?.groupLabel).toBe('Idea'); - expect(byId.get('ep-drafted')?.groupLabel).toMatch(/^Group \d+$/); + // All medium priority → a single Medium bucket, ordered by stage + // (idea → plan_complete → in_review). + for (const id of ['ep-idea', 'ep-drafted', 'ep-written']) { + expect(byId.get(id)?.groupLabel).toBe('Medium'); + expect(byId.get(id)?.group).toBe(1); + } + expect(regrouped.map(i => i.id)).toEqual(['ep-idea', 'ep-drafted', 'ep-written']); }); }); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 3a9e761a..17bd98ef 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -119,11 +119,14 @@ describe('createListRenderer — line-count invariant', () => { expect(withNotification.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows); }); - it('regression (WL-0MSAK8YLB0025EGW): renders exactly one In Review section, after Other', () => { - // Simulated merged list: wl next results carry group metadata (including a - // non-completed in_review item with an "In Review" label) while mandatory - // wl list items (completed/in_review) carry none. After regroupWorkItems - // the renderer must emit exactly one ── In Review ── separator, after Other. + it('regression (WL-0MSAK8YLB0025EGW): merged list renders exactly one section per priority bucket', () => { + // Simulated merged list: wl next results carry group metadata (including + // a non-completed in_review item with an "In Review" label) while + // mandatory wl list items (completed/in_review) carry none. After + // regroupWorkItems the renderer must emit exactly one ── ── + // separator per priority bucket (Critical → High → Medium → Low), so no + // duplicate section headings can occur (the old duplicate "In Review" + // sections regression). Priority-first ordering is WL-0MSI1LVTJ001M9EY. const merged: WorkItem[] = [ { ...makeItem('NEXT-PLAN'), stage: 'plan_complete', priority: 'high', group: 2, groupLabel: 'Group 1' }, { ...makeItem('NEXT-REVIEW'), stage: 'in_review', priority: 'medium', status: 'in-progress', group: 5, groupLabel: 'In Review' }, @@ -134,11 +137,18 @@ describe('createListRenderer — line-count invariant', () => { ]; const regrouped = regroupWorkItems(merged, 3); const output = renderer(regrouped, 0, 0, TERM_80x24, null, 'list', null); - const inReviewSeparators = (output.match(/── In Review ──/g) ?? []).length; - expect(inReviewSeparators).toBe(1); - // In Review separator appears after the Other separator in the rendered output. - expect(output.indexOf('── Other ──')).toBeGreaterThan(-1); - expect(output.indexOf('── In Review ──')).toBeGreaterThan(output.indexOf('── Other ──')); + + // Exactly one separator per priority bucket present in the merged set. + for (const label of ['Critical', 'High', 'Medium']) { + expect((output.match(new RegExp(`── ${label} ──`, 'g')) ?? []).length).toBe(1); + } + // No stale group labels leak through regroup. + expect(output).not.toContain('── Other ──'); + expect(output).not.toContain('── In Review ──'); + // Sections appear in priority order: Critical before High before Medium. + expect(output.indexOf('── Critical ──')).toBeGreaterThan(-1); + expect(output.indexOf('── High ──')).toBeGreaterThan(output.indexOf('── Critical ──')); + expect(output.indexOf('── Medium ──')).toBeGreaterThan(output.indexOf('── High ──')); }); }); From b3f0bead086f41ef4158bf4f515fabaf0d105c01 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 11 Aug 2026 12:14:09 +0100 Subject: [PATCH 27/40] WL-0MSND1J6A009D7J3: Implementation complete --- packages/herdr/README.md | 34 +- packages/herdr/src/grouping.ts | 12 +- src/audit.d.ts | 44 + src/audit.d.ts.map | 1 + src/audit.js | 94 + src/audit.js.map | 1 + src/cli-output.d.ts | 143 ++ src/cli-output.d.ts.map | 1 + src/cli-output.js | 265 +++ src/cli-output.js.map | 1 + src/cli-types.d.ts | 228 +++ src/cli-types.d.ts.map | 1 + src/cli-types.js | 2 + src/cli-types.js.map | 1 + src/cli-utils.d.ts | 75 + src/cli-utils.d.ts.map | 1 + src/cli-utils.js | 221 +++ src/cli-utils.js.map | 1 + src/commands/audit-result.d.ts | 9 + src/commands/audit-result.d.ts.map | 1 + src/commands/audit-result.js | 183 ++ src/commands/audit-result.js.map | 1 + src/commands/audit.d.ts | 3 + src/commands/audit.d.ts.map | 1 + src/commands/audit.js | 81 + src/commands/audit.js.map | 1 + src/commands/cli-utils.d.ts | 28 + src/commands/cli-utils.d.ts.map | 1 + src/commands/cli-utils.js | 86 + src/commands/cli-utils.js.map | 1 + src/commands/close.d.ts | 29 + src/commands/close.d.ts.map | 1 + src/commands/close.js | 333 ++++ src/commands/close.js.map | 1 + src/commands/comment.d.ts | 6 + src/commands/comment.d.ts.map | 1 + src/commands/comment.js | 216 +++ src/commands/comment.js.map | 1 + src/commands/completion.d.ts | 12 + src/commands/completion.d.ts.map | 1 + src/commands/completion.js | 339 ++++ src/commands/completion.js.map | 1 + src/commands/create.d.ts | 6 + src/commands/create.d.ts.map | 1 + src/commands/create.js | 218 +++ src/commands/create.js.map | 1 + src/commands/delete.d.ts | 16 + src/commands/delete.d.ts.map | 1 + src/commands/delete.js | 100 + src/commands/delete.js.map | 1 + src/commands/dep.d.ts | 6 + src/commands/dep.d.ts.map | 1 + src/commands/dep.js | 215 +++ src/commands/dep.js.map | 1 + src/commands/doctor.d.ts | 6 + src/commands/doctor.d.ts.map | 1 + src/commands/doctor.js | 1121 +++++++++++ src/commands/doctor.js.map | 1 + src/commands/export.d.ts | 6 + src/commands/export.d.ts.map | 1 + src/commands/export.js | 56 + src/commands/export.js.map | 1 + src/commands/github.d.ts | 13 + src/commands/github.d.ts.map | 1 + src/commands/github.js | 856 +++++++++ src/commands/github.js.map | 1 + src/commands/grouping.d.ts | 73 + src/commands/grouping.d.ts.map | 1 + src/commands/grouping.js | 238 +++ src/commands/grouping.js.map | 1 + src/commands/helpers.d.ts | 89 + src/commands/helpers.d.ts.map | 1 + src/commands/helpers.js | 734 ++++++++ src/commands/helpers.js.map | 1 + src/commands/import.d.ts | 6 + src/commands/import.d.ts.map | 1 + src/commands/import.js | 41 + src/commands/import.js.map | 1 + src/commands/in-progress.d.ts | 6 + src/commands/in-progress.d.ts.map | 1 + src/commands/in-progress.js | 60 + src/commands/in-progress.js.map | 1 + src/commands/init.d.ts | 6 + src/commands/init.d.ts.map | 1 + src/commands/init.js | 1438 ++++++++++++++ src/commands/init.js.map | 1 + src/commands/list.d.ts | 6 + src/commands/list.d.ts.map | 1 + src/commands/list.js | 190 ++ src/commands/list.js.map | 1 + src/commands/migrate.d.ts | 6 + src/commands/migrate.d.ts.map | 1 + src/commands/migrate.js | 153 ++ src/commands/migrate.js.map | 1 + src/commands/next.d.ts | 6 + src/commands/next.d.ts.map | 1 + src/commands/next.js | 282 +++ src/commands/next.js.map | 1 + src/commands/plugins.d.ts | 9 + src/commands/plugins.d.ts.map | 1 + src/commands/plugins.js | 136 ++ src/commands/plugins.js.map | 1 + src/commands/re-sort.d.ts | 6 + src/commands/re-sort.d.ts.map | 1 + src/commands/re-sort.js | 53 + src/commands/re-sort.js.map | 1 + src/commands/recent.d.ts | 6 + src/commands/recent.d.ts.map | 1 + src/commands/recent.js | 79 + src/commands/recent.js.map | 1 + src/commands/reviewed.d.ts | 6 + src/commands/reviewed.d.ts.map | 1 + src/commands/reviewed.js | 52 + src/commands/reviewed.js.map | 1 + src/commands/search.d.ts | 10 + src/commands/search.d.ts.map | 1 + src/commands/search.js | 290 +++ src/commands/search.js.map | 1 + src/commands/show.d.ts | 6 + src/commands/show.d.ts.map | 1 + src/commands/show.js | 102 + src/commands/show.js.map | 1 + src/commands/status-stage-validation.d.ts | 16 + src/commands/status-stage-validation.d.ts.map | 1 + src/commands/status-stage-validation.js | 91 + src/commands/status-stage-validation.js.map | 1 + src/commands/status.d.ts | 6 + src/commands/status.d.ts.map | 1 + src/commands/status.js | 107 ++ src/commands/status.js.map | 1 + src/commands/sync.d.ts | 23 + src/commands/sync.d.ts.map | 1 + src/commands/sync.js | 481 +++++ src/commands/sync.js.map | 1 + src/commands/unlock.d.ts | 11 + src/commands/unlock.d.ts.map | 1 + src/commands/unlock.js | 114 ++ src/commands/unlock.js.map | 1 + src/commands/update.d.ts | 6 + src/commands/update.d.ts.map | 1 + src/commands/update.js | 530 ++++++ src/commands/update.js.map | 1 + src/config.d.ts | 66 + src/config.d.ts.map | 1 + src/config.js | 447 +++++ src/config.js.map | 1 + src/database.d.ts | 22 + src/database.d.ts.map | 1 + src/database.js | 65 + src/database.js.map | 1 + src/delegate-helper.d.ts | 72 + src/delegate-helper.d.ts.map | 1 + src/delegate-helper.js | 155 ++ src/delegate-helper.js.map | 1 + src/doctor/dependency-check.d.ts | 4 + src/doctor/dependency-check.d.ts.map | 1 + src/doctor/dependency-check.js | 40 + src/doctor/dependency-check.js.map | 1 + src/doctor/file-paths-check.d.ts | 51 + src/doctor/file-paths-check.d.ts.map | 1 + src/doctor/file-paths-check.js | 110 ++ src/doctor/file-paths-check.js.map | 1 + src/doctor/foreign-items-check.d.ts | 99 + src/doctor/foreign-items-check.d.ts.map | 1 + src/doctor/foreign-items-check.js | 167 ++ src/doctor/foreign-items-check.js.map | 1 + src/doctor/hook-upgrade.d.ts | 76 + src/doctor/hook-upgrade.d.ts.map | 1 + src/doctor/hook-upgrade.js | 453 +++++ src/doctor/hook-upgrade.js.map | 1 + src/doctor/status-stage-check.d.ts | 15 + src/doctor/status-stage-check.d.ts.map | 1 + src/doctor/status-stage-check.js | 150 ++ src/doctor/status-stage-check.js.map | 1 + src/file-lock.d.ts | 122 ++ src/file-lock.d.ts.map | 1 + src/file-lock.js | 443 +++++ src/file-lock.js.map | 1 + src/github-metrics.d.ts | 8 + src/github-metrics.d.ts.map | 1 + src/github-metrics.js | 33 + src/github-metrics.js.map | 1 + src/github-pre-filter.d.ts | 16 + src/github-pre-filter.d.ts.map | 1 + src/github-pre-filter.js | 183 ++ src/github-pre-filter.js.map | 1 + src/github-sync.d.ts | 137 ++ src/github-sync.d.ts.map | 1 + src/github-sync.js | 1132 +++++++++++ src/github-sync.js.map | 1 + src/github-throttler.d.ts | 67 + src/github-throttler.d.ts.map | 1 + src/github-throttler.js | 208 +++ src/github-throttler.js.map | 1 + src/github.d.ts | 255 +++ src/github.d.ts.map | 1 + src/github.js | 1662 +++++++++++++++++ src/github.js.map | 1 + src/icons.d.ts | 229 +++ src/icons.d.ts.map | 1 + src/icons.js | 538 ++++++ src/icons.js.map | 1 + src/jsonl.d.ts | 37 + src/jsonl.d.ts.map | 1 + src/jsonl.js | 422 +++++ src/jsonl.js.map | 1 + src/lib/runtime.d.ts | 83 + src/lib/runtime.d.ts.map | 1 + src/lib/runtime.js | 149 ++ src/lib/runtime.js.map | 1 + src/lib/search.d.ts | 244 +++ src/lib/search.d.ts.map | 1 + src/lib/search.js | 601 ++++++ src/lib/search.js.map | 1 + src/logger.d.ts | 20 + src/logger.d.ts.map | 1 + src/logger.js | 37 + src/logger.js.map | 1 + src/logging.d.ts | 12 + src/logging.d.ts.map | 1 + src/logging.js | 69 + src/logging.js.map | 1 + src/markdown-renderer.d.ts | 17 + src/markdown-renderer.d.ts.map | 1 + src/markdown-renderer.js | 45 + src/markdown-renderer.js.map | 1 + src/migrations/index.d.ts | 26 + src/migrations/index.d.ts.map | 1 + src/migrations/index.js | 297 +++ src/migrations/index.js.map | 1 + src/openbrain.d.ts | 62 + src/openbrain.d.ts.map | 1 + src/openbrain.js | 314 ++++ src/openbrain.js.map | 1 + src/pager.d.ts | 14 + src/pager.d.ts.map | 1 + src/pager.js | 40 + src/pager.js.map | 1 + src/persistent-store.d.ts | 317 ++++ src/persistent-store.d.ts.map | 1 + src/persistent-store.js | 1423 ++++++++++++++ src/persistent-store.js.map | 1 + src/pi-audit.d.ts | 56 + src/pi-audit.d.ts.map | 1 + src/pi-audit.js | 172 ++ src/pi-audit.js.map | 1 + src/plugin-loader.d.ts | 82 + src/plugin-loader.d.ts.map | 1 + src/plugin-loader.js | 211 +++ src/plugin-loader.js.map | 1 + src/plugin-types.d.ts | 91 + src/plugin-types.d.ts.map | 1 + src/plugin-types.js | 5 + src/plugin-types.js.map | 1 + src/process-lifecycle.d.ts | 155 ++ src/process-lifecycle.d.ts.map | 1 + src/process-lifecycle.js | 396 ++++ src/process-lifecycle.js.map | 1 + src/progress.d.ts | 42 + src/progress.d.ts.map | 1 + src/progress.js | 157 ++ src/progress.js.map | 1 + src/read-cache-cli.d.ts | 106 ++ src/read-cache-cli.d.ts.map | 1 + src/read-cache-cli.js | 203 ++ src/read-cache-cli.js.map | 1 + src/read-cache.d.ts | 177 ++ src/read-cache.d.ts.map | 1 + src/read-cache.js | 437 +++++ src/read-cache.js.map | 1 + src/search-metrics.d.ts | 19 + src/search-metrics.d.ts.map | 1 + src/search-metrics.js | 43 + src/search-metrics.js.map | 1 + src/spawn-counter.d.ts | 35 + src/spawn-counter.d.ts.map | 1 + src/spawn-counter.js | 67 + src/spawn-counter.js.map | 1 + src/status-stage-rules.d.ts | 27 + src/status-stage-rules.d.ts.map | 1 + src/status-stage-rules.js | 104 ++ src/status-stage-rules.js.map | 1 + src/status-stage-validation.d.ts | 8 + src/status-stage-validation.d.ts.map | 1 + src/status-stage-validation.js | 59 + src/status-stage-validation.js.map | 1 + src/sync-defaults.d.ts | 3 + src/sync-defaults.d.ts.map | 1 + src/sync-defaults.js | 3 + src/sync-defaults.js.map | 1 + src/sync.d.ts | 130 ++ src/sync.d.ts.map | 1 + src/sync.js | 999 ++++++++++ src/sync.js.map | 1 + src/sync/merge-utils.d.ts | 10 + src/sync/merge-utils.d.ts.map | 1 + src/sync/merge-utils.js | 57 + src/sync/merge-utils.js.map | 1 + src/theme.d.ts | 29 + src/theme.d.ts.map | 1 + src/theme.js | 32 + src/theme.js.map | 1 + src/types.d.ts | 7 + src/types.d.ts.map | 1 + src/types.js | 2 + src/types.js.map | 1 + src/validators/priority.d.ts | 7 + src/validators/priority.d.ts.map | 1 + src/validators/priority.js | 43 + src/validators/priority.js.map | 1 + src/version.d.ts | 2 + src/version.d.ts.map | 1 + src/version.js | 3 + src/version.js.map | 1 + src/worklog-paths.d.ts | 63 + src/worklog-paths.d.ts.map | 1 + src/worklog-paths.js | 128 ++ src/worklog-paths.js.map | 1 + tests/cli/cli-helpers.d.ts | 92 + tests/cli/cli-helpers.d.ts.map | 1 + tests/cli/cli-helpers.js | 403 ++++ tests/cli/cli-helpers.js.map | 1 + tests/cli/cli-inproc.d.ts | 6 + tests/cli/cli-inproc.d.ts.map | 1 + tests/cli/cli-inproc.js | 383 ++++ tests/cli/cli-inproc.js.map | 1 + tests/test-utils.d.ts | 79 + tests/test-utils.d.ts.map | 1 + tests/test-utils.js | 526 ++++++ tests/test-utils.js.map | 1 + 330 files changed, 27595 insertions(+), 18 deletions(-) create mode 100644 src/audit.d.ts create mode 100644 src/audit.d.ts.map create mode 100644 src/audit.js create mode 100644 src/audit.js.map create mode 100644 src/cli-output.d.ts create mode 100644 src/cli-output.d.ts.map create mode 100644 src/cli-output.js create mode 100644 src/cli-output.js.map create mode 100644 src/cli-types.d.ts create mode 100644 src/cli-types.d.ts.map create mode 100644 src/cli-types.js create mode 100644 src/cli-types.js.map create mode 100644 src/cli-utils.d.ts create mode 100644 src/cli-utils.d.ts.map create mode 100644 src/cli-utils.js create mode 100644 src/cli-utils.js.map create mode 100644 src/commands/audit-result.d.ts create mode 100644 src/commands/audit-result.d.ts.map create mode 100644 src/commands/audit-result.js create mode 100644 src/commands/audit-result.js.map create mode 100644 src/commands/audit.d.ts create mode 100644 src/commands/audit.d.ts.map create mode 100644 src/commands/audit.js create mode 100644 src/commands/audit.js.map create mode 100644 src/commands/cli-utils.d.ts create mode 100644 src/commands/cli-utils.d.ts.map create mode 100644 src/commands/cli-utils.js create mode 100644 src/commands/cli-utils.js.map create mode 100644 src/commands/close.d.ts create mode 100644 src/commands/close.d.ts.map create mode 100644 src/commands/close.js create mode 100644 src/commands/close.js.map create mode 100644 src/commands/comment.d.ts create mode 100644 src/commands/comment.d.ts.map create mode 100644 src/commands/comment.js create mode 100644 src/commands/comment.js.map create mode 100644 src/commands/completion.d.ts create mode 100644 src/commands/completion.d.ts.map create mode 100644 src/commands/completion.js create mode 100644 src/commands/completion.js.map create mode 100644 src/commands/create.d.ts create mode 100644 src/commands/create.d.ts.map create mode 100644 src/commands/create.js create mode 100644 src/commands/create.js.map create mode 100644 src/commands/delete.d.ts create mode 100644 src/commands/delete.d.ts.map create mode 100644 src/commands/delete.js create mode 100644 src/commands/delete.js.map create mode 100644 src/commands/dep.d.ts create mode 100644 src/commands/dep.d.ts.map create mode 100644 src/commands/dep.js create mode 100644 src/commands/dep.js.map create mode 100644 src/commands/doctor.d.ts create mode 100644 src/commands/doctor.d.ts.map create mode 100644 src/commands/doctor.js create mode 100644 src/commands/doctor.js.map create mode 100644 src/commands/export.d.ts create mode 100644 src/commands/export.d.ts.map create mode 100644 src/commands/export.js create mode 100644 src/commands/export.js.map create mode 100644 src/commands/github.d.ts create mode 100644 src/commands/github.d.ts.map create mode 100644 src/commands/github.js create mode 100644 src/commands/github.js.map create mode 100644 src/commands/grouping.d.ts create mode 100644 src/commands/grouping.d.ts.map create mode 100644 src/commands/grouping.js create mode 100644 src/commands/grouping.js.map create mode 100644 src/commands/helpers.d.ts create mode 100644 src/commands/helpers.d.ts.map create mode 100644 src/commands/helpers.js create mode 100644 src/commands/helpers.js.map create mode 100644 src/commands/import.d.ts create mode 100644 src/commands/import.d.ts.map create mode 100644 src/commands/import.js create mode 100644 src/commands/import.js.map create mode 100644 src/commands/in-progress.d.ts create mode 100644 src/commands/in-progress.d.ts.map create mode 100644 src/commands/in-progress.js create mode 100644 src/commands/in-progress.js.map create mode 100644 src/commands/init.d.ts create mode 100644 src/commands/init.d.ts.map create mode 100644 src/commands/init.js create mode 100644 src/commands/init.js.map create mode 100644 src/commands/list.d.ts create mode 100644 src/commands/list.d.ts.map create mode 100644 src/commands/list.js create mode 100644 src/commands/list.js.map create mode 100644 src/commands/migrate.d.ts create mode 100644 src/commands/migrate.d.ts.map create mode 100644 src/commands/migrate.js create mode 100644 src/commands/migrate.js.map create mode 100644 src/commands/next.d.ts create mode 100644 src/commands/next.d.ts.map create mode 100644 src/commands/next.js create mode 100644 src/commands/next.js.map create mode 100644 src/commands/plugins.d.ts create mode 100644 src/commands/plugins.d.ts.map create mode 100644 src/commands/plugins.js create mode 100644 src/commands/plugins.js.map create mode 100644 src/commands/re-sort.d.ts create mode 100644 src/commands/re-sort.d.ts.map create mode 100644 src/commands/re-sort.js create mode 100644 src/commands/re-sort.js.map create mode 100644 src/commands/recent.d.ts create mode 100644 src/commands/recent.d.ts.map create mode 100644 src/commands/recent.js create mode 100644 src/commands/recent.js.map create mode 100644 src/commands/reviewed.d.ts create mode 100644 src/commands/reviewed.d.ts.map create mode 100644 src/commands/reviewed.js create mode 100644 src/commands/reviewed.js.map create mode 100644 src/commands/search.d.ts create mode 100644 src/commands/search.d.ts.map create mode 100644 src/commands/search.js create mode 100644 src/commands/search.js.map create mode 100644 src/commands/show.d.ts create mode 100644 src/commands/show.d.ts.map create mode 100644 src/commands/show.js create mode 100644 src/commands/show.js.map create mode 100644 src/commands/status-stage-validation.d.ts create mode 100644 src/commands/status-stage-validation.d.ts.map create mode 100644 src/commands/status-stage-validation.js create mode 100644 src/commands/status-stage-validation.js.map create mode 100644 src/commands/status.d.ts create mode 100644 src/commands/status.d.ts.map create mode 100644 src/commands/status.js create mode 100644 src/commands/status.js.map create mode 100644 src/commands/sync.d.ts create mode 100644 src/commands/sync.d.ts.map create mode 100644 src/commands/sync.js create mode 100644 src/commands/sync.js.map create mode 100644 src/commands/unlock.d.ts create mode 100644 src/commands/unlock.d.ts.map create mode 100644 src/commands/unlock.js create mode 100644 src/commands/unlock.js.map create mode 100644 src/commands/update.d.ts create mode 100644 src/commands/update.d.ts.map create mode 100644 src/commands/update.js create mode 100644 src/commands/update.js.map create mode 100644 src/config.d.ts create mode 100644 src/config.d.ts.map create mode 100644 src/config.js create mode 100644 src/config.js.map create mode 100644 src/database.d.ts create mode 100644 src/database.d.ts.map create mode 100644 src/database.js create mode 100644 src/database.js.map create mode 100644 src/delegate-helper.d.ts create mode 100644 src/delegate-helper.d.ts.map create mode 100644 src/delegate-helper.js create mode 100644 src/delegate-helper.js.map create mode 100644 src/doctor/dependency-check.d.ts create mode 100644 src/doctor/dependency-check.d.ts.map create mode 100644 src/doctor/dependency-check.js create mode 100644 src/doctor/dependency-check.js.map create mode 100644 src/doctor/file-paths-check.d.ts create mode 100644 src/doctor/file-paths-check.d.ts.map create mode 100644 src/doctor/file-paths-check.js create mode 100644 src/doctor/file-paths-check.js.map create mode 100644 src/doctor/foreign-items-check.d.ts create mode 100644 src/doctor/foreign-items-check.d.ts.map create mode 100644 src/doctor/foreign-items-check.js create mode 100644 src/doctor/foreign-items-check.js.map create mode 100644 src/doctor/hook-upgrade.d.ts create mode 100644 src/doctor/hook-upgrade.d.ts.map create mode 100644 src/doctor/hook-upgrade.js create mode 100644 src/doctor/hook-upgrade.js.map create mode 100644 src/doctor/status-stage-check.d.ts create mode 100644 src/doctor/status-stage-check.d.ts.map create mode 100644 src/doctor/status-stage-check.js create mode 100644 src/doctor/status-stage-check.js.map create mode 100644 src/file-lock.d.ts create mode 100644 src/file-lock.d.ts.map create mode 100644 src/file-lock.js create mode 100644 src/file-lock.js.map create mode 100644 src/github-metrics.d.ts create mode 100644 src/github-metrics.d.ts.map create mode 100644 src/github-metrics.js create mode 100644 src/github-metrics.js.map create mode 100644 src/github-pre-filter.d.ts create mode 100644 src/github-pre-filter.d.ts.map create mode 100644 src/github-pre-filter.js create mode 100644 src/github-pre-filter.js.map create mode 100644 src/github-sync.d.ts create mode 100644 src/github-sync.d.ts.map create mode 100644 src/github-sync.js create mode 100644 src/github-sync.js.map create mode 100644 src/github-throttler.d.ts create mode 100644 src/github-throttler.d.ts.map create mode 100644 src/github-throttler.js create mode 100644 src/github-throttler.js.map create mode 100644 src/github.d.ts create mode 100644 src/github.d.ts.map create mode 100644 src/github.js create mode 100644 src/github.js.map create mode 100644 src/icons.d.ts create mode 100644 src/icons.d.ts.map create mode 100644 src/icons.js create mode 100644 src/icons.js.map create mode 100644 src/jsonl.d.ts create mode 100644 src/jsonl.d.ts.map create mode 100644 src/jsonl.js create mode 100644 src/jsonl.js.map create mode 100644 src/lib/runtime.d.ts create mode 100644 src/lib/runtime.d.ts.map create mode 100644 src/lib/runtime.js create mode 100644 src/lib/runtime.js.map create mode 100644 src/lib/search.d.ts create mode 100644 src/lib/search.d.ts.map create mode 100644 src/lib/search.js create mode 100644 src/lib/search.js.map create mode 100644 src/logger.d.ts create mode 100644 src/logger.d.ts.map create mode 100644 src/logger.js create mode 100644 src/logger.js.map create mode 100644 src/logging.d.ts create mode 100644 src/logging.d.ts.map create mode 100644 src/logging.js create mode 100644 src/logging.js.map create mode 100644 src/markdown-renderer.d.ts create mode 100644 src/markdown-renderer.d.ts.map create mode 100644 src/markdown-renderer.js create mode 100644 src/markdown-renderer.js.map create mode 100644 src/migrations/index.d.ts create mode 100644 src/migrations/index.d.ts.map create mode 100644 src/migrations/index.js create mode 100644 src/migrations/index.js.map create mode 100644 src/openbrain.d.ts create mode 100644 src/openbrain.d.ts.map create mode 100644 src/openbrain.js create mode 100644 src/openbrain.js.map create mode 100644 src/pager.d.ts create mode 100644 src/pager.d.ts.map create mode 100644 src/pager.js create mode 100644 src/pager.js.map create mode 100644 src/persistent-store.d.ts create mode 100644 src/persistent-store.d.ts.map create mode 100644 src/persistent-store.js create mode 100644 src/persistent-store.js.map create mode 100644 src/pi-audit.d.ts create mode 100644 src/pi-audit.d.ts.map create mode 100644 src/pi-audit.js create mode 100644 src/pi-audit.js.map create mode 100644 src/plugin-loader.d.ts create mode 100644 src/plugin-loader.d.ts.map create mode 100644 src/plugin-loader.js create mode 100644 src/plugin-loader.js.map create mode 100644 src/plugin-types.d.ts create mode 100644 src/plugin-types.d.ts.map create mode 100644 src/plugin-types.js create mode 100644 src/plugin-types.js.map create mode 100644 src/process-lifecycle.d.ts create mode 100644 src/process-lifecycle.d.ts.map create mode 100644 src/process-lifecycle.js create mode 100644 src/process-lifecycle.js.map create mode 100644 src/progress.d.ts create mode 100644 src/progress.d.ts.map create mode 100644 src/progress.js create mode 100644 src/progress.js.map create mode 100644 src/read-cache-cli.d.ts create mode 100644 src/read-cache-cli.d.ts.map create mode 100644 src/read-cache-cli.js create mode 100644 src/read-cache-cli.js.map create mode 100644 src/read-cache.d.ts create mode 100644 src/read-cache.d.ts.map create mode 100644 src/read-cache.js create mode 100644 src/read-cache.js.map create mode 100644 src/search-metrics.d.ts create mode 100644 src/search-metrics.d.ts.map create mode 100644 src/search-metrics.js create mode 100644 src/search-metrics.js.map create mode 100644 src/spawn-counter.d.ts create mode 100644 src/spawn-counter.d.ts.map create mode 100644 src/spawn-counter.js create mode 100644 src/spawn-counter.js.map create mode 100644 src/status-stage-rules.d.ts create mode 100644 src/status-stage-rules.d.ts.map create mode 100644 src/status-stage-rules.js create mode 100644 src/status-stage-rules.js.map create mode 100644 src/status-stage-validation.d.ts create mode 100644 src/status-stage-validation.d.ts.map create mode 100644 src/status-stage-validation.js create mode 100644 src/status-stage-validation.js.map create mode 100644 src/sync-defaults.d.ts create mode 100644 src/sync-defaults.d.ts.map create mode 100644 src/sync-defaults.js create mode 100644 src/sync-defaults.js.map create mode 100644 src/sync.d.ts create mode 100644 src/sync.d.ts.map create mode 100644 src/sync.js create mode 100644 src/sync.js.map create mode 100644 src/sync/merge-utils.d.ts create mode 100644 src/sync/merge-utils.d.ts.map create mode 100644 src/sync/merge-utils.js create mode 100644 src/sync/merge-utils.js.map create mode 100644 src/theme.d.ts create mode 100644 src/theme.d.ts.map create mode 100644 src/theme.js create mode 100644 src/theme.js.map create mode 100644 src/types.d.ts create mode 100644 src/types.d.ts.map create mode 100644 src/types.js create mode 100644 src/types.js.map create mode 100644 src/validators/priority.d.ts create mode 100644 src/validators/priority.d.ts.map create mode 100644 src/validators/priority.js create mode 100644 src/validators/priority.js.map create mode 100644 src/version.d.ts create mode 100644 src/version.d.ts.map create mode 100644 src/version.js create mode 100644 src/version.js.map create mode 100644 src/worklog-paths.d.ts create mode 100644 src/worklog-paths.d.ts.map create mode 100644 src/worklog-paths.js create mode 100644 src/worklog-paths.js.map create mode 100644 tests/cli/cli-helpers.d.ts create mode 100644 tests/cli/cli-helpers.d.ts.map create mode 100644 tests/cli/cli-helpers.js create mode 100644 tests/cli/cli-helpers.js.map create mode 100644 tests/cli/cli-inproc.d.ts create mode 100644 tests/cli/cli-inproc.d.ts.map create mode 100644 tests/cli/cli-inproc.js create mode 100644 tests/cli/cli-inproc.js.map create mode 100644 tests/test-utils.d.ts create mode 100644 tests/test-utils.d.ts.map create mode 100644 tests/test-utils.js create mode 100644 tests/test-utils.js.map diff --git a/packages/herdr/README.md b/packages/herdr/README.md index d2fc3a2a..6cbdac4c 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -20,7 +20,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **Quit** — Press `q` to exit - **Metadata panel** — The bottom portion of the list view (roughly 20–40% of the pane height, responsive to terminal size) is reserved for the selected item's metadata: ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). - **Command log** — Every plugin-dispatched command that targets a work item (via `` substitution or an explicit item ID) is recorded to a local JSON log. For `in_progress` items the panel shows the **last command** at the bottom, so you can see exactly what was last dispatched against the item. See [Command log](#command-log). -- **Stage grouping** — Work items are grouped by their Worklog stage (standard lifecycle stages only: `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done` — no custom stage values). Podcast episode items group exactly as their frontmatter stages map 1:1 (PRD §7.2). See [Stage grouping](#stage-grouping). +- **Priority-first grouping** — Work items are grouped by priority, most actionable first: one section per bucket (`Critical` → `High` → `Medium` → `Low`) and within each section by Worklog stage in workflow order (`idea` → `intake_complete` → `plan_complete` → `in_progress` → `in_review` → `done`), then by id. See [Priority-first grouping](#priority-first-grouping). - **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, full GFM rendering: headings, lists, tables, blockquotes, code, links) as a preview. The description section is rendered with the same markdown renderer. A persistent **Related Docs** table of contents at the top of the detail view lists every `.md` Key File (`↑↓/j:k` to navigate, `Enter` to open in the viewer), and the metadata panel shows a display-only `Related Docs` row. See [Markdown viewer](#markdown-viewer). - **Inline note links** — Inline `[NOTE : ...]` markers (PRD §7.1) render as clickable links to the note work items: the marker is displayed as `↗`, and the note text is never shown in the viewer. See [Inline note links](#inline-note-links). - **Code Freeze awareness** — While a ship-it release is in progress the project is in *Code Freeze*: the worklist shows a prominent banner and blocks all implement commands (`/skill:implement*`) with a notice dialog until the release finishes. See [Code Freeze](#code-freeze). @@ -491,19 +491,25 @@ clamped to a minimum of 3 rows so it is always usable. list, filtering, or refreshing resets the panel scroll so the top of the panel is always visible again. -## Stage grouping - -Work items are grouped by their Worklog **stage** using the standard -lifecycle stages only — `idea`, `intake_complete`, `plan_complete`, -`in_progress`, `in_review`, `done`. No custom stage values are required for -grouping, so podcast episode items group exactly as their frontmatter -`pipeline_stage` maps 1:1 onto the Worklog stages (PRD §7.2). Groups render -in the canonical order (Critical → Group N → Idea → Other → In Review) with -group separators in the list; stage changes re-group items on the next -refresh. Non-critical `in_progress` items join the file-path-partitioned -`Group N` lists alongside `plan_complete`/`intake_complete` items and sort -ahead of them (actively-worked items first); "Other" remains only as a -safety net for unknown/custom stages. +## Priority-first grouping + +Work items in the selection list are grouped by **priority**, most actionable +first: one section per priority bucket — `Critical` → `High` → `Medium` → `Low` +— each rendered with a `──