@0xgeorgemathew/zcode 0.4.1 → 0.5.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/lib/plan.js ADDED
@@ -0,0 +1,424 @@
1
+ 'use strict';
2
+ // zcode plan: a DAG of task sessions from one plan file, run by a supervisor.
3
+ //
4
+ // The plan file ({cwd?, decisions?, context?, files?, model?, effort?,
5
+ // sessions:[{id, after?, ...contract keys}]}) is validated up front
6
+ // (checkPlan), then a supervisor launches each session as a detached zcode
7
+ // session once everything it is `after` has completed. The sessions are the
8
+ // source of truth: a plan's status is derived from their records (plus
9
+ // "never started" for sessions that were not launched), so it stays accurate
10
+ // when the supervisor dies. <root>/plans/<name>.json only remembers the plan
11
+ // file, the supervisor pid and launches that failed before a session existed.
12
+ //
13
+ // No process.exit in here: lib/commands/plan.js owns the CLI.
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const { spawn } = require('node:child_process');
17
+ const store = require('./store.js');
18
+ const { buildContract } = require('./taskspec.js');
19
+ const { ContractError } = require('./contract.js');
20
+ const { workspaceRoot, normalizeOwns, ownsOverlap, OwnershipError } = require('./workspace.js');
21
+ const { normalizeModel, normalizeEffort, effortProblem, unknownModelMessage, DEFAULT_MODEL } = require('./models.js');
22
+ const { buildBrief } = require('./brief.js');
23
+
24
+ const BIN = path.join(__dirname, '..', 'bin', 'zcode.js');
25
+ const PLAN_KEYS = ['cwd', 'decisions', 'context', 'files', 'model', 'effort', 'sessions'];
26
+ const POLL_MS = 500;
27
+
28
+ class PlanError extends Error {}
29
+
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ const toArray = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
32
+
33
+ // Reads and parses the plan file. `cwdOverride` is an explicit --cwd; else the
34
+ // plan's "cwd" (relative to the plan file); else the process cwd.
35
+ function resolvePlan(file, cwdOverride) {
36
+ const abs = path.resolve(file);
37
+ let text;
38
+ try {
39
+ text = fs.readFileSync(abs, 'utf8');
40
+ } catch {
41
+ throw new PlanError(`plan file "${file}" could not be read.`);
42
+ }
43
+ let plan;
44
+ try {
45
+ plan = JSON.parse(text);
46
+ } catch (err) {
47
+ throw new PlanError(`plan file "${file}" is not valid JSON: ${err.message}`);
48
+ }
49
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) throw new PlanError('plan must be a JSON object.');
50
+ const dir = path.dirname(abs);
51
+ const cwd = cwdOverride ? path.resolve(cwdOverride) : plan.cwd ? path.resolve(dir, String(plan.cwd)) : process.cwd();
52
+ return { file: abs, dir, cwd, plan };
53
+ }
54
+
55
+ // Plan name: --id, else the plan file's basename without extension.
56
+ function planName(id, file) {
57
+ const name = id || path.basename(file, path.extname(file));
58
+ if (!store.validId(name)) {
59
+ throw new PlanError(`plan name "${name}" is not a valid id (letters, digits, ".", "_", "-"); pass --id.`);
60
+ }
61
+ return name;
62
+ }
63
+
64
+ const sessionList = (plan) => (Array.isArray(plan.sessions) ? plan.sessions.filter((s) => s && typeof s === 'object') : []);
65
+ const afterOf = (s) => toArray(s.after).map(String);
66
+
67
+ // The session's contract object: top-level decisions/context/files first,
68
+ // the plan's model/effort as defaults. `id` and `after` are plan keys, not
69
+ // contract keys.
70
+ function sessionBase(r, s) {
71
+ const { id, after, ...rest } = s;
72
+ const top = r.plan;
73
+ const base = { ...rest };
74
+ for (const key of ['decisions', 'context', 'files']) {
75
+ const merged = [...toArray(top[key]), ...toArray(rest[key])];
76
+ if (merged.length) base[key] = merged;
77
+ }
78
+ for (const key of ['model', 'effort']) {
79
+ if (base[key] === undefined && top[key] !== undefined) base[key] = top[key];
80
+ }
81
+ return base;
82
+ }
83
+
84
+ // Every problem with the plan, one string each ([] = valid). `state` is the
85
+ // stored plan state (sessions it already owns are not "taken").
86
+ function checkPlan(r, { name, state } = {}) {
87
+ const problems = [];
88
+ const { plan } = r;
89
+ const unknown = Object.keys(plan).filter((k) => !PLAN_KEYS.includes(k));
90
+ if (unknown.length) problems.push(`unknown plan key(s): ${unknown.join(', ')}. Accepted: ${PLAN_KEYS.join(', ')}`);
91
+ if (!Array.isArray(plan.sessions) || plan.sessions.length === 0) {
92
+ problems.push('plan needs a non-empty "sessions" array.');
93
+ return problems;
94
+ }
95
+ try {
96
+ if (!fs.statSync(r.cwd).isDirectory()) throw new Error('not a directory');
97
+ } catch {
98
+ problems.push(`cwd "${r.cwd}" is not an existing directory.`);
99
+ return problems;
100
+ }
101
+
102
+ const sessions = plan.sessions;
103
+ const ids = [];
104
+ sessions.forEach((s, i) => {
105
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
106
+ problems.push(`sessions[${i}] must be an object.`);
107
+ return;
108
+ }
109
+ const label = typeof s.id === 'string' ? `session "${s.id}"` : `sessions[${i}]`;
110
+ if (!store.validId(s.id)) {
111
+ problems.push(`${label}: "id" must be 1-64 chars of letters, digits, ".", "_" or "-", starting with a letter or digit.`);
112
+ } else if (s.id.length + 4 > 64) {
113
+ problems.push(`${label}: id is too long (the preflight session "${s.id}.pre" must fit 64 chars).`);
114
+ } else if (ids.includes(s.id)) {
115
+ problems.push(`duplicate session id "${s.id}".`);
116
+ }
117
+ if (typeof s.id === 'string') ids.push(s.id);
118
+ if (s.after !== undefined && !toArray(s.after).every((a) => typeof a === 'string')) {
119
+ problems.push(`${label}: "after" must be a list of session ids.`);
120
+ }
121
+ });
122
+ const valid = sessionList(plan).filter((s) => typeof s.id === 'string');
123
+
124
+ // after refs and cycles
125
+ const known = new Set(valid.map((s) => s.id));
126
+ const deps = new Map(valid.map((s) => [s.id, afterOf(s)]));
127
+ for (const s of valid) {
128
+ for (const a of afterOf(s)) {
129
+ if (a === s.id) problems.push(`session "${s.id}" is after itself.`);
130
+ else if (!known.has(a)) problems.push(`session "${s.id}": after "${a}" is not a session in this plan.`);
131
+ }
132
+ }
133
+ let cyclic = false;
134
+ const mark = new Map();
135
+ const walk = (id, trail) => {
136
+ if (mark.get(id) === 2) return;
137
+ if (mark.get(id) === 1) {
138
+ cyclic = true;
139
+ problems.push(`dependency cycle: ${[...trail.slice(trail.indexOf(id)), id].join(' -> ')}`);
140
+ return;
141
+ }
142
+ mark.set(id, 1);
143
+ for (const a of deps.get(id) || []) if (known.has(a) && a !== id) walk(a, [...trail, id]);
144
+ mark.set(id, 2);
145
+ };
146
+ for (const id of known) if (!cyclic) walk(id, []);
147
+
148
+ // contracts, model/effort, ownership
149
+ const contracts = new Map();
150
+ for (const s of valid) {
151
+ let contract;
152
+ try {
153
+ contract = buildContract({ base: sessionBase(r, s), baseDir: r.dir, cwd: r.cwd });
154
+ } catch (err) {
155
+ if (err instanceof ContractError) problems.push(`session "${s.id}": ${err.message}`);
156
+ else throw err;
157
+ continue;
158
+ }
159
+ const model = contract.model === null ? DEFAULT_MODEL : normalizeModel(contract.model);
160
+ if (!model) {
161
+ problems.push(`session "${s.id}": ${unknownModelMessage(contract.model)}`);
162
+ } else {
163
+ const effort = contract.effort === null ? 'max' : normalizeEffort(contract.effort);
164
+ if (!effort) problems.push(`session "${s.id}": contract effort "${contract.effort}" is not valid.`);
165
+ else {
166
+ const p = effortProblem(model, effort, { source: `session "${s.id}" effort` });
167
+ if (p) problems.push(p);
168
+ }
169
+ }
170
+ contracts.set(s.id, contract);
171
+ }
172
+
173
+ // Sessions that can run at the same time (neither is, transitively, after
174
+ // the other) must own disjoint paths.
175
+ if (!cyclic) {
176
+ const reach = new Map();
177
+ const closure = (id) => {
178
+ if (reach.has(id)) return reach.get(id);
179
+ const set = new Set();
180
+ reach.set(id, set);
181
+ for (const a of deps.get(id) || []) {
182
+ if (!known.has(a)) continue;
183
+ set.add(a);
184
+ for (const x of closure(a)) set.add(x);
185
+ }
186
+ return set;
187
+ };
188
+ for (const id of known) closure(id);
189
+ let root;
190
+ const owns = new Map();
191
+ for (const [id, c] of contracts) {
192
+ try {
193
+ root = root || workspaceRoot(r.cwd);
194
+ owns.set(id, normalizeOwns(c.owns, r.cwd, root));
195
+ } catch (err) {
196
+ if (err instanceof OwnershipError) problems.push(`session "${id}": ${err.message}`);
197
+ else throw err;
198
+ }
199
+ }
200
+ const order = [...owns.keys()];
201
+ for (let i = 0; i < order.length; i++) {
202
+ for (let j = i + 1; j < order.length; j++) {
203
+ const [a, b] = [order[i], order[j]];
204
+ if (reach.get(a).has(b) || reach.get(b).has(a)) continue;
205
+ if (ownsOverlap(owns.get(a), owns.get(b))) {
206
+ const show = (id) => (contracts.get(id).owns.length ? contracts.get(id).owns.join(', ') : 'whole workspace');
207
+ problems.push(`sessions "${a}" and "${b}" can run concurrently but their owns overlap (${show(a)} / ${show(b)}); add "after" or make owns disjoint.`);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ // Ids already taken by sessions that are not this plan's.
214
+ const mine = new Set(state && Array.isArray(state.sessions) ? state.sessions : []);
215
+ for (const id of known) {
216
+ if (store.readSession(id) && !mine.has(id)) {
217
+ problems.push(`session "${id}" already exists (zcode status ${id}); a plan needs new ids, or continue its own sessions with "zcode plan resume ${name || '<name>'}".`);
218
+ }
219
+ }
220
+ return problems;
221
+ }
222
+
223
+ // --- status, derived from the sessions ------------------------------------------
224
+
225
+ const LIVE = new Set(['queued', 'running']);
226
+ const FINAL_OK = 'completed';
227
+
228
+ function launchFailure(id, message) {
229
+ return {
230
+ type: 'result', ok: false, status: 'failed', id, kind: 'task',
231
+ error: { kind: 'launch', message }, issues: [message],
232
+ };
233
+ }
234
+
235
+ // {id: {status, view?}} for every session of the plan. A session that was
236
+ // never started is `pending`, or `skipped` when something it (transitively)
237
+ // depends on ended without completing.
238
+ function sessionStatuses(r, state) {
239
+ const out = new Map();
240
+ const sessions = sessionList(r.plan);
241
+ const byId = new Map(sessions.map((s) => [s.id, s]));
242
+ const resolve = (id) => {
243
+ if (out.has(id)) return out.get(id);
244
+ const s = byId.get(id);
245
+ const record = store.readSession(id);
246
+ let entry;
247
+ if (record) {
248
+ const view = store.viewSession(record);
249
+ if (LIVE.has(view.state)) entry = { status: view.state, view };
250
+ else if (view.state === 'interrupted') entry = { status: 'interrupted', view };
251
+ else entry = { status: view.status || 'failed', view };
252
+ } else if (state && state.launchErrors && state.launchErrors[id]) {
253
+ entry = { status: 'failed', launchError: state.launchErrors[id] };
254
+ } else {
255
+ const upstream = afterOf(s).filter((a) => byId.has(a)).map(resolve);
256
+ const dead = upstream.some((u) => !['pending', 'queued', 'running', FINAL_OK].includes(u.status));
257
+ entry = { status: dead ? 'skipped' : 'pending' };
258
+ }
259
+ out.set(id, entry);
260
+ return entry;
261
+ };
262
+ for (const s of sessions) resolve(s.id);
263
+ return out;
264
+ }
265
+
266
+ function aggregate(statuses, supervisorAlive) {
267
+ const all = [...statuses.values()].map((e) => e.status);
268
+ if (all.some((s) => LIVE.has(s))) return 'running';
269
+ if (all.includes('pending')) return supervisorAlive ? 'running' : 'interrupted';
270
+ if (all.every((s) => s === FINAL_OK)) return 'completed';
271
+ if (all.includes('blocked')) return 'blocked';
272
+ if (all.some((s) => ['failed', 'cancelled', 'interrupted'].includes(s))) return 'failed';
273
+ return 'partial';
274
+ }
275
+
276
+ const exitCodeForPlan = (status) => (status === 'completed' ? 0 : status === 'blocked' ? 10 : status === 'running' ? 124 : 8);
277
+
278
+ function supervisorAlive(state) {
279
+ return Boolean(state && state.supervisor && store.pidAlive(state.supervisor.pid));
280
+ }
281
+
282
+ // {type:"plan", name, status, sessions:[{id,status}], briefs:[…]}: briefs of
283
+ // sessions that ended without completing (never for pending/skipped).
284
+ function planReport(name, r, state, { briefs = true } = {}) {
285
+ const { finalResult } = require('./commands/sessions.js');
286
+ const statuses = sessionStatuses(r, state);
287
+ const status = aggregate(statuses, supervisorAlive(state));
288
+ const payload = {
289
+ type: 'plan',
290
+ name,
291
+ status,
292
+ sessions: sessionList(r.plan).map((s) => ({ id: s.id, status: statuses.get(s.id).status })),
293
+ };
294
+ if (briefs) {
295
+ payload.briefs = [];
296
+ for (const s of sessionList(r.plan)) {
297
+ const e = statuses.get(s.id);
298
+ if (e.status === FINAL_OK) continue;
299
+ if (e.launchError) payload.briefs.push(buildBrief(launchFailure(s.id, e.launchError)));
300
+ else if (e.view && !LIVE.has(e.view.state)) payload.briefs.push(buildBrief(finalResult(e.view)));
301
+ }
302
+ }
303
+ return payload;
304
+ }
305
+
306
+ // --- launching --------------------------------------------------------------------
307
+
308
+ function exec() {
309
+ return require('./commands/exec.js');
310
+ }
311
+
312
+ // The detached-start invocation for one plan session (id overrides the plan
313
+ // id: preflight uses "<id>.pre").
314
+ function sessionInvocation(r, name, s, { id = s.id, preflight = false } = {}) {
315
+ const { parseArgv } = require('../bin/zcode.js');
316
+ const argv = ['task', '--id', id, '--cwd', r.cwd, '--label', `plan:${name}`];
317
+ if (preflight) argv.push('--preflight');
318
+ return exec().buildInvocation(parseArgv(argv), { base: sessionBase(r, s), dir: r.dir });
319
+ }
320
+
321
+ function launchSession(r, name, s) {
322
+ const e = exec();
323
+ try {
324
+ e.startDetached(sessionInvocation(r, name, s));
325
+ return null;
326
+ } catch (err) {
327
+ if (err instanceof store.SessionExistsError) return null; // already started
328
+ if (err instanceof e.UsageError || err instanceof ContractError || err.exitCode) return err.message;
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ // Resume one session of the plan (detached). Returns null or the refusal.
334
+ function resumeSession(id, message) {
335
+ const { parseArgv } = require('../bin/zcode.js');
336
+ const e = exec();
337
+ try {
338
+ e.startDetached(e.buildResume(parseArgv(['resume', id]), id, message));
339
+ return null;
340
+ } catch (err) {
341
+ if (err instanceof e.UsageError || err.exitCode) return err.message;
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ // --- supervisor ---------------------------------------------------------------------
347
+
348
+ // Launches ready sessions until nothing is running or launchable. One
349
+ // supervisor per plan (pid lock). Returns {done:true} | {timedOut:true} |
350
+ // {busy:true} (another supervisor holds the plan).
351
+ async function supervise(name, { deadline = Infinity, signal } = {}) {
352
+ const release = store.claimPlan(name);
353
+ if (!release) return { busy: true };
354
+ try {
355
+ store.updatePlanState(name, { supervisor: { pid: process.pid } });
356
+ for (;;) {
357
+ const state = store.readPlanState(name);
358
+ const r = resolvePlan(state.file, state.cwd);
359
+ const sessions = sessionList(r.plan);
360
+ const byId = new Map(sessions.map((s) => [s.id, s]));
361
+ const statuses = sessionStatuses(r, state);
362
+ for (const s of sessions) {
363
+ if (statuses.get(s.id).status !== 'pending') continue;
364
+ if (!afterOf(s).every((a) => statuses.get(a) && statuses.get(a).status === FINAL_OK)) continue;
365
+ const failure = launchSession(r, name, byId.get(s.id));
366
+ if (failure) {
367
+ store.updatePlanState(name, (cur) => ({ launchErrors: { ...(cur.launchErrors || {}), [s.id]: failure } }));
368
+ }
369
+ }
370
+ const now = sessionStatuses(r, store.readPlanState(name));
371
+ const active = [...now.values()].some((e) => LIVE.has(e.status) || e.status === 'pending');
372
+ if (!active) return { done: true };
373
+ if (signal && signal.aborted) return { done: true };
374
+ if (Date.now() >= deadline) return { timedOut: true };
375
+ await sleep(POLL_MS);
376
+ }
377
+ } finally {
378
+ try {
379
+ store.updatePlanState(name, { supervisor: null });
380
+ } catch {
381
+ /* plan state removed */
382
+ }
383
+ release();
384
+ }
385
+ }
386
+
387
+ // A detached supervisor process (`zcode __plan <name>`); records its pid.
388
+ function startSupervisor(name) {
389
+ const dir = store.plansDir();
390
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
391
+ const log = fs.openSync(path.join(dir, `${name}.log`), 'a', 0o600);
392
+ const child = spawn(process.execPath, [BIN, '__plan', name], {
393
+ detached: true,
394
+ stdio: ['ignore', log, log],
395
+ env: process.env,
396
+ });
397
+ child.unref();
398
+ fs.closeSync(log);
399
+ store.updatePlanState(name, { supervisor: { pid: child.pid } });
400
+ return child.pid;
401
+ }
402
+
403
+ module.exports = {
404
+ PlanError,
405
+ resolvePlan,
406
+ planName,
407
+ sessionList,
408
+ afterOf,
409
+ sessionBase,
410
+ checkPlan,
411
+ sessionStatuses,
412
+ aggregate,
413
+ exitCodeForPlan,
414
+ supervisorAlive,
415
+ planReport,
416
+ launchFailure,
417
+ sessionInvocation,
418
+ launchSession,
419
+ resumeSession,
420
+ supervise,
421
+ startSupervisor,
422
+ sleep,
423
+ POLL_MS,
424
+ };
package/lib/ports.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ // Per-session TCP port reservation. Parallel sessions that each start a dev
3
+ // or preview server must not fight over one port, so every session gets its
4
+ // own ($ZCODE_PORT): a lock file per port under <root>/ports/ (pid lock, so a
5
+ // dead session's port is reclaimed) plus a real bind probe, because the port
6
+ // may also be held by a process zcode knows nothing about.
7
+ const fs = require('node:fs');
8
+ const net = require('node:net');
9
+ const path = require('node:path');
10
+ const { rootDir } = require('./isolation.js');
11
+ const { claimLockFile } = require('./store.js');
12
+
13
+ const PORT_MIN = 41000;
14
+ const PORT_MAX = 48999;
15
+
16
+ // Resolves true when nothing is listening on `port` (all interfaces).
17
+ function portFree(port) {
18
+ return new Promise((resolve) => {
19
+ const server = net.createServer();
20
+ server.once('error', () => resolve(false));
21
+ server.listen({ port, exclusive: true }, () => server.close(() => resolve(true)));
22
+ });
23
+ }
24
+
25
+ // Reserves a free port for this process; `preferred` (a resumed session's
26
+ // previous port) is tried first. Returns {port, release}, or null when the
27
+ // whole range is taken.
28
+ async function reservePort({ env = process.env, preferred = null } = {}) {
29
+ const dir = path.join(rootDir(env), 'ports');
30
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ const span = PORT_MAX - PORT_MIN + 1;
32
+ const start = Math.floor(Math.random() * span);
33
+ const candidates = [];
34
+ if (Number.isInteger(preferred) && preferred >= PORT_MIN && preferred <= PORT_MAX) candidates.push(preferred);
35
+ for (let i = 0; i < span; i++) candidates.push(PORT_MIN + ((start + i) % span));
36
+ for (const port of candidates) {
37
+ const release = claimLockFile(path.join(dir, `${port}.lock`));
38
+ if (!release) continue;
39
+ if (await portFree(port)) return { port, release };
40
+ release();
41
+ }
42
+ return null;
43
+ }
44
+
45
+ module.exports = { reservePort, portFree, PORT_MIN, PORT_MAX };
package/lib/skills.js ADDED
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ // The orchestrator skills shipped in the package, and where `zcode skills`
3
+ // installs them:
4
+ //
5
+ // skills/claude-code/zcode -> ~/.claude/skills/zcode
6
+ // skills/codex/zcode -> ${CODEX_HOME:-~/.codex}/skills/zcode
7
+ //
8
+ // Only the zcode skill directory is ever replaced or removed.
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+
13
+ const pkgRoot = path.join(__dirname, '..');
14
+ const PLATFORMS = ['claude', 'codex'];
15
+
16
+ function targets(env = process.env) {
17
+ const home = env.HOME || os.homedir();
18
+ return [
19
+ {
20
+ platform: 'claude',
21
+ name: 'Claude Code',
22
+ from: path.join(pkgRoot, 'skills', 'claude-code', 'zcode'),
23
+ to: path.join(home, '.claude', 'skills', 'zcode'),
24
+ },
25
+ {
26
+ platform: 'codex',
27
+ name: 'Codex',
28
+ from: path.join(pkgRoot, 'skills', 'codex', 'zcode'),
29
+ to: path.join(env.CODEX_HOME || path.join(home, '.codex'), 'skills', 'zcode'),
30
+ },
31
+ ];
32
+ }
33
+
34
+ function select(platforms, env) {
35
+ const want = platforms && platforms.length ? new Set(platforms) : new Set(PLATFORMS);
36
+ return targets(env).filter((t) => want.has(t.platform));
37
+ }
38
+
39
+ function installSkills({ platforms, env = process.env } = {}) {
40
+ const done = [];
41
+ for (const t of select(platforms, env)) {
42
+ fs.mkdirSync(path.dirname(t.to), { recursive: true });
43
+ fs.rmSync(t.to, { recursive: true, force: true });
44
+ fs.cpSync(t.from, t.to, { recursive: true });
45
+ done.push(t);
46
+ }
47
+ return done;
48
+ }
49
+
50
+ function uninstallSkills({ platforms, env = process.env } = {}) {
51
+ const done = [];
52
+ for (const t of select(platforms, env)) {
53
+ if (!fs.existsSync(t.to)) continue;
54
+ fs.rmSync(t.to, { recursive: true, force: true });
55
+ done.push(t);
56
+ }
57
+ return done;
58
+ }
59
+
60
+ function skillStatus({ env = process.env } = {}) {
61
+ return targets(env).map((t) => ({ ...t, installed: fs.existsSync(path.join(t.to, 'SKILL.md')) }));
62
+ }
63
+
64
+ module.exports = { installSkills, uninstallSkills, skillStatus, targets, PLATFORMS };
package/lib/store.js CHANGED
@@ -239,6 +239,50 @@ function claimSession(id, env = process.env) {
239
239
  return claimLockFile(path.join(sessionDir(id, env), 'owner.lock'));
240
240
  }
241
241
 
242
+ // --- plans (zcode plan) -------------------------------------------------------
243
+ // <root>/plans/<name>.json: the supervisor's bookkeeping for a plan file. The
244
+ // source of truth for a plan's progress is its sessions; this only remembers
245
+ // which file, which supervisor and which launches failed.
246
+
247
+ function plansDir(env = process.env) {
248
+ return path.join(rootDir(env), 'plans');
249
+ }
250
+
251
+ const planFile = (name, env = process.env) => path.join(plansDir(env), `${name}.json`);
252
+
253
+ function readPlanState(name, env = process.env) {
254
+ return validId(name) ? readJson(planFile(name, env)) : null;
255
+ }
256
+
257
+ // Exclusive create: a second `plan run` under the same name is refused.
258
+ // Returns false when the plan already exists.
259
+ function createPlanState(name, state, env = process.env) {
260
+ fs.mkdirSync(plansDir(env), { recursive: true, mode: 0o700 });
261
+ const now = new Date().toISOString();
262
+ try {
263
+ fs.writeFileSync(planFile(name, env), JSON.stringify({ schema: 'zcode.plan-state/1', name, createdAt: now, updatedAt: now, ...state }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
264
+ return true;
265
+ } catch (err) {
266
+ if (err.code === 'EEXIST') return false;
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ function updatePlanState(name, patch, env = process.env) {
272
+ const current = readPlanState(name, env);
273
+ if (!current) throw new Error(`plan "${name}" has no state`);
274
+ const delta = typeof patch === 'function' ? patch(current) : patch;
275
+ const next = { ...current, ...delta, updatedAt: new Date().toISOString() };
276
+ writeJsonAtomic(planFile(name, env), next);
277
+ return next;
278
+ }
279
+
280
+ // One supervisor per plan: a pid lock next to the state.
281
+ function claimPlan(name, env = process.env) {
282
+ fs.mkdirSync(plansDir(env), { recursive: true, mode: 0o700 });
283
+ return claimLockFile(path.join(plansDir(env), `${name}.lock`));
284
+ }
285
+
242
286
  // Machine-wide soft cap on running sessions (ZAI_CODE_MAX_ACTIVE): N slot
243
287
  // lock files; a session holds one while it runs.
244
288
  function claimSlot(max, env = process.env) {
@@ -271,6 +315,12 @@ module.exports = {
271
315
  listSessions,
272
316
  resolveSessionId,
273
317
  claimSession,
318
+ plansDir,
319
+ readPlanState,
320
+ createPlanState,
321
+ updatePlanState,
322
+ claimPlan,
274
323
  claimSlot,
324
+ claimLockFile,
275
325
  writeJsonAtomic,
276
326
  };