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.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +9 -0
- package/README.md +319 -2
- package/SECURITY.md +18 -0
- package/bin/orbit-handoff.mjs +8 -0
- package/bin/orbit-thread.mjs +8 -0
- package/docs/PUBLISHING.md +113 -0
- package/docs/SUBMISSION.md +72 -0
- package/docs/VALIDATION.md +179 -0
- package/lib/cli.mjs +296 -0
- package/package.json +25 -4
- package/plugin/.claude-plugin/plugin.json +16 -0
- package/plugin/LICENSE +21 -0
- package/plugin/README.md +26 -0
- package/plugin/assets/icon.png +0 -0
- package/plugin/plugin.json +34 -0
- package/plugin/skills/handoff/SKILL.md +46 -0
- package/plugin/skills/handoff/agents/openai.yaml +6 -0
- package/plugin/skills/handoff/scripts/write_handoff.sh +33 -0
- package/plugin/skills/orbit-setup/SKILL.md +42 -0
- package/plugin/skills/orbit-setup/agents/openai.yaml +6 -0
- package/plugin/skills/orbit-setup/scripts/project_config.mjs +168 -0
- package/plugin/skills/orbit-setup/scripts/setup_project.mjs +500 -0
|
@@ -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
|
+
}
|