orbit-thread 0.0.0-stage → 1.1.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.
@@ -0,0 +1,500 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ensureProjectConfig, readProjectConfig, renderSubagentBlock } from './project_config.mjs';
7
+
8
+ const begin = '<!-- orbit-thread:start -->';
9
+ const end = '<!-- orbit-thread:end -->';
10
+ const legacyBegin = '<!-- orbit-handoff:start -->';
11
+ const legacyEnd = '<!-- orbit-handoff:end -->';
12
+ const ignoreBegin = '# orbit-thread:start';
13
+ const ignoreEnd = '# orbit-thread:end';
14
+ const legacyIgnoreBegin = '# orbit-handoff:start';
15
+ const legacyIgnoreEnd = '# orbit-handoff:end';
16
+
17
+ const agentsBlock = config => `${begin}
18
+ ## Orbit Thread workflow
19
+
20
+ - Durable truth is the tracked source plus current living docs. \`HANDOFF-STATE.md\` is concise execution state only, never project history.
21
+ - Before substantial work, inspect current Git state and load context selectively:
22
+ - product behaviour, scope or copy → \`PRODUCT.md\`;
23
+ - UI/UX → \`DESIGN.md\`;
24
+ - runtime, data flow, infrastructure or integrations → \`ARCHITECTURE.md\`;
25
+ - auth, tenancy, secrets, public entry points or other security-sensitive work → \`SECURITY.md\`;
26
+ - future planning only → \`ROADMAP.md\`.
27
+ Use the matching file in \`docs/\` or the project root. For cross-cutting work, read only the additional docs it actually touches.
28
+ - Inspect only source files needed for the task. Do not broadly scan generated, vendor, dependency, build, coverage or cache directories, and do not reread unchanged context without a reason.
29
+ - Living docs describe CURRENT truth. After verified work changes that truth, update affected existing sections in place during the same milestone. Never append session diaries or duplicate superseded sections; Git is history. Never rewrite an applied migration.
30
+ - Run the smallest relevant verification before claiming a milestone or task complete. Never claim a test, deployment, commit, push or external action that did not run.
31
+ - Run the installed \`handoff\` skill after a meaningful verified milestone that changed repository state or durable project truth, and after each completed coding task with such changes before the final reply. Also checkpoint before \`/clear\`, session end, or a usage/context stop when warning is available. Skip read-only questions and trivial edits. Intermediate checkpoints must not stop unfinished work.
32
+ - Each checkpoint overwrites \`HANDOFF-STATE.md\`. After a fresh/cleared/recovered session: read the handoff once if present, inspect branch/status/diff/recent log, read only relevant living docs, then inspect only source needed for the exact next action. Do not reconstruct old conversation history or repeat completed work.
33
+ ${renderSubagentBlock(config)}
34
+ - Read-only Git inspection is allowed. Commit, push, merge, rebase, reset, branch deletion or other Git writes require explicit authority in the current request. Never force-push or destructively clean without explicit authority.
35
+ - Never place secrets, credentials, environment values, private keys, recovery codes, personal data or sensitive production data in living docs or \`HANDOFF-STATE.md\`.
36
+ ${end}
37
+ `;
38
+
39
+ const claudeBlock = `${begin}
40
+ ## Orbit Thread
41
+
42
+ Read and follow \`AGENTS.md\` for project workflow, selective context loading, subagents and checkpoints. On a fresh or recovered session, resume from \`HANDOFF-STATE.md\` + current Git state + only the relevant living docs instead of reconstructing old conversation history.
43
+ ${end}
44
+ `;
45
+ const templates = {
46
+ 'PRODUCT.md': `# Product
47
+
48
+ > Living current-state document. Replace placeholders as product truth becomes known; do not append session history.
49
+
50
+ ## Purpose
51
+ - TODO: what the product does and who it serves.
52
+
53
+ ## Core workflows
54
+ - TODO: the main user-visible workflows.
55
+
56
+ ## Scope and non-goals
57
+ - TODO: what is in scope and explicitly out of scope.
58
+
59
+ ## Current constraints
60
+ - TODO: material product limits that affect implementation.
61
+ `,
62
+ 'ARCHITECTURE.md': `# Architecture
63
+
64
+ > Living current-state document. Update existing sections in place when the system changes.
65
+
66
+ ## System shape
67
+ - TODO: major runtime components and responsibilities.
68
+
69
+ ## Boundaries and data flow
70
+ - TODO: important trust, service and module boundaries.
71
+
72
+ ## Persistence and integrations
73
+ - TODO: databases, storage, queues and external services.
74
+
75
+ ## Operational constraints
76
+ - TODO: deployment, runtime and reliability constraints.
77
+ `,
78
+ 'SECURITY.md': `# Security
79
+
80
+ > Living current-state document. Record actual controls, invariants and known risks only.
81
+
82
+ ## Trust boundaries
83
+ - TODO: authentication, authorisation, tenancy and public-entry boundaries.
84
+
85
+ ## Data and secrets
86
+ - TODO: sensitive data handling, encryption, secret storage and logging rules.
87
+
88
+ ## Abuse and failure controls
89
+ - TODO: rate limits, anti-abuse, validation and fail-closed behaviour.
90
+
91
+ ## Known risks
92
+ - TODO: unresolved security limitations or verification still required.
93
+ `,
94
+ 'ROADMAP.md': `# Roadmap
95
+
96
+ > Forward-looking current plan only. Replace completed or superseded items instead of appending history.
97
+
98
+ ## Current phase
99
+ - TODO: the active project phase and outcome.
100
+
101
+ ## Next
102
+ 1. TODO: highest-priority next milestone.
103
+
104
+ ## Later
105
+ - TODO: worthwhile later work.
106
+
107
+ ## Explicitly deferred
108
+ - TODO: work intentionally not being done now.
109
+ `,
110
+ 'DESIGN.md': `# Design
111
+
112
+ > Living current-state design truth. Keep this concise and update it in place.
113
+
114
+ ## Direction
115
+ - TODO: visual/product design direction.
116
+
117
+ ## System
118
+ - TODO: typography, colour, spacing, components and interaction conventions.
119
+
120
+ ## Accessibility and responsive behaviour
121
+ - TODO: current accessibility and device rules.
122
+
123
+ ## Surface-specific decisions
124
+ - TODO: only durable decisions that affect future UI work.
125
+ `,
126
+ };
127
+
128
+ const exists = p => {
129
+ try { fs.lstatSync(p); return true; }
130
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
131
+ };
132
+ const read = p => exists(p) ? fs.readFileSync(p, 'utf8') : '';
133
+
134
+ function safePath(p) {
135
+ let cursor = path.resolve(p);
136
+ while (true) {
137
+ if (exists(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Refusing symlink: ${cursor}`);
138
+ const parent = path.dirname(cursor);
139
+ if (parent === cursor) break;
140
+ cursor = parent;
141
+ }
142
+ }
143
+
144
+ function atomicWrite(p, content) {
145
+ safePath(p);
146
+ fs.mkdirSync(path.dirname(p), { recursive: true });
147
+ const temporary = path.join(path.dirname(p), `.orbit-thread-${crypto.randomUUID()}.tmp`);
148
+ try {
149
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: exists(p) ? fs.statSync(p).mode & 0o777 : 0o644 });
150
+ fs.renameSync(temporary, p);
151
+ } finally {
152
+ if (exists(temporary)) fs.unlinkSync(temporary);
153
+ }
154
+ }
155
+
156
+ export function resolveProjectRoot(cwd = process.cwd()) {
157
+ try {
158
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
159
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
160
+ }).trim();
161
+ } catch {
162
+ return path.resolve(cwd);
163
+ }
164
+ }
165
+
166
+ function statePath(root) {
167
+ return path.join(root, '.orbit-thread/state.json');
168
+ }
169
+
170
+ function legacyStatePath(root) {
171
+ return path.join(root, '.orbit-handoff/state.json');
172
+ }
173
+
174
+ function loadState(root) {
175
+ const current = statePath(root);
176
+ const legacy = legacyStatePath(root);
177
+ if (exists(current) && exists(legacy)) throw new Error('Both Orbit Thread and legacy Orbit Handoff state files exist; reconcile them before setup.');
178
+ const source = exists(current) ? current : exists(legacy) ? legacy : null;
179
+ if (!source) return { state: { product: 'orbit-thread', schema: 3, configuredAgents: [], additions: {} }, source: null };
180
+
181
+ const parsed = JSON.parse(read(source));
182
+ if (!parsed.additions || typeof parsed.additions !== 'object' || Array.isArray(parsed.additions)) throw new Error(`Invalid Orbit Thread state: ${source}`);
183
+ for (const [name, addition] of Object.entries(parsed.additions)) {
184
+ if (!['AGENTS.md', 'CLAUDE.md', '.gitignore'].includes(name) || typeof addition.inserted !== 'string' || typeof addition.created !== 'boolean') {
185
+ throw new Error(`Invalid Orbit Thread ownership entry: ${source}`);
186
+ }
187
+ }
188
+
189
+ if (parsed.product === 'orbit-thread' && parsed.schema === 3) {
190
+ if (!Array.isArray(parsed.configuredAgents) || parsed.configuredAgents.some(agent => !['codex', 'claude'].includes(agent))) throw new Error(`Invalid Orbit Thread state: ${source}`);
191
+ return { state: parsed, source };
192
+ }
193
+
194
+ if (parsed.product === 'orbit-handoff' && [1, 2].includes(parsed.schema)) {
195
+ const configuredAgents = parsed.schema === 1
196
+ ? [...(parsed.additions['AGENTS.md'] ? ['codex'] : []), ...(parsed.additions['CLAUDE.md'] ? ['claude'] : [])]
197
+ : parsed.configuredAgents;
198
+ if (!Array.isArray(configuredAgents) || configuredAgents.some(agent => !['codex', 'claude'].includes(agent))) throw new Error(`Invalid legacy Orbit Handoff state: ${source}`);
199
+ return {
200
+ state: { product: 'orbit-thread', schema: 3, configuredAgents: [...new Set(configuredAgents)], additions: parsed.additions },
201
+ source,
202
+ };
203
+ }
204
+
205
+ throw new Error(`Unsupported Orbit Thread state: ${source}`);
206
+ }
207
+
208
+ function persistState(root, state, source = null) {
209
+ const p = statePath(root);
210
+ if (Object.keys(state.additions).length || state.configuredAgents.length) {
211
+ atomicWrite(p, JSON.stringify(state, null, 2) + '\n');
212
+ } else if (exists(p)) {
213
+ fs.unlinkSync(p);
214
+ const dir = path.dirname(p);
215
+ if (exists(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
216
+ }
217
+ const legacy = legacyStatePath(root);
218
+ if (source === legacy && exists(legacy)) {
219
+ fs.unlinkSync(legacy);
220
+ const dir = path.dirname(legacy);
221
+ if (exists(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
222
+ }
223
+ }
224
+ function desiredIgnoreBlock(before) {
225
+ const lines = before.split(/\r?\n/);
226
+ const missing = [];
227
+ if (!lines.includes('HANDOFF-STATE.md') && !lines.includes('/HANDOFF-STATE.md')) missing.push('HANDOFF-STATE.md');
228
+ if (!lines.includes('.orbit-thread/state.json') && !lines.includes('/.orbit-thread/state.json')) missing.push('.orbit-thread/state.json');
229
+ return missing.length ? `${ignoreBegin}\n${missing.join('\n')}\n${ignoreEnd}\n` : null;
230
+ }
231
+
232
+ function desiredBlock(name, config, before = '') {
233
+ if (name === 'AGENTS.md') return agentsBlock(config);
234
+ if (name === 'CLAUDE.md') return claudeBlock;
235
+ if (name === '.gitignore') return desiredIgnoreBlock(before);
236
+ throw new Error(`Unknown managed file: ${name}`);
237
+ }
238
+
239
+ function markersForInserted(name, inserted) {
240
+ if (name === '.gitignore') {
241
+ return inserted.includes(legacyIgnoreBegin) ? [legacyIgnoreBegin, legacyIgnoreEnd] : [ignoreBegin, ignoreEnd];
242
+ }
243
+ return inserted.includes(legacyBegin) ? [legacyBegin, legacyEnd] : [begin, end];
244
+ }
245
+
246
+ function hasAnyManagedMarker(name, text) {
247
+ const values = name === '.gitignore'
248
+ ? [ignoreBegin, ignoreEnd, legacyIgnoreBegin, legacyIgnoreEnd]
249
+ : [begin, end, legacyBegin, legacyEnd];
250
+ return values.some(value => text.includes(value));
251
+ }
252
+
253
+ function planManaged(root, state, name, config) {
254
+ const p = path.join(root, name);
255
+ safePath(p);
256
+ const before = read(p);
257
+ const owned = state.additions[name];
258
+
259
+ if (owned) {
260
+ if (!before.includes(owned.inserted) || before.indexOf(owned.inserted) !== before.lastIndexOf(owned.inserted)) {
261
+ throw new Error(`Managed Orbit Thread block was edited or duplicated in ${name}; review it before running setup.`);
262
+ }
263
+ const [first, last] = markersForInserted(name, owned.inserted);
264
+ if (before.split(first).length !== 2 || before.split(last).length !== 2) {
265
+ throw new Error(`Managed Orbit Thread block was edited or duplicated in ${name}; review it before running setup.`);
266
+ }
267
+ const previousInserted = owned.inserted;
268
+ const leading = previousInserted.match(/^(?:\r?\n)*/)?.[0] ?? '';
269
+ const baseText = before.slice(0, before.indexOf(previousInserted)) + before.slice(before.indexOf(previousInserted) + previousInserted.length);
270
+ const rawDesired = desiredBlock(name, config, baseText);
271
+ if (!rawDesired) {
272
+ delete state.additions[name];
273
+ return { p, name, content: baseText, action: 'updated' };
274
+ }
275
+ const inserted = leading + rawDesired;
276
+ if (inserted === previousInserted) return null;
277
+ state.additions[name].inserted = inserted;
278
+ return { p, name, content: before.replace(previousInserted, inserted), action: 'updated' };
279
+ }
280
+
281
+
282
+ if (hasAnyManagedMarker(name, before)) {
283
+ throw new Error(`Unowned or malformed Orbit Thread/legacy block in ${name}; refusing to overwrite it.`);
284
+ }
285
+
286
+ const rawDesired = desiredBlock(name, config, before);
287
+ if (!rawDesired) return null;
288
+ const inserted = (before ? (before.endsWith('\n') ? '\n' : '\n\n') : '') + rawDesired;
289
+ state.additions[name] = { inserted, created: !exists(p) };
290
+ return { p, name, content: before + inserted, action: exists(p) ? 'updated' : 'created' };
291
+ }
292
+
293
+ function ensureDocs(root) {
294
+ const results = [];
295
+ for (const [name, template] of Object.entries(templates)) {
296
+ const rootFile = path.join(root, name);
297
+ const docsFile = path.join(root, 'docs', name);
298
+ safePath(rootFile);
299
+ safePath(docsFile);
300
+ if (exists(rootFile)) {
301
+ results.push({ name: path.relative(root, rootFile), action: 'preserved' });
302
+ continue;
303
+ }
304
+ if (exists(docsFile)) {
305
+ results.push({ name: path.relative(root, docsFile), action: 'preserved' });
306
+ continue;
307
+ }
308
+ atomicWrite(docsFile, template);
309
+ results.push({ name: path.relative(root, docsFile), action: 'created' });
310
+ }
311
+ return results;
312
+ }
313
+
314
+ export function setupProject(root, selected = ['codex', 'claude']) {
315
+ root = path.resolve(root);
316
+ safePath(root);
317
+ const agents = [...new Set(selected)];
318
+ if (!agents.length || agents.some(agent => !['codex', 'claude'].includes(agent))) throw new Error('Agent must be codex, claude or both');
319
+
320
+ const loaded = loadState(root);
321
+ const state = loaded.state;
322
+ const config = readProjectConfig(root);
323
+ state.configuredAgents = [...new Set([...state.configuredAgents, ...agents])].sort();
324
+
325
+ const names = ['AGENTS.md', '.gitignore'];
326
+ if (state.configuredAgents.includes('claude')) names.push('CLAUDE.md');
327
+
328
+ const planned = names.map(name => planManaged(root, state, name, config)).filter(Boolean);
329
+ const docs = ensureDocs(root);
330
+
331
+ ensureProjectConfig(root);
332
+ for (const change of planned) atomicWrite(change.p, change.content);
333
+ persistState(root, state, loaded.source);
334
+
335
+ return { root, managed: planned.map(({ name, action }) => ({ name, action })), docs };
336
+ }
337
+
338
+ function removeAddition(root, state, name) {
339
+ const addition = state.additions[name];
340
+ if (!addition) return { name, action: 'not-managed' };
341
+ const p = path.join(root, name);
342
+ safePath(p);
343
+ if (!exists(p)) {
344
+ delete state.additions[name];
345
+ return { name, action: 'missing' };
346
+ }
347
+
348
+ const before = read(p);
349
+ const index = before.indexOf(addition.inserted);
350
+ if (index < 0 || index !== before.lastIndexOf(addition.inserted)) {
351
+ return { name, action: 'preserved-edited' };
352
+ }
353
+
354
+ const after = before.slice(0, index) + before.slice(index + addition.inserted.length);
355
+ if (!after && addition.created) fs.unlinkSync(p);
356
+ else atomicWrite(p, after);
357
+ delete state.additions[name];
358
+ return { name, action: 'removed' };
359
+ }
360
+
361
+ export function removeProjectConfig(root, selected = ['codex', 'claude']) {
362
+ root = path.resolve(root);
363
+ const loaded = loadState(root);
364
+ const state = loaded.state;
365
+ const remove = new Set(selected);
366
+ state.configuredAgents = state.configuredAgents.filter(agent => !remove.has(agent));
367
+
368
+ const results = [];
369
+ if (!state.configuredAgents.includes('claude')) results.push(removeAddition(root, state, 'CLAUDE.md'));
370
+ if (!state.configuredAgents.length) {
371
+ results.push(removeAddition(root, state, 'AGENTS.md'));
372
+ results.push(removeAddition(root, state, '.gitignore'));
373
+ }
374
+
375
+ persistState(root, state, loaded.source);
376
+ return results;
377
+ }
378
+
379
+ function gitTracked(root, relative) {
380
+ try {
381
+ execFileSync('git', ['ls-files', '--error-unmatch', relative], {
382
+ cwd: root, stdio: ['ignore', 'ignore', 'ignore'],
383
+ });
384
+ return true;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ export function checkProject(root, selected = ['codex', 'claude']) {
391
+ root = path.resolve(root);
392
+ const loaded = loadState(root);
393
+ const state = loaded.state;
394
+ const messages = [];
395
+ let ok = true;
396
+ if (loaded.source === legacyStatePath(root)) { messages.push('project state: legacy Orbit Handoff state detected; run setup to migrate'); ok = false; }
397
+
398
+ try {
399
+ const config = readProjectConfig(root);
400
+ messages.push(`config: valid (max subagents ${config.subagents.max})`);
401
+ if (!exists(path.join(root, '.orbit-thread/config.json'))) {
402
+ messages.push('config: default only; run setup to create .orbit-thread/config.json');
403
+ ok = false;
404
+ }
405
+ } catch (error) {
406
+ messages.push(`config: invalid (${error.message})`);
407
+ ok = false;
408
+ }
409
+
410
+ for (const agent of selected) {
411
+ const configured = state.configuredAgents.includes(agent);
412
+ messages.push(`${agent}: ${configured ? 'project workflow configured' : 'project workflow not configured'}`);
413
+ if (!configured) ok = false;
414
+ }
415
+
416
+ for (const name of ['AGENTS.md', ...(selected.includes('claude') ? ['CLAUDE.md'] : [])]) {
417
+ const addition = state.additions[name];
418
+ const intact = addition && read(path.join(root, name)).includes(addition.inserted);
419
+ messages.push(`${name}: ${intact ? 'managed block intact' : 'managed block missing or edited'}`);
420
+ if (!intact) ok = false;
421
+ }
422
+
423
+ const ignore = read(path.join(root, '.gitignore'));
424
+ const ignoreLines = ignore.split(/\r?\n/);
425
+ const handoffIgnored = ignoreLines.includes('HANDOFF-STATE.md') || ignoreLines.includes('/HANDOFF-STATE.md');
426
+ const stateIgnored = ignoreLines.includes('.orbit-thread/state.json') || ignoreLines.includes('/.orbit-thread/state.json');
427
+ messages.push(`HANDOFF-STATE.md: ${handoffIgnored ? 'ignored' : 'ignore rule missing'}`);
428
+ messages.push(`.orbit-thread/state.json: ${stateIgnored ? 'ignored' : 'ignore rule missing'}`);
429
+ if (!handoffIgnored || !stateIgnored) ok = false;
430
+
431
+ if (gitTracked(root, 'HANDOFF-STATE.md')) {
432
+ messages.push('HANDOFF-STATE.md: tracked by Git (should remain local)');
433
+ ok = false;
434
+ }
435
+
436
+ for (const name of Object.keys(templates)) {
437
+ const rootFile = exists(path.join(root, name));
438
+ const docsFile = exists(path.join(root, 'docs', name));
439
+ const present = rootFile || docsFile;
440
+ messages.push(`${name}: ${present ? 'present' : 'missing'}${rootFile && docsFile ? ' (duplicate root/docs copies)' : ''}`);
441
+ if (!present || (rootFile && docsFile)) ok = false;
442
+ }
443
+
444
+ const handoff = path.join(root, 'HANDOFF-STATE.md');
445
+ if (exists(handoff)) {
446
+ const text = read(handoff);
447
+ const lines = text ? text.split(/\r?\n/).length - (text.endsWith('\n') ? 1 : 0) : 0;
448
+ messages.push(`handoff: ${lines} line${lines === 1 ? '' : 's'}`);
449
+ if (!text || lines > 50) ok = false;
450
+ } else {
451
+ messages.push('handoff: not created yet');
452
+ }
453
+
454
+ return { ok, messages };
455
+ }
456
+ function parseAgent(value) {
457
+ if (value === 'both') return ['codex', 'claude'];
458
+ if (['codex', 'claude'].includes(value)) return [value];
459
+ throw new Error('Agent must be codex, claude or both');
460
+ }
461
+
462
+ async function runCli(argv) {
463
+ let command = 'setup';
464
+ let agent = 'both';
465
+ if (argv[0] && !argv[0].startsWith('-')) command = argv.shift();
466
+ while (argv.length) {
467
+ const flag = argv.shift();
468
+ if (flag === '--agent') agent = argv.shift();
469
+ else throw new Error(`Unknown option: ${flag}`);
470
+ }
471
+
472
+ const root = resolveProjectRoot();
473
+ const selected = parseAgent(agent);
474
+
475
+ if (command === 'setup') {
476
+ const result = setupProject(root, selected);
477
+ for (const item of [...result.managed, ...result.docs]) console.log(`${item.action}: ${item.name}`);
478
+ console.log('Orbit Thread setup complete. HANDOFF-STATE.md will be created by the handoff skill at the first checkpoint.');
479
+ return;
480
+ }
481
+ if (command === 'check') {
482
+ const result = checkProject(root, selected);
483
+ result.messages.forEach(message => console.log(message));
484
+ if (!result.ok) process.exitCode = 1;
485
+ return;
486
+ }
487
+ if (command === 'remove') {
488
+ removeProjectConfig(root, selected).forEach(item => console.log(`${item.action}: ${item.name}`));
489
+ return;
490
+ }
491
+ throw new Error(`Unknown setup command: ${command}`);
492
+ }
493
+
494
+ const direct = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
495
+ if (direct) {
496
+ runCli(process.argv.slice(2)).catch(error => {
497
+ console.error(error.message);
498
+ process.exitCode = 1;
499
+ });
500
+ }