@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.
- package/CHANGELOG.md +25 -0
- package/extensions/plan-mode/__tests__/git.test.ts +17 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
- package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
- package/extensions/plan-mode/__tests__/prompts.test.ts +23 -0
- package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
- package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
- package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
- package/extensions/plan-mode/commands/list-plans.ts +21 -168
- package/extensions/plan-mode/exec-pending.ts +59 -0
- package/extensions/plan-mode/git.ts +21 -0
- package/extensions/plan-mode/index.ts +9 -7
- package/extensions/plan-mode/prompts.ts +6 -1
- package/extensions/plan-mode/references/context.ts +5 -5
- package/extensions/plan-mode/references/plan-index.ts +1 -1
- package/extensions/plan-mode/resolve-plan.ts +5 -4
- package/extensions/plan-mode/resume.ts +7 -5
- package/extensions/plan-mode/tools/add-task.ts +1 -1
- package/extensions/plan-mode/tools/initiative-status.ts +6 -6
- package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
- package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
- package/extensions/plan-mode/tools/revise-plan.ts +9 -7
- package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
- package/extensions/plan-mode/tools/submit-plan.ts +18 -8
- package/extensions/plan-mode/tools/update-initiative.ts +2 -2
- package/extensions/plan-mode/tools/update-plan.ts +3 -3
- package/extensions/plan-mode/types.ts +17 -51
- package/extensions/plan-mode/utils.ts +2 -29
- package/package.json +2 -1
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
- package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
- package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
- package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
- package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
- package/extensions/plan-mode/__tests__/schema.test.ts +0 -319
- package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
- package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -94
- package/extensions/plan-mode/effects/filesystem.ts +0 -65
- package/extensions/plan-mode/effects/runtime.ts +0 -24
- package/extensions/plan-mode/errors.ts +0 -105
- package/extensions/plan-mode/initiative.ts +0 -172
- package/extensions/plan-mode/plan-storage.ts +0 -6
- package/extensions/plan-mode/reconcile.ts +0 -235
- package/extensions/plan-mode/schema.ts +0 -97
- package/extensions/plan-mode/storage/atomic-write.ts +0 -75
- package/extensions/plan-mode/storage/file-lock.ts +0 -49
- package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
- package/extensions/plan-mode/storage/plan-storage.ts +0 -88
- package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
- package/extensions/plan-mode/storage/task-storage.ts +0 -111
- package/extensions/plan-mode/task-status.ts +0 -46
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
activeTasksResolved,
|
|
4
|
-
deferredTasks,
|
|
5
|
-
isPlanFinalizable,
|
|
6
|
-
reactivateForExecution,
|
|
7
|
-
} from '../task-status.js';
|
|
8
|
-
import type { TaskRecord, TaskStatus } from '../types.js';
|
|
9
|
-
|
|
10
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
11
|
-
let counter = 0;
|
|
12
|
-
const make = (status: TaskStatus, origin?: 'plan' | 'discovered'): TaskRecord => ({
|
|
13
|
-
_type: 'task',
|
|
14
|
-
id: `t-${String(++counter).padStart(3, '0')}`,
|
|
15
|
-
description: 'task',
|
|
16
|
-
status,
|
|
17
|
-
origin,
|
|
18
|
-
created_at: now,
|
|
19
|
-
updated_at: now,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('deferredTasks', () => {
|
|
23
|
-
test('returns only deferred tasks', () => {
|
|
24
|
-
const tasks = [make('done'), make('deferred', 'discovered'), make('pending')];
|
|
25
|
-
expect(deferredTasks(tasks).map((t) => t.status)).toEqual(['deferred']);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('activeTasksResolved', () => {
|
|
30
|
-
test('true when only done/skipped/deferred remain', () => {
|
|
31
|
-
expect(
|
|
32
|
-
activeTasksResolved([make('done'), make('skipped'), make('deferred', 'discovered')]),
|
|
33
|
-
).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('false when a task is still pending', () => {
|
|
37
|
-
expect(activeTasksResolved([make('done'), make('pending')])).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('false when a task is still blocked', () => {
|
|
41
|
-
expect(activeTasksResolved([make('done'), make('blocked')])).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('isPlanFinalizable', () => {
|
|
46
|
-
test('true when all work is done/skipped and nothing is deferred', () => {
|
|
47
|
-
expect(isPlanFinalizable([make('done'), make('skipped')])).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('false when a deferred follow-up awaits the user', () => {
|
|
51
|
-
expect(isPlanFinalizable([make('done'), make('deferred', 'discovered')])).toBe(false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('false when active work remains', () => {
|
|
55
|
-
expect(isPlanFinalizable([make('pending')])).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('reactivateForExecution', () => {
|
|
60
|
-
test('flips blocked and deferred tasks to pending and stamps updated_at', () => {
|
|
61
|
-
const tasks = [make('done'), make('blocked'), make('deferred', 'discovered')];
|
|
62
|
-
const ts = '2026-06-01T00:00:00.000Z';
|
|
63
|
-
const changed = reactivateForExecution(tasks, ts);
|
|
64
|
-
expect(changed).toBe(true);
|
|
65
|
-
expect(tasks.map((t) => t.status)).toEqual(['done', 'pending', 'pending']);
|
|
66
|
-
expect(tasks[1].updated_at).toBe(ts);
|
|
67
|
-
expect(tasks[2].updated_at).toBe(ts);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('returns false and leaves tasks untouched when nothing to reactivate', () => {
|
|
71
|
-
const tasks = [make('done'), make('pending')];
|
|
72
|
-
expect(reactivateForExecution(tasks, '2026-06-01T00:00:00.000Z')).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Cause, Effect, Exit, Option } from 'effect';
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
7
|
-
import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
-
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
9
|
-
|
|
10
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
11
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
12
|
-
|
|
13
|
-
const runExit = <A, E>(program: Effect.Effect<A, E, FileSystem>) =>
|
|
14
|
-
Effect.runPromiseExit(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
15
|
-
|
|
16
|
-
const failureTag = <A, E>(exit: Exit.Exit<A, E>): string | undefined => {
|
|
17
|
-
if (!Exit.isFailure(exit)) return undefined;
|
|
18
|
-
const error = Option.getOrUndefined(Cause.failureOption(exit.cause)) as
|
|
19
|
-
| { _tag?: string }
|
|
20
|
-
| undefined;
|
|
21
|
-
return error?._tag;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
let dir: string;
|
|
25
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
26
|
-
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
27
|
-
const task: TaskRecord = {
|
|
28
|
-
_type: 'task',
|
|
29
|
-
id: 't-001',
|
|
30
|
-
description: 'Do work',
|
|
31
|
-
details: 'Details',
|
|
32
|
-
status: 'pending',
|
|
33
|
-
created_at: now,
|
|
34
|
-
updated_at: now,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
beforeEach(async () => {
|
|
38
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
|
|
39
|
-
});
|
|
40
|
-
afterEach(async () => {
|
|
41
|
-
await rm(dir, { recursive: true, force: true });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('tasks.jsonl storage', () => {
|
|
45
|
-
test('round trips meta and tasks', async () => {
|
|
46
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
47
|
-
await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
51
|
-
const lightweight: TaskRecord = {
|
|
52
|
-
_type: 'task',
|
|
53
|
-
id: 't-002',
|
|
54
|
-
description: 'Quick fix',
|
|
55
|
-
status: 'pending',
|
|
56
|
-
created_at: now,
|
|
57
|
-
updated_at: now,
|
|
58
|
-
};
|
|
59
|
-
await run(writeTasksJsonl(dir, meta, [lightweight]));
|
|
60
|
-
const result = await run(readTasksJsonl(dir));
|
|
61
|
-
expect(result?.tasks[0]?.id).toBe('t-002');
|
|
62
|
-
expect(result?.tasks[0]?.details).toBeUndefined();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('missing file returns undefined', async () => {
|
|
66
|
-
await expect(run(readTasksJsonl(dir))).resolves.toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('rejects corrupt lines with JsonlParseError', async () => {
|
|
70
|
-
await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
|
|
71
|
-
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('JsonlParseError');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('rejects empty files with MissingMetaRecord', async () => {
|
|
75
|
-
await Bun.write(join(dir, 'tasks.jsonl'), '');
|
|
76
|
-
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('MissingMetaRecord');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('updates a task by id and rewrites the snapshot', async () => {
|
|
80
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
81
|
-
const updated = await run(updateTask(dir, 't-001', { status: 'done', notes: 'finished' }));
|
|
82
|
-
|
|
83
|
-
expect(updated.status).toBe('done');
|
|
84
|
-
expect(updated.notes).toBe('finished');
|
|
85
|
-
expect((await run(readTasksJsonl(dir)))?.tasks[0]?.status).toBe('done');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('fails with TaskNotFound for an unknown task id', async () => {
|
|
89
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
90
|
-
expect(failureTag(await runExit(updateTask(dir, 't-999', { status: 'done' })))).toBe(
|
|
91
|
-
'TaskNotFound',
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FileSystem service — the single seam for plan-mode disk I/O.
|
|
3
|
-
*
|
|
4
|
-
* Storage programs depend on this `Context.Tag` rather than touching
|
|
5
|
-
* `node:fs/promises` directly, which makes them trivially testable and keeps
|
|
6
|
-
* all failure modes typed (`PlanReadError` / `PlanWriteError`).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Context, Effect } from 'effect';
|
|
10
|
-
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
11
|
-
import { PlanReadError, PlanWriteError } from '../errors.js';
|
|
12
|
-
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
13
|
-
|
|
14
|
-
export interface FileSystemService {
|
|
15
|
-
readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
|
|
16
|
-
readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
17
|
-
readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
18
|
-
readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
19
|
-
readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
|
|
20
|
-
readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
|
|
24
|
-
FileSystem,
|
|
25
|
-
FileSystemService
|
|
26
|
-
>() {}
|
|
27
|
-
|
|
28
|
-
export const nodeFileSystemService: FileSystemService = {
|
|
29
|
-
readFileString: (path) =>
|
|
30
|
-
Effect.tryPromise({
|
|
31
|
-
try: () => readFile(path, 'utf-8'),
|
|
32
|
-
catch: (cause) => new PlanReadError({ path, cause }),
|
|
33
|
-
}),
|
|
34
|
-
|
|
35
|
-
writeFileString: (path, data) =>
|
|
36
|
-
Effect.tryPromise({
|
|
37
|
-
try: () => writeFile(path, data, 'utf-8'),
|
|
38
|
-
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
39
|
-
}),
|
|
40
|
-
|
|
41
|
-
writeFileAtomic: (path, data) => writeFileAtomic(path, data),
|
|
42
|
-
|
|
43
|
-
makeDir: (path) =>
|
|
44
|
-
Effect.tryPromise({
|
|
45
|
-
try: async () => {
|
|
46
|
-
await mkdir(path, { recursive: true });
|
|
47
|
-
},
|
|
48
|
-
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
49
|
-
}),
|
|
50
|
-
|
|
51
|
-
listDirectories: (path) =>
|
|
52
|
-
Effect.tryPromise({
|
|
53
|
-
try: async () => {
|
|
54
|
-
const entries = await readdir(path, { withFileTypes: true });
|
|
55
|
-
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
56
|
-
},
|
|
57
|
-
catch: (cause) => new PlanReadError({ path, cause }),
|
|
58
|
-
}),
|
|
59
|
-
|
|
60
|
-
removeFile: (path) =>
|
|
61
|
-
Effect.tryPromise({
|
|
62
|
-
try: () => unlink(path),
|
|
63
|
-
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
64
|
-
}),
|
|
65
|
-
};
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live Effect runtime for the plan-mode extension.
|
|
3
|
-
*
|
|
4
|
-
* Build the layer once inside the extension entry and run storage programs
|
|
5
|
-
* through the `runPlanIO` bridge so the imperative pi event handlers keep their
|
|
6
|
-
* `await fn(...)` shape.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Effect, Layer } from 'effect';
|
|
10
|
-
import { FileSystem, nodeFileSystemService } from './filesystem.js';
|
|
11
|
-
|
|
12
|
-
export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
|
|
13
|
-
return Layer.succeed(FileSystem, nodeFileSystemService);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Build a bridge that runs storage programs against the live filesystem layer. */
|
|
17
|
-
export function makePlanRuntime() {
|
|
18
|
-
const layer = makeRuntimeLayer();
|
|
19
|
-
return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
|
|
20
|
-
return Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tagged errors for plan-mode disk I/O and JSONL validation.
|
|
3
|
-
*
|
|
4
|
-
* These replace ad-hoc `throw new Error(...)` so storage programs surface
|
|
5
|
-
* typed, inspectable failures. They are mapped back to user-facing strings at
|
|
6
|
-
* the tool boundary via `errorMessage`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Data } from 'effect';
|
|
10
|
-
|
|
11
|
-
export class PlanReadError extends Data.TaggedError('PlanReadError')<{
|
|
12
|
-
readonly path: string;
|
|
13
|
-
readonly cause: unknown;
|
|
14
|
-
}> {
|
|
15
|
-
get message(): string {
|
|
16
|
-
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
|
|
21
|
-
readonly path: string;
|
|
22
|
-
readonly cause: unknown;
|
|
23
|
-
}> {
|
|
24
|
-
get message(): string {
|
|
25
|
-
return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
|
|
30
|
-
readonly path: string;
|
|
31
|
-
readonly line: number;
|
|
32
|
-
readonly cause?: unknown;
|
|
33
|
-
}> {
|
|
34
|
-
get message(): string {
|
|
35
|
-
return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
|
|
40
|
-
readonly path: string;
|
|
41
|
-
readonly line: number;
|
|
42
|
-
readonly reason: string;
|
|
43
|
-
}> {
|
|
44
|
-
get message(): string {
|
|
45
|
-
return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
|
|
50
|
-
readonly path: string;
|
|
51
|
-
}> {
|
|
52
|
-
get message(): string {
|
|
53
|
-
return `${this.path} is missing meta record`;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
|
|
58
|
-
readonly planDir: string;
|
|
59
|
-
readonly taskId: string;
|
|
60
|
-
}> {
|
|
61
|
-
get message(): string {
|
|
62
|
-
return `Task not found: ${this.taskId}`;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
|
|
67
|
-
readonly planDir: string;
|
|
68
|
-
}> {
|
|
69
|
-
get message(): string {
|
|
70
|
-
return `No tasks.jsonl found in ${this.planDir}`;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export type PlanStorageError =
|
|
75
|
-
| PlanReadError
|
|
76
|
-
| PlanWriteError
|
|
77
|
-
| JsonlParseError
|
|
78
|
-
| JsonlValidationError
|
|
79
|
-
| MissingMetaRecord
|
|
80
|
-
| TaskNotFound
|
|
81
|
-
| TasksFileNotFound;
|
|
82
|
-
|
|
83
|
-
export function causeMessage(cause: unknown): string {
|
|
84
|
-
if (cause instanceof Error) return cause.message;
|
|
85
|
-
return String(cause);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function errorMessage(error: unknown): string {
|
|
89
|
-
if (error instanceof Error) return error.message;
|
|
90
|
-
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
91
|
-
const message = (error as { message?: unknown }).message;
|
|
92
|
-
if (typeof message === 'string') return message;
|
|
93
|
-
}
|
|
94
|
-
return String(error);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Convert any error (including tagged errors) into a native Error for the tool boundary. */
|
|
98
|
-
export function toNativeError(error: unknown): Error {
|
|
99
|
-
if (error instanceof Error) return error;
|
|
100
|
-
const native = new Error(errorMessage(error));
|
|
101
|
-
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
102
|
-
native.name = String((error as { _tag: unknown })._tag);
|
|
103
|
-
}
|
|
104
|
-
return native;
|
|
105
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Initiative logic — ready-work computation and the initiative→plan projection.
|
|
3
|
-
*
|
|
4
|
-
* Two layers live here:
|
|
5
|
-
* - PURE: `computePlanReadiness`, `isInitiativeFinalizable`, `initiativeRollup`
|
|
6
|
-
* reason over a plans-manifest snapshot with no IO. They are the basis for
|
|
7
|
-
* "what work is unblocked right now" — the foundation for phase-2 subagent
|
|
8
|
-
* fan-out.
|
|
9
|
-
* - IO: `reconcileInitiativeStatus` / `reconcileInitiativeForPlan` keep an
|
|
10
|
-
* initiative's registry status a PROJECTION of its member plans, mirroring
|
|
11
|
-
* `reconcilePlanStatus` one level up. They read the PLANS manifest, so they
|
|
12
|
-
* live here (not in `initiatives-manifest.ts`) to keep the dependency
|
|
13
|
-
* direction one-way: initiative.ts → {plans-manifest, initiatives-manifest}.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Effect } from 'effect';
|
|
17
|
-
import { FileSystem } from './effects/filesystem.js';
|
|
18
|
-
import type { JsonlParseError, JsonlValidationError, PlanWriteError } from './errors.js';
|
|
19
|
-
import { readPlansManifest, type PlanManifestEntry } from './storage/plans-manifest.js';
|
|
20
|
-
import {
|
|
21
|
-
applyInitiativeUpsert,
|
|
22
|
-
mutateInitiativesManifest,
|
|
23
|
-
} from './storage/initiatives-manifest.js';
|
|
24
|
-
import type { PlanStatus } from './types.js';
|
|
25
|
-
|
|
26
|
-
// ── Pure: readiness + projection rules ───────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export interface PlanReadiness {
|
|
29
|
-
name: string;
|
|
30
|
-
/** True when every plan in `depends_on` is `done`. */
|
|
31
|
-
ready: boolean;
|
|
32
|
-
/** Dependency plan names that are not yet `done` (unknown deps count too). */
|
|
33
|
-
blockedBy: string[];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* For each `in-progress` plan, whether all of its plan-level dependencies are
|
|
38
|
-
* `done`. Only a `done` dependency unblocks — a missing, in-progress, or
|
|
39
|
-
* terminally-closed (superseded/abandoned) dependency keeps a plan blocked.
|
|
40
|
-
*/
|
|
41
|
-
export function computePlanReadiness(plans: readonly PlanManifestEntry[]): PlanReadiness[] {
|
|
42
|
-
const statusByName = new Map(plans.map((plan) => [plan.name, plan.status]));
|
|
43
|
-
return plans
|
|
44
|
-
.filter((plan) => plan.status === 'in-progress')
|
|
45
|
-
.map((plan) => {
|
|
46
|
-
const deps = plan.depends_on ?? [];
|
|
47
|
-
const blockedBy = deps.filter((dep) => statusByName.get(dep) !== 'done');
|
|
48
|
-
return { name: plan.name, ready: blockedBy.length === 0, blockedBy };
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Member plans of an initiative (linked by name in the plans manifest). */
|
|
53
|
-
export function membersOf(
|
|
54
|
-
initiative: string,
|
|
55
|
-
plans: readonly PlanManifestEntry[],
|
|
56
|
-
): PlanManifestEntry[] {
|
|
57
|
-
return plans.filter((plan) => plan.initiative === initiative);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* An initiative is finalizable (`done`) when it has ≥1 member plan AND every
|
|
62
|
-
* member is terminal (no member is `in-progress`). Mirrors the plan-level rule
|
|
63
|
-
* one level up.
|
|
64
|
-
*/
|
|
65
|
-
export function isInitiativeFinalizable(
|
|
66
|
-
initiative: string,
|
|
67
|
-
plans: readonly PlanManifestEntry[],
|
|
68
|
-
): boolean {
|
|
69
|
-
const members = membersOf(initiative, plans);
|
|
70
|
-
if (members.length === 0) return false;
|
|
71
|
-
return members.every((plan) => plan.status !== 'in-progress');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface InitiativeMemberRow {
|
|
75
|
-
name: string;
|
|
76
|
-
title: string;
|
|
77
|
-
status: PlanStatus;
|
|
78
|
-
/** Present for in-progress members. */
|
|
79
|
-
ready?: boolean;
|
|
80
|
-
blockedBy?: string[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface InitiativeRollup {
|
|
84
|
-
name: string;
|
|
85
|
-
total: number;
|
|
86
|
-
done: number;
|
|
87
|
-
/** Terminal but not done (superseded / abandoned). */
|
|
88
|
-
closed: number;
|
|
89
|
-
inProgress: number;
|
|
90
|
-
ready: number;
|
|
91
|
-
blocked: number;
|
|
92
|
-
members: InitiativeMemberRow[];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Aggregate an initiative's member plans into counts + per-member readiness. */
|
|
96
|
-
export function initiativeRollup(
|
|
97
|
-
initiative: string,
|
|
98
|
-
plans: readonly PlanManifestEntry[],
|
|
99
|
-
): InitiativeRollup {
|
|
100
|
-
const members = membersOf(initiative, plans);
|
|
101
|
-
const readiness = new Map(computePlanReadiness(plans).map((row) => [row.name, row]));
|
|
102
|
-
|
|
103
|
-
let done = 0;
|
|
104
|
-
let closed = 0;
|
|
105
|
-
let inProgress = 0;
|
|
106
|
-
let ready = 0;
|
|
107
|
-
let blocked = 0;
|
|
108
|
-
|
|
109
|
-
const rows: InitiativeMemberRow[] = members.map((plan) => {
|
|
110
|
-
if (plan.status === 'done') done += 1;
|
|
111
|
-
else if (plan.status === 'in-progress') inProgress += 1;
|
|
112
|
-
else closed += 1; // superseded / abandoned
|
|
113
|
-
|
|
114
|
-
const row: InitiativeMemberRow = { name: plan.name, title: plan.title, status: plan.status };
|
|
115
|
-
if (plan.status === 'in-progress') {
|
|
116
|
-
const r = readiness.get(plan.name);
|
|
117
|
-
row.ready = r?.ready ?? true;
|
|
118
|
-
row.blockedBy = r?.blockedBy ?? [];
|
|
119
|
-
if (row.ready) ready += 1;
|
|
120
|
-
else blocked += 1;
|
|
121
|
-
}
|
|
122
|
-
return row;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
return { name: initiative, total: members.length, done, closed, inProgress, ready, blocked, members: rows };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── IO: keep initiative registry status a projection of member plans ─────────
|
|
129
|
-
|
|
130
|
-
type ReconcileError = JsonlParseError | JsonlValidationError | PlanWriteError;
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Re-derive an initiative's registry status from its member plans.
|
|
134
|
-
*
|
|
135
|
-
* Like `reconcilePlanStatus`: only reflects state for a KNOWN initiative (never
|
|
136
|
-
* conjures an entry), and never clobbers a manually-set terminal status
|
|
137
|
-
* (`superseded` / `abandoned`). Only `in-progress` ⇄ `done` is derived.
|
|
138
|
-
*/
|
|
139
|
-
export function reconcileInitiativeStatus(
|
|
140
|
-
name: string,
|
|
141
|
-
): Effect.Effect<void, ReconcileError, FileSystem> {
|
|
142
|
-
// The whole read-decide-write runs under the initiatives lock so a concurrent
|
|
143
|
-
// writer cannot slip a status change between our read and our write.
|
|
144
|
-
return mutateInitiativesManifest((initiatives) =>
|
|
145
|
-
Effect.gen(function* () {
|
|
146
|
-
const existing = initiatives.find((entry) => entry.name === name);
|
|
147
|
-
if (!existing) return false;
|
|
148
|
-
if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
|
|
149
|
-
const plans = yield* readPlansManifest();
|
|
150
|
-
const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
|
|
151
|
-
if (existing.status === status) return false;
|
|
152
|
-
applyInitiativeUpsert(initiatives, name, { status, title: existing.title });
|
|
153
|
-
return true;
|
|
154
|
-
}),
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Reconcile the initiative that a given plan belongs to (no-op when the plan is
|
|
160
|
-
* standalone). Call this after any plan-status write so the initiative level
|
|
161
|
-
* stays in sync without callers needing to know the parent name.
|
|
162
|
-
*/
|
|
163
|
-
export function reconcileInitiativeForPlan(
|
|
164
|
-
planName: string,
|
|
165
|
-
): Effect.Effect<void, ReconcileError, FileSystem> {
|
|
166
|
-
return Effect.gen(function* () {
|
|
167
|
-
const plans = yield* readPlansManifest();
|
|
168
|
-
const plan = plans.find((entry) => entry.name === planName);
|
|
169
|
-
if (!plan?.initiative) return;
|
|
170
|
-
yield* reconcileInitiativeStatus(plan.initiative);
|
|
171
|
-
});
|
|
172
|
-
}
|