@crewhaus/tool-plan 0.3.0

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/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @crewhaus/tool-plan
2
+
3
+ v0.3.0 Goal 1 (design §2.4/§2.6): the continuity tool surface —
4
+ RegisteredTools over [`@crewhaus/continuity-store`](../continuity-store),
5
+ packaged like `tool-memory` (one factory bound to a spec name; the caller
6
+ registers the tools into the runtime catalog).
7
+
8
+ ```ts
9
+ import { createPlanTools } from "@crewhaus/tool-plan";
10
+ import { defaultCatalog } from "@crewhaus/tool-catalog";
11
+
12
+ const bundle = createPlanTools({
13
+ specName: "support-bot",
14
+ appendEvent: (e) => eventLog.append(e), // plan_update / goal_update / action_proof
15
+ });
16
+ for (const tool of bundle.all) defaultCatalog.register(tool);
17
+ ```
18
+
19
+ ## The tool table
20
+
21
+ | Tool | Flags | What it does |
22
+ | --- | --- | --- |
23
+ | `FocusRead` | readOnly | Focus body + active plan pointer + REQ ledger — check first, never re-ask answered questions |
24
+ | `FocusWrite` | destructive (audit-and-allow, **no** justification — it's the hot loop) | Replace the focus body (capped at `focusMaxChars`) |
25
+ | `PlanRead` | readOnly | Numbered steps with ladder statuses; `claimed` rendered as unverified |
26
+ | `PlanUpdate` | destructive (audit-and-allow) | `create` / `add_step` / `set_step_status` (up to `claimed`) / `set_active` |
27
+ | `PlanComplete` | destructive | The **proven** transition: requires `evidence: [{toolUseId}]`, machine-checked against session event logs (child sessions included) |
28
+ | `GoalWrite` | destructive | Create a goal (`goals.yaml`) |
29
+ | `GoalUpdate` | destructive | Update a goal; `proven` requires evidence like a step |
30
+ | `GoalList` | readOnly | List goals with progress |
31
+ | `MemoryClear` | destructive + **requireJustification: true** | Clear focus/plans/goals/all — via trash, restorable, never hard-deleted |
32
+
33
+ The `proven` gate teaches instead of blocking silently: a bad citation
34
+ rejects with `no verified evidence for tu_…: run the action first, then
35
+ complete the step with its toolUseId.` — the *bias toward action*: narration
36
+ can never produce a ✓.
37
+
38
+ ## Event emission (decoupled)
39
+
40
+ Mutations emit the additive event-log kinds `plan_update`, `goal_update`, and
41
+ `action_proof {planId, step, toolUseId, verdict}` through the **injected**
42
+ `appendEvent` callback — this package never imports runtime wiring; emitters
43
+ or memory-service decide where events land (session JSONL, trace bus, both).
44
+ Rejected proof attempts emit `action_proof` too (`missing` / `error_result`),
45
+ so the audit trail records proof pressure, not just wins.
46
+
47
+ ## Pillar 3 notes
48
+
49
+ Plan/focus/ledger content is user-authored working state (origin `"user"`
50
+ semantics, like tool-memory writes). All tools are `scope: "internal"`
51
+ (process-local files). `MemoryClear` carries the intent gate
52
+ (`requireJustification: true`) because model-initiated forgetting is rare and
53
+ destructive-by-intent; routine Focus/Plan updates are audit-and-allow so a
54
+ judge call per update doesn't drown the loop (§7.4).
@@ -0,0 +1,112 @@
1
+ /**
2
+ * v0.3.0 Goal 1 — `tool-plan` (design §2.4/§2.6, PR 7). RegisteredTools over
3
+ * `@crewhaus/continuity-store`, packaged like `tool-memory`: one factory
4
+ * bound to a spec name, returning tools the caller registers into the
5
+ * runtime catalog.
6
+ *
7
+ * The tool table (product-DX §2.6 + proof ladder §2.4):
8
+ *
9
+ * FocusRead readOnly — current focus, active plan pointer, REQ ledger
10
+ * FocusWrite destructive — the hot loop: audit-and-allow, deliberately
11
+ * NO requireJustification (a judge call per
12
+ * routine focus update would drown the loop)
13
+ * PlanRead readOnly
14
+ * PlanUpdate destructive — create plans/steps, ladder moves UP TO
15
+ * `claimed`, active-plan pointer; audit-and-allow
16
+ * PlanComplete destructive — the `proven` transition: requires
17
+ * evidence [{toolUseId}], machine-checked
18
+ * against session event logs (child sessions
19
+ * included); rejects teach, never bluff
20
+ * GoalWrite destructive
21
+ * GoalUpdate destructive — `proven` requires evidence, like steps
22
+ * GoalList readOnly
23
+ * MemoryClear destructive + requireJustification — clears via trash
24
+ * (never hard-deletes); the intent gate is the
25
+ * brake on model-initiated forgetting
26
+ *
27
+ * Events: mutations emit the additive event-log kinds `plan_update`,
28
+ * `goal_update`, and `action_proof {planId, step, toolUseId, verdict}`
29
+ * through an INJECTED `appendEvent` seam — this package never imports
30
+ * runtime wiring, so emitters/memory-service decide where events land.
31
+ * Rejected proof attempts emit `action_proof` too (verdict `missing` /
32
+ * `error_result`): the audit trail records proof pressure, not just wins.
33
+ *
34
+ * Pillar 3: plan/focus/ledger content is user-authored working state
35
+ * (origin "user" semantics, like tool-memory writes) — recalled wiki/fact
36
+ * content is what gets the new `memory` TrustOrigin, not this. All tools are
37
+ * `scope: "internal"` (process-local files only).
38
+ */
39
+ import { type ContinuityScope, type ContinuityStore } from "@crewhaus/continuity-store";
40
+ import type { ActionProofEventPayload, GoalUpdateEventPayload, PlanUpdateEventPayload } from "@crewhaus/event-log";
41
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
42
+ /** The three additive event kinds this package emits (event-log union). */
43
+ export type ContinuityEventKind = "plan_update" | "goal_update" | "action_proof";
44
+ export type ContinuityEvent = {
45
+ readonly kind: "plan_update";
46
+ readonly payload: PlanUpdateEventPayload;
47
+ } | {
48
+ readonly kind: "goal_update";
49
+ readonly payload: GoalUpdateEventPayload;
50
+ } | {
51
+ readonly kind: "action_proof";
52
+ readonly payload: ActionProofEventPayload;
53
+ };
54
+ /**
55
+ * The injected append seam: typically wired to the session event log's
56
+ * `append` (and/or the trace bus) by the emitter or memory-service — kept as
57
+ * a callback so this package stays decoupled from runtime-core. Should not
58
+ * throw; a throwing sink fails the tool call loudly rather than dropping
59
+ * audit events silently.
60
+ */
61
+ export type AppendContinuityEvent = (event: ContinuityEvent) => void | Promise<void>;
62
+ /**
63
+ * v0.3.0 §2.5 seam — the per-run state-store key plan-mutating tools flip
64
+ * (through `ToolExecuteContext.bridge.runState`, when the runtime wires one)
65
+ * so runtime-core re-renders the volatile `<current_plan>` tail before the
66
+ * next model call. Deliberately a duplicated literal: this package never
67
+ * imports runtime wiring, and runtime-core exports the same literal as its
68
+ * own `PLAN_DIRTY_STATE_KEY`; the runtime-core continuity suite pins the
69
+ * end-to-end round trip (a real PlanUpdate execute → the loop's dirty-check
70
+ * → tail re-render), so a drift in either literal fails tests.
71
+ */
72
+ export declare const PLAN_DIRTY_STATE_KEY = "plan.dirty";
73
+ export type CreatePlanToolsOptions = {
74
+ readonly specName: string;
75
+ readonly rootDir?: string;
76
+ readonly scope?: ContinuityScope;
77
+ /** Where session `.jsonl` logs live (proof verification). */
78
+ readonly sessionRootDir?: string;
79
+ /** Fallback session for evidence refs when the runtime context carries no
80
+ * sessionId (refs and `ctx.runContext.sessionId` take precedence). */
81
+ readonly sessionId?: string;
82
+ /** Inject a custom store implementation for tests. */
83
+ readonly store?: ContinuityStore;
84
+ /** Event-log append seam — see `AppendContinuityEvent`. */
85
+ readonly appendEvent?: AppendContinuityEvent;
86
+ readonly now?: () => Date;
87
+ };
88
+ export type PlanToolBundle = {
89
+ readonly focusRead: RegisteredTool;
90
+ readonly focusWrite: RegisteredTool;
91
+ readonly planRead: RegisteredTool;
92
+ readonly planUpdate: RegisteredTool;
93
+ readonly planComplete: RegisteredTool;
94
+ readonly goalWrite: RegisteredTool;
95
+ readonly goalUpdate: RegisteredTool;
96
+ readonly goalList: RegisteredTool;
97
+ readonly memoryClear: RegisteredTool;
98
+ /** Every tool above, registration-ready. */
99
+ readonly all: readonly RegisteredTool[];
100
+ /** Exposed for direct inspection (tests, CLI verbs). */
101
+ readonly store: ContinuityStore;
102
+ };
103
+ /** Thrown when a plan-scoped call has neither a planId nor an active plan. */
104
+ export declare class NoActivePlanError extends Error {
105
+ readonly name = "NoActivePlanError";
106
+ }
107
+ /**
108
+ * Construct the continuity tool set bound to a spec's continuity store. The
109
+ * caller registers the returned tools into the runtime catalog (e.g.
110
+ * `for (const t of bundle.all) defaultCatalog.register(t)`).
111
+ */
112
+ export declare function createPlanTools(opts: CreatePlanToolsOptions): PlanToolBundle;
package/dist/index.js ADDED
@@ -0,0 +1,524 @@
1
+ /**
2
+ * v0.3.0 Goal 1 — `tool-plan` (design §2.4/§2.6, PR 7). RegisteredTools over
3
+ * `@crewhaus/continuity-store`, packaged like `tool-memory`: one factory
4
+ * bound to a spec name, returning tools the caller registers into the
5
+ * runtime catalog.
6
+ *
7
+ * The tool table (product-DX §2.6 + proof ladder §2.4):
8
+ *
9
+ * FocusRead readOnly — current focus, active plan pointer, REQ ledger
10
+ * FocusWrite destructive — the hot loop: audit-and-allow, deliberately
11
+ * NO requireJustification (a judge call per
12
+ * routine focus update would drown the loop)
13
+ * PlanRead readOnly
14
+ * PlanUpdate destructive — create plans/steps, ladder moves UP TO
15
+ * `claimed`, active-plan pointer; audit-and-allow
16
+ * PlanComplete destructive — the `proven` transition: requires
17
+ * evidence [{toolUseId}], machine-checked
18
+ * against session event logs (child sessions
19
+ * included); rejects teach, never bluff
20
+ * GoalWrite destructive
21
+ * GoalUpdate destructive — `proven` requires evidence, like steps
22
+ * GoalList readOnly
23
+ * MemoryClear destructive + requireJustification — clears via trash
24
+ * (never hard-deletes); the intent gate is the
25
+ * brake on model-initiated forgetting
26
+ *
27
+ * Events: mutations emit the additive event-log kinds `plan_update`,
28
+ * `goal_update`, and `action_proof {planId, step, toolUseId, verdict}`
29
+ * through an INJECTED `appendEvent` seam — this package never imports
30
+ * runtime wiring, so emitters/memory-service decide where events land.
31
+ * Rejected proof attempts emit `action_proof` too (verdict `missing` /
32
+ * `error_result`): the audit trail records proof pressure, not just wins.
33
+ *
34
+ * Pillar 3: plan/focus/ledger content is user-authored working state
35
+ * (origin "user" semantics, like tool-memory writes) — recalled wiki/fact
36
+ * content is what gets the new `memory` TrustOrigin, not this. All tools are
37
+ * `scope: "internal"` (process-local files only).
38
+ */
39
+ import { EvidenceError, createContinuityStore, renderStatus, } from "@crewhaus/continuity-store";
40
+ import { formatAgentIdentity } from "@crewhaus/run-context";
41
+ import { buildTool } from "@crewhaus/tool-builder";
42
+ import { z } from "zod";
43
+ /**
44
+ * v0.3.0 §2.5 seam — the per-run state-store key plan-mutating tools flip
45
+ * (through `ToolExecuteContext.bridge.runState`, when the runtime wires one)
46
+ * so runtime-core re-renders the volatile `<current_plan>` tail before the
47
+ * next model call. Deliberately a duplicated literal: this package never
48
+ * imports runtime wiring, and runtime-core exports the same literal as its
49
+ * own `PLAN_DIRTY_STATE_KEY`; the runtime-core continuity suite pins the
50
+ * end-to-end round trip (a real PlanUpdate execute → the loop's dirty-check
51
+ * → tail re-render), so a drift in either literal fails tests.
52
+ */
53
+ export const PLAN_DIRTY_STATE_KEY = "plan.dirty";
54
+ // ---------------------------------------------------------------------------
55
+ // schemas
56
+ // ---------------------------------------------------------------------------
57
+ const planIdSchema = z
58
+ .string()
59
+ .regex(/^plan-\d{4}$/)
60
+ .describe("Plan id, e.g. plan-0001.");
61
+ const optionalPlanIdSchema = planIdSchema
62
+ .optional()
63
+ .describe("Plan id, e.g. plan-0001. Omit to use the active plan.");
64
+ const focusReadSchema = z.object({});
65
+ const focusWriteSchema = z.object({
66
+ focus: z
67
+ .string()
68
+ .min(1)
69
+ .describe("The new focus body: what you are working on right now and the next actions. Short — hard-capped at focusMaxChars (default 4096)."),
70
+ });
71
+ const planReadSchema = z.object({
72
+ planId: optionalPlanIdSchema,
73
+ });
74
+ const claimableStatusSchema = z
75
+ .enum(["open", "in_progress", "claimed"])
76
+ .describe("Ladder status. 'claimed' records unverified progress; 'proven' is NOT settable here — use PlanComplete with evidence.");
77
+ const planUpdateSchema = z.discriminatedUnion("action", [
78
+ z.object({
79
+ action: z.literal("create"),
80
+ title: z.string().min(1).describe("Plan title."),
81
+ steps: z.array(z.string().min(1)).optional().describe("Initial numbered steps, in order."),
82
+ }),
83
+ z.object({
84
+ action: z.literal("add_step"),
85
+ planId: optionalPlanIdSchema,
86
+ text: z.string().min(1).describe("The new step."),
87
+ }),
88
+ z.object({
89
+ action: z.literal("set_step_status"),
90
+ planId: optionalPlanIdSchema,
91
+ step: z.number().int().min(1).describe("1-based step number."),
92
+ status: claimableStatusSchema,
93
+ }),
94
+ z.object({
95
+ action: z.literal("set_active"),
96
+ planId: planIdSchema,
97
+ }),
98
+ ]);
99
+ const evidenceRefSchema = z.object({
100
+ toolUseId: z.string().min(1).describe("A toolUseId from a tool call you actually ran."),
101
+ sessionId: z
102
+ .string()
103
+ .regex(/^sess_[0-9a-f]{16}$/)
104
+ .optional()
105
+ .describe("Session the call ran in. Omit for the current session."),
106
+ });
107
+ const planCompleteSchema = z.object({
108
+ planId: optionalPlanIdSchema,
109
+ step: z.number().int().min(1).describe("1-based step number to mark proven."),
110
+ evidence: z
111
+ .array(evidenceRefSchema)
112
+ .min(1)
113
+ .describe("Evidence for the proven transition — cite the tool call(s) that did the work."),
114
+ });
115
+ const goalWriteSchema = z.object({
116
+ title: z.string().min(1).describe("The goal."),
117
+ target: z.number().optional().describe("Optional numeric target."),
118
+ current: z.number().optional().describe("Optional current value."),
119
+ unit: z.string().optional().describe("Optional unit for target/current (e.g. '%')."),
120
+ });
121
+ const goalUpdateSchema = z.object({
122
+ goalId: z
123
+ .string()
124
+ .regex(/^goal-\d{4}$/)
125
+ .describe("Goal id, e.g. goal-0001."),
126
+ title: z.string().min(1).optional(),
127
+ status: z
128
+ .enum(["open", "in_progress", "claimed", "proven"])
129
+ .optional()
130
+ .describe("Ladder status. 'proven' requires evidence."),
131
+ target: z.number().optional(),
132
+ current: z.number().optional(),
133
+ unit: z.string().optional(),
134
+ evidence: z
135
+ .array(evidenceRefSchema)
136
+ .optional()
137
+ .describe("Required when status is 'proven' — the tool call(s) that achieved the goal."),
138
+ });
139
+ const goalListSchema = z.object({});
140
+ const memoryClearSchema = z.object({
141
+ scope: z
142
+ .enum(["focus", "plans", "goals", "all"])
143
+ .describe("What to clear. Files move to .crewhaus/trash/<ts>/ — recoverable, never deleted."),
144
+ });
145
+ // ---------------------------------------------------------------------------
146
+ // rendering helpers
147
+ // ---------------------------------------------------------------------------
148
+ function renderPlan(plan) {
149
+ const lines = [`${plan.id} — ${plan.title}`];
150
+ if (plan.steps.length === 0) {
151
+ lines.push(" (no steps yet — add some with PlanUpdate {action: 'add_step'})");
152
+ }
153
+ for (const step of plan.steps) {
154
+ const proofSuffix = step.status === "proven" && step.proofs.length > 0
155
+ ? ` — proof: ${step.proofs.map((p) => p.toolUseId).join(", ")}`
156
+ : "";
157
+ lines.push(` ${step.index}. ${renderStatus(step.status)} ${step.text}${proofSuffix}`);
158
+ }
159
+ return lines.join("\n");
160
+ }
161
+ function renderGoal(goal) {
162
+ const progress = goal.target !== undefined
163
+ ? ` (${goal.current ?? 0}/${goal.target}${goal.unit !== undefined ? ` ${goal.unit}` : ""})`
164
+ : "";
165
+ return `- ${goal.id} ${renderStatus(goal.status)} ${goal.title}${progress}`;
166
+ }
167
+ function renderRequirement(req) {
168
+ return `- ${req.id} [${req.status}] ${JSON.stringify(req.text)} (user, ${req.source.sessionId}, turn ${req.source.turn})`;
169
+ }
170
+ /** Thrown when a plan-scoped call has neither a planId nor an active plan. */
171
+ export class NoActivePlanError extends Error {
172
+ name = "NoActivePlanError";
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // the factory
176
+ // ---------------------------------------------------------------------------
177
+ /**
178
+ * Construct the continuity tool set bound to a spec's continuity store. The
179
+ * caller registers the returned tools into the runtime catalog (e.g.
180
+ * `for (const t of bundle.all) defaultCatalog.register(t)`).
181
+ */
182
+ export function createPlanTools(opts) {
183
+ const store = opts.store ??
184
+ createContinuityStore({
185
+ specName: opts.specName,
186
+ ...(opts.rootDir !== undefined ? { rootDir: opts.rootDir } : {}),
187
+ ...(opts.scope !== undefined ? { scope: opts.scope } : {}),
188
+ ...(opts.sessionRootDir !== undefined ? { sessionRootDir: opts.sessionRootDir } : {}),
189
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
190
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
191
+ });
192
+ async function emit(event) {
193
+ if (opts.appendEvent !== undefined)
194
+ await opts.appendEvent(event);
195
+ }
196
+ function currentSessionId(ctx) {
197
+ return ctx?.runContext?.sessionId ?? opts.sessionId;
198
+ }
199
+ /** v0.3.0 §7.1 — attribution: stamp the acting run context's identity
200
+ * (e.g. `subagent=researcher`) into emitted event payloads so a plan
201
+ * mutation made from inside a sub-agent records WHO acted. Mirrors
202
+ * tool-wiki's `createdBy` stamping: prefers `ctx.runContext`, falls back
203
+ * to the opaque bridge's runContext (the field the runtime threads on
204
+ * every tool execute). Empty for top-level runs. */
205
+ function identityOf(ctx) {
206
+ const rc = ctx?.runContext ?? ctx?.bridge?.runContext;
207
+ return rc?.agentIdentity !== undefined
208
+ ? { agentIdentity: formatAgentIdentity(rc.agentIdentity) }
209
+ : {};
210
+ }
211
+ /** §2.5 — flag the runtime's per-run state-store after a successful
212
+ * mutation so the loop re-renders the plan tail before the next model
213
+ * call. No-op when the runtime didn't wire `bridge.runState` (pre-0.3.0
214
+ * bridge builders, direct tool execution in tests/CLI verbs). */
215
+ function markPlanDirty(ctx) {
216
+ const bridge = ctx?.bridge;
217
+ bridge?.runState?.set({ [PLAN_DIRTY_STATE_KEY]: true });
218
+ }
219
+ async function resolvePlanId(planId) {
220
+ if (planId !== undefined)
221
+ return planId;
222
+ const active = await store.getActivePlan();
223
+ if (active === null) {
224
+ throw new NoActivePlanError("no active plan — pass planId, or create a plan first with PlanUpdate {action: 'create'}.");
225
+ }
226
+ return active.id;
227
+ }
228
+ const focusRead = buildTool({
229
+ name: "FocusRead",
230
+ description: "Read the persisted focus: what this harness is working on, the active plan pointer, and the REQ-nnn requirements ledger. Check this FIRST at session start and before asking the user anything they may already have answered.",
231
+ inputSchema: focusReadSchema,
232
+ readOnly: true,
233
+ execute: async () => {
234
+ const focus = await store.readFocus();
235
+ if (focus === null) {
236
+ return "no focus set — write one with FocusWrite.";
237
+ }
238
+ const lines = ["focus:"];
239
+ lines.push(focus.body !== "" ? focus.body : " (empty)");
240
+ lines.push(`active plan: ${focus.activePlanId ?? "none"}`);
241
+ lines.push("requirements:");
242
+ if (focus.requirements.length > 0) {
243
+ if (focus.ledgerTruncated)
244
+ lines.push("[ledger truncated]");
245
+ for (const req of focus.requirements)
246
+ lines.push(renderRequirement(req));
247
+ }
248
+ else {
249
+ lines.push(" (none)");
250
+ }
251
+ return lines.join("\n");
252
+ },
253
+ });
254
+ const focusWrite = buildTool({
255
+ name: "FocusWrite",
256
+ description: "Replace the persisted focus body (current objective + next actions). This is the hot loop — keep it current and short (capped at focusMaxChars). Requirements are appended via the ledger, not here.",
257
+ inputSchema: focusWriteSchema,
258
+ destructive: true, // audit-and-allow; deliberately NO requireJustification (§7.4)
259
+ execute: async (input, ctx) => {
260
+ await store.writeFocus(input.focus);
261
+ markPlanDirty(ctx);
262
+ return `focus updated (${input.focus.length} chars)`;
263
+ },
264
+ });
265
+ const planRead = buildTool({
266
+ name: "PlanRead",
267
+ description: "Read a plan's numbered steps with their proof-ladder statuses. 'claimed' steps are rendered as unverified — only 'proven' steps have machine-checked evidence. Omit planId for the active plan.",
268
+ inputSchema: planReadSchema,
269
+ readOnly: true,
270
+ execute: async (input) => {
271
+ if (input.planId !== undefined) {
272
+ const plan = await store.getPlan(input.planId);
273
+ if (plan === null)
274
+ return `no plan "${input.planId}"`;
275
+ return renderPlan(plan);
276
+ }
277
+ const active = await store.getActivePlan();
278
+ if (active !== null)
279
+ return renderPlan(active);
280
+ const plans = await store.listPlans();
281
+ if (plans.length === 0) {
282
+ return "no plans yet — create one with PlanUpdate {action: 'create'}.";
283
+ }
284
+ return [
285
+ "no active plan. available plans:",
286
+ ...plans.map((p) => `- ${p.id} — ${p.title}`),
287
+ ].join("\n");
288
+ },
289
+ });
290
+ const planUpdate = buildTool({
291
+ name: "PlanUpdate",
292
+ description: "Mutate the persisted plan: create a plan, add a step, move a step's ladder status (open → in_progress → claimed), or set the active plan. 'claimed' records progress WITHOUT verification — the 'proven' transition goes through PlanComplete with evidence.",
293
+ inputSchema: planUpdateSchema,
294
+ destructive: true, // audit-and-allow (§7.4): a judge call per routine plan update would drown the loop
295
+ execute: async (input, ctx) => {
296
+ switch (input.action) {
297
+ case "create": {
298
+ const plan = await store.createPlan({
299
+ title: input.title,
300
+ ...(input.steps !== undefined ? { steps: input.steps } : {}),
301
+ });
302
+ await emit({
303
+ kind: "plan_update",
304
+ payload: { planId: plan.id, action: "create", title: plan.title, ...identityOf(ctx) },
305
+ });
306
+ markPlanDirty(ctx);
307
+ return `created ${plan.id} — ${plan.title} (${plan.steps.length} step(s))\n${renderPlan(plan)}`;
308
+ }
309
+ case "add_step": {
310
+ const planId = await resolvePlanId(input.planId);
311
+ const plan = await store.addStep(planId, input.text);
312
+ await emit({
313
+ kind: "plan_update",
314
+ payload: { planId, action: "add_step", step: plan.steps.length, ...identityOf(ctx) },
315
+ });
316
+ markPlanDirty(ctx);
317
+ return `added step ${plan.steps.length} to ${planId}`;
318
+ }
319
+ case "set_step_status": {
320
+ const planId = await resolvePlanId(input.planId);
321
+ await store.setStepStatus(planId, input.step, input.status);
322
+ await emit({
323
+ kind: "plan_update",
324
+ payload: {
325
+ planId,
326
+ action: "set_step_status",
327
+ step: input.step,
328
+ status: input.status,
329
+ ...identityOf(ctx),
330
+ },
331
+ });
332
+ markPlanDirty(ctx);
333
+ return `${planId} step ${input.step} → ${input.status}${input.status === "claimed" ? " (unverified — prove it with PlanComplete)" : ""}`;
334
+ }
335
+ case "set_active": {
336
+ await store.setActivePlan(input.planId);
337
+ await emit({
338
+ kind: "plan_update",
339
+ payload: { planId: input.planId, action: "set_active", ...identityOf(ctx) },
340
+ });
341
+ markPlanDirty(ctx);
342
+ return `active plan → ${input.planId}`;
343
+ }
344
+ }
345
+ },
346
+ });
347
+ const planComplete = buildTool({
348
+ name: "PlanComplete",
349
+ description: "The proven transition (machine-checked): mark a plan step proven by citing the toolUseId(s) of the calls that did the work. Each id is resolved against the session event logs (child sessions included); missing or errored results are rejected. Do the work first, then complete the step.",
350
+ inputSchema: planCompleteSchema,
351
+ destructive: true,
352
+ execute: async (input, ctx) => {
353
+ const planId = await resolvePlanId(input.planId);
354
+ const sessionId = currentSessionId(ctx);
355
+ const refs = input.evidence.map((ref) => ({
356
+ toolUseId: ref.toolUseId,
357
+ ...(ref.sessionId !== undefined
358
+ ? { sessionId: ref.sessionId }
359
+ : sessionId !== undefined
360
+ ? { sessionId }
361
+ : {}),
362
+ }));
363
+ try {
364
+ const plan = await store.proveStep(planId, input.step, refs);
365
+ const step = plan.steps.find((s) => s.index === input.step);
366
+ for (const proof of step?.proofs ?? []) {
367
+ if (refs.some((r) => r.toolUseId === proof.toolUseId)) {
368
+ await emit({
369
+ kind: "action_proof",
370
+ payload: {
371
+ planId,
372
+ step: input.step,
373
+ toolUseId: proof.toolUseId,
374
+ verdict: "verified",
375
+ ...identityOf(ctx),
376
+ },
377
+ });
378
+ }
379
+ }
380
+ await emit({
381
+ kind: "plan_update",
382
+ payload: {
383
+ planId,
384
+ action: "prove_step",
385
+ step: input.step,
386
+ status: "proven",
387
+ ...identityOf(ctx),
388
+ },
389
+ });
390
+ markPlanDirty(ctx);
391
+ return `${planId} step ${input.step} proven — evidence: ${refs
392
+ .map((r) => r.toolUseId)
393
+ .join(", ")}`;
394
+ }
395
+ catch (err) {
396
+ if (err instanceof EvidenceError) {
397
+ // Audit the rejected attempt too — proof pressure is signal.
398
+ await emit({
399
+ kind: "action_proof",
400
+ payload: {
401
+ planId,
402
+ step: input.step,
403
+ toolUseId: err.toolUseId,
404
+ verdict: err.verdict,
405
+ ...identityOf(ctx),
406
+ },
407
+ });
408
+ }
409
+ throw err;
410
+ }
411
+ },
412
+ });
413
+ const goalWrite = buildTool({
414
+ name: "GoalWrite",
415
+ description: "Create a persisted goal ({title, target?, current?, unit?}) in goals.yaml. Goals carry the same open → in_progress → claimed → proven ladder as plan steps.",
416
+ inputSchema: goalWriteSchema,
417
+ destructive: true,
418
+ execute: async (input, ctx) => {
419
+ const goal = await store.writeGoal({
420
+ title: input.title,
421
+ ...(input.target !== undefined ? { target: input.target } : {}),
422
+ ...(input.current !== undefined ? { current: input.current } : {}),
423
+ ...(input.unit !== undefined ? { unit: input.unit } : {}),
424
+ });
425
+ await emit({
426
+ kind: "goal_update",
427
+ payload: { goalId: goal.id, action: "create", title: goal.title, ...identityOf(ctx) },
428
+ });
429
+ markPlanDirty(ctx);
430
+ return `created ${goal.id} — ${goal.title}`;
431
+ },
432
+ });
433
+ const goalUpdate = buildTool({
434
+ name: "GoalUpdate",
435
+ description: "Update a goal: title, target/current progress, or ladder status. 'claimed' is free; 'proven' requires evidence [{toolUseId}] verified against the session event logs, exactly like PlanComplete.",
436
+ inputSchema: goalUpdateSchema,
437
+ destructive: true,
438
+ execute: async (input, ctx) => {
439
+ const sessionId = currentSessionId(ctx);
440
+ const evidence = input.evidence?.map((ref) => ({
441
+ toolUseId: ref.toolUseId,
442
+ ...(ref.sessionId !== undefined
443
+ ? { sessionId: ref.sessionId }
444
+ : sessionId !== undefined
445
+ ? { sessionId }
446
+ : {}),
447
+ }));
448
+ const goal = await store.updateGoal(input.goalId, {
449
+ ...(input.title !== undefined ? { title: input.title } : {}),
450
+ ...(input.status !== undefined ? { status: input.status } : {}),
451
+ ...(input.target !== undefined ? { target: input.target } : {}),
452
+ ...(input.current !== undefined ? { current: input.current } : {}),
453
+ ...(input.unit !== undefined ? { unit: input.unit } : {}),
454
+ ...(evidence !== undefined ? { evidence } : {}),
455
+ });
456
+ await emit({
457
+ kind: "goal_update",
458
+ payload: {
459
+ goalId: goal.id,
460
+ action: "update",
461
+ ...(input.status !== undefined ? { status: input.status } : {}),
462
+ ...identityOf(ctx),
463
+ },
464
+ });
465
+ markPlanDirty(ctx);
466
+ return `${goal.id} updated — ${renderGoal(goal).slice(2)}`;
467
+ },
468
+ });
469
+ const goalList = buildTool({
470
+ name: "GoalList",
471
+ description: "List persisted goals with ladder statuses and progress. 'claimed' goals are rendered as unverified.",
472
+ inputSchema: goalListSchema,
473
+ readOnly: true,
474
+ execute: async () => {
475
+ const goals = await store.listGoals();
476
+ if (goals.length === 0)
477
+ return "no goals yet — create one with GoalWrite.";
478
+ return goals.map(renderGoal).join("\n");
479
+ },
480
+ });
481
+ const memoryClear = buildTool({
482
+ name: "MemoryClear",
483
+ description: "Clear continuity state (focus | plans | goals | all). Files move to .crewhaus/trash/<ts>/ and can be restored — nothing is hard-deleted. Destructive: requires a justification.",
484
+ inputSchema: memoryClearSchema,
485
+ destructive: true,
486
+ // Pillar 3 intent gate (§7.4): model-initiated forgetting is exactly
487
+ // where the justification judge belongs — unlike the hot-loop
488
+ // Focus/Plan updates, clears are rare and destructive-by-intent.
489
+ requireJustification: true,
490
+ execute: async (input, ctx) => {
491
+ const result = await store.clear(input.scope);
492
+ if (result.moved.length === 0) {
493
+ return `nothing to clear for scope "${input.scope}" (store is empty)`;
494
+ }
495
+ markPlanDirty(ctx);
496
+ return (`cleared: ${input.scope} (${result.moved.length} file(s)/dir(s)) → ${result.trashDir}\n` +
497
+ `undo: crewhaus memory restore ${result.ts}`);
498
+ },
499
+ });
500
+ const all = [
501
+ focusRead,
502
+ focusWrite,
503
+ planRead,
504
+ planUpdate,
505
+ planComplete,
506
+ goalWrite,
507
+ goalUpdate,
508
+ goalList,
509
+ memoryClear,
510
+ ];
511
+ return {
512
+ focusRead,
513
+ focusWrite,
514
+ planRead,
515
+ planUpdate,
516
+ planComplete,
517
+ goalWrite,
518
+ goalUpdate,
519
+ goalList,
520
+ memoryClear,
521
+ all,
522
+ store,
523
+ };
524
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@crewhaus/tool-plan",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "v0.3.0 Goal 1 — Focus*/Plan*/Goal*/MemoryClear RegisteredTools over @crewhaus/continuity-store: the claimed→proven proof ladder as a tool surface, with plan_update/goal_update/action_proof event emission via an injected append seam.",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/continuity-store": "0.3.0",
19
+ "@crewhaus/event-log": "0.3.0",
20
+ "@crewhaus/run-context": "0.3.0",
21
+ "@crewhaus/tool-builder": "0.3.0",
22
+ "@crewhaus/tool-catalog": "0.3.0",
23
+ "zod": "^3.23.8"
24
+ },
25
+ "license": "Apache-2.0",
26
+ "author": {
27
+ "name": "Max Meier",
28
+ "email": "max@crewhaus.ai",
29
+ "url": "https://crewhaus.ai"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/crewhaus/factory.git",
34
+ "directory": "packages/tool-plan"
35
+ },
36
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-plan#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/crewhaus/factory/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
44
+ }