orbit-thread 0.0.0-stage → 1.2.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/cli.mjs ADDED
@@ -0,0 +1,304 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { setupProject, checkProject, removeProjectConfig, resolveProjectRoot } from '../plugin/skills/orbit-setup/scripts/setup_project.mjs';
8
+ import { configureProject, readProjectConfig } from '../plugin/skills/orbit-setup/scripts/project_config.mjs';
9
+ import { resumeProject } from './resume.mjs';
10
+
11
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
13
+ const skillsRoot = path.join(packageRoot, 'plugin/skills');
14
+ const marker = '.orbit-thread.json';
15
+ const legacyMarker = '.orbit-handoff.json';
16
+ const agents = ['codex', 'claude'];
17
+ const skills = ['handoff', 'orbit-setup', 'orbit-resume'];
18
+ const digest = data => crypto.createHash('sha256').update(data).digest('hex');
19
+ const exists = p => { try { fs.lstatSync(p); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
20
+
21
+ function safePath(p) {
22
+ let cursor = path.resolve(p);
23
+ while (true) {
24
+ if (exists(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Refusing symlink: ${cursor}`);
25
+ const parent = path.dirname(cursor);
26
+ if (parent === cursor) break;
27
+ cursor = parent;
28
+ }
29
+ }
30
+
31
+ function atomicWrite(p, content) {
32
+ safePath(p);
33
+ fs.mkdirSync(path.dirname(p), { recursive: true });
34
+ const temporary = path.join(path.dirname(p), `.orbit-thread-${crypto.randomUUID()}.tmp`);
35
+ try {
36
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: exists(p) ? fs.statSync(p).mode & 0o777 : 0o644 });
37
+ fs.renameSync(temporary, p);
38
+ } finally { if (exists(temporary)) fs.unlinkSync(temporary); }
39
+ }
40
+
41
+ function files(dir, prefix = '') {
42
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
43
+ const relative = path.posix.join(prefix, entry.name);
44
+ if (entry.isSymbolicLink()) throw new Error(`Refusing symlink: ${relative}`);
45
+ return entry.isDirectory() ? files(path.join(dir, entry.name), relative) : [relative];
46
+ }).sort();
47
+ }
48
+
49
+ function skillSource(skill) { return path.join(skillsRoot, skill); }
50
+ function bundled(skill) { return Object.fromEntries(files(skillSource(skill)).map(name => [name, fs.readFileSync(path.join(skillSource(skill), name))])); }
51
+ function agentSkillsRoot(agent, scope, root) { return path.join(scope === 'user' ? os.homedir() : root, agent === 'codex' ? '.agents/skills' : '.claude/skills'); }
52
+ function location(agent, scope, root, skill) { return path.join(agentSkillsRoot(agent, scope, root), skill); }
53
+
54
+ function ownershipPath(dir) {
55
+ safePath(dir);
56
+ const current = path.join(dir, marker);
57
+ const legacy = path.join(dir, legacyMarker);
58
+ if (exists(current) && exists(legacy)) throw new Error(`Both Orbit Thread and legacy Orbit Handoff ownership files exist at ${dir}; reconcile them before continuing.`);
59
+ if (exists(current)) return current;
60
+ if (exists(legacy)) return legacy;
61
+ return null;
62
+ }
63
+
64
+ function readOwnership(dir) {
65
+ const p = ownershipPath(dir);
66
+ if (!p) return null;
67
+ const state = JSON.parse(fs.readFileSync(p, 'utf8'));
68
+ if (!['orbit-handoff', 'orbit-thread'].includes(state.product) || state.schema !== 1 || typeof state.version !== 'string' || !state.files || typeof state.files !== 'object' || Array.isArray(state.files)) throw new Error(`Invalid ownership file: ${p}`);
69
+ for (const [name, hash] of Object.entries(state.files)) {
70
+ if (!name || name === marker || name === legacyMarker || path.posix.isAbsolute(name) || name.includes('\\') || name.split('/').some(part => ['.', '..', ''].includes(part)) || !/^[a-f0-9]{64}$/.test(hash)) throw new Error(`Invalid ownership entry: ${p}`);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ function preflight(dir, skill, updating) {
76
+ const state = readOwnership(dir);
77
+ if (exists(dir) && !state) throw new Error(`An unmanaged ${skill} skill already exists at ${dir}; move it aside yourself before installing.`);
78
+ if (updating && !state) return true;
79
+ if (state) {
80
+ for (const [name, hash] of Object.entries(state.files)) {
81
+ const p = path.join(dir, name); safePath(p);
82
+ if (!exists(p) || digest(fs.readFileSync(p)) !== hash) throw new Error(`Managed file was edited or removed: ${p}. Preserve your changes before updating.`);
83
+ }
84
+ for (const name of Object.keys(bundled(skill))) if (exists(path.join(dir, name)) && !Object.hasOwn(state.files, name)) throw new Error(`Unrelated file conflicts with this release: ${path.join(dir, name)}`);
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function installSkill(dir, skill) {
90
+ const previousPath = ownershipPath(dir);
91
+ const previous = readOwnership(dir);
92
+ const content = bundled(skill);
93
+ for (const [name, data] of Object.entries(content)) atomicWrite(path.join(dir, name), data);
94
+ for (const name of Object.keys(previous?.files ?? {})) if (!Object.hasOwn(content, name) && exists(path.join(dir, name))) fs.unlinkSync(path.join(dir, name));
95
+ atomicWrite(path.join(dir, marker), JSON.stringify({ product: 'orbit-thread', schema: 1, version: pkg.version, skill,
96
+ files: Object.fromEntries(Object.entries(content).map(([name, data]) => [name, digest(data)])) }, null, 2) + '\n');
97
+ if (previousPath && previousPath.endsWith(legacyMarker) && exists(previousPath)) fs.unlinkSync(previousPath);
98
+ }
99
+
100
+ function prune(dir, boundary) {
101
+ while (dir !== boundary && exists(dir) && fs.readdirSync(dir).length === 0) { fs.rmdirSync(dir); dir = path.dirname(dir); }
102
+ }
103
+
104
+ function uninstallSkill(dir) {
105
+ const ownerFile = ownershipPath(dir);
106
+ const state = readOwnership(dir);
107
+ if (!state) { if (exists(dir)) console.log(`Unmanaged skill preserved: ${dir}`); return; }
108
+ const retained = {};
109
+ for (const [name, hash] of Object.entries(state.files)) {
110
+ const p = path.join(dir, name); safePath(p);
111
+ if (!exists(p)) continue;
112
+ if (digest(fs.readFileSync(p)) === hash) { fs.unlinkSync(p); prune(path.dirname(p), dir); }
113
+ else { retained[name] = hash; console.log(`Preserved edited file: ${p}`); }
114
+ }
115
+ if (Object.keys(retained).length) atomicWrite(ownerFile, JSON.stringify({ ...state, files: retained }, null, 2) + '\n');
116
+ else if (ownerFile && exists(ownerFile)) fs.unlinkSync(ownerFile);
117
+ prune(dir, path.dirname(dir));
118
+ }
119
+
120
+ function skillStatus(root, selected, scope) {
121
+ let complete = true;
122
+ for (const agent of selected) {
123
+ for (const skill of skills) {
124
+ const dir = location(agent, scope, root, skill);
125
+ const state = readOwnership(dir);
126
+ let label = exists(dir) ? 'unmanaged skill' : 'not installed';
127
+ if (state) {
128
+ const contents = bundled(skill);
129
+ const intact = Object.entries(state.files).every(([name, hash]) => { const p = path.join(dir, name); return exists(p) && digest(fs.readFileSync(p)) === hash; });
130
+ const current = intact && Object.keys(state.files).length === Object.keys(contents).length && Object.entries(contents).every(([name, data]) => state.files[name] === digest(data));
131
+ label = !intact ? `v${state.version}, locally modified` : current ? `v${state.version}, current` : `v${state.version}, update available in this package`;
132
+ if (!current) complete = false;
133
+ } else complete = false;
134
+ console.log(`${agent}/${skill} (${scope}): ${label} — ${dir}`);
135
+ }
136
+ }
137
+ return complete;
138
+ }
139
+
140
+ const help = `Orbit Thread ${pkg.version}
141
+
142
+ Usage: orbit-thread <command> [options]
143
+
144
+ Commands:
145
+ install Install the Orbit Thread skills.
146
+ setup Prepare/upgrade the current project workflow and living docs.
147
+ resume Show concise project continuity and current Git state (read-only).
148
+ configure Set project subagent preferences.
149
+ doctor Check installed skills and project continuity health.
150
+ update Upgrade unchanged managed skill files to this package version.
151
+ uninstall Remove managed skills and instruction blocks.
152
+ init Alias for setup.
153
+ check Check managed skill installation (legacy-compatible).
154
+
155
+ Common options:
156
+ --agent codex|claude|both
157
+ --scope project|user
158
+ --yes, -y
159
+
160
+ Configure options:
161
+ --model, --subagent-model <name>
162
+ --reasoning, --effort <level>
163
+ --max-subagents 0|1|2
164
+ --reset-subagents
165
+ --show
166
+
167
+ Examples:
168
+ npx orbit-thread install
169
+ npx orbit-thread setup
170
+ npx orbit-thread configure --agent claude --model "Claude Sonnet 5.5" --effort low
171
+ npx orbit-thread configure --agent codex --model "GPT-6.1 Sol" --reasoning high
172
+ npx orbit-thread doctor
173
+ npx orbit-thread@latest update --agent both --scope project --yes
174
+ `;
175
+
176
+ function parseOptions(rest) {
177
+ const options = { yes: false, show: false, resetSubagents: false };
178
+ for (let i = 0; i < rest.length; i++) {
179
+ const flag = rest[i];
180
+ const take = () => {
181
+ const value = rest[++i];
182
+ if (value === undefined) throw new Error(`Missing value for ${flag}`);
183
+ return value;
184
+ };
185
+ if (flag === '--yes' || flag === '-y') options.yes = true;
186
+ else if (flag === '--agent') options.agent = take();
187
+ else if (flag === '--scope') options.scope = take();
188
+ else if (flag === '--model' || flag === '--subagent-model') options.model = take();
189
+ else if (flag === '--reasoning' || flag === '--effort') options.reasoning = take();
190
+ else if (flag === '--max-subagents') options.maxSubagents = take();
191
+ else if (flag === '--reset-subagents') options.resetSubagents = true;
192
+ else if (flag === '--show') options.show = true;
193
+ else throw new Error(`Unknown option: ${flag}`);
194
+ }
195
+ return options;
196
+ }
197
+
198
+ function selectedAgents(value = 'both') {
199
+ if (!['codex', 'claude', 'both'].includes(value)) throw new Error('Agent must be codex, claude or both');
200
+ return value === 'both' ? agents : [value];
201
+ }
202
+
203
+ export async function main(argv) {
204
+ if (argv.includes('--help') || argv.includes('-h') || !argv.length) { console.log(help); return; }
205
+ if (argv.includes('--version') || argv.includes('-v')) { console.log(pkg.version); return; }
206
+
207
+ let [command, ...rest] = argv;
208
+ if (command === 'init') command = 'setup';
209
+ if (!['install', 'setup', 'resume', 'configure', 'check', 'doctor', 'update', 'uninstall'].includes(command)) throw new Error(`Unknown command: ${command}`);
210
+
211
+ const options = parseOptions(rest);
212
+ const scope = options.scope ?? 'project';
213
+ if (!['project', 'user'].includes(scope)) throw new Error('Scope must be project or user');
214
+ if (['setup', 'configure'].includes(command) && scope !== 'project') throw new Error(`${command} configures the current project; use --scope project`);
215
+ if (process.platform === 'win32') throw new Error('Use a Linux environment in WSL; native Windows is not supported');
216
+
217
+ let selected = selectedAgents(options.agent);
218
+ const root = resolveProjectRoot(process.cwd());
219
+
220
+ if (command === 'resume') {
221
+ if (rest.length) throw new Error('resume does not accept options');
222
+ resumeProject(root);
223
+ return;
224
+ }
225
+
226
+ if (command === 'configure') {
227
+ if (options.show) { console.log(JSON.stringify(readProjectConfig(root), null, 2)); return; }
228
+ if ((options.model !== undefined || options.reasoning !== undefined) && selected.length !== 1) {
229
+ throw new Error('Choose exactly one --agent when setting a subagent model or reasoning/effort preference.');
230
+ }
231
+ const config = configureProject(root, {
232
+ agent: selected.length === 1 ? selected[0] : undefined,
233
+ maxSubagents: options.maxSubagents,
234
+ model: options.model,
235
+ reasoning: options.reasoning,
236
+ resetSubagents: options.resetSubagents,
237
+ });
238
+ console.log(`Orbit Thread: project subagent configuration updated (max ${config.subagents.max}).`);
239
+ return;
240
+ }
241
+
242
+ if (command === 'check') {
243
+ if (!skillStatus(root, selected, scope)) process.exitCode = 1;
244
+ return;
245
+ }
246
+
247
+ if (command === 'doctor') {
248
+ const installed = skillStatus(root, selected, scope);
249
+ let healthy = installed;
250
+ if (scope === 'project') {
251
+ const result = checkProject(root, selected);
252
+ result.messages.forEach(message => console.log(message));
253
+ healthy = healthy && result.ok;
254
+ }
255
+ if (!healthy) process.exitCode = 1;
256
+ return;
257
+ }
258
+
259
+ const interactive = !options.yes;
260
+ if (interactive && !process.stdin.isTTY) throw new Error('Non-interactive input: pass --yes (and optionally --agent / --scope)');
261
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
262
+ try {
263
+ let effectiveScope = scope;
264
+ if (!options.agent && rl) {
265
+ const answer = (await rl.question('Choose agent: 1) Codex 2) Claude Code 3) Both [3]: ')).trim() || '3';
266
+ const chosen = { '1':'codex', '2':'claude', '3':'both' }[answer];
267
+ if (!chosen) throw new Error('Choose 1, 2 or 3');
268
+ selected = selectedAgents(chosen);
269
+ }
270
+ if (!options.scope && rl && command !== 'setup') {
271
+ const answer = (await rl.question('Choose scope: 1) Current project 2) User/global [1]: ')).trim() || '1';
272
+ effectiveScope = { '1':'project', '2':'user' }[answer];
273
+ if (!effectiveScope) throw new Error('Choose 1 or 2');
274
+ }
275
+
276
+ if (command === 'setup') {
277
+ console.log(`Will configure Orbit Thread project continuity in ${root}. Existing docs and unrelated instructions are preserved.`);
278
+ if (rl && !['y','yes'].includes((await rl.question('Continue? [y/N]: ')).trim().toLowerCase())) { console.log('Cancelled.'); return; }
279
+ const result = setupProject(root, selected);
280
+ for (const item of [...result.managed, ...result.docs]) console.log(`${item.action}: ${item.name}`);
281
+ console.log('Orbit Thread: setup complete. HANDOFF-STATE.md will be created at the first meaningful checkpoint.');
282
+ return;
283
+ }
284
+
285
+ const targets = selected.flatMap(a => skills.map(skill => ({ agent:a, skill, dir:location(a, effectiveScope, root, skill) })));
286
+ let eligible;
287
+ if (command === 'install' || command === 'update') {
288
+ if (command === 'update' && !targets.some(t => readOwnership(t.dir))) throw new Error('No managed Orbit Thread/legacy Orbit Handoff installation found; run install first');
289
+ eligible = targets.map(t => preflight(t.dir, t.skill, command === 'update'));
290
+ } else targets.forEach(t => readOwnership(t.dir));
291
+
292
+ console.log(`Will ${command} Orbit Thread ${pkg.version} (${effectiveScope}):\n${targets.map(t => t.dir).join('\n')}`);
293
+ if (rl && !['y','yes'].includes((await rl.question('Continue? [y/N]: ')).trim().toLowerCase())) { console.log('Cancelled.'); return; }
294
+
295
+ if (command === 'uninstall') {
296
+ targets.forEach(t => uninstallSkill(t.dir));
297
+ if (effectiveScope === 'project') removeProjectConfig(root, selected);
298
+ } else {
299
+ targets.forEach((t, i) => { if (command === 'install' || eligible[i]) installSkill(t.dir, t.skill); });
300
+ }
301
+ const suffix = command === 'install' && effectiveScope === 'project' ? '\nRun `npx orbit-thread setup` to prepare this project.' : '';
302
+ console.log(`Orbit Thread: ${command} complete.${suffix}`);
303
+ } finally { rl?.close(); }
304
+ }
package/lib/resume.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ const handoffPath = root => path.join(root, 'HANDOFF-STATE.md');
6
+ const secret = /\b(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|password|secret|authorization|bearer)\b\s*[:=]\s*[^\s,;]+/gi;
7
+
8
+ function scrub(value) {
9
+ const safe = value.slice(0, 500)
10
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
11
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
12
+ .replace(/\u009b[0-?]*[ -/]*[@-~]/g, '')
13
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
14
+ if (/\b(?:password|passphrase|token|secret|credential|private\s+key|api[_ -]?key|authorization|bearer)\b/i.test(safe)) {
15
+ return '[redacted sensitive-looking handoff line]';
16
+ }
17
+ return safe.replace(secret, '$1=[redacted]');
18
+ }
19
+
20
+ function readHandoff(root) {
21
+ const file = handoffPath(root);
22
+ try {
23
+ const stat = fs.lstatSync(file);
24
+ if (stat.isSymbolicLink() || !stat.isFile()) return { state: 'invalid', reason: 'not a regular local file' };
25
+ if (stat.size > 32768) return { state: 'invalid', reason: 'larger than the 32 KB safety limit' };
26
+ const text = fs.readFileSync(file, 'utf8');
27
+ const lines = text.split(/\r?\n/);
28
+ if (lines.length > 51) return { state: 'invalid', reason: 'over the 50-line limit' };
29
+ if (!/^# Handoff State\s*$/m.test(text)) return { state: 'invalid', reason: 'missing the Handoff State heading' };
30
+ const updated = text.match(/^Updated:\s*(\d{2})-(\d{2})-(\d{4})(?:\s+\d{2}:\d{2})?\s*$/m);
31
+ const objective = section(text, 'Current objective');
32
+ if (!updated || !objective.length) return { state: 'invalid', reason: 'missing a valid update date or current objective' };
33
+ const date = new Date(Number(updated[3]), Number(updated[2]) - 1, Number(updated[1]));
34
+ if (Number.isNaN(date.valueOf()) || date.getDate() !== Number(updated[1]) || date.getMonth() !== Number(updated[2]) - 1) {
35
+ return { state: 'invalid', reason: 'invalid update date' };
36
+ }
37
+ return {
38
+ state: 'valid', text, updated: `${updated[3]}-${updated[2]}-${updated[1]}`,
39
+ fields: {
40
+ objective,
41
+ completed: section(text, 'Completed milestones'),
42
+ unfinished: section(text, 'In progress'),
43
+ risks: section(text, 'Open issues / risks'),
44
+ verification: section(text, 'Verification'),
45
+ next: section(text, 'Immediate next steps'),
46
+ },
47
+ };
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return { state: 'missing' };
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ function section(text, title) {
55
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ const match = text.match(new RegExp(`^##\\s+${escaped}\\s*\\r?\\n([\\s\\S]*?)(?=^##\\s+|\\s*$)`, 'm'));
57
+ if (!match) return [];
58
+ return match[1].split(/\r?\n/).map(line => line.trim()).filter(line => line && !/^<!--/.test(line)).slice(0, 6).map(line => scrub(line));
59
+ }
60
+
61
+ function git(root, args) {
62
+ try {
63
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
64
+ } catch { return null; }
65
+ }
66
+
67
+ function currentGit(root) {
68
+ const branch = git(root, ['symbolic-ref', '--short', '-q', 'HEAD']) ?? (git(root, ['rev-parse', '--short', 'HEAD']) ? 'detached HEAD' : null);
69
+ const status = git(root, ['status', '--short', '--untracked-files=normal']);
70
+ const commitDate = git(root, ['log', '-1', '--format=%cs']);
71
+ const changed = (status ?? '').split(/\r?\n/).filter(Boolean);
72
+ const safeChanged = changed.filter(line => !/(^|\s)(?:\.env(?:\.|\s|$)|\.dev\.vars(?:\s|$)|.*(?:secret|credential|private[_-]?key|id_rsa|id_ed25519|\.pem|\.key|\.netrc|\.npmrc|\.pypirc).*)/i.test(line));
73
+ return { branch, status, changed: safeChanged.slice(0, 8), count: changed.length, commitDate };
74
+ }
75
+
76
+ function relevantDocs(text, root) {
77
+ const topics = [
78
+ ['PRODUCT.md', /product|workflow|scope|user-facing|feature/i],
79
+ ['DESIGN.md', /ui|ux|design|layout|visual|accessibility/i],
80
+ ['ARCHITECTURE.md', /architecture|runtime|data flow|integration|database|storage|deploy/i],
81
+ ['SECURITY.md', /auth|security|permission|secret|tenant|trust|privacy/i],
82
+ ['ROADMAP.md', /roadmap|plan|milestone|later|defer/i],
83
+ ];
84
+ const matches = [];
85
+ for (const [name, pattern] of topics) {
86
+ if (!pattern.test(text)) continue;
87
+ for (const file of [path.join(root, name), path.join(root, 'docs', name)]) {
88
+ try {
89
+ if (fs.lstatSync(file).isFile() && !fs.lstatSync(file).isSymbolicLink()) { matches.push(path.relative(root, file)); break; }
90
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
91
+ }
92
+ }
93
+ return [...new Set(matches)].slice(0, 3);
94
+ }
95
+
96
+ function printLines(label, lines, fallback) {
97
+ console.log(`${label}:`);
98
+ if (!lines.length) console.log(` ${fallback}`);
99
+ else lines.forEach(line => console.log(` ${line}`));
100
+ }
101
+
102
+ export function resumeProject(root) {
103
+ root = path.resolve(root);
104
+ const handoff = readHandoff(root);
105
+ const gitState = currentGit(root);
106
+ let status = handoff.state;
107
+ let reason = handoff.reason;
108
+ const recordedBranch = handoff.state === 'valid' ? handoff.text.match(/^(?:current\s+)?branch:\s*([^\s]+)/im)?.[1] : null;
109
+ if (recordedBranch && gitState.branch) {
110
+ if (recordedBranch !== gitState.branch) { status = 'possibly outdated'; reason = `recorded branch ${recordedBranch} differs from current branch`; }
111
+ }
112
+ const today = new Date();
113
+ const todayDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
114
+ if (handoff.state === 'valid' && handoff.updated && handoff.updated > todayDate) {
115
+ status = 'possibly outdated';
116
+ reason = 'handoff date is in the future';
117
+ }
118
+ if (handoff.state === 'valid' && gitState.commitDate && handoff.updated && gitState.commitDate > handoff.updated) {
119
+ status = 'possibly outdated';
120
+ reason = `Git has a commit dated ${gitState.commitDate}, after the handoff date`;
121
+ }
122
+
123
+ console.log('Orbit Resume (read-only)');
124
+ console.log(`Project: ${scrub(path.basename(root))}`);
125
+ console.log(`Handoff: ${status}${reason ? ` — ${scrub(reason)}` : ''}`);
126
+ if (handoff.state === 'valid') {
127
+ const usable = status === 'valid';
128
+ printLines('Objective', usable ? handoff.fields.objective : [], usable ? 'Not recorded.' : 'Treat the handoff as a lead; current repository evidence takes precedence.');
129
+ printLines('Completed milestones', usable ? handoff.fields.completed : [], 'No verified milestones recorded.');
130
+ printLines('Unfinished work', usable ? handoff.fields.unfinished : [], 'No in-progress work recorded.');
131
+ printLines('Risks', usable ? handoff.fields.risks : [], 'No risks recorded.');
132
+ printLines('Verification', usable ? handoff.fields.verification : [], 'No verification recorded.');
133
+ } else {
134
+ printLines('Objective', [], 'Unknown; do not infer it from an absent or invalid handoff.');
135
+ printLines('Completed milestones', [], 'No reliable handoff evidence.');
136
+ printLines('Unfinished work', [], 'Check the current task and Git changes before proceeding.');
137
+ printLines('Risks', [], handoff.state === 'invalid' ? 'Handoff was not used because it is invalid.' : 'No handoff exists yet.');
138
+ printLines('Verification', [], 'No reliable handoff evidence.');
139
+ }
140
+
141
+ console.log('Git status:');
142
+ if (!gitState.branch && gitState.status === null) console.log(' Git repository unavailable.');
143
+ else {
144
+ console.log(` Branch: ${scrub(gitState.branch ?? 'unknown')}`);
145
+ console.log(` Working tree: ${gitState.count ? `dirty (${gitState.count} change${gitState.count === 1 ? '' : 's'})` : 'clean'}`);
146
+ gitState.changed.forEach(line => console.log(` ${scrub(line)}`));
147
+ if (gitState.count > gitState.changed.length) console.log(` ${gitState.count - gitState.changed.length} sensitive-looking path(s) omitted.`);
148
+ }
149
+
150
+ const trustedNext = handoff.state === 'valid' && status === 'valid' ? handoff.fields.next : [];
151
+ const docs = relevantDocs([...(trustedNext.length ? trustedNext : []), ...(handoff.state === 'valid' ? handoff.fields.objective : [])].join(' '), root);
152
+ printLines('Next steps', trustedNext, handoff.state === 'valid' && status === 'valid' ? 'No next step recorded; inspect current changes and choose one from repository evidence.' : 'Inspect current Git changes, then identify the next action from the user request and tracked project docs.');
153
+ printLines('Relevant living docs', docs, 'None identified from the handoff; load a living doc only if the next action needs it.');
154
+ console.log('Keep unfinished working changes. Do not reset or clean the repository. Current Git and tracked docs override stale handoff details.');
155
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "orbit-thread",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Token-efficient project setup, checkpoints and coding-session continuity for Codex and Claude Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "orbit-thread": "bin/orbit-thread.mjs",
8
+ "orbit-handoff": "bin/orbit-thread.mjs"
9
+ },
10
+ "engines": { "node": ">=20" },
11
+ "os": ["darwin", "linux"],
12
+ "license": "MIT",
13
+ "author": "Shabih Anwar",
14
+ "repository": { "type": "git", "url": "git+https://github.com/shab1h-gh/orbit-thread.git" },
15
+ "homepage": "https://github.com/shab1h-gh/orbit-thread#readme",
16
+ "bugs": { "url": "https://github.com/shab1h-gh/orbit-thread/issues" },
17
+ "keywords": ["codex", "claude-code", "agent-skills", "continuity", "project-setup", "handoff", "resume", "context-efficiency"],
18
+ "files": ["bin/", "lib/", "plugin/", "docs/", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "CONTRIBUTING.md", "CHANGELOG.md"],
19
+ "scripts": {
20
+ "test": "node --test",
21
+ "validate": "node scripts/validate.mjs",
22
+ "build:plugin": "python3 scripts/package_plugin.py",
23
+ "scan:public": "node scripts/scan-public.mjs",
24
+ "smoke:pack": "node scripts/smoke-pack.mjs"
25
+ },
26
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }
27
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "orbit-thread",
3
+ "displayName": "Orbit Thread",
4
+ "version": "1.2.0",
5
+ "description": "Token-efficient project setup, checkpoints and coding-session continuity.",
6
+ "author": { "name": "Shabih Anwar", "url": "https://github.com/shab1h-gh" },
7
+ "homepage": "https://github.com/shab1h-gh/orbit-thread",
8
+ "repository": "https://github.com/shab1h-gh/orbit-thread",
9
+ "license": "MIT",
10
+ "icon": "./assets/icon.png",
11
+ "documentationUrl": "https://github.com/shab1h-gh/orbit-thread#readme",
12
+ "supportUrl": "https://github.com/shab1h-gh/orbit-thread/issues",
13
+ "privacyPolicyUrl": "https://github.com/shab1h-gh/orbit-thread/blob/main/PRIVACY.md",
14
+ "keywords": ["continuity", "handoff", "resume", "project-setup", "developer-tools"],
15
+ "skills": "./skills/"
16
+ }
package/plugin/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shabih Anwar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ # Orbit Thread
2
+
3
+ Orbit Thread provides three local coding skills:
4
+
5
+ - **Orbit Setup** prepares a project with lightweight living docs, selective context rules and configurable subagent preferences.
6
+ - **Handoff** overwrites a compact, evidence-only `HANDOFF-STATE.md` so a fresh coding session can resume from Git + current project truth rather than a large old conversation.
7
+ - **Resume** reconstructs concise project context from the latest usable handoff, current Git state and task-relevant living docs.
8
+
9
+ Standalone commands:
10
+
11
+ - Codex: `$orbit-setup`, `$handoff` and `$orbit-resume`
12
+ - Claude Code: `/orbit-setup`, `/handoff` and `/orbit-resume`
13
+
14
+ Claude plugin commands:
15
+
16
+ - `/orbit-thread:orbit-setup`
17
+ - `/orbit-thread:handoff`
18
+ - `/orbit-thread:orbit-resume`
19
+
20
+ Setup creates only missing PRODUCT, DESIGN, ARCHITECTURE, SECURITY and ROADMAP templates, preserves existing equivalents, manages a concise Orbit Thread section in AGENTS.md/CLAUDE.md, and keeps local handoff/tool state Git-ignored.
21
+
22
+ Start each coding task in a fresh chat and run Resume once at session start, automatically where supported or manually as a fallback. The project rules update living docs in place, load only relevant context, default to no subagents with a maximum of two, and request Handoff after meaningful verified milestones/completed coding tasks.
23
+
24
+ Handoff is overwrite-only, capped at 50 lines, secret-checked before atomic replacement and makes no network calls or Git mutations. Resume and `npx orbit-thread resume` are read-only and preserve unfinished work.
25
+
26
+ There is no MCP server, telemetry or external account.
27
+
28
+ See the project README for installation, migration and update instructions.
Binary file
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "orbit-thread",
4
+ "version": "1.2.0",
5
+ "description": "Token-efficient project setup, checkpoints and coding-session continuity.",
6
+ "author": { "name": "Shabih Anwar", "url": "https://github.com/shab1h-gh" },
7
+ "homepage": "https://github.com/shab1h-gh/orbit-thread",
8
+ "repository": "https://github.com/shab1h-gh/orbit-thread",
9
+ "license": "MIT",
10
+ "keywords": ["handoff", "resume", "codex", "claude-code", "continuity", "project-setup", "developer-tools"],
11
+ "extensions": {
12
+ "com.openai": {
13
+ "interface": {
14
+ "displayName": "Orbit Thread",
15
+ "shortDescription": "Lean coding continuity",
16
+ "longDescription": "Orbit Thread gives coding agents a small, durable continuity layer. Orbit Setup creates only missing PRODUCT, DESIGN, ARCHITECTURE, SECURITY and ROADMAP living docs, adds selective context-loading and bounded-subagent rules, and keeps local execution state separate from tracked project truth. Handoff overwrites a compact HANDOFF-STATE.md at verified checkpoints. Resume starts a fresh chat from the current handoff, read-only Git state and only the living docs needed for the task, reducing repeated context while preserving unfinished changes. Handoffs are capped at 50 lines, validated before atomic replacement, and the writer makes no network calls or Git mutations. Existing project docs and unrelated instructions are preserved.",
17
+ "developerName": "Shabih Anwar",
18
+ "category": "Developer Tools",
19
+ "capabilities": ["Prepare project continuity", "Save verified coding checkpoints", "Resume coding sessions"],
20
+ "websiteURL": "https://github.com/shab1h-gh/orbit-thread",
21
+ "supportURL": "https://github.com/shab1h-gh/orbit-thread/issues",
22
+ "privacyPolicyURL": "https://github.com/shab1h-gh/orbit-thread/blob/main/PRIVACY.md",
23
+ "defaultPrompt": ["Prepare this coding project for efficient continuity, resume a fresh coding session, or save verified current state at a meaningful checkpoint."],
24
+ "brandColor": "#4338CA",
25
+ "composerIcon": "./assets/icon.png",
26
+ "logo": "./assets/icon.png"
27
+ },
28
+ "onboardingSkill": "./skills/orbit-setup/SKILL.md",
29
+ "publication": {
30
+ "release_notes": "Orbit Thread v1.1 adds project setup, living docs, configurable subagent preferences, automatic meaningful checkpoints, fresh-context recovery and Orbit Handoff migration support."
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: handoff
3
+ description: Use Orbit Thread to overwrite HANDOFF-STATE.md with a compact, secret-free checkpoint of verified coding state. Invoke when the user asks for a handoff, when Orbit Thread project instructions require a meaningful checkpoint, before a fresh/cleared session, or before finalising a completed coding task whose repository state or durable project truth changed.
4
+ ---
5
+
6
+ # Orbit Thread Handoff
7
+
8
+ Write concise current operational state for the next session, not a changelog or transcript.
9
+
10
+ ## Workflow
11
+
12
+ 1. Gather state once: `date '+%d-%m-%Y %H:%M'; git status --short --branch; git diff --stat HEAD; git log -1 --oneline`. If Git is unavailable, record that fact and use only current-session evidence. Reuse a handoff already read this session; otherwise read it once. Consult only files changed or discussed for the current work.
13
+ 2. Reconcile previous state with current evidence. Keep only facts still true and work still relevant. Drop resolved, superseded or duplicated history. Repository state and relevant tracked docs override stale handoff content.
14
+ 3. Pipe the new Markdown to this skill's `scripts/write_handoff.sh`, resolving the script relative to the loaded skill directory. In Claude Code, `${CLAUDE_SKILL_DIR}/scripts/write_handoff.sh` resolves that location; in Codex, use the loaded skill directory. Quote the path and run it with `sh`.
15
+ 4. The writer overwrites repo-root `HANDOFF-STATE.md` atomically, rejects empty/over-50-line/obviously secret-bearing content, and prints the line count. Do not re-read after a successful write.
16
+ 5. If this is an intermediate checkpoint and the current request still has unfinished work, continue. If the task is complete, return the normal final response.
17
+
18
+ ## Format
19
+
20
+ ```markdown
21
+ # Handoff State
22
+
23
+ Updated: DD-MM-YYYY HH:MM
24
+
25
+ ## Current objective
26
+ ## Completed milestones
27
+ ## In progress
28
+ ## Current state
29
+ ## Decisions
30
+ ## Open issues / risks
31
+ ## Immediate next steps
32
+ ## Verification
33
+ ```
34
+
35
+ Omit empty sections. Aim for about 30 lines; hard cap 50. State each fact once. Immediate next steps must be genuinely outstanding.
36
+
37
+ ## Rules
38
+
39
+ - Checkpoint after meaningful verified milestones and completed coding tasks when the configured project rules require it; skip read-only questions and trivial edits.
40
+ - Use read-only Git inspection only. Never commit, stage, amend, push, reset or otherwise mutate Git.
41
+ - Evidence only. Never claim a test, deployment, commit, push or external action happened without evidence.
42
+ - Never include secrets, credentials, tokens, private keys, recovery codes, `.env`/`.dev.vars` values, personal data or sensitive production data.
43
+ - Do not record the current HEAD SHA; it becomes stale easily. Exact SHAs are allowed only for immutable earlier revisions such as releases, deployments or rollback points.
44
+ - When Git is available, record the current branch as `Branch: <name>` under `Current state` so a later Resume can detect a branch change. Never record the current HEAD SHA.
45
+ - `HANDOFF-STATE.md` is local current state, intended to be Git-ignored. Never append history or make committing it a next step.
46
+ - Durable product, design, architecture, security or roadmap truth belongs in tracked living docs. Handoff does not replace those files.
47
+ - At an intermediate checkpoint, do not stop merely because the handoff was written.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "Orbit Thread Handoff"
3
+ short_description: "Save verified coding state"
4
+ default_prompt: "Use $handoff to save the verified current state and immediate next steps."
5
+ policy:
6
+ allow_implicit_invocation: true