@dreki-gg/pi-plan-mode 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/extensions/plan-mode/__tests__/git.test.ts +17 -0
  3. package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
  4. package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
  5. package/extensions/plan-mode/__tests__/prompts.test.ts +23 -0
  6. package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
  7. package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
  8. package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
  9. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
  10. package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
  11. package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
  12. package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
  13. package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
  14. package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
  15. package/extensions/plan-mode/commands/list-plans.ts +21 -168
  16. package/extensions/plan-mode/exec-pending.ts +59 -0
  17. package/extensions/plan-mode/git.ts +21 -0
  18. package/extensions/plan-mode/index.ts +9 -7
  19. package/extensions/plan-mode/prompts.ts +6 -1
  20. package/extensions/plan-mode/references/context.ts +5 -5
  21. package/extensions/plan-mode/references/plan-index.ts +1 -1
  22. package/extensions/plan-mode/resolve-plan.ts +5 -4
  23. package/extensions/plan-mode/resume.ts +7 -5
  24. package/extensions/plan-mode/tools/add-task.ts +1 -1
  25. package/extensions/plan-mode/tools/initiative-status.ts +6 -6
  26. package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
  27. package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
  28. package/extensions/plan-mode/tools/revise-plan.ts +9 -7
  29. package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
  30. package/extensions/plan-mode/tools/submit-plan.ts +18 -8
  31. package/extensions/plan-mode/tools/update-initiative.ts +2 -2
  32. package/extensions/plan-mode/tools/update-plan.ts +3 -3
  33. package/extensions/plan-mode/types.ts +17 -51
  34. package/extensions/plan-mode/utils.ts +2 -29
  35. package/package.json +2 -1
  36. package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
  37. package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
  38. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
  39. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
  40. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
  41. package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
  42. package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
  43. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
  44. package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
  45. package/extensions/plan-mode/__tests__/schema.test.ts +0 -319
  46. package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
  47. package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -94
  48. package/extensions/plan-mode/effects/filesystem.ts +0 -65
  49. package/extensions/plan-mode/effects/runtime.ts +0 -24
  50. package/extensions/plan-mode/errors.ts +0 -105
  51. package/extensions/plan-mode/initiative.ts +0 -172
  52. package/extensions/plan-mode/plan-storage.ts +0 -6
  53. package/extensions/plan-mode/reconcile.ts +0 -235
  54. package/extensions/plan-mode/schema.ts +0 -97
  55. package/extensions/plan-mode/storage/atomic-write.ts +0 -75
  56. package/extensions/plan-mode/storage/file-lock.ts +0 -49
  57. package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
  58. package/extensions/plan-mode/storage/plan-storage.ts +0 -88
  59. package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
  60. package/extensions/plan-mode/storage/task-storage.ts +0 -111
  61. package/extensions/plan-mode/task-status.ts +0 -46
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.26.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Internal refactor: the task-management engine (storage, schema, reconcile,
8
+ initiative projection, task-status, plan resolution) now lives in the new
9
+ `@dreki-gg/taskman` package, which plan-mode depends on. No behavior change —
10
+ the same tools, commands, and `.plans/` layout, with the engine shared so other
11
+ harnesses can drive the same ledger via the `taskman` CLI.
12
+ - Updated dependencies
13
+ - @dreki-gg/taskman@0.2.0
14
+
15
+ ## 0.26.0
16
+
17
+ ### Minor Changes
18
+
19
+ - Make plans more executable by zero-context executors. Plans now stamp the git
20
+ commit they were written against (`base_commit`), and the execution prompt runs
21
+ a drift check — if HEAD has moved, the executor is told to diff, re-read the
22
+ affected files, and proceed with caution. Planning guidance also now asks
23
+ delegation tasks to end their details with a verification gate (a concrete
24
+ command + expected output) and STOP conditions, so success is machine-checkable.
25
+ Fully backward-compatible: older plans without `base_commit` simply skip the
26
+ drift check.
27
+
3
28
  ## 0.25.0
4
29
 
5
30
  ### Minor Changes
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { tmpdir } from 'node:os';
3
+ import { readHeadCommit } from '../git.js';
4
+
5
+ describe('readHeadCommit', () => {
6
+ test('returns the HEAD sha inside a git repo', async () => {
7
+ // This monorepo is a git repo, so cwd resolves a real commit.
8
+ const sha = await readHeadCommit(process.cwd());
9
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
10
+ });
11
+
12
+ test('returns undefined outside a git repo (never throws)', async () => {
13
+ // tmpdir is not a git repo; the helper must swallow the error.
14
+ const sha = await readHeadCommit(tmpdir());
15
+ expect(sha).toBeUndefined();
16
+ });
17
+ });
@@ -4,11 +4,11 @@ import { chdir } from 'node:process';
4
4
  import { mkdtemp, rm } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
7
- import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
8
- import { makePlanRuntime } from '../effects/runtime.js';
9
- import { upsertPlanEntry } from '../storage/plans-manifest.js';
10
- import { upsertInitiativeEntry } from '../storage/initiatives-manifest.js';
11
- import { writeTasksJsonl } from '../storage/task-storage.js';
7
+ import { FileSystem, nodeFileSystemService } from '@dreki-gg/taskman';
8
+ import { makePlanRuntime } from '@dreki-gg/taskman';
9
+ import { upsertPlanEntry } from '@dreki-gg/taskman';
10
+ import { upsertInitiativeEntry } from '@dreki-gg/taskman';
11
+ import { writeTasksJsonl } from '@dreki-gg/taskman';
12
12
  import type { TaskRecord } from '../types.js';
13
13
  import { registerInitiativeStatusTool } from '../tools/initiative-status.js';
14
14
 
@@ -3,10 +3,10 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { upsertPlanEntry } from '../storage/plans-manifest.js';
8
- import { writeTasksJsonl } from '../storage/task-storage.js';
9
- import { saveHandoff } from '../storage/plan-storage.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { upsertPlanEntry } from '@dreki-gg/taskman';
8
+ import { writeTasksJsonl } from '@dreki-gg/taskman';
9
+ import { saveHandoff } from '@dreki-gg/taskman';
10
10
  import type { TaskMeta, TaskRecord } from '../types.js';
11
11
  import { buildPlanContextPack, resolvePlanReference } from '../references/context.js';
12
12
 
@@ -23,6 +23,11 @@ describe('buildPlanModePrompt', () => {
23
23
  expect(prompt).toContain('technical-options');
24
24
  });
25
25
 
26
+ test('instructs delegation tasks to include a verification gate', () => {
27
+ expect(prompt).toContain('verification gate');
28
+ expect(prompt).toContain('STOP conditions');
29
+ });
30
+
26
31
  test('mentions handoff instead of context and risks', () => {
27
32
  expect(prompt).toContain('handoff');
28
33
  expect(prompt).not.toContain('- risks:');
@@ -72,6 +77,24 @@ describe('buildExecutionPrompt', () => {
72
77
  expect(prompt).toContain('Details: Full instructions here');
73
78
  });
74
79
 
80
+ test('includes a drift check when base_commit is present', () => {
81
+ const plan: PlanData = {
82
+ title: 'Test',
83
+ planName: 'test',
84
+ handoff: '# H',
85
+ tasks: [makeTask()],
86
+ base_commit: 'deadbeef',
87
+ };
88
+ const prompt = buildExecutionPrompt(plan)!;
89
+ expect(prompt).toContain('Drift check');
90
+ expect(prompt).toContain('deadbeef');
91
+ });
92
+
93
+ test('omits the drift check when base_commit is absent', () => {
94
+ const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask()] };
95
+ expect(buildExecutionPrompt(plan)!).not.toContain('Drift check');
96
+ });
97
+
75
98
  test('returns undefined when no pending tasks', () => {
76
99
  const plan: PlanData = {
77
100
  title: 'Test',
@@ -3,9 +3,9 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { writeTasksJsonl } from '../storage/task-storage.js';
8
- import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { writeTasksJsonl } from '@dreki-gg/taskman';
8
+ import { readPlansManifest, upsertPlanEntry } from '@dreki-gg/taskman';
9
9
  import { registerReconcilePlansTool } from '../tools/reconcile-plans.js';
10
10
  import type { TaskMeta, TaskRecord } from '../types.js';
11
11
 
@@ -3,10 +3,10 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { writeTasksJsonl } from '../storage/task-storage.js';
8
- import { upsertPlanEntry } from '../storage/plans-manifest.js';
9
- import { saveHandoff } from '../storage/plan-storage.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { writeTasksJsonl } from '@dreki-gg/taskman';
8
+ import { upsertPlanEntry } from '@dreki-gg/taskman';
9
+ import { saveHandoff } from '@dreki-gg/taskman';
10
10
  import { PlanModeState } from '../state.js';
11
11
  import { resolveActivePlan } from '../resolve-plan.js';
12
12
  import type { TaskMeta, TaskRecord } from '../types.js';
@@ -3,11 +3,11 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
7
  import { registerRevisePlanTool } from '../tools/revise-plan.js';
8
- import { readTasksJsonl, writeTasksJsonl } from '../storage/task-storage.js';
9
- import { saveHandoff, loadHandoff } from '../storage/plan-storage.js';
10
- import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
8
+ import { readTasksJsonl, writeTasksJsonl } from '@dreki-gg/taskman';
9
+ import { saveHandoff, loadHandoff } from '@dreki-gg/taskman';
10
+ import { readPlansManifest, upsertPlanEntry } from '@dreki-gg/taskman';
11
11
  import type { PlanData, TaskRecord } from '../types.js';
12
12
 
13
13
  const runPlanIO = makePlanRuntime();
@@ -3,8 +3,8 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { readInitiativesManifest } from '@dreki-gg/taskman';
8
8
  import { registerSubmitInitiativeTool } from '../tools/submit-initiative.js';
9
9
 
10
10
  const runPlanIO = makePlanRuntime();
@@ -3,12 +3,12 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { readPlansManifest } from '../storage/plans-manifest.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { readPlansManifest } from '@dreki-gg/taskman';
8
8
  import {
9
9
  readInitiativesManifest,
10
10
  upsertInitiativeEntry,
11
- } from '../storage/initiatives-manifest.js';
11
+ } from '@dreki-gg/taskman';
12
12
  import { registerSubmitPlanTool } from '../tools/submit-plan.js';
13
13
 
14
14
  const runPlanIO = makePlanRuntime();
@@ -3,11 +3,11 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
7
  import {
8
8
  readInitiativesManifest,
9
9
  upsertInitiativeEntry,
10
- } from '../storage/initiatives-manifest.js';
10
+ } from '@dreki-gg/taskman';
11
11
  import { registerUpdateInitiativeTool } from '../tools/update-initiative.js';
12
12
 
13
13
  const runPlanIO = makePlanRuntime();
@@ -3,8 +3,8 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { makePlanRuntime } from '../effects/runtime.js';
7
- import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
6
+ import { makePlanRuntime } from '@dreki-gg/taskman';
7
+ import { readPlansManifest, upsertPlanEntry } from '@dreki-gg/taskman';
8
8
  import { registerUpdatePlanTool } from '../tools/update-plan.js';
9
9
 
10
10
  const runPlanIO = makePlanRuntime();
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { isSafeCommand, isPlanPath, nextTaskId } from '../utils.js';
2
+ import { isSafeCommand, isPlanPath } from '../utils.js';
3
+ import { nextTaskId } from '@dreki-gg/taskman';
3
4
 
4
5
  describe('nextTaskId', () => {
5
6
  test('increments the max numeric suffix', () => {
@@ -1,121 +1,29 @@
1
1
  /**
2
2
  * /initiatives command — list, filter, and sort initiatives interactively.
3
3
  *
4
- * The initiative sibling of /plans. Reads the initiatives registry plus the
5
- * plans manifest to roll up member-plan progress (done/total, ready/blocked).
4
+ * The pure listing logic lives in `@dreki-gg/taskman`; this file is the
5
+ * pi-interactive shell around it.
6
6
  */
7
7
 
8
- import { Effect } from 'effect';
9
8
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
10
- import type { RunPlanIO } from '../effects/runtime.js';
11
- import { FileSystem } from '../effects/filesystem.js';
12
- import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
13
- import {
14
- readInitiativesManifest,
15
- type InitiativeManifestEntry,
16
- } from '../storage/initiatives-manifest.js';
17
- import { initiativeRollup } from '../initiative.js';
18
- import type { InitiativeStatus } from '../types.js';
19
-
20
- export type StatusFilter = 'all' | InitiativeStatus;
21
-
22
- export interface InitiativeListItem {
23
- name: string;
24
- title: string;
25
- status: InitiativeStatus;
26
- created_at: string;
27
- totalPlans: number;
28
- donePlans: number;
29
- ready: number;
30
- blocked: number;
31
- }
32
-
33
- export function filterInitiatives(
34
- items: InitiativeListItem[],
35
- filter: StatusFilter,
36
- ): InitiativeListItem[] {
37
- if (filter === 'all') return items;
38
- return items.filter((i) => i.status === filter);
39
- }
40
-
41
- const STATUS_ICON: Record<InitiativeStatus, string> = {
42
- 'in-progress': '🔵',
43
- done: '✅',
44
- superseded: '🔄',
45
- abandoned: '❌',
46
- };
47
-
48
- export function formatInitiativeList(items: InitiativeListItem[], filter: StatusFilter): string {
49
- if (items.length === 0) {
50
- return filter === 'all'
51
- ? 'No initiatives found in .plans/initiatives.jsonl'
52
- : `No initiatives with status "${filter}"`;
53
- }
54
- const header =
55
- filter === 'all'
56
- ? `All initiatives (${items.length})`
57
- : `Initiatives: ${filter} (${items.length})`;
58
- const lines = items.map((i) => {
59
- const icon = STATUS_ICON[i.status];
60
- const progress =
61
- i.totalPlans > 0
62
- ? ` [${i.donePlans}/${i.totalPlans} plans, ready ${i.ready}, blocked ${i.blocked}]`
63
- : ' [no plans]';
64
- const date = i.created_at.slice(0, 10);
65
- return ` ${icon} ${i.name} — ${i.title}${progress} (${date})`;
66
- });
67
- return `${header}\n${lines.join('\n')}`;
68
- }
69
-
70
- export function loadInitiativeListItems(): Effect.Effect<InitiativeListItem[], never, FileSystem> {
71
- return Effect.gen(function* () {
72
- const initiatives = yield* Effect.orElseSucceed(
73
- readInitiativesManifest(),
74
- () => [] as InitiativeManifestEntry[],
75
- );
76
- const plans = yield* Effect.orElseSucceed(
77
- readPlansManifest(),
78
- () => [] as PlanManifestEntry[],
79
- );
80
- return initiatives.map((entry): InitiativeListItem => {
81
- const r = initiativeRollup(entry.name, plans);
82
- return {
83
- name: entry.name,
84
- title: entry.title,
85
- status: entry.status,
86
- created_at: entry.created_at,
87
- totalPlans: r.total,
88
- donePlans: r.done,
89
- ready: r.ready,
90
- blocked: r.blocked,
91
- };
92
- });
93
- });
94
- }
95
-
96
- const FILTER_ALIASES: Record<string, StatusFilter> = {
97
- all: 'all',
98
- 'in-progress': 'in-progress',
99
- active: 'in-progress',
100
- done: 'done',
101
- completed: 'done',
102
- superseded: 'superseded',
103
- abandoned: 'abandoned',
104
- };
105
-
106
- export function parseFilter(raw: string): StatusFilter {
107
- for (const token of raw.toLowerCase().split(/\s+/)) {
108
- if (FILTER_ALIASES[token]) return FILTER_ALIASES[token];
109
- }
110
- return 'all';
111
- }
9
+ import type { RunPlanIO } from '@dreki-gg/taskman';
10
+ import { InitiativeListing } from '@dreki-gg/taskman';
11
+
12
+ export type StatusFilter = InitiativeListing.StatusFilter;
13
+ export type InitiativeListItem = InitiativeListing.InitiativeListItem;
14
+ export const {
15
+ filterInitiatives,
16
+ formatInitiativeList,
17
+ loadInitiativeListItems,
18
+ parseInitiativeFilter: parseFilter,
19
+ } = InitiativeListing;
112
20
 
113
21
  export async function handleListInitiatives(
114
22
  ctx: ExtensionCommandContext,
115
23
  runPlanIO: RunPlanIO,
116
24
  args?: string,
117
25
  ): Promise<void> {
118
- const items = await runPlanIO(loadInitiativeListItems());
26
+ const items = await runPlanIO(InitiativeListing.loadInitiativeListItems());
119
27
  if (items.length === 0) {
120
28
  ctx.ui.notify('No initiatives found in .plans/initiatives.jsonl', 'info');
121
29
  return;
@@ -123,7 +31,7 @@ export async function handleListInitiatives(
123
31
 
124
32
  let filter: StatusFilter = 'all';
125
33
  if (args?.trim()) {
126
- filter = parseFilter(args.trim());
34
+ filter = InitiativeListing.parseInitiativeFilter(args.trim());
127
35
  } else {
128
36
  const choice = await ctx.ui.select('Filter initiatives by status:', [
129
37
  'All',
@@ -143,6 +51,8 @@ export async function handleListInitiatives(
143
51
  filter = map[choice] ?? 'all';
144
52
  }
145
53
 
146
- const filtered = filterInitiatives(items, filter);
147
- ctx.ui.notify(formatInitiativeList(filtered, filter), 'info');
54
+ ctx.ui.notify(
55
+ InitiativeListing.formatInitiativeList(InitiativeListing.filterInitiatives(items, filter), filter),
56
+ 'info',
57
+ );
148
58
  }
@@ -1,155 +1,47 @@
1
1
  /**
2
2
  * /plans command — list, filter, and sort plans interactively.
3
3
  *
4
- * Reads from the plans manifest AND task snapshots to build a rich table
5
- * with status, task counts, and dates. The user can filter by status and
6
- * sort by different criteria via interactive selectors.
4
+ * The pure listing logic (filter / sort / format / load) lives in
5
+ * `@dreki-gg/taskman`; this file is the pi-interactive shell around it.
7
6
  */
8
7
 
9
- import { Effect } from 'effect';
10
8
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
11
- import type { RunPlanIO } from '../effects/runtime.js';
12
- import { FileSystem } from '../effects/filesystem.js';
13
- import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
14
- import { readTasksJsonl } from '../storage/task-storage.js';
15
- import type { PlanStatus } from '../types.js';
16
-
17
- // ── Types ────────────────────────────────────────────────────────────────────
18
-
19
- export type SortField = 'name' | 'date-asc' | 'date-desc' | 'tasks';
20
- export type StatusFilter = 'all' | PlanStatus;
21
-
22
- export interface PlanListItem {
23
- name: string;
24
- title: string;
25
- status: PlanStatus;
26
- created_at: string;
27
- completed_at: string | null;
28
- totalTasks: number;
29
- doneTasks: number;
30
- pendingTasks: number;
31
- }
32
-
33
- // ── Core logic (pure, testable) ──────────────────────────────────────────────
34
-
35
- export function filterPlans(plans: PlanListItem[], filter: StatusFilter): PlanListItem[] {
36
- if (filter === 'all') return plans;
37
- return plans.filter((p) => p.status === filter);
38
- }
39
-
40
- export function sortPlans(plans: PlanListItem[], sort: SortField): PlanListItem[] {
41
- const sorted = [...plans];
42
- switch (sort) {
43
- case 'name':
44
- sorted.sort((a, b) => a.name.localeCompare(b.name));
45
- break;
46
- case 'date-asc':
47
- sorted.sort((a, b) => a.created_at.localeCompare(b.created_at));
48
- break;
49
- case 'date-desc':
50
- sorted.sort((a, b) => b.created_at.localeCompare(a.created_at));
51
- break;
52
- case 'tasks':
53
- sorted.sort((a, b) => b.totalTasks - a.totalTasks);
54
- break;
55
- }
56
- return sorted;
57
- }
58
-
59
- const STATUS_ICON: Record<PlanStatus, string> = {
60
- 'in-progress': '🔵',
61
- done: '✅',
62
- superseded: '🔄',
63
- abandoned: '❌',
64
- };
65
-
66
- export function formatPlanList(plans: PlanListItem[], filter: StatusFilter, sort: SortField): string {
67
- if (plans.length === 0) {
68
- return filter === 'all'
69
- ? 'No plans found in .plans/plans.jsonl'
70
- : `No plans with status "${filter}"`;
71
- }
72
-
73
- const sortLabel: Record<SortField, string> = {
74
- name: 'name',
75
- 'date-asc': 'oldest first',
76
- 'date-desc': 'newest first',
77
- tasks: 'most tasks first',
78
- };
79
-
80
- const header =
81
- filter === 'all'
82
- ? `All plans (${plans.length}) — sorted by ${sortLabel[sort]}`
83
- : `Plans: ${filter} (${plans.length}) — sorted by ${sortLabel[sort]}`;
84
-
85
- const lines = plans.map((p) => {
86
- const icon = STATUS_ICON[p.status];
87
- const progress =
88
- p.totalTasks > 0 ? ` [${p.doneTasks}/${p.totalTasks} tasks]` : ' [no tasks]';
89
- const date = p.created_at.slice(0, 10);
90
- return ` ${icon} ${p.name} — ${p.title}${progress} (${date})`;
91
- });
92
-
93
- return `${header}\n${lines.join('\n')}`;
94
- }
95
-
96
- // ── Data loading (Effect-based) ──────────────────────────────────────────────
97
-
98
- export function loadPlanListItems(): Effect.Effect<PlanListItem[], never, FileSystem> {
99
- return Effect.gen(function* () {
100
- const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => [] as PlanManifestEntry[]);
101
- const items: PlanListItem[] = [];
102
-
103
- for (const entry of manifest) {
104
- const dir = `.plans/${entry.name}`;
105
- const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => undefined);
106
- const totalTasks = snapshot?.tasks.length ?? 0;
107
- const doneTasks = snapshot?.tasks.filter(
108
- (t) => t.status === 'done' || t.status === 'skipped',
109
- ).length ?? 0;
110
- const pendingTasks = snapshot?.tasks.filter((t) => t.status === 'pending').length ?? 0;
111
-
112
- items.push({
113
- name: entry.name,
114
- title: entry.title,
115
- status: entry.status,
116
- created_at: entry.created_at,
117
- completed_at: entry.completed_at,
118
- totalTasks,
119
- doneTasks,
120
- pendingTasks,
121
- });
122
- }
123
-
124
- return items;
125
- });
126
- }
127
-
128
- // ── Interactive command handler ──────────────────────────────────────────────
9
+ import type { RunPlanIO } from '@dreki-gg/taskman';
10
+ import { PlanListing } from '@dreki-gg/taskman';
11
+
12
+ // Re-export the engine listing types/helpers for the rest of the extension.
13
+ export type SortField = PlanListing.SortField;
14
+ export type StatusFilter = PlanListing.StatusFilter;
15
+ export type PlanListItem = PlanListing.PlanListItem;
16
+ export const {
17
+ filterPlans,
18
+ sortPlans,
19
+ formatPlanList,
20
+ loadPlanListItems,
21
+ parseListArgs: parseArgs,
22
+ } = PlanListing;
129
23
 
130
24
  export async function handleListPlans(
131
25
  ctx: ExtensionCommandContext,
132
26
  runPlanIO: RunPlanIO,
133
27
  args?: string,
134
28
  ): Promise<void> {
135
- const allItems = await runPlanIO(loadPlanListItems());
29
+ const allItems = await runPlanIO(PlanListing.loadPlanListItems());
136
30
 
137
31
  if (allItems.length === 0) {
138
32
  ctx.ui.notify('No plans found in .plans/plans.jsonl', 'info');
139
33
  return;
140
34
  }
141
35
 
142
- // Parse inline args: /plans [filter] [sort]
143
- // e.g. /plans done tasks, /plans in-progress date-asc
36
+ // Parse inline args: /plans [filter] [sort] — e.g. /plans done tasks
144
37
  let filter: StatusFilter = 'all';
145
38
  let sort: SortField = 'date-desc';
146
39
 
147
40
  if (args?.trim()) {
148
- const parsed = parseArgs(args.trim());
41
+ const parsed = PlanListing.parseListArgs(args.trim());
149
42
  filter = parsed.filter;
150
43
  sort = parsed.sort;
151
44
  } else {
152
- // Interactive mode: ask the user
153
45
  const filterChoice = await ctx.ui.select('Filter plans by status:', [
154
46
  'All',
155
47
  'In-progress',
@@ -185,45 +77,6 @@ export async function handleListPlans(
185
77
  sort = sortMap[sortChoice] ?? 'date-desc';
186
78
  }
187
79
 
188
- const filtered = filterPlans(allItems, filter);
189
- const sorted = sortPlans(filtered, sort);
190
- const output = formatPlanList(sorted, filter, sort);
191
-
192
- ctx.ui.notify(output, 'info');
193
- }
194
-
195
- // ── Arg parsing ──────────────────────────────────────────────────────────────
196
-
197
- const FILTER_ALIASES: Record<string, StatusFilter> = {
198
- all: 'all',
199
- 'in-progress': 'in-progress',
200
- pending: 'in-progress',
201
- active: 'in-progress',
202
- done: 'done',
203
- completed: 'done',
204
- superseded: 'superseded',
205
- abandoned: 'abandoned',
206
- };
207
-
208
- const SORT_ALIASES: Record<string, SortField> = {
209
- name: 'name',
210
- 'date-asc': 'date-asc',
211
- oldest: 'date-asc',
212
- 'date-desc': 'date-desc',
213
- newest: 'date-desc',
214
- tasks: 'tasks',
215
- 'task-count': 'tasks',
216
- };
217
-
218
- export function parseArgs(raw: string): { filter: StatusFilter; sort: SortField } {
219
- const tokens = raw.toLowerCase().split(/\s+/);
220
- let filter: StatusFilter = 'all';
221
- let sort: SortField = 'date-desc';
222
-
223
- for (const token of tokens) {
224
- if (FILTER_ALIASES[token]) filter = FILTER_ALIASES[token];
225
- else if (SORT_ALIASES[token]) sort = SORT_ALIASES[token];
226
- }
227
-
228
- return { filter, sort };
80
+ const sorted = PlanListing.sortPlans(PlanListing.filterPlans(allItems, filter), sort);
81
+ ctx.ui.notify(PlanListing.formatPlanList(sorted, filter, sort), 'info');
229
82
  }