xtruct 0.1.0__py3-none-any.whl
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.
- xtruct/__init__.py +3 -0
- xtruct/__main__.py +5 -0
- xtruct/_cli/bin/xtruct-foundry.js +3 -0
- xtruct/_cli/bin/xtruct.js +541 -0
- xtruct/_cli/global/claude-md-block.md +5 -0
- xtruct/_cli/global/skills/foundry/SKILL.md +46 -0
- xtruct/_cli/lib/dashboard.html +326 -0
- xtruct/_cli/lib/dashboard.js +212 -0
- xtruct/_cli/lib/paths.js +17 -0
- xtruct/_cli/lib/setup.js +136 -0
- xtruct/_cli/lib/tracker.js +155 -0
- xtruct/_cli/lib/ui.js +95 -0
- xtruct/_cli/package.json +44 -0
- xtruct/_cli/template/.claude/agents/architect.md +35 -0
- xtruct/_cli/template/.claude/agents/integrator.md +16 -0
- xtruct/_cli/template/.claude/agents/junior-dev.md +31 -0
- xtruct/_cli/template/.claude/agents/qa-lead.md +17 -0
- xtruct/_cli/template/.claude/agents/reviewer.md +23 -0
- xtruct/_cli/template/.claude/agents/senior-lead.md +29 -0
- xtruct/_cli/template/.claude/hooks/guard-bash.sh +38 -0
- xtruct/_cli/template/.claude/hooks/guard-files.sh +24 -0
- xtruct/_cli/template/.claude/settings.json +39 -0
- xtruct/_cli/template/.claude/skills/run-epics/SKILL.md +45 -0
- xtruct/_cli/template/.claude/workflows/plan-product.js +136 -0
- xtruct/_cli/template/.claude/workflows/run-epic.js +248 -0
- xtruct/_cli/template/.github/pull_request_template.md +24 -0
- xtruct/_cli/template/CLAUDE.md +65 -0
- xtruct/_cli/template/Makefile +8 -0
- xtruct/_cli/template/_gitignore +13 -0
- xtruct/_cli/template/contracts/README.md +4 -0
- xtruct/_cli/template/docs/CONVENTIONS.md +3 -0
- xtruct/_cli/template/docs/templates/ARCHITECTURE.md +30 -0
- xtruct/_cli/template/docs/templates/PRD.md +25 -0
- xtruct/_cli/template/docs/templates/ci.yml +22 -0
- xtruct/_cli/template/foundry.config.json +25 -0
- xtruct/_cli/template/scripts/bootstrap-github.sh +48 -0
- xtruct/_cli/template/scripts/capacity.mjs +53 -0
- xtruct/_cli/template/scripts/merge-pr.sh +97 -0
- xtruct/launcher.py +56 -0
- xtruct-0.1.0.dist-info/METADATA +41 -0
- xtruct-0.1.0.dist-info/RECORD +44 -0
- xtruct-0.1.0.dist-info/WHEEL +4 -0
- xtruct-0.1.0.dist-info/entry_points.txt +2 -0
- xtruct-0.1.0.dist-info/licenses/LICENSE +21 -0
xtruct/__init__.py
ADDED
xtruct/__main__.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const os = require('node:os')
|
|
7
|
+
const { parseArgs } = require('node:util')
|
|
8
|
+
const { spawnSync } = require('node:child_process')
|
|
9
|
+
|
|
10
|
+
const PKG = require('../package.json')
|
|
11
|
+
const ui = require('../lib/ui')
|
|
12
|
+
const { sym, paint, bold, dim, C } = ui
|
|
13
|
+
const TEMPLATE = path.join(__dirname, '..', 'template')
|
|
14
|
+
const USER_CONFIG = path.join(require('../lib/paths').CONFIG_DIR, 'config.json')
|
|
15
|
+
|
|
16
|
+
// npm strips .gitignore from published packages, so the template ships it under another name.
|
|
17
|
+
const RENAMES = { _gitignore: '.gitignore' }
|
|
18
|
+
|
|
19
|
+
// Company files that `update` refreshes. Everything else in a product (docs, contracts, code,
|
|
20
|
+
// Makefile, foundry.config.json) belongs to the product and is never touched.
|
|
21
|
+
const MANAGED = [
|
|
22
|
+
'CLAUDE.md',
|
|
23
|
+
'.claude/agents',
|
|
24
|
+
'.claude/hooks',
|
|
25
|
+
'.claude/workflows',
|
|
26
|
+
'.claude/skills',
|
|
27
|
+
'.claude/settings.json',
|
|
28
|
+
'scripts/capacity.mjs',
|
|
29
|
+
'scripts/merge-pr.sh',
|
|
30
|
+
'scripts/bootstrap-github.sh',
|
|
31
|
+
'docs/templates',
|
|
32
|
+
'.github/pull_request_template.md',
|
|
33
|
+
]
|
|
34
|
+
const EXECUTABLE_DIRS = ['scripts', '.claude/hooks']
|
|
35
|
+
const USER_KEYS = ['clickup.workspace_id', 'clickup.space_id', 'clickup.space_name', 'defaults.dir']
|
|
36
|
+
|
|
37
|
+
function help() {
|
|
38
|
+
const rows = [
|
|
39
|
+
[ui.cmd('new') + ' ' + dim('<name>'), 'Create a product repo from the company template'],
|
|
40
|
+
[ui.cmd('init'), 'Add the company to the current repo (never overwrites)'],
|
|
41
|
+
[ui.cmd('update'), 'Refresh agents, hooks, workflows and scripts in this repo'],
|
|
42
|
+
[ui.cmd('dashboard'), 'Watch the company work, live in your browser'],
|
|
43
|
+
[ui.cmd('doctor'), 'Check prerequisites'],
|
|
44
|
+
[ui.cmd('capacity'), 'How many agents this machine can run at once'],
|
|
45
|
+
[ui.cmd('config') + ' ' + dim('[key [value]]'), 'Show or set your defaults'],
|
|
46
|
+
[ui.cmd('status'), 'Every project: open work, recent requests, uncommitted changes'],
|
|
47
|
+
[ui.cmd('track') + ' ' + dim('add|start|done|block|drop|list'), 'Your work items across all projects'],
|
|
48
|
+
[ui.cmd('setup'), 'Install the tracking hooks and the work-tracking skill into Claude Code'],
|
|
49
|
+
]
|
|
50
|
+
const opts = [
|
|
51
|
+
[paint(C.violet, '--dir') + ' ' + dim('<parent>'), 'new: where to create the product'],
|
|
52
|
+
[paint(C.violet, '--no-git'), 'new: skip git init and the first commit'],
|
|
53
|
+
[paint(C.violet, '--dry-run'), 'update: show what would change'],
|
|
54
|
+
[paint(C.violet, '--json'), 'capacity: machine-readable output'],
|
|
55
|
+
[paint(C.violet, '--port') + ' ' + dim('<n>'), 'dashboard: port (default 4141)'],
|
|
56
|
+
[paint(C.violet, '--demo'), 'dashboard: show sample data'],
|
|
57
|
+
[paint(C.violet, '--clickup-space') + ' ' + dim('<id>'), 'new/init: ClickUp space (also --clickup-workspace, --clickup-space-name)'],
|
|
58
|
+
]
|
|
59
|
+
const keys = USER_KEYS.map(k => [paint(C.violet, k), ''])
|
|
60
|
+
keys[0][1] = 'ClickUp workspace new products use'
|
|
61
|
+
keys[1][1] = 'ClickUp space new products use'
|
|
62
|
+
keys[2][1] = 'or a space name to look up instead'
|
|
63
|
+
keys[3][1] = 'parent folder for new (default: current directory)'
|
|
64
|
+
return [
|
|
65
|
+
ui.banner(PKG.version, 'A software company made of Claude Code agents').replace(/\n$/, ''),
|
|
66
|
+
ui.heading('Usage'),
|
|
67
|
+
' xtruct ' + ui.cmd('<command>') + ' ' + dim('[options]'),
|
|
68
|
+
ui.heading('Commands'),
|
|
69
|
+
ui.table(rows, { indent: 4 }),
|
|
70
|
+
ui.heading('Options'),
|
|
71
|
+
ui.table(opts, { indent: 4 }),
|
|
72
|
+
ui.heading('Config'),
|
|
73
|
+
ui.table(keys, { indent: 4 }),
|
|
74
|
+
' ' + dim('saved in ' + USER_CONFIG.replace(os.homedir(), '~')),
|
|
75
|
+
'',
|
|
76
|
+
' ' + dim('Docs') + ' ' + paint(C.cyan, 'https://github.com/AdityaNarayan29/xtruct/tree/main/foundry'),
|
|
77
|
+
'',
|
|
78
|
+
].join('\n')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- helpers ----------
|
|
82
|
+
|
|
83
|
+
const ok = s => console.log(` ${sym.ok} ${s}`)
|
|
84
|
+
const warn = s => console.log(` ${sym.warn} ${s}`)
|
|
85
|
+
const step = (symbol, label, detail = '') => console.log(` ${symbol} ${ui.padEnd(label, 22)}${dim(detail)}`)
|
|
86
|
+
|
|
87
|
+
function die(msg) {
|
|
88
|
+
console.error(`\n ${ui.badge(C.rose, 'error')} ${msg}\n`)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** "32 files · 6 agents · 2 workflows · 1 skill · 2 guards" for a list of template paths. */
|
|
93
|
+
function describeTemplate(files) {
|
|
94
|
+
const direct = dir => files.filter(f => new RegExp(`^${dir.replace('.', '\\.')}/[^/]+$`).test(f)).length
|
|
95
|
+
const skills = new Set(files.filter(f => f.startsWith('.claude/skills/')).map(f => f.split('/')[2])).size
|
|
96
|
+
const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
97
|
+
return [plural(files.length, 'file'), plural(direct('.claude/agents'), 'agent'), plural(direct('.claude/workflows'), 'workflow'), plural(skills, 'skill'), plural(direct('.claude/hooks'), 'guard')].join(' · ')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const home = p => p.replace(os.homedir(), '~')
|
|
101
|
+
|
|
102
|
+
function readJSON(file, fallback) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.code === 'ENOENT' && fallback !== undefined) return fallback
|
|
107
|
+
die(`could not read ${file}: ${e.message}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeJSON(file, data) {
|
|
112
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
113
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getKey(obj, key) {
|
|
117
|
+
return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setKey(obj, key, value) {
|
|
121
|
+
const parts = key.split('.')
|
|
122
|
+
let o = obj
|
|
123
|
+
for (const k of parts.slice(0, -1)) o = o[k] ??= {}
|
|
124
|
+
o[parts.at(-1)] = value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function run(cmd, args, opts = {}) {
|
|
128
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
129
|
+
return { ok: r.status === 0, out: (r.stdout || '') + (r.stderr || ''), missing: r.error?.code === 'ENOENT' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every file in the template as [templateRelPath, productRelPath]. */
|
|
133
|
+
function templateFiles(dir = TEMPLATE, rel = '') {
|
|
134
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
|
135
|
+
const from = path.join(rel, e.name)
|
|
136
|
+
if (e.isDirectory()) return templateFiles(path.join(dir, e.name), from)
|
|
137
|
+
const base = RENAMES[e.name] || e.name
|
|
138
|
+
return [[from, path.join(rel, base)]]
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const isManaged = rel => MANAGED.some(m => rel === m || rel.startsWith(m + '/'))
|
|
143
|
+
|
|
144
|
+
/** Copy template files into dest. Returns {written, created, skipped, unchanged}. */
|
|
145
|
+
function copyTemplate(dest, { overwrite, filter = () => true, dryRun = false }) {
|
|
146
|
+
const result = { written: [], created: [], skipped: [], unchanged: [] }
|
|
147
|
+
for (const [from, to] of templateFiles()) {
|
|
148
|
+
if (!filter(to)) continue
|
|
149
|
+
const src = path.join(TEMPLATE, from)
|
|
150
|
+
const dst = path.join(dest, to)
|
|
151
|
+
const exists = fs.existsSync(dst)
|
|
152
|
+
if (exists && fs.readFileSync(dst).equals(fs.readFileSync(src))) {
|
|
153
|
+
result.unchanged.push(to)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (exists && !overwrite) {
|
|
157
|
+
result.skipped.push(to)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (!dryRun) {
|
|
161
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
162
|
+
fs.copyFileSync(src, dst)
|
|
163
|
+
if (EXECUTABLE_DIRS.some(d => to.startsWith(d + '/')) && to.endsWith('.sh')) fs.chmodSync(dst, 0o755)
|
|
164
|
+
}
|
|
165
|
+
result.written.push(to)
|
|
166
|
+
if (!exists) result.created.push(to)
|
|
167
|
+
}
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clickupDefaults(flags) {
|
|
172
|
+
const user = readJSON(USER_CONFIG, {})
|
|
173
|
+
return {
|
|
174
|
+
workspace_id: flags['clickup-workspace'] ?? getKey(user, 'clickup.workspace_id') ?? '',
|
|
175
|
+
space_id: flags['clickup-space'] ?? getKey(user, 'clickup.space_id') ?? '',
|
|
176
|
+
space_name: flags['clickup-space-name'] ?? getKey(user, 'clickup.space_name') ?? '',
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeProductConfig(dest, name, flags) {
|
|
181
|
+
const file = path.join(dest, 'foundry.config.json')
|
|
182
|
+
const cfg = readJSON(file)
|
|
183
|
+
cfg.product = name
|
|
184
|
+
cfg.foundry_version = PKG.version
|
|
185
|
+
Object.assign(cfg.clickup, clickupDefaults(flags))
|
|
186
|
+
writeJSON(file, cfg)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function findProductRoot(start = process.cwd()) {
|
|
190
|
+
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
191
|
+
if (fs.existsSync(path.join(dir, 'foundry.config.json'))) return dir
|
|
192
|
+
if (path.dirname(dir) === dir) return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function nextSteps(dir) {
|
|
197
|
+
const n = i => paint(C.cyan, String(i))
|
|
198
|
+
const lines = [
|
|
199
|
+
`${n(1)} ${dir === '.' ? dim('you are already in the product') : ui.cmd('cd ' + dir)}`,
|
|
200
|
+
`${n(2)} ${ui.cmd('xtruct doctor')}`,
|
|
201
|
+
`${n(3)} open Claude Code here and say:`,
|
|
202
|
+
` ${paint(C.violet, '"run plan-product with idea: <what to build>"')}`,
|
|
203
|
+
]
|
|
204
|
+
console.log('\n' + ui.box(lines, { title: 'Next', color: C.cyan }) + '\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------- commands ----------
|
|
208
|
+
|
|
209
|
+
function cmdNew(positionals, flags) {
|
|
210
|
+
const name = positionals[0]
|
|
211
|
+
if (!name) die('usage: xtruct new <name>')
|
|
212
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) die(`use lowercase letters, digits and dashes: ${name}`)
|
|
213
|
+
const parent = path.resolve(flags.dir || getKey(readJSON(USER_CONFIG, {}), 'defaults.dir')?.replace(/^~/, os.homedir()) || '.')
|
|
214
|
+
const dest = path.join(parent, name)
|
|
215
|
+
if (fs.existsSync(dest)) die(`already exists: ${dest}`)
|
|
216
|
+
|
|
217
|
+
console.log(ui.banner(PKG.version, 'A software company made of Claude Code agents'))
|
|
218
|
+
console.log(` ${sym.info} Creating ${bold(name)} ${dim('in ' + home(parent))}\n`)
|
|
219
|
+
const r = copyTemplate(dest, { overwrite: false })
|
|
220
|
+
step(sym.ok, 'Company template', describeTemplate(r.written))
|
|
221
|
+
writeProductConfig(dest, name, flags)
|
|
222
|
+
const space = clickupDefaults(flags)
|
|
223
|
+
step(sym.ok, 'foundry.config.json', space.space_id ? `ClickUp space ${space.space_id}` : 'defaults')
|
|
224
|
+
|
|
225
|
+
if (!flags['no-git']) {
|
|
226
|
+
const steps = [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-qm', 'chore: company scaffold from xtruct']]
|
|
227
|
+
const failed = steps.map(s => run('git', s, { cwd: dest })).find(r => !r.ok)
|
|
228
|
+
if (failed) step(sym.warn, 'Git repository', 'not committed: ' + failed.out.trim().split('\n')[0])
|
|
229
|
+
else step(sym.ok, 'Git repository', 'main · ' + run('git', ['rev-parse', '--short', 'HEAD'], { cwd: dest }).out.trim())
|
|
230
|
+
}
|
|
231
|
+
if (!space.space_id && !space.space_name) {
|
|
232
|
+
step(sym.warn, 'ClickUp space', 'not set; the planner will look it up (xtruct config clickup.space_id <id>)')
|
|
233
|
+
}
|
|
234
|
+
const rel = path.relative(process.cwd(), dest)
|
|
235
|
+
nextSteps([rel, home(dest)].sort((a, b) => a.length - b.length)[0] || '.')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cmdInit(flags) {
|
|
239
|
+
const dest = process.cwd()
|
|
240
|
+
const fresh = !fs.existsSync(path.join(dest, 'foundry.config.json'))
|
|
241
|
+
console.log(ui.banner(PKG.version, 'Adding the company to ' + path.basename(dest)))
|
|
242
|
+
const r = copyTemplate(dest, { overwrite: false })
|
|
243
|
+
if (fresh) writeProductConfig(dest, path.basename(dest).toLowerCase().replace(/[^a-z0-9-]/g, '-'), flags)
|
|
244
|
+
step(sym.ok, 'Company template', r.written.length ? describeTemplate(r.written) : 'already present')
|
|
245
|
+
if (r.skipped.length) {
|
|
246
|
+
step(sym.warn, 'Kept your files', `${r.skipped.length} existing; merge the company versions by hand:`)
|
|
247
|
+
r.skipped.forEach(f => console.log(` ${dim('·')} ${f}`))
|
|
248
|
+
if (r.skipped.includes('Makefile')) warn('your Makefile must provide: setup dev lint typecheck test e2e build ci')
|
|
249
|
+
if (r.skipped.includes('CLAUDE.md')) warn('add the company handbook to your CLAUDE.md (see the xtruct template)')
|
|
250
|
+
}
|
|
251
|
+
nextSteps('.')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cmdUpdate(flags) {
|
|
255
|
+
const root = findProductRoot()
|
|
256
|
+
if (!root) die('not inside an xtruct product (no foundry.config.json found)')
|
|
257
|
+
const cfgFile = path.join(root, 'foundry.config.json')
|
|
258
|
+
const cfg = readJSON(cfgFile)
|
|
259
|
+
const dryRun = Boolean(flags['dry-run'])
|
|
260
|
+
const r = copyTemplate(root, { overwrite: true, filter: isManaged, dryRun })
|
|
261
|
+
const from = cfg.foundry_version || 'unknown'
|
|
262
|
+
|
|
263
|
+
console.log(ui.banner(PKG.version, `${dryRun ? 'Checking' : 'Updating'} ${cfg.product || path.basename(root)}`))
|
|
264
|
+
if (!r.written.length) {
|
|
265
|
+
ok(`company files already match ${bold(PKG.version)}`)
|
|
266
|
+
} else {
|
|
267
|
+
console.log(` ${sym.info} ${dryRun ? 'Would update' : 'Updated'} ${bold(r.written.length)} file${r.written.length === 1 ? '' : 's'} ${dim(from + ' → ' + PKG.version)}\n`)
|
|
268
|
+
for (const f of r.written) {
|
|
269
|
+
const created = r.created.includes(f)
|
|
270
|
+
console.log(` ${created ? paint(C.green, '+') : paint(C.amber, '~')} ${f}${created ? dim(' new') : ''}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!dryRun && cfg.foundry_version !== PKG.version) {
|
|
274
|
+
cfg.foundry_version = PKG.version
|
|
275
|
+
writeJSON(cfgFile, cfg)
|
|
276
|
+
}
|
|
277
|
+
if (!dryRun && r.written.length) {
|
|
278
|
+
console.log(`\n ${dim('Review with')} ${ui.cmd('git diff')}${dim(', then commit. In a protected repo, open a PR with the founder-approved label.')}`)
|
|
279
|
+
}
|
|
280
|
+
console.log()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cmdDoctor() {
|
|
284
|
+
const rows = []
|
|
285
|
+
let failures = 0, warnings = 0
|
|
286
|
+
const add = (state, label, detail = '') => {
|
|
287
|
+
if (state === 'fail') failures++
|
|
288
|
+
if (state === 'warn') warnings++
|
|
289
|
+
rows.push([{ ok: sym.ok, fail: sym.fail, warn: sym.warn }[state], state === 'ok' ? label : bold(label), dim(detail)])
|
|
290
|
+
}
|
|
291
|
+
const version = (bin, args = ['--version']) => run(bin, args).out.split('\n')[0].replace(/^[^0-9]*/, '').split(' ')[0]
|
|
292
|
+
|
|
293
|
+
const [major] = process.versions.node.split('.').map(Number)
|
|
294
|
+
add(major >= 18 ? 'ok' : 'fail', 'node', major >= 18 ? process.versions.node : 'install Node 18 or newer')
|
|
295
|
+
for (const [bin, hint] of [
|
|
296
|
+
['git', 'install git'],
|
|
297
|
+
['gh', 'install the GitHub CLI: https://cli.github.com'],
|
|
298
|
+
['jq', 'brew install jq · apt install jq'],
|
|
299
|
+
['make', 'xcode-select --install on macOS'],
|
|
300
|
+
]) {
|
|
301
|
+
const missing = run(bin, ['--version']).missing
|
|
302
|
+
add(missing ? 'fail' : 'ok', bin, missing ? hint : version(bin))
|
|
303
|
+
}
|
|
304
|
+
const auth = run('gh', ['auth', 'status'])
|
|
305
|
+
if (!auth.missing) add(auth.ok ? 'ok' : 'fail', 'gh login', auth.ok ? (auth.out.match(/account (\S+)/) || [])[1] || 'logged in' : 'run: gh auth login')
|
|
306
|
+
if (run('claude', ['--version']).missing) add('warn', 'claude CLI', 'not on PATH; fine in VS Code, JetBrains or the desktop app')
|
|
307
|
+
else add('ok', 'claude', version('claude'))
|
|
308
|
+
const xtruct = [
|
|
309
|
+
path.join(os.homedir(), '.claude', 'skills', 'xtruct'),
|
|
310
|
+
path.join(os.homedir(), '.agents', 'skills', 'xtruct'),
|
|
311
|
+
path.join(process.cwd(), '.claude', 'skills', 'xtruct'),
|
|
312
|
+
].find(dir => fs.existsSync(path.join(dir, 'SKILL.md')))
|
|
313
|
+
add(xtruct ? 'ok' : 'warn', 'xtruct skill', xtruct ? home(xtruct) : 'optional diagrams: npx skills add AdityaNarayan29/xtruct -g')
|
|
314
|
+
|
|
315
|
+
console.log(ui.banner(PKG.version, 'Checking this machine'))
|
|
316
|
+
console.log(ui.table(rows, { gap: 2 }))
|
|
317
|
+
|
|
318
|
+
const root = findProductRoot()
|
|
319
|
+
if (root) {
|
|
320
|
+
const cfg = readJSON(path.join(root, 'foundry.config.json'))
|
|
321
|
+
const prows = [[sym.ok, cfg.product || path.basename(root), dim(home(root))]]
|
|
322
|
+
if (cfg.foundry_version && cfg.foundry_version !== PKG.version) prows.push([sym.warn, bold('version'), dim(`product on ${cfg.foundry_version}, you have ${PKG.version}: xtruct update`)])
|
|
323
|
+
prows.push(cfg.clickup?.space_id || cfg.clickup?.space_name
|
|
324
|
+
? [sym.ok, 'ClickUp', dim(cfg.clickup.space_id || cfg.clickup.space_name)]
|
|
325
|
+
: [sym.warn, bold('ClickUp'), dim('no space set; the planner will try to find one')])
|
|
326
|
+
prows.push(cfg.github?.repo
|
|
327
|
+
? [sym.ok, 'GitHub', dim(cfg.github.repo)]
|
|
328
|
+
: [sym.warn, bold('GitHub'), dim('no repo yet; run scripts/bootstrap-github.sh after approving the spec')])
|
|
329
|
+
warnings += prows.filter(r => r[0] === sym.warn).length
|
|
330
|
+
console.log(ui.heading('Product'))
|
|
331
|
+
console.log(ui.table(prows, { gap: 2 }))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
console.log()
|
|
335
|
+
if (failures) console.log(` ${ui.badge(C.rose, `${failures} to fix`)} ${dim('before agents can run')}`)
|
|
336
|
+
else console.log(` ${ui.badge(C.green, 'ready')} ${dim(warnings ? `${warnings} optional item${warnings > 1 ? 's' : ''} above` : 'everything checks out')}`)
|
|
337
|
+
console.log(` ${dim('Also needed: the ClickUp connector in Claude (claude.ai → Settings → Connectors)')}\n`)
|
|
338
|
+
process.exit(failures ? 1 : 0)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readCapacity() {
|
|
342
|
+
const root = findProductRoot()
|
|
343
|
+
const script = root && fs.existsSync(path.join(root, 'scripts', 'capacity.mjs'))
|
|
344
|
+
? path.join(root, 'scripts', 'capacity.mjs')
|
|
345
|
+
: path.join(TEMPLATE, 'scripts', 'capacity.mjs')
|
|
346
|
+
const r = run(process.execPath, [script], { cwd: root || process.cwd() })
|
|
347
|
+
if (!r.ok) die(`capacity check failed: ${r.out.trim()}`)
|
|
348
|
+
return { raw: r.out, cap: JSON.parse(r.out) }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cmdCapacity(flags) {
|
|
352
|
+
const { raw, cap: c } = readCapacity()
|
|
353
|
+
if (flags.json) return process.stdout.write(raw)
|
|
354
|
+
const colors = [C.cyan, C.green, C.violet, C.amber, C.orange]
|
|
355
|
+
console.log(ui.banner(PKG.version, 'How many agents this machine can run at once'))
|
|
356
|
+
console.log(ui.table([
|
|
357
|
+
[dim('machine'), `${c.cpus} cores · ${c.ram_gb} GB · load ${c.load_1m}`],
|
|
358
|
+
[dim('budget'), `${paint(C.cyan, bold(c.total_agents))} agents at once ${dim('limited by ' + c.limited_by.replace('_', ' '))}`],
|
|
359
|
+
[dim('ceiling'), `${c.per_workflow_ceiling} per workflow ${dim('(Claude Code runtime)')}`],
|
|
360
|
+
]))
|
|
361
|
+
console.log()
|
|
362
|
+
for (let i = 0; i < c.epics_at_once; i++) {
|
|
363
|
+
console.log(` ${paint(colors[i % colors.length], '■'.repeat(c.agents_per_epic))} ${dim(`epic ${i + 1} · ${c.agents_per_epic}`)}`)
|
|
364
|
+
}
|
|
365
|
+
const spare = c.total_agents - c.epics_at_once * c.agents_per_epic
|
|
366
|
+
if (spare > 0) console.log(` ${dim('□'.repeat(spare))} ${dim(`${spare} spare`)}`)
|
|
367
|
+
console.log(`\n ${dim('Tune in foundry.config.json → limits: agents_per_core, ram_per_agent_gb, reserve_ram_gb, max_agents')}\n`)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function cmdConfig(positionals) {
|
|
371
|
+
const [key, value] = positionals
|
|
372
|
+
const cfg = readJSON(USER_CONFIG, {})
|
|
373
|
+
if (!key) {
|
|
374
|
+
console.log()
|
|
375
|
+
console.log(ui.table(USER_KEYS.map(k => [paint(C.violet, k), getKey(cfg, k) ?? dim('not set')])))
|
|
376
|
+
console.log(`\n ${dim(home(USER_CONFIG))}\n`)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (!USER_KEYS.includes(key)) die(`unknown key ${key}. Known: ${USER_KEYS.join(', ')}`)
|
|
380
|
+
if (value === undefined) {
|
|
381
|
+
console.log(getKey(cfg, key) ?? '')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
setKey(cfg, key, value)
|
|
385
|
+
writeJSON(USER_CONFIG, cfg)
|
|
386
|
+
ok(`${paint(C.violet, key)} = ${value}`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------- tracking ----------
|
|
390
|
+
|
|
391
|
+
const tracker = () => require('../lib/tracker')
|
|
392
|
+
const STATE_COLOR = { open: C.cyan, doing: C.green, blocked: C.rose, done: C.dim, dropped: C.dim }
|
|
393
|
+
|
|
394
|
+
function itemRow(t, i) {
|
|
395
|
+
const state = paint(STATE_COLOR[i.state] || C.slate, i.state.padEnd(7))
|
|
396
|
+
const note = i.notes.at(-1)
|
|
397
|
+
return [paint(C.violet, '#' + i.id), state, i.title, dim(t.ago(i.updated) + (i.link ? ' · ' + i.link : '') + (note ? ' · ' + note.text : ''))]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function cmdTrack(positionals, flags) {
|
|
401
|
+
const t = tracker()
|
|
402
|
+
const [action = 'list', ...rest] = positionals
|
|
403
|
+
const project = flags.project ? path.resolve(flags.project) : t.projectOf()
|
|
404
|
+
const verbs = { start: 'doing', done: 'done', block: 'blocked', drop: 'dropped', reopen: 'open' }
|
|
405
|
+
|
|
406
|
+
if (action === 'add') {
|
|
407
|
+
const title = rest.join(' ').trim()
|
|
408
|
+
if (!title) die('usage: xtruct track add "<what you are working on>" [--link <url>] [--note <text>]')
|
|
409
|
+
const item = t.add({ title, project, link: flags.link, note: flags.note })
|
|
410
|
+
if (flags.json) return console.log(JSON.stringify(item))
|
|
411
|
+
return ok(`${paint(C.violet, '#' + item.id)} ${item.title} ${dim('· ' + t.short(project))}`)
|
|
412
|
+
}
|
|
413
|
+
if (verbs[action] || action === 'note') {
|
|
414
|
+
const id = rest[0]
|
|
415
|
+
if (!id) die(`usage: xtruct track ${action} <id> [--note <text>]`)
|
|
416
|
+
const note = flags.note || (action === 'note' ? rest.slice(1).join(' ') : undefined)
|
|
417
|
+
const item = t.update(id, { state: verbs[action], note, link: flags.link })
|
|
418
|
+
if (!item) die(`no work item #${id}`)
|
|
419
|
+
if (flags.json) return console.log(JSON.stringify(item))
|
|
420
|
+
return ok(`${paint(C.violet, '#' + item.id)} ${paint(STATE_COLOR[item.state], item.state)} ${item.title}`)
|
|
421
|
+
}
|
|
422
|
+
if (action === 'list') {
|
|
423
|
+
const items = t.list({ project: flags.all ? undefined : project, all: Boolean(flags.done) })
|
|
424
|
+
if (flags.json) return console.log(JSON.stringify(items))
|
|
425
|
+
if (!items.length) return console.log(`\n ${dim(flags.all ? 'No open work items anywhere.' : 'No open work items in ' + t.short(project) + '. Use --all for every project.')}\n`)
|
|
426
|
+
let last = null
|
|
427
|
+
console.log()
|
|
428
|
+
for (const i of items) {
|
|
429
|
+
if (i.project !== last) { console.log(` ${bold(t.short(i.project))}`); last = i.project }
|
|
430
|
+
console.log(ui.table([itemRow(t, i)], { indent: 4, gap: 2 }))
|
|
431
|
+
}
|
|
432
|
+
return console.log()
|
|
433
|
+
}
|
|
434
|
+
die(`unknown track action "${action}". Use add, start, done, block, drop, reopen, note or list`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function cmdStatus(flags) {
|
|
438
|
+
const t = tracker()
|
|
439
|
+
const projects = t.portfolio({ days: Number(flags.days) || 14 })
|
|
440
|
+
if (flags.json) return console.log(JSON.stringify(projects))
|
|
441
|
+
console.log(ui.banner(PKG.version, 'Everything you have in flight'))
|
|
442
|
+
if (!projects.length) return console.log(` ${dim('Nothing tracked yet. Prompts and work items will show up here.')}\n`)
|
|
443
|
+
for (const p of projects) {
|
|
444
|
+
const git = t.gitSummary(p.project)
|
|
445
|
+
const product = fs.existsSync(path.join(p.project, 'foundry.config.json'))
|
|
446
|
+
const head = [
|
|
447
|
+
bold(t.short(p.project)),
|
|
448
|
+
product ? paint(C.cyan, '◆ product') : '',
|
|
449
|
+
git ? dim(`${git.branch}${git.changed ? ` · ${git.changed} uncommitted` : ''}`) : '',
|
|
450
|
+
dim(`· ${p.prompts} request${p.prompts === 1 ? '' : 's'}${p.last ? ' · last ' + t.ago(p.last) : ''}`),
|
|
451
|
+
].filter(Boolean).join(' ')
|
|
452
|
+
console.log(` ${p.items.length ? sym.info : dim('·')} ${head}`)
|
|
453
|
+
if (p.items.length) console.log(ui.table(p.items.map(i => itemRow(t, i)), { indent: 6, gap: 2 }))
|
|
454
|
+
}
|
|
455
|
+
console.log(`\n ${dim('Details:')} ${ui.cmd('xtruct track list --all')}\n`)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Hooks must never block or break a session: read stdin, do the work, swallow every error. */
|
|
459
|
+
function cmdHook(positionals) {
|
|
460
|
+
const event = positionals[0]
|
|
461
|
+
let input = {}
|
|
462
|
+
try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}') } catch {}
|
|
463
|
+
try {
|
|
464
|
+
const t = tracker()
|
|
465
|
+
const project = t.projectOf(input.cwd || process.cwd())
|
|
466
|
+
if (event === 'prompt') {
|
|
467
|
+
const prompt = String(input.prompt || '').trim()
|
|
468
|
+
if (prompt) t.appendJournal({ at: new Date().toISOString(), project, session: input.session_id || null, prompt: prompt.slice(0, 600) })
|
|
469
|
+
} else if (event === 'session') {
|
|
470
|
+
const r = t.resume(project)
|
|
471
|
+
const open = r.items.length
|
|
472
|
+
process.stdout.write(JSON.stringify({
|
|
473
|
+
systemMessage: open
|
|
474
|
+
? `◆ xtruct: ${open} open item${open > 1 ? 's' : ''} in ${path.basename(project)}${r.git?.changed ? ` · ${r.git.changed} uncommitted` : ''}`
|
|
475
|
+
: undefined,
|
|
476
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: r.text },
|
|
477
|
+
}))
|
|
478
|
+
}
|
|
479
|
+
} catch { /* never fail the user's session */ }
|
|
480
|
+
process.exit(0)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function cmdSetup(flags) {
|
|
484
|
+
require('../lib/setup').run({ dryRun: Boolean(flags['dry-run']), uninstall: Boolean(flags.uninstall), version: PKG.version })
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------- main ----------
|
|
488
|
+
|
|
489
|
+
function main() {
|
|
490
|
+
let parsed
|
|
491
|
+
try {
|
|
492
|
+
parsed = parseArgs({
|
|
493
|
+
allowPositionals: true,
|
|
494
|
+
options: {
|
|
495
|
+
dir: { type: 'string' },
|
|
496
|
+
'no-git': { type: 'boolean' },
|
|
497
|
+
'dry-run': { type: 'boolean' },
|
|
498
|
+
json: { type: 'boolean' },
|
|
499
|
+
port: { type: 'string' },
|
|
500
|
+
'no-open': { type: 'boolean' },
|
|
501
|
+
demo: { type: 'boolean' },
|
|
502
|
+
project: { type: 'string' },
|
|
503
|
+
link: { type: 'string' },
|
|
504
|
+
note: { type: 'string' },
|
|
505
|
+
all: { type: 'boolean' },
|
|
506
|
+
done: { type: 'boolean' },
|
|
507
|
+
days: { type: 'string' },
|
|
508
|
+
uninstall: { type: 'boolean' },
|
|
509
|
+
'clickup-workspace': { type: 'string' },
|
|
510
|
+
'clickup-space': { type: 'string' },
|
|
511
|
+
'clickup-space-name': { type: 'string' },
|
|
512
|
+
help: { type: 'boolean', short: 'h' },
|
|
513
|
+
version: { type: 'boolean', short: 'v' },
|
|
514
|
+
},
|
|
515
|
+
})
|
|
516
|
+
} catch (e) {
|
|
517
|
+
die(e.message)
|
|
518
|
+
}
|
|
519
|
+
const { values: flags, positionals } = parsed
|
|
520
|
+
const [cmd, ...rest] = positionals
|
|
521
|
+
|
|
522
|
+
if (flags.version) return console.log(PKG.version)
|
|
523
|
+
if (flags.help || !cmd || cmd === 'help') return console.log(help())
|
|
524
|
+
|
|
525
|
+
switch (cmd) {
|
|
526
|
+
case 'new': return cmdNew(rest, flags)
|
|
527
|
+
case 'init': return cmdInit(flags)
|
|
528
|
+
case 'update': return cmdUpdate(flags)
|
|
529
|
+
case 'doctor': return cmdDoctor()
|
|
530
|
+
case 'capacity': return cmdCapacity(flags)
|
|
531
|
+
case 'track': return cmdTrack(rest, flags)
|
|
532
|
+
case 'status': return cmdStatus(flags)
|
|
533
|
+
case 'hook': return cmdHook(rest)
|
|
534
|
+
case 'setup': return cmdSetup(flags)
|
|
535
|
+
case 'dashboard': return require('../lib/dashboard').start({ root: findProductRoot(), port: Number(flags.port) || 4141, open: !flags['no-open'], demo: Boolean(flags.demo), capacity: () => readCapacity().cap, version: PKG.version })
|
|
536
|
+
case 'config': return cmdConfig(rest)
|
|
537
|
+
default: die(`unknown command "${cmd}". Run xtruct --help`)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
main()
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## Work tracking (xtruct)
|
|
2
|
+
|
|
3
|
+
- Every task that changes files or takes several steps gets a work item: `xtruct track add "<title>"` at the start, `xtruct track done <id> --note "<what changed>"` at the end, `xtruct track block <id> --note "<what's needed>"` when waiting on me. Questions don't need one. Follow the `foundry` skill.
|
|
4
|
+
- Each session starts with a "Where the founder left off" note. Use it to continue open work instead of starting over, and mention open items when they relate to what I ask.
|
|
5
|
+
- New products or multi-part features go through Xtruct as the `foundry` skill describes. Never launch an agent team without my explicit OK in the conversation.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: foundry
|
|
3
|
+
description: Track and route the founder's work across every project. Use at the start of any task that changes files or takes several steps (open a work item), when finishing or pausing it (close or block it), when the founder asks what's in flight, where they left off or for status, and when they ask for a new product or a multi-part feature (route it into Xtruct).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Xtruct: keep track, route big work
|
|
7
|
+
|
|
8
|
+
The founder works across many projects and must never lose track. Every session starts with a "Where the founder left off" note (from a hook) listing open work items, recent requests and uncommitted changes for this project. Use it to pick up where things stopped instead of starting over.
|
|
9
|
+
|
|
10
|
+
## Keep the ledger (every non-trivial task)
|
|
11
|
+
|
|
12
|
+
| When | Run |
|
|
13
|
+
|---|---|
|
|
14
|
+
| A task that changes files or takes several steps begins | `xtruct track add "<short title>"`, unless an open item already covers it (check the session note or `xtruct track list`) |
|
|
15
|
+
| You start working on it | `xtruct track start <id>` |
|
|
16
|
+
| It's finished | `xtruct track done <id> --note "<what changed and where>"` |
|
|
17
|
+
| You're waiting on the founder, access or a decision | `xtruct track block <id> --note "<what's needed>"` |
|
|
18
|
+
| The founder drops it | `xtruct track drop <id>` |
|
|
19
|
+
| You stop mid-task | Leave it open with `xtruct track note <id> "<next step>"` |
|
|
20
|
+
|
|
21
|
+
- Pure questions and explanations don't get items.
|
|
22
|
+
- Titles say the outcome ("Add refund webhook to payments API"), not the activity.
|
|
23
|
+
- Add `--link <url>` for a PR, ClickUp task or artifact.
|
|
24
|
+
- When the founder asks "what's pending?", "where was I?" or "status": run `xtruct status` (all projects) or `xtruct track list` (this one), and summarize.
|
|
25
|
+
|
|
26
|
+
## Route the work
|
|
27
|
+
|
|
28
|
+
| Request | What to do |
|
|
29
|
+
|---|---|
|
|
30
|
+
| A question, an explanation, or a small change (a few files, under about an hour of human work) | Do it yourself, tracked as above. |
|
|
31
|
+
| A multi-part feature in a **Xtruct product** (`foundry.config.json` exists) | Propose it as an epic: goal, rough task list, what runs in parallel. **Ask the founder to confirm**, then create the epic in ClickUp and use the `run-epics` skill. |
|
|
32
|
+
| A multi-part feature in **any other repo** | Do it yourself in tracked steps. If it's large, offer `xtruct init` to bring in the agent company. That changes the repo, so ask first. |
|
|
33
|
+
| A brand-new product | Confirm the name and idea, then `xtruct new <name>`, then the `plan-product` workflow in the new folder. |
|
|
34
|
+
|
|
35
|
+
**Never start a multi-agent workflow without the founder's explicit OK in this conversation.** Agent teams cost real money. Say how many agents and roughly what they'll do before asking.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
xtruct status everything in flight, all projects
|
|
41
|
+
xtruct track list [--all] [--done]
|
|
42
|
+
xtruct track add "<title>" [--link <url>] [--note <text>]
|
|
43
|
+
xtruct track start|done|block|drop|reopen <id> [--note <text>]
|
|
44
|
+
xtruct dashboard live view inside an Xtruct product
|
|
45
|
+
xtruct capacity agents this machine can run
|
|
46
|
+
```
|