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,33 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Replace HANDOFF-STATE.md (repo root by default) with stdin after validation.
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
target="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)/HANDOFF-STATE.md}"
|
|
6
|
+
tmp="$(mktemp "$(dirname "$target")/.handoff.XXXXXX")"
|
|
7
|
+
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
|
8
|
+
|
|
9
|
+
cat > "$tmp"
|
|
10
|
+
|
|
11
|
+
if [ ! -s "$tmp" ]; then
|
|
12
|
+
echo "handoff rejected: content is empty" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# awk also counts a final line without a trailing newline.
|
|
17
|
+
lines="$(awk 'END { print NR }' "$tmp")"
|
|
18
|
+
if [ "$lines" -gt 50 ]; then
|
|
19
|
+
echo "handoff rejected: $lines lines; maximum is 50" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Backstop for obvious secrets. Report line numbers only, never the matched text.
|
|
24
|
+
secret_re='-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|(sk|rk)_(live|test)_[0-9A-Za-z]{10,}|whsec_[0-9A-Za-z]{10,}|gh[pousr]_[0-9A-Za-z]{20,}|github_pat_[0-9A-Za-z_]{20,}|xox[abprs]-[0-9A-Za-z-]{10,}|eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\.|(secret|token|passw(or)?d|api[_-]?key)[a-z_]*["'\'']?[[:space:]]*[:=][[:space:]]*["'\'']?[^[:space:]"'\''`]{12,}'
|
|
25
|
+
hits="$(grep -Ein -e "$secret_re" "$tmp" | cut -d: -f1 | tr '\n' ' ' || true)"
|
|
26
|
+
if [ -n "$hits" ]; then
|
|
27
|
+
echo "handoff rejected: possible secret on line(s) ${hits% }" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
mv "$tmp" "$target"
|
|
32
|
+
trap - EXIT HUP INT TERM
|
|
33
|
+
printf 'wrote %s (%s lines)\n' "$target" "$lines"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orbit-setup
|
|
3
|
+
description: Set up or upgrade a coding project for token-efficient long-running work with Orbit Thread. Use when the user invokes orbit-setup, asks to initialise project continuity, wants lightweight living project docs and agent rules before development, or asks to configure Orbit Thread project workflow.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Orbit Thread Setup
|
|
7
|
+
|
|
8
|
+
Prepare the current project for efficient Codex and Claude Code sessions without overwriting existing project knowledge.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Resolve this skill's directory and run `scripts/setup_project.mjs` with Node from inside the user's project:
|
|
13
|
+
- Claude standalone: `node "${CLAUDE_SKILL_DIR}/scripts/setup_project.mjs" setup --agent both`
|
|
14
|
+
- Codex: resolve the loaded skill directory and run the same script from there.
|
|
15
|
+
2. The script creates only missing living-document templates, preserves equivalent root/docs files, updates the managed Orbit Thread blocks in `AGENTS.md` and `CLAUDE.md`, creates `.orbit-thread/config.json`, and ensures local handoff/tool state is Git-ignored.
|
|
16
|
+
3. Do not create `HANDOFF-STATE.md` during setup. The handoff skill creates it at the first meaningful checkpoint.
|
|
17
|
+
4. If the conversation already contains clear durable project facts, fill only relevant TODOs in newly created docs. Do not broadly scan the repository or invent product, architecture, security or design decisions.
|
|
18
|
+
5. Report created, updated and preserved files concisely.
|
|
19
|
+
|
|
20
|
+
## Project shape
|
|
21
|
+
|
|
22
|
+
Orbit Thread prefers these living docs under `docs/` when no equivalent root/docs file already exists:
|
|
23
|
+
|
|
24
|
+
- `PRODUCT.md` — product purpose, workflows, scope and constraints.
|
|
25
|
+
- `DESIGN.md` — durable visual/interaction rules.
|
|
26
|
+
- `ARCHITECTURE.md` — system shape, boundaries, persistence and operations.
|
|
27
|
+
- `SECURITY.md` — trust boundaries, controls and known risks.
|
|
28
|
+
- `ROADMAP.md` — forward-looking current plan only.
|
|
29
|
+
|
|
30
|
+
These are living current-state documents, not changelogs. Update affected sections in place as implementation truth changes; Git is the history.
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
- Preserve existing files and unrelated instructions. Never replace project docs merely to standardise layout or create duplicate root/docs copies.
|
|
35
|
+
- Keep setup token-efficient: inspect only target files and facts needed for setup.
|
|
36
|
+
- Do not read secret files or write credentials, personal data or sensitive production data into project docs.
|
|
37
|
+
- Do not commit, push, merge, initialise Git or make other Git writes.
|
|
38
|
+
- Setup may run before Git is initialised; then the current directory is the project root.
|
|
39
|
+
- Setup-created living docs and `.orbit-thread/config.json` are project-owned and are preserved on uninstall.
|
|
40
|
+
- For subagent preferences, use the npm CLI after setup, for example:
|
|
41
|
+
`npx orbit-thread configure --agent claude --model "Claude Sonnet 5.5" --effort low`
|
|
42
|
+
or `npx orbit-thread configure --agent codex --model "GPT-6.1 Sol" --reasoning high`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Orbit Thread Setup"
|
|
3
|
+
short_description: "Prepare efficient coding continuity"
|
|
4
|
+
default_prompt: "Use $orbit-setup to prepare this project with lightweight living docs and Orbit Thread workflow rules."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const PRODUCT = 'orbit-thread';
|
|
6
|
+
const SCHEMA = 1;
|
|
7
|
+
const agents = ['codex', 'claude'];
|
|
8
|
+
const start = '<!-- orbit-thread:subagents:start -->';
|
|
9
|
+
const end = '<!-- orbit-thread:subagents:end -->';
|
|
10
|
+
|
|
11
|
+
const exists = p => {
|
|
12
|
+
try { fs.lstatSync(p); return true; }
|
|
13
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function safePath(p) {
|
|
17
|
+
let cursor = path.resolve(p);
|
|
18
|
+
while (true) {
|
|
19
|
+
if (exists(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Refusing symlink: ${cursor}`);
|
|
20
|
+
const parent = path.dirname(cursor);
|
|
21
|
+
if (parent === cursor) break;
|
|
22
|
+
cursor = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function atomicWrite(p, content) {
|
|
27
|
+
safePath(p);
|
|
28
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
29
|
+
const temporary = path.join(path.dirname(p), `.orbit-thread-${crypto.randomUUID()}.tmp`);
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: exists(p) ? fs.statSync(p).mode & 0o777 : 0o644 });
|
|
32
|
+
fs.renameSync(temporary, p);
|
|
33
|
+
} finally {
|
|
34
|
+
if (exists(temporary)) fs.unlinkSync(temporary);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const configPath = root => path.join(root, '.orbit-thread/config.json');
|
|
39
|
+
const statePath = root => path.join(root, '.orbit-thread/state.json');
|
|
40
|
+
const defaultConfig = () => ({
|
|
41
|
+
product: PRODUCT,
|
|
42
|
+
schema: SCHEMA,
|
|
43
|
+
subagents: {
|
|
44
|
+
max: 2,
|
|
45
|
+
codex: { model: null, reasoning: null },
|
|
46
|
+
claude: { model: null, reasoning: null },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function validate(config, source = 'Orbit Thread config') {
|
|
51
|
+
if (!config || config.product !== PRODUCT || config.schema !== SCHEMA ||
|
|
52
|
+
!config.subagents || !Number.isInteger(config.subagents.max) ||
|
|
53
|
+
config.subagents.max < 0 || config.subagents.max > 2) {
|
|
54
|
+
throw new Error(`Invalid ${source}`);
|
|
55
|
+
}
|
|
56
|
+
for (const agent of agents) {
|
|
57
|
+
const pref = config.subagents[agent];
|
|
58
|
+
if (!pref || !Object.hasOwn(pref, 'model') || !Object.hasOwn(pref, 'reasoning')) throw new Error(`Invalid ${source}`);
|
|
59
|
+
for (const key of ['model', 'reasoning']) {
|
|
60
|
+
const value = pref[key];
|
|
61
|
+
const limit = key === 'model' ? 100 : 40;
|
|
62
|
+
if (value !== null && (typeof value !== 'string' || !value.trim() || value.includes('\n') || value.length > limit)) {
|
|
63
|
+
throw new Error(`Invalid ${source}: ${agent}.${key}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readProjectConfig(root) {
|
|
71
|
+
const p = configPath(path.resolve(root));
|
|
72
|
+
if (!exists(p)) return defaultConfig();
|
|
73
|
+
return validate(JSON.parse(fs.readFileSync(p, 'utf8')), p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ensureProjectConfig(root) {
|
|
77
|
+
root = path.resolve(root);
|
|
78
|
+
const config = readProjectConfig(root);
|
|
79
|
+
if (!exists(configPath(root))) atomicWrite(configPath(root), JSON.stringify(config, null, 2) + '\n');
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderSubagentBlock(config) {
|
|
84
|
+
validate(config);
|
|
85
|
+
const lines = [start];
|
|
86
|
+
if (config.subagents.max === 0) {
|
|
87
|
+
lines.push('- Subagents are disabled for this project.');
|
|
88
|
+
} else {
|
|
89
|
+
lines.push(`- Subagents: default to none. Use at most ${config.subagents.max} total/concurrently, only for genuinely separable work where isolated context materially helps. Give each subagent a narrow scope, avoid duplicate repository reads, and require concise findings. No recursive subagents. The main agent owns integration and final decisions.`);
|
|
90
|
+
for (const agent of agents) {
|
|
91
|
+
const pref = config.subagents[agent];
|
|
92
|
+
if (!pref.model && !pref.reasoning) continue;
|
|
93
|
+
const details = [
|
|
94
|
+
pref.model ? `model ${pref.model}` : null,
|
|
95
|
+
pref.reasoning ? `reasoning/effort ${pref.reasoning}` : null,
|
|
96
|
+
].filter(Boolean).join(', ');
|
|
97
|
+
lines.push(`- Preferred ${agent === 'codex' ? 'Codex' : 'Claude'} subagent setting when the host supports it: ${details}. Treat this as a preference, not a reason to block work if the exact setting is unavailable.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
lines.push(end);
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clean(value, label, max) {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
if (value === null) return null;
|
|
107
|
+
const text = String(value).trim();
|
|
108
|
+
if (!text || text.includes('\n') || text.length > max) throw new Error(`Invalid ${label}`);
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function replaceManagedSubagentBlock(text, replacement) {
|
|
113
|
+
const first = text.indexOf(start);
|
|
114
|
+
const last = text.indexOf(end);
|
|
115
|
+
if (first < 0 || last < first || text.indexOf(start, first + start.length) >= 0 || text.indexOf(end, last + end.length) >= 0) {
|
|
116
|
+
throw new Error('Orbit Thread subagent block is missing or malformed; run setup before configure.');
|
|
117
|
+
}
|
|
118
|
+
return text.slice(0, first) + replacement + text.slice(last + end.length);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function configureProject(root, { agent, maxSubagents, model, reasoning, resetSubagents = false } = {}) {
|
|
122
|
+
root = path.resolve(root);
|
|
123
|
+
const stateFile = statePath(root);
|
|
124
|
+
const agentsFile = path.join(root, 'AGENTS.md');
|
|
125
|
+
if (!exists(stateFile) || !exists(agentsFile)) throw new Error('Orbit Thread project setup is not configured; run `npx orbit-thread setup` first.');
|
|
126
|
+
|
|
127
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
128
|
+
const owned = state?.additions?.['AGENTS.md'];
|
|
129
|
+
if (!owned || typeof owned.inserted !== 'string') throw new Error('Orbit Thread AGENTS.md ownership state is missing; run setup first.');
|
|
130
|
+
|
|
131
|
+
let config = readProjectConfig(root);
|
|
132
|
+
if (resetSubagents) {
|
|
133
|
+
if (agent && agents.includes(agent)) {
|
|
134
|
+
config.subagents[agent] = { model: null, reasoning: null };
|
|
135
|
+
} else {
|
|
136
|
+
for (const name of agents) config.subagents[name] = { model: null, reasoning: null };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (maxSubagents !== undefined) {
|
|
140
|
+
const value = Number(maxSubagents);
|
|
141
|
+
if (!Number.isInteger(value) || value < 0 || value > 2) throw new Error('max-subagents must be 0, 1 or 2');
|
|
142
|
+
config.subagents.max = value;
|
|
143
|
+
}
|
|
144
|
+
const cleanModel = clean(model, 'subagent model', 100);
|
|
145
|
+
const cleanReasoning = clean(reasoning, 'subagent reasoning/effort', 40);
|
|
146
|
+
if (cleanModel !== undefined || cleanReasoning !== undefined) {
|
|
147
|
+
if (!agents.includes(agent)) throw new Error('Choose --agent codex or --agent claude when setting a model or reasoning/effort preference.');
|
|
148
|
+
if (cleanModel !== undefined) config.subagents[agent].model = cleanModel;
|
|
149
|
+
if (cleanReasoning !== undefined) config.subagents[agent].reasoning = cleanReasoning;
|
|
150
|
+
}
|
|
151
|
+
validate(config);
|
|
152
|
+
|
|
153
|
+
const before = fs.readFileSync(agentsFile, 'utf8');
|
|
154
|
+
const index = before.indexOf(owned.inserted);
|
|
155
|
+
if (index < 0 || index !== before.lastIndexOf(owned.inserted)) {
|
|
156
|
+
throw new Error('Managed Orbit Thread AGENTS.md block was edited or duplicated; reconcile it before configure.');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const replacement = renderSubagentBlock(config);
|
|
160
|
+
const nextInserted = replaceManagedSubagentBlock(owned.inserted, replacement);
|
|
161
|
+
const nextFile = before.slice(0, index) + nextInserted + before.slice(index + owned.inserted.length);
|
|
162
|
+
state.additions['AGENTS.md'].inserted = nextInserted;
|
|
163
|
+
|
|
164
|
+
atomicWrite(configPath(root), JSON.stringify(config, null, 2) + '\n');
|
|
165
|
+
atomicWrite(agentsFile, nextFile);
|
|
166
|
+
atomicWrite(stateFile, JSON.stringify(state, null, 2) + '\n');
|
|
167
|
+
return config;
|
|
168
|
+
}
|