@oisin-ee/oisinpi 0.7.2 → 0.9.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/node_modules/@oisin-ee/pi-guardrails/package.json +1 -1
  3. package/node_modules/@oisin-ee/pi-prewalk/README.md +2 -1
  4. package/node_modules/@oisin-ee/pi-prewalk/package.json +1 -1
  5. package/node_modules/@oisin-ee/pi-prewalk/src/adapter.ts +2 -0
  6. package/node_modules/@oisin-ee/pi-prewalk/src/generated/prewalk_core.d.ts +4 -0
  7. package/node_modules/@oisin-ee/pi-prewalk/src/generated/prewalk_core_bg.wasm +0 -0
  8. package/node_modules/@oisin-ee/pi-prewalk/src/ports.ts +1 -1
  9. package/node_modules/@oisin-ee/pi-prewalk/src/tasks.ts +48 -0
  10. package/node_modules/@oisin-ee/pi-tiers/README.md +65 -0
  11. package/node_modules/@oisin-ee/pi-tiers/package.json +35 -0
  12. package/node_modules/@oisin-ee/pi-tiers/src/branch.ts +75 -0
  13. package/node_modules/@oisin-ee/pi-tiers/src/config.ts +48 -0
  14. package/node_modules/@oisin-ee/pi-tiers/src/health.ts +92 -0
  15. package/node_modules/@oisin-ee/pi-tiers/src/index.ts +70 -0
  16. package/node_modules/@oisin-ee/pi-tiers/src/lock.ts +53 -0
  17. package/node_modules/@oisin-ee/pi-tiers/src/policy.ts +85 -0
  18. package/node_modules/@oisin-ee/pi-tiers/src/ports.ts +29 -0
  19. package/node_modules/@oisin-ee/pi-tiers/src/quota.ts +81 -0
  20. package/node_modules/@oisin-ee/pi-tiers/src/recovery.ts +114 -0
  21. package/node_modules/@oisin-ee/pi-tiers/src/responses.ts +44 -0
  22. package/node_modules/@oisin-ee/pi-tiers/src/router.ts +176 -0
  23. package/node_modules/@oisin-ee/pi-tiers/src/source.ts +258 -0
  24. package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
  25. package/node_modules/@oisin-ee/pi-todo/src/contract-fixture.ts +103 -0
  26. package/node_modules/@oisin-ee/pi-todo/src/display.ts +8 -2
  27. package/node_modules/@oisin-ee/pi-todo/src/gate.ts +79 -0
  28. package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core.d.ts +342 -5
  29. package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core.js +222 -5
  30. package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core_bg.wasm +0 -0
  31. package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core_bg.wasm.d.ts +8 -1
  32. package/node_modules/@oisin-ee/pi-todo/src/guidance.ts +5 -1
  33. package/node_modules/@oisin-ee/pi-todo/src/handoff.ts +84 -0
  34. package/node_modules/@oisin-ee/pi-todo/src/import-fixture.ts +63 -0
  35. package/node_modules/@oisin-ee/pi-todo/src/index.ts +17 -43
  36. package/node_modules/@oisin-ee/pi-todo/src/lease-fixture.ts +95 -0
  37. package/node_modules/@oisin-ee/pi-todo/src/provider-fixture.ts +50 -0
  38. package/node_modules/@oisin-ee/pi-todo/src/provider.ts +289 -0
  39. package/node_modules/@oisin-ee/pi-todo/src/registration-fixture.ts +126 -0
  40. package/node_modules/@oisin-ee/pi-todo/src/repository.ts +31 -0
  41. package/node_modules/@oisin-ee/pi-todo/src/runtime-fixture.ts +209 -0
  42. package/node_modules/@oisin-ee/pi-todo/src/schema.ts +86 -0
  43. package/node_modules/@oisin-ee/pi-todo/src/seed-fixture.ts +35 -0
  44. package/node_modules/@oisin-ee/pi-todo/src/seed-transport.ts +52 -0
  45. package/node_modules/@oisin-ee/pi-todo/src/source.ts +33 -3
  46. package/node_modules/@oisin-ee/pi-todo/src/state.ts +78 -1
  47. package/node_modules/@oisin-ee/pi-todo/src/tool.ts +15 -14
  48. package/node_modules/@oisin-ee/pi-todo/src/wasm.ts +26 -0
  49. package/package.json +8 -5
@@ -0,0 +1,258 @@
1
+ import type { Change, Health } from "./health.ts";
2
+ import { RETIRED_MS, STALE_MS, parseOwner, retiredName, sameOwner } from "./lock.ts";
3
+ import { emptyHealth, parseHealth, updateHealth } from "./health.ts";
4
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import type { Owner } from "./lock.ts";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import { setTimeout } from "node:timers/promises";
10
+
11
+ const ZERO = 0,
12
+ LOCK_WAIT = 25,
13
+ IO_TIMEOUT = 2000,
14
+ /**
15
+ * Recognizes a specific filesystem error without suppressing other failures.
16
+ * @param {unknown} error Failure.
17
+ * @param {string} code Expected code.
18
+ * @returns {boolean} Match.
19
+ */
20
+ hasCode = (error: unknown, code: string): boolean =>
21
+ error instanceof Error && "code" in error && error.code === code,
22
+ /**
23
+ * Reads optional JSON source.
24
+ * @param {string} file Path.
25
+ * @returns {Promise<string | undefined>} Source, absent only for missing files.
26
+ */
27
+ readOptional = async (file: string): Promise<string | undefined> => {
28
+ try {
29
+ return await readFile(file, "utf8");
30
+ } catch (error) {
31
+ if (hasCode(error, "ENOENT")) {
32
+ return undefined;
33
+ }
34
+ throw error;
35
+ }
36
+ },
37
+ /**
38
+ * Sleeps without retaining an abort listener after completion.
39
+ * @param {number} delay Milliseconds.
40
+ * @param {AbortSignal | undefined} signal Cancellation.
41
+ * @returns {Promise<void>} Completion.
42
+ */
43
+ wait = async (delay: number, signal?: AbortSignal): Promise<void> => {
44
+ await setTimeout(Math.max(ZERO, delay), undefined, { signal });
45
+ },
46
+ /**
47
+ * Probes process existence without treating permission failures as death.
48
+ * @param {number} pid Recorded process.
49
+ * @returns {boolean} Whether the process is gone.
50
+ */
51
+ dead = (pid: number): boolean => {
52
+ try {
53
+ process.kill(pid, ZERO);
54
+ return false;
55
+ } catch (error) {
56
+ return hasCode(error, "ESRCH");
57
+ }
58
+ },
59
+ /**
60
+ * Reads a complete generation, ignoring missing metadata during creation.
61
+ * @param {string} directory Lock directory.
62
+ * @returns {Promise<Owner | undefined>} Owner.
63
+ */
64
+ ownerAt = async (directory: string): Promise<Owner | undefined> => {
65
+ const source = await readOptional(path.join(directory, "owner.json"));
66
+ return source === undefined ? undefined : parseOwner(source);
67
+ },
68
+ /**
69
+ * Gives an abandoned empty directory stable metadata and a nonempty tombstone.
70
+ * @param {string} directory Lock directory.
71
+ * @returns {Promise<void>} Completion.
72
+ */
73
+ identifyAbandoned = async (directory: string): Promise<void> => {
74
+ try {
75
+ const details = await stat(directory);
76
+ if (Date.now() - details.mtimeMs > STALE_MS) {
77
+ await writeFile(
78
+ path.join(directory, "owner.json"),
79
+ JSON.stringify({ created_ms: Math.floor(details.mtimeMs), pid: process.pid }),
80
+ { flag: "wx" },
81
+ );
82
+ }
83
+ } catch (error) {
84
+ if (!hasCode(error, "ENOENT") && !hasCode(error, "EEXIST")) {
85
+ throw error;
86
+ }
87
+ }
88
+ },
89
+ /**
90
+ * Finds a stale generation, including interrupted directory creation.
91
+ * @param {string} directory Lock directory.
92
+ * @returns {Promise<Owner | undefined>} Stale owner.
93
+ */
94
+ staleOwner = async (directory: string): Promise<Owner | undefined> => {
95
+ const owner = await ownerAt(directory);
96
+ if (owner === undefined) {
97
+ await identifyAbandoned(directory);
98
+ return undefined;
99
+ }
100
+ return Date.now() - owner.created_ms > STALE_MS || dead(owner.pid) ? owner : undefined;
101
+ },
102
+ /**
103
+ * Retires only a stale, unchanged generation to its unique nonempty directory.
104
+ * @param {string} directory Lock directory.
105
+ * @returns {Promise<boolean>} Whether this waiter won retirement.
106
+ */
107
+ retire = async (directory: string): Promise<boolean> => {
108
+ const owner = await staleOwner(directory);
109
+ if (owner === undefined || !sameOwner(await ownerAt(directory), owner)) {
110
+ return false;
111
+ }
112
+ try {
113
+ await rename(directory, path.join(path.dirname(directory), retiredName(owner)));
114
+ return true;
115
+ } catch (error) {
116
+ if (hasCode(error, "ENOENT") || hasCode(error, "ENOTEMPTY") || hasCode(error, "EEXIST")) {
117
+ return false;
118
+ }
119
+ throw error;
120
+ }
121
+ },
122
+ /**
123
+ * Removes only retired directories outside the contention retention window.
124
+ * @param {string} directory State directory.
125
+ * @returns {Promise<void>} Completion.
126
+ */
127
+ cleanRetired = async (directory: string): Promise<void> => {
128
+ const entries = await readdir(directory, { withFileTypes: true });
129
+ await Promise.all(
130
+ entries
131
+ .filter(
132
+ (entry: Readonly<{ name: string; isDirectory: () => boolean }>) =>
133
+ entry.isDirectory() && /^retired-\d+-\d+$/u.test(entry.name),
134
+ )
135
+ .map(async (entry: Readonly<{ name: string }>) => {
136
+ const retired = path.join(directory, entry.name);
137
+ try {
138
+ const details = await stat(retired);
139
+ if (Date.now() - details.ctimeMs > RETIRED_MS) {
140
+ await rm(retired, { force: true, recursive: true });
141
+ }
142
+ } catch (error) {
143
+ if (!hasCode(error, "ENOENT")) {
144
+ throw error;
145
+ }
146
+ }
147
+ }),
148
+ );
149
+ },
150
+ /**
151
+ * Publishes complete owner metadata after exclusive directory creation.
152
+ * @param {string} directory Acquired directory.
153
+ * @returns {Promise<Owner>} Published generation.
154
+ */
155
+ publishOwner = async (directory: string): Promise<Owner> => {
156
+ const owner = { created_ms: Date.now(), pid: process.pid };
157
+ await writeFile(path.join(directory, "owner.tmp"), JSON.stringify(owner), { flag: "wx" });
158
+ await rename(path.join(directory, "owner.tmp"), path.join(directory, "owner.json"));
159
+ await cleanRetired(path.dirname(directory));
160
+ return owner;
161
+ },
162
+ /**
163
+ * Acquires a recoverable directory lock with a bounded wait.
164
+ * @param {string} directory Lock directory.
165
+ * @param {number} deadline Deadline.
166
+ * @returns {Promise<Owner>} Acquired generation.
167
+ */
168
+ acquire = async (directory: string, deadline: number): Promise<Owner> => {
169
+ try {
170
+ await mkdir(directory);
171
+ } catch (error) {
172
+ if (!hasCode(error, "EEXIST") || Date.now() >= deadline) {
173
+ throw error;
174
+ }
175
+ await retire(directory);
176
+ await wait(LOCK_WAIT);
177
+ return acquire(directory, deadline);
178
+ }
179
+ return publishOwner(directory);
180
+ },
181
+ /**
182
+ * Releases only the generation this transaction acquired.
183
+ * @param {string} directory Lock directory.
184
+ * @param {Owner} owner Acquired generation.
185
+ * @returns {Promise<void>} Completion.
186
+ */
187
+ release = async (directory: string, owner: Owner): Promise<void> => {
188
+ if (sameOwner(await ownerAt(directory), owner)) {
189
+ await rm(directory, { recursive: true });
190
+ }
191
+ };
192
+
193
+ /** Atomic host-local circuit and cooldown persistence. */
194
+ class Store {
195
+ readonly #directory: string;
196
+ readonly #file: string;
197
+
198
+ /**
199
+ * Selects the agent state directory.
200
+ * @param {string} directory State root.
201
+ */
202
+ public constructor(directory = path.join(getAgentDir(), "state", "pi-tiers")) {
203
+ this.#directory = directory;
204
+ this.#file = path.join(directory, "state.json");
205
+ }
206
+
207
+ /**
208
+ * Reads an atomic state snapshot.
209
+ * @returns {Promise<Health>} Current shared health.
210
+ */
211
+ public async read(): Promise<Health> {
212
+ const source = await readOptional(this.#file);
213
+ return source === undefined ? emptyHealth() : parseHealth(source);
214
+ }
215
+
216
+ /**
217
+ * Writes and syncs a temporary snapshot before atomic replacement.
218
+ * @param {Health} health New state.
219
+ * @returns {Promise<void>} Durable replacement.
220
+ */
221
+ async #write(health: Health): Promise<void> {
222
+ const temporary = path.join(this.#directory, `${crypto.randomUUID()}.tmp`),
223
+ file = await open(temporary, "wx");
224
+ try {
225
+ await file.writeFile(JSON.stringify(health));
226
+ await file.sync();
227
+ await rename(temporary, this.#file);
228
+ } finally {
229
+ await file.close();
230
+ await rm(temporary, { force: true });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Holds the lock across the entire read-modify-rename transaction.
236
+ * @param {Change} change Failure to record.
237
+ * @returns {Promise<void>} Durable completion.
238
+ */
239
+ public async change(change: Change): Promise<void> {
240
+ await mkdir(this.#directory, { recursive: true });
241
+ const lockPath = path.join(this.#directory, "lock"),
242
+ lock = await acquire(lockPath, Date.now() + IO_TIMEOUT);
243
+ try {
244
+ await this.#write(updateHealth(await this.read(), change));
245
+ } finally {
246
+ await release(lockPath, lock);
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Reads the user-owned tier map.
253
+ * @returns {Promise<string | undefined>} Config source.
254
+ */
255
+ const readConfig = async (): Promise<string | undefined> =>
256
+ readOptional(path.join(getAgentDir(), "tiers.json"));
257
+
258
+ export { acquire, readConfig, release, retire, Store, wait };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "description": "A todo tool for pi with a Rust core: one active task, state on the session branch.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -0,0 +1,103 @@
1
+ import type { TodoParams, TodoSession } from "./tool.ts";
2
+ import { STATE_ENTRY } from "./state.ts";
3
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import type { TodoList } from "./wasm.ts";
5
+ import { runTodo } from "./tool.ts";
6
+
7
+ interface Fixture {
8
+ readonly entries: SessionEntry[];
9
+ readonly session: TodoSession;
10
+ readonly call: (params: TodoParams) => TodoList;
11
+ }
12
+
13
+ /**
14
+ * Creates a branch-backed caller using the real tool and WASM core.
15
+ * @param {readonly unknown[]} snapshots Existing stored snapshots.
16
+ * @returns {Fixture} The tool caller and Pi append seam.
17
+ */
18
+ const first = 0,
19
+ second = 1,
20
+ third = 2,
21
+ last = -1,
22
+ fixture = (snapshots: readonly unknown[] = []): Fixture => {
23
+ const entries = snapshots.map((data): SessionEntry => ({
24
+ customType: STATE_ENTRY,
25
+ data,
26
+ id: crypto.randomUUID(),
27
+ parentId: null,
28
+ timestamp: "2026-10-20T00:00:00Z",
29
+ type: "custom",
30
+ })),
31
+ session: TodoSession = {
32
+ append: (customType, data): void => {
33
+ entries.push({
34
+ customType,
35
+ data,
36
+ id: crypto.randomUUID(),
37
+ parentId: entries.at(last)?.id ?? null,
38
+ timestamp: "2026-10-20T00:00:00Z",
39
+ type: "custom",
40
+ });
41
+ },
42
+ branch: () => entries,
43
+ sessionId: "test-session",
44
+ };
45
+ return { call: (params) => runTodo(params, session).details, entries, session };
46
+ },
47
+ /**
48
+ * A complete self-assigned contract for deterministic behavior tests.
49
+ * @returns {NonNullable<TodoParams["contract"]>} Authored contract fields.
50
+ */
51
+ contract = (): NonNullable<TodoParams["contract"]> => ({
52
+ acceptance: {
53
+ argv: ["moon", "run", "todo-core:test"],
54
+ cwd: ".",
55
+ expected: "passes",
56
+ kind: "command",
57
+ },
58
+ assignment: { kind: "self" },
59
+ deliverable: "Executable task contracts",
60
+ dependsOn: [],
61
+ scope: { generates: [], owns: ["src/"], reads: ["README.md"], repositoryId: "oisinpi" },
62
+ }),
63
+ /**
64
+ * Returns the runtime ID from the first root in a tool result.
65
+ * @param {TodoList} list Real tool result.
66
+ * @returns {string} Runtime ID.
67
+ */
68
+ firstId = (list: TodoList): string => {
69
+ const id = list.tasks.at(first)?.unit?.reference.unitId;
70
+ if (id === undefined) {
71
+ throw new Error("Expected a runtime unit ID");
72
+ }
73
+ return id;
74
+ },
75
+ /**
76
+ * Requires a root identity at a known fixture position.
77
+ * @param {TodoList} list Tool result.
78
+ * @param {number} index Root position.
79
+ * @returns {string} Runtime ID.
80
+ */
81
+ rootId = (list: TodoList, index: number): string => {
82
+ const id = list.tasks.at(index)?.unit?.reference.unitId;
83
+ if (id === undefined) {
84
+ throw new Error("Expected root identity");
85
+ }
86
+ return id;
87
+ },
88
+ /**
89
+ * Requires a child identity from the first outcome.
90
+ * @param {TodoList} list Tool result.
91
+ * @param {number} index Child position.
92
+ * @returns {string} Runtime ID.
93
+ */
94
+ childId = (list: TodoList, index: number): string => {
95
+ const id = list.tasks.at(first)?.subtasks?.at(index)?.unit?.reference.unitId;
96
+ if (id === undefined) {
97
+ throw new Error("Expected child identity");
98
+ }
99
+ return id;
100
+ };
101
+
102
+ export { childId, contract, first, firstId, fixture, last, rootId, second, third };
103
+ export type { Fixture };
@@ -41,8 +41,14 @@ const emptyCount = 0,
41
41
  active = list.tasks.flatMap((task) => [
42
42
  ...(task.status === "in_progress" ? [task.content] : []),
43
43
  ...(task.subtasks ?? [])
44
- .filter((child: Readonly<Subtask>) => child.status === "in_progress")
45
- .map((child: Readonly<Subtask>) => `${task.content} → ${child.content}`),
44
+ .filter(
45
+ (child: Readonly<Pick<Subtask, "content" | "status">>) =>
46
+ child.status === "in_progress",
47
+ )
48
+ .map(
49
+ (child: Readonly<Pick<Subtask, "content" | "status">>) =>
50
+ `${task.content} → ${child.content}`,
51
+ ),
46
52
  ]),
47
53
  current =
48
54
  active.length > emptyCount
@@ -0,0 +1,79 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskProvider } from "./provider.ts";
3
+ import { ownsTodo } from "./lifecycle.ts";
4
+
5
+ /** Exact structured reads and workflow controls, never command-text inference. */
6
+ const controls = new Set([
7
+ "ask",
8
+ "codemode",
9
+ "interview",
10
+ "todo",
11
+ "tool_search",
12
+ "read",
13
+ "ls",
14
+ "find",
15
+ "grep",
16
+ "hypa_read",
17
+ "hypa_ls",
18
+ "hypa_find",
19
+ "hypa_grep",
20
+ "web_search",
21
+ "web_fetch",
22
+ "lsp_diagnostics",
23
+ "ctx_search",
24
+ "ctx_stats",
25
+ "mcp__codebase_memory__search_graph",
26
+ "mcp__codebase_memory__get_code_snippet",
27
+ "mcp__codebase_memory__trace_path",
28
+ "mcp__codebase_memory__detect_changes",
29
+ "hindsight_search_knowledge_pages",
30
+ "hindsight_list_knowledge_pages",
31
+ "hindsight_read_knowledge_page",
32
+ "hindsight_reflect",
33
+ "hindsight_sync_status",
34
+ "hindsight_diagnose",
35
+ "hindsight_capture_initiative",
36
+ "mcp__dogsled__spawn",
37
+ "mcp__dogsled__send",
38
+ "mcp__dogsled__cancel",
39
+ "mcp__dogsled__close",
40
+ "mcp__dogsled__stop",
41
+ "mcp__dogsled__read",
42
+ "mcp__dogsled__status",
43
+ "mcp__dogsled__inbox",
44
+ ]),
45
+ /**
46
+ * Checks actual calls and leases execution-bearing calls.
47
+ * @param {Readonly<ExtensionAPI>} pi Runtime hooks and metadata.
48
+ * @param {Readonly<TaskProvider>} provider Task state and leases.
49
+ */
50
+ registerGate = (
51
+ pi: Readonly<Pick<ExtensionAPI, "on" | "getAllTools">>,
52
+ provider: Readonly<TaskProvider>,
53
+ ): void => {
54
+ pi.on("tool_call", (event: Readonly<{ toolName: string; toolCallId: string }>, ctx) => {
55
+ if (!ownsTodo(pi)) {
56
+ return { block: false };
57
+ }
58
+ provider.context(ctx);
59
+ if (controls.has(event.toolName)) {
60
+ return { block: false };
61
+ }
62
+ const active = provider.capability().active();
63
+ if (!active.ok || active.value === null) {
64
+ return {
65
+ block: true,
66
+ reason: active.ok
67
+ ? "Start a complete self-assigned leaf with todo before shell, code, mutation, or unknown tools."
68
+ : active.error.message,
69
+ };
70
+ }
71
+ provider.lease(event.toolCallId, active.value.ref);
72
+ return { block: false };
73
+ });
74
+ pi.on("tool_execution_end", (event: Readonly<{ toolCallId: string }>) => {
75
+ provider.release(event.toolCallId);
76
+ });
77
+ };
78
+
79
+ export { registerGate };