@dreki-gg/pi-plan-mode 0.26.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 +12 -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__/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/index.ts +8 -7
- 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 +4 -4
- package/extensions/plan-mode/resume.ts +6 -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 +7 -7
- package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
- package/extensions/plan-mode/tools/submit-plan.ts +7 -7
- 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 -59
- 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 -334
- package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
- package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -101
- 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 -99
- 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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* per-keystroke disk hit.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { RunPlanIO } from '
|
|
10
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
11
11
|
import { loadPlanListItems, type PlanListItem } from '../commands/list-plans.js';
|
|
12
12
|
|
|
13
13
|
const DEFAULT_TTL_MS = 2_000;
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
18
18
|
import type { PlanModeState } from './state.js';
|
|
19
19
|
import type { PlanData } from './types.js';
|
|
20
|
-
import type { RunPlanIO } from '
|
|
21
|
-
import { readPlansManifest } from '
|
|
22
|
-
import { readTasksJsonl } from '
|
|
23
|
-
import { loadHandoff } from '
|
|
20
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
21
|
+
import { readPlansManifest } from '@dreki-gg/taskman';
|
|
22
|
+
import { readTasksJsonl } from '@dreki-gg/taskman';
|
|
23
|
+
import { loadHandoff } from '@dreki-gg/taskman';
|
|
24
24
|
|
|
25
25
|
export interface ResolvedPlan {
|
|
26
26
|
/** The attached plan, when resolvable. Already written into `state`. */
|
|
@@ -9,13 +9,14 @@ import type {
|
|
|
9
9
|
} from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import type { PlanModeState } from './state.js';
|
|
11
11
|
import type { PlanData } from './types.js';
|
|
12
|
-
import type { RunPlanIO } from '
|
|
12
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
13
13
|
import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
|
|
14
|
-
import { readPlansManifest } from '
|
|
15
|
-
import { loadHandoff
|
|
16
|
-
import {
|
|
14
|
+
import { readPlansManifest } from '@dreki-gg/taskman';
|
|
15
|
+
import { loadHandoff } from '@dreki-gg/taskman';
|
|
16
|
+
import { writeExecPending } from './exec-pending.js';
|
|
17
|
+
import { readTasksJsonl, writeTasksJsonl } from '@dreki-gg/taskman';
|
|
17
18
|
import { enterPlanMode } from './phase-transitions.js';
|
|
18
|
-
import { reactivateForExecution } from '
|
|
19
|
+
import { reactivateForExecution } from '@dreki-gg/taskman';
|
|
19
20
|
|
|
20
21
|
export async function pickExecutionModel(
|
|
21
22
|
ctx: ExtensionContext,
|
|
@@ -11,7 +11,7 @@ import { Text } from '@earendil-works/pi-tui';
|
|
|
11
11
|
import { Type } from 'typebox';
|
|
12
12
|
import type { TaskRecord } from '../types.js';
|
|
13
13
|
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
14
|
-
import { nextTaskId } from '
|
|
14
|
+
import { nextTaskId } from '@dreki-gg/taskman';
|
|
15
15
|
|
|
16
16
|
export interface AddTaskCallbacks {
|
|
17
17
|
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
@@ -12,12 +12,12 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
12
12
|
import { Text } from '@earendil-works/pi-tui';
|
|
13
13
|
import { Type } from 'typebox';
|
|
14
14
|
import { Effect } from 'effect';
|
|
15
|
-
import type { RunPlanIO } from '
|
|
16
|
-
import { FileSystem } from '
|
|
17
|
-
import { readPlansManifest, type PlanManifestEntry } from '
|
|
18
|
-
import { readInitiativesManifest } from '
|
|
19
|
-
import { readTasksJsonl } from '
|
|
20
|
-
import { initiativeRollup, membersOf, type InitiativeRollup } from '
|
|
15
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
16
|
+
import { FileSystem } from '@dreki-gg/taskman';
|
|
17
|
+
import { readPlansManifest, type PlanManifestEntry } from '@dreki-gg/taskman';
|
|
18
|
+
import { readInitiativesManifest } from '@dreki-gg/taskman';
|
|
19
|
+
import { readTasksJsonl } from '@dreki-gg/taskman';
|
|
20
|
+
import { initiativeRollup, membersOf, type InitiativeRollup } from '@dreki-gg/taskman';
|
|
21
21
|
|
|
22
22
|
/** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
|
|
23
23
|
function normalizeName(hint: string): string {
|
|
@@ -12,10 +12,10 @@ import { Type } from 'typebox';
|
|
|
12
12
|
import { Effect } from 'effect';
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import { FileSystem } from '
|
|
16
|
-
import type { RunPlanIO } from '
|
|
15
|
+
import { FileSystem } from '@dreki-gg/taskman';
|
|
16
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
17
17
|
import { buildPrototypeDocument } from '../html/render.js';
|
|
18
|
-
import { toKebabCase } from '
|
|
18
|
+
import { toKebabCase } from '@dreki-gg/taskman';
|
|
19
19
|
|
|
20
20
|
const PREVIEW_DIR = '.plans/_prototypes';
|
|
21
21
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
11
|
import { Text } from '@earendil-works/pi-tui';
|
|
12
12
|
import { Type } from 'typebox';
|
|
13
|
-
import type { RunPlanIO } from '
|
|
13
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
14
14
|
import {
|
|
15
15
|
applyInitiativeReconcile,
|
|
16
16
|
applyReconcile,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
collectPlanDrift,
|
|
19
19
|
type InitiativeDriftRow,
|
|
20
20
|
type PlanDriftRow,
|
|
21
|
-
} from '
|
|
21
|
+
} from '@dreki-gg/taskman';
|
|
22
22
|
|
|
23
23
|
function describeRow(row: PlanDriftRow): string {
|
|
24
24
|
const progress = row.hasTasks ? ` (${row.resolved}/${row.total})` : '';
|
|
@@ -16,17 +16,17 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
16
16
|
import { Text } from '@earendil-works/pi-tui';
|
|
17
17
|
import { Type } from 'typebox';
|
|
18
18
|
import { Effect } from 'effect';
|
|
19
|
-
import { saveHandoff } from '
|
|
20
|
-
import { writeTasksJsonl } from '
|
|
19
|
+
import { saveHandoff } from '@dreki-gg/taskman';
|
|
20
|
+
import { writeTasksJsonl } from '@dreki-gg/taskman';
|
|
21
21
|
import {
|
|
22
22
|
readPlansManifest,
|
|
23
23
|
reconcilePlanStatus,
|
|
24
24
|
upsertPlanEntry,
|
|
25
|
-
} from '
|
|
26
|
-
import { reconcileInitiativeForPlan, reconcileInitiativeStatus } from '
|
|
27
|
-
import { isPlanFinalizable } from '
|
|
28
|
-
import { toKebabCase } from '
|
|
29
|
-
import type { RunPlanIO } from '
|
|
25
|
+
} from '@dreki-gg/taskman';
|
|
26
|
+
import { reconcileInitiativeForPlan, reconcileInitiativeStatus } from '@dreki-gg/taskman';
|
|
27
|
+
import { isPlanFinalizable } from '@dreki-gg/taskman';
|
|
28
|
+
import { toKebabCase } from '@dreki-gg/taskman';
|
|
29
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
30
30
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
31
31
|
|
|
32
32
|
export interface RevisePlanCallbacks {
|
|
@@ -12,10 +12,10 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
12
12
|
import { Text } from '@earendil-works/pi-tui';
|
|
13
13
|
import { Type } from 'typebox';
|
|
14
14
|
import { Effect } from 'effect';
|
|
15
|
-
import { saveInitiative } from '
|
|
16
|
-
import { upsertInitiativeEntry } from '
|
|
17
|
-
import type { RunPlanIO } from '
|
|
18
|
-
import { toKebabCase } from '
|
|
15
|
+
import { saveInitiative } from '@dreki-gg/taskman';
|
|
16
|
+
import { upsertInitiativeEntry } from '@dreki-gg/taskman';
|
|
17
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
18
|
+
import { toKebabCase } from '@dreki-gg/taskman';
|
|
19
19
|
|
|
20
20
|
export function registerSubmitInitiativeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
21
21
|
pi.registerTool({
|
|
@@ -6,13 +6,13 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
6
6
|
import { Text } from '@earendil-works/pi-tui';
|
|
7
7
|
import { Type } from 'typebox';
|
|
8
8
|
import { Effect } from 'effect';
|
|
9
|
-
import { saveHandoff } from '
|
|
10
|
-
import { writeTasksJsonl } from '
|
|
11
|
-
import { upsertPlanEntry } from '
|
|
12
|
-
import { readInitiativesManifest } from '
|
|
13
|
-
import { reconcileInitiativeForPlan } from '
|
|
14
|
-
import type { RunPlanIO } from '
|
|
15
|
-
import { toKebabCase } from '
|
|
9
|
+
import { saveHandoff } from '@dreki-gg/taskman';
|
|
10
|
+
import { writeTasksJsonl } from '@dreki-gg/taskman';
|
|
11
|
+
import { upsertPlanEntry } from '@dreki-gg/taskman';
|
|
12
|
+
import { readInitiativesManifest } from '@dreki-gg/taskman';
|
|
13
|
+
import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
|
|
14
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
15
|
+
import { toKebabCase } from '@dreki-gg/taskman';
|
|
16
16
|
import { readHeadCommit } from '../git.js';
|
|
17
17
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
18
18
|
|
|
@@ -12,11 +12,11 @@ import { StringEnum } from '@earendil-works/pi-ai';
|
|
|
12
12
|
import { Text } from '@earendil-works/pi-tui';
|
|
13
13
|
import { Type } from 'typebox';
|
|
14
14
|
import type { InitiativeStatus } from '../types.js';
|
|
15
|
-
import type { RunPlanIO } from '
|
|
15
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
16
16
|
import {
|
|
17
17
|
readInitiativesManifest,
|
|
18
18
|
upsertInitiativeEntry,
|
|
19
|
-
} from '
|
|
19
|
+
} from '@dreki-gg/taskman';
|
|
20
20
|
|
|
21
21
|
/** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
|
|
22
22
|
function normalizeName(hint: string): string {
|
|
@@ -16,9 +16,9 @@ import { StringEnum } from '@earendil-works/pi-ai';
|
|
|
16
16
|
import { Text } from '@earendil-works/pi-tui';
|
|
17
17
|
import { Type } from 'typebox';
|
|
18
18
|
import type { PlanStatus } from '../types.js';
|
|
19
|
-
import type { RunPlanIO } from '
|
|
20
|
-
import { readPlansManifest, upsertPlanEntry } from '
|
|
21
|
-
import { reconcileInitiativeForPlan } from '
|
|
19
|
+
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
20
|
+
import { readPlansManifest, upsertPlanEntry } from '@dreki-gg/taskman';
|
|
21
|
+
import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
|
|
22
22
|
|
|
23
23
|
/** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
|
|
24
24
|
function normalizeName(hint: string): string {
|
|
@@ -1,63 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Plan-mode types.
|
|
3
|
+
*
|
|
4
|
+
* Engine record/value types now live in `@dreki-gg/taskman` and are re-exported
|
|
5
|
+
* here so the rest of the extension keeps importing from `./types.js`. The
|
|
6
|
+
* pi-session-only types (`PersistedState`) stay local.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
export type
|
|
9
|
+
export type {
|
|
10
|
+
TaskStatus,
|
|
11
|
+
TaskOrigin,
|
|
12
|
+
PlanStatus,
|
|
13
|
+
InitiativeStatus,
|
|
14
|
+
TaskRecord,
|
|
15
|
+
TaskMeta,
|
|
16
|
+
PlanData,
|
|
17
|
+
ThinkingLevel,
|
|
18
|
+
ExecPendingConfig,
|
|
19
|
+
} from '@dreki-gg/taskman';
|
|
6
20
|
|
|
7
|
-
|
|
8
|
-
export type TaskOrigin = 'plan' | 'discovered';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Plan lifecycle status. Only `in-progress` is active; `done`, `superseded`,
|
|
12
|
-
* and `abandoned` are terminal and drop out of active-plan resolution.
|
|
13
|
-
*/
|
|
14
|
-
export type PlanStatus = 'in-progress' | 'done' | 'superseded' | 'abandoned';
|
|
15
|
-
|
|
16
|
-
/** Initiative lifecycle reuses the plan lifecycle literals. */
|
|
17
|
-
export type InitiativeStatus = PlanStatus;
|
|
18
|
-
|
|
19
|
-
export interface TaskRecord {
|
|
20
|
-
_type: 'task';
|
|
21
|
-
id: string;
|
|
22
|
-
description: string;
|
|
23
|
-
details?: string;
|
|
24
|
-
status: TaskStatus;
|
|
25
|
-
/** Defaults to 'plan' when absent (back-compat with older tasks.jsonl files). */
|
|
26
|
-
origin?: TaskOrigin;
|
|
27
|
-
depends_on?: string[];
|
|
28
|
-
notes?: string;
|
|
29
|
-
created_at: string;
|
|
30
|
-
updated_at: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface TaskMeta {
|
|
34
|
-
_type: 'meta';
|
|
35
|
-
title: string;
|
|
36
|
-
plan_name: string;
|
|
37
|
-
created_at: string;
|
|
38
|
-
/**
|
|
39
|
-
* Git commit (HEAD) the plan was written against, captured at submit time.
|
|
40
|
-
* Optional for back-compat: older tasks.jsonl files predate this field, and
|
|
41
|
-
* it stays undefined when git metadata is unavailable (no repo, no commits).
|
|
42
|
-
*/
|
|
43
|
-
base_commit?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface PlanData {
|
|
47
|
-
title: string;
|
|
48
|
-
planName: string;
|
|
49
|
-
handoff: string;
|
|
50
|
-
tasks: TaskRecord[];
|
|
51
|
-
/** Git commit the plan was written against; powers the executor drift check. */
|
|
52
|
-
base_commit?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
56
|
-
|
|
57
|
-
export interface ExecPendingConfig {
|
|
58
|
-
model: { provider: string; id: string };
|
|
59
|
-
thinking: string;
|
|
60
|
-
}
|
|
21
|
+
import type { PlanData } from '@dreki-gg/taskman';
|
|
61
22
|
|
|
62
23
|
export interface PersistedState {
|
|
63
24
|
planEnabled: boolean;
|
|
@@ -66,6 +27,3 @@ export interface PersistedState {
|
|
|
66
27
|
plan: PlanData | undefined;
|
|
67
28
|
executionStartIdx: number | undefined;
|
|
68
29
|
}
|
|
69
|
-
|
|
70
|
-
// Record validation lives in `schema.ts` (Effect Schema). The interfaces above
|
|
71
|
-
// remain the mutable shapes used by the imperative orchestration code.
|
|
@@ -33,32 +33,5 @@ export function isPlanPath(filePath: string): boolean {
|
|
|
33
33
|
return /(?:^|\/)?\.plans\//.test(normalized);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
export function toKebabCase(name: string): string {
|
|
39
|
-
return name
|
|
40
|
-
.toLowerCase()
|
|
41
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
42
|
-
.replace(/^-+|-+$/g, '')
|
|
43
|
-
.slice(0, 60);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Generate the next sequential task id (`t-NNN`) given existing ids.
|
|
48
|
-
*
|
|
49
|
-
* Uses the max numeric suffix of `t-<digits>` ids + 1, zero-padded to 3.
|
|
50
|
-
* Falls back to `t-<count+1>` when no ids match the pattern.
|
|
51
|
-
*/
|
|
52
|
-
export function nextTaskId(existingIds: readonly string[]): string {
|
|
53
|
-
let max = 0;
|
|
54
|
-
let matched = false;
|
|
55
|
-
for (const id of existingIds) {
|
|
56
|
-
const m = /^t-(\d+)$/.exec(id);
|
|
57
|
-
if (!m) continue;
|
|
58
|
-
matched = true;
|
|
59
|
-
const n = Number.parseInt(m[1], 10);
|
|
60
|
-
if (n > max) max = n;
|
|
61
|
-
}
|
|
62
|
-
const next = matched ? max + 1 : existingIds.length + 1;
|
|
63
|
-
return `t-${String(next).padStart(3, '0')}`;
|
|
64
|
-
}
|
|
36
|
+
// Plan name / task id helpers (`toKebabCase`, `nextTaskId`) now live in
|
|
37
|
+
// `@dreki-gg/taskman`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
|
+
"@dreki-gg/taskman": "^0.2.0",
|
|
44
45
|
"effect": "^3.21.2"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Effect, Exit } from 'effect';
|
|
3
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
7
|
-
|
|
8
|
-
let dir: string;
|
|
9
|
-
|
|
10
|
-
beforeEach(async () => {
|
|
11
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-atomic-'));
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(async () => {
|
|
15
|
-
await rm(dir, { recursive: true, force: true });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe('writeFileAtomic', () => {
|
|
19
|
-
test('writes complete content to the target path', async () => {
|
|
20
|
-
const target = join(dir, 'data.txt');
|
|
21
|
-
|
|
22
|
-
await Effect.runPromise(writeFileAtomic(target, 'hello world'));
|
|
23
|
-
|
|
24
|
-
expect(await readFile(target, 'utf8')).toBe('hello world');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('replaces existing content atomically from the caller perspective', async () => {
|
|
28
|
-
const target = join(dir, 'data.txt');
|
|
29
|
-
await writeFile(target, 'old');
|
|
30
|
-
|
|
31
|
-
await Effect.runPromise(writeFileAtomic(target, 'new'));
|
|
32
|
-
|
|
33
|
-
expect(await readFile(target, 'utf8')).toBe('new');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('fails with PlanWriteError and leaves target untouched when the write fails', async () => {
|
|
37
|
-
const target = join(dir, 'data.txt');
|
|
38
|
-
await writeFile(target, 'original');
|
|
39
|
-
|
|
40
|
-
const exit = await Effect.runPromiseExit(writeFileAtomic(target, 'next', { mode: 0o400 }));
|
|
41
|
-
|
|
42
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
43
|
-
expect(await readFile(target, 'utf8')).toBe('original');
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression: concurrent read-modify-write on the registries must not lose
|
|
3
|
-
* updates. Each `Effect.runPromise` mimics a separate tool execute running in
|
|
4
|
-
* the same block (e.g. three `submit_initiative` calls). Before the file-lock
|
|
5
|
-
* fix, their reads all saw the same starting file and the last write clobbered
|
|
6
|
-
* the rest — only one entry survived.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
10
|
-
import { Effect } from 'effect';
|
|
11
|
-
import { chdir } from 'node:process';
|
|
12
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { tmpdir } from 'node:os';
|
|
15
|
-
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
16
|
-
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
17
|
-
import {
|
|
18
|
-
readInitiativesManifest,
|
|
19
|
-
upsertInitiativeEntry,
|
|
20
|
-
} from '../storage/initiatives-manifest.js';
|
|
21
|
-
import { writeTasksJsonl, readTasksJsonl, updateTask } from '../storage/task-storage.js';
|
|
22
|
-
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
23
|
-
|
|
24
|
-
// Each call gets its OWN runPromise so the writes are genuinely concurrent —
|
|
25
|
-
// the same shape as independent tool executes sharing one Node process.
|
|
26
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
27
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
28
|
-
|
|
29
|
-
const originalCwd = process.cwd();
|
|
30
|
-
let dir: string;
|
|
31
|
-
|
|
32
|
-
beforeEach(async () => {
|
|
33
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-concurrent-'));
|
|
34
|
-
chdir(dir);
|
|
35
|
-
});
|
|
36
|
-
afterEach(async () => {
|
|
37
|
-
chdir(originalCwd);
|
|
38
|
-
await rm(dir, { recursive: true, force: true });
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('concurrent registry writes', () => {
|
|
42
|
-
test('5 concurrent plan upserts all persist (no lost updates)', async () => {
|
|
43
|
-
const names = ['a', 'b', 'c', 'd', 'e'];
|
|
44
|
-
await Promise.all(
|
|
45
|
-
names.map((n) => run(upsertPlanEntry(n, { status: 'in-progress', title: n.toUpperCase() }))),
|
|
46
|
-
);
|
|
47
|
-
const entries = await run(readPlansManifest());
|
|
48
|
-
expect(entries.map((e) => e.name).sort()).toEqual(names);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('3 concurrent initiative upserts all persist (no lost updates)', async () => {
|
|
52
|
-
const names = ['one', 'two', 'three'];
|
|
53
|
-
await Promise.all(
|
|
54
|
-
names.map((n) => run(upsertInitiativeEntry(n, { status: 'in-progress', title: n }))),
|
|
55
|
-
);
|
|
56
|
-
const entries = await run(readInitiativesManifest());
|
|
57
|
-
expect(entries.map((e) => e.name).sort()).toEqual([...names].sort());
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('concurrent task updates to the same plan all persist', async () => {
|
|
61
|
-
const planDir = join('.plans', 'p');
|
|
62
|
-
const meta: TaskMeta = {
|
|
63
|
-
_type: 'meta',
|
|
64
|
-
plan_name: 'p',
|
|
65
|
-
title: 'P',
|
|
66
|
-
created_at: 'now',
|
|
67
|
-
};
|
|
68
|
-
const tasks: TaskRecord[] = ['t-001', 't-002', 't-003'].map((id) => ({
|
|
69
|
-
_type: 'task',
|
|
70
|
-
id,
|
|
71
|
-
description: id,
|
|
72
|
-
status: 'pending',
|
|
73
|
-
created_at: 'now',
|
|
74
|
-
updated_at: 'now',
|
|
75
|
-
}));
|
|
76
|
-
await run(writeTasksJsonl(planDir, meta, tasks));
|
|
77
|
-
|
|
78
|
-
await Promise.all(
|
|
79
|
-
tasks.map((t) => run(updateTask(planDir, t.id, { status: 'done' }))),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const snapshot = await run(readTasksJsonl(planDir));
|
|
83
|
-
expect(snapshot?.tasks.every((t) => t.status === 'done')).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Effect } from 'effect';
|
|
3
|
-
import { chdir } from 'node:process';
|
|
4
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
8
|
-
import {
|
|
9
|
-
computePlanReadiness,
|
|
10
|
-
initiativeRollup,
|
|
11
|
-
isInitiativeFinalizable,
|
|
12
|
-
reconcileInitiativeForPlan,
|
|
13
|
-
reconcileInitiativeStatus,
|
|
14
|
-
} from '../initiative.js';
|
|
15
|
-
import { upsertPlanEntry, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
16
|
-
import {
|
|
17
|
-
readInitiativesManifest,
|
|
18
|
-
upsertInitiativeEntry,
|
|
19
|
-
} from '../storage/initiatives-manifest.js';
|
|
20
|
-
|
|
21
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
22
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
23
|
-
|
|
24
|
-
function plan(partial: Partial<PlanManifestEntry> & { name: string }): PlanManifestEntry {
|
|
25
|
-
return {
|
|
26
|
-
_type: 'plan',
|
|
27
|
-
status: 'in-progress',
|
|
28
|
-
title: partial.name,
|
|
29
|
-
created_at: 'now',
|
|
30
|
-
completed_at: null,
|
|
31
|
-
...partial,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('computePlanReadiness (pure)', () => {
|
|
36
|
-
test('a plan with no deps is ready', () => {
|
|
37
|
-
const rows = computePlanReadiness([plan({ name: 'a' })]);
|
|
38
|
-
expect(rows).toEqual([{ name: 'a', ready: true, blockedBy: [] }]);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('a plan whose dep is done is ready; otherwise blocked', () => {
|
|
42
|
-
const plans = [
|
|
43
|
-
plan({ name: 'schema', status: 'done' }),
|
|
44
|
-
plan({ name: 'api', depends_on: ['schema'] }),
|
|
45
|
-
plan({ name: 'ui', depends_on: ['api'] }),
|
|
46
|
-
];
|
|
47
|
-
const byName = new Map(computePlanReadiness(plans).map((r) => [r.name, r]));
|
|
48
|
-
expect(byName.get('api')?.ready).toBe(true);
|
|
49
|
-
expect(byName.get('ui')?.ready).toBe(false);
|
|
50
|
-
expect(byName.get('ui')?.blockedBy).toEqual(['api']);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('only in-progress plans are reported (done dep itself is not a row)', () => {
|
|
54
|
-
const rows = computePlanReadiness([
|
|
55
|
-
plan({ name: 'schema', status: 'done' }),
|
|
56
|
-
plan({ name: 'api', depends_on: ['schema'] }),
|
|
57
|
-
]);
|
|
58
|
-
expect(rows.map((r) => r.name)).toEqual(['api']);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('an unknown or terminally-closed dep keeps a plan blocked', () => {
|
|
62
|
-
const rows = computePlanReadiness([
|
|
63
|
-
plan({ name: 'ghost-dep', status: 'abandoned' }),
|
|
64
|
-
plan({ name: 'x', depends_on: ['ghost-dep', 'missing'] }),
|
|
65
|
-
]);
|
|
66
|
-
const x = rows.find((r) => r.name === 'x');
|
|
67
|
-
expect(x?.ready).toBe(false);
|
|
68
|
-
expect(x?.blockedBy.sort()).toEqual(['ghost-dep', 'missing']);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('isInitiativeFinalizable / initiativeRollup (pure)', () => {
|
|
73
|
-
const plans = [
|
|
74
|
-
plan({ name: 'a', initiative: 'big', status: 'done' }),
|
|
75
|
-
plan({ name: 'b', initiative: 'big', status: 'in-progress', depends_on: ['a'] }),
|
|
76
|
-
plan({ name: 'c', initiative: 'big', status: 'in-progress', depends_on: ['b'] }),
|
|
77
|
-
plan({ name: 'solo' }), // not a member
|
|
78
|
-
];
|
|
79
|
-
|
|
80
|
-
test('not finalizable while a member is in-progress', () => {
|
|
81
|
-
expect(isInitiativeFinalizable('big', plans)).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('finalizable when all members are terminal', () => {
|
|
85
|
-
const allClosed = [
|
|
86
|
-
plan({ name: 'a', initiative: 'big', status: 'done' }),
|
|
87
|
-
plan({ name: 'b', initiative: 'big', status: 'superseded' }),
|
|
88
|
-
];
|
|
89
|
-
expect(isInitiativeFinalizable('big', allClosed)).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('an empty initiative is never finalizable', () => {
|
|
93
|
-
expect(isInitiativeFinalizable('nope', plans)).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('rollup counts members, progress, and readiness', () => {
|
|
97
|
-
const r = initiativeRollup('big', plans);
|
|
98
|
-
expect(r.total).toBe(3);
|
|
99
|
-
expect(r.done).toBe(1);
|
|
100
|
-
expect(r.inProgress).toBe(2);
|
|
101
|
-
expect(r.ready).toBe(1); // b (dep a is done)
|
|
102
|
-
expect(r.blocked).toBe(1); // c (dep b is in-progress)
|
|
103
|
-
const b = r.members.find((m) => m.name === 'b');
|
|
104
|
-
expect(b?.ready).toBe(true);
|
|
105
|
-
const c = r.members.find((m) => m.name === 'c');
|
|
106
|
-
expect(c?.blockedBy).toEqual(['b']);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('reconcileInitiativeStatus / reconcileInitiativeForPlan (IO projection)', () => {
|
|
111
|
-
const originalCwd = process.cwd();
|
|
112
|
-
let dir: string;
|
|
113
|
-
|
|
114
|
-
beforeEach(async () => {
|
|
115
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-init-proj-'));
|
|
116
|
-
chdir(dir);
|
|
117
|
-
});
|
|
118
|
-
afterEach(async () => {
|
|
119
|
-
chdir(originalCwd);
|
|
120
|
-
await rm(dir, { recursive: true, force: true });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('flips an initiative to done when its last in-progress member completes', async () => {
|
|
124
|
-
await run(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
125
|
-
await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
126
|
-
await run(upsertPlanEntry('b', { status: 'in-progress', title: 'B', initiative: 'big' }));
|
|
127
|
-
|
|
128
|
-
await run(reconcileInitiativeStatus('big'));
|
|
129
|
-
let [entry] = await run(readInitiativesManifest());
|
|
130
|
-
expect(entry.status).toBe('in-progress'); // b still open
|
|
131
|
-
|
|
132
|
-
await run(upsertPlanEntry('b', { status: 'done', title: 'B', initiative: 'big' }));
|
|
133
|
-
await run(reconcileInitiativeForPlan('b'));
|
|
134
|
-
[entry] = await run(readInitiativesManifest());
|
|
135
|
-
expect(entry.status).toBe('done');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('reopens a done initiative when a member goes back in-progress', async () => {
|
|
139
|
-
await run(upsertInitiativeEntry('big', { status: 'done', title: 'Big' }));
|
|
140
|
-
await run(upsertPlanEntry('a', { status: 'in-progress', title: 'A', initiative: 'big' }));
|
|
141
|
-
await run(reconcileInitiativeStatus('big'));
|
|
142
|
-
const [entry] = await run(readInitiativesManifest());
|
|
143
|
-
expect(entry.status).toBe('in-progress');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('never overrides a manually-closed (superseded) initiative', async () => {
|
|
147
|
-
await run(upsertInitiativeEntry('big', { status: 'superseded', title: 'Big', reason: 'x' }));
|
|
148
|
-
await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
149
|
-
await run(reconcileInitiativeStatus('big'));
|
|
150
|
-
const [entry] = await run(readInitiativesManifest());
|
|
151
|
-
expect(entry.status).toBe('superseded');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test('reconcileInitiativeForPlan is a no-op for a standalone plan', async () => {
|
|
155
|
-
await run(upsertPlanEntry('solo', { status: 'done', title: 'Solo' }));
|
|
156
|
-
await run(reconcileInitiativeForPlan('solo'));
|
|
157
|
-
await expect(run(readInitiativesManifest())).resolves.toEqual([]);
|
|
158
|
-
});
|
|
159
|
-
});
|