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/_cli/lib/setup.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// `xtruct setup`: wires Xtruct into Claude Code for every project on this machine.
|
|
3
|
+
// 1. the `xtruct` command on PATH (a link in ~/.local/bin, plus the old `xtruct-foundry` name)
|
|
4
|
+
// 2. the global `foundry` skill (~/.claude/skills/foundry)
|
|
5
|
+
// 3. a marked block in ~/.claude/CLAUDE.md
|
|
6
|
+
// 4. two hooks + one permission in ~/.claude/settings.json (merged, backed up first)
|
|
7
|
+
// Safe to run again; `--uninstall` removes exactly what it added.
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const path = require('node:path')
|
|
11
|
+
const os = require('node:os')
|
|
12
|
+
const ui = require('./ui')
|
|
13
|
+
|
|
14
|
+
const HOME = os.homedir()
|
|
15
|
+
const CLAUDE = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude')
|
|
16
|
+
const SETTINGS = path.join(CLAUDE, 'settings.json')
|
|
17
|
+
const CLAUDE_MD = path.join(CLAUDE, 'CLAUDE.md')
|
|
18
|
+
const SKILL_DIR = path.join(CLAUDE, 'skills', 'foundry')
|
|
19
|
+
const BIN_DIR = process.env.XTRUCT_FOUNDRY_BIN_DIR || path.join(HOME, '.local', 'bin')
|
|
20
|
+
const ROOT = path.join(__dirname, '..')
|
|
21
|
+
const CLI = path.join(ROOT, 'bin', 'xtruct.js')
|
|
22
|
+
const COMMANDS = ['xtruct', 'xtruct-foundry'] // the second keeps hooks and notes from older installs working
|
|
23
|
+
const MARK_START = '<!-- xtruct:start -->'
|
|
24
|
+
const MARK_END = '<!-- xtruct:end -->'
|
|
25
|
+
const LEGACY_MARKS = ['<!-- xtruct-foundry:start -->', '<!-- xtruct-foundry:end -->']
|
|
26
|
+
const PERMISSION = 'Bash(xtruct:*)'
|
|
27
|
+
const LEGACY_PERMISSIONS = ['Bash(xtruct-foundry:*)']
|
|
28
|
+
const isOurs = h => typeof h.command === 'string' && /bin[\\/]xtruct(-foundry)?\.js"? hook /.test(h.command)
|
|
29
|
+
|
|
30
|
+
const q = s => `"${s.replace(/(["\\$`])/g, '\\$1')}"`
|
|
31
|
+
const hookCommand = event => `${q(process.execPath)} ${q(CLI)} hook ${event}`
|
|
32
|
+
|
|
33
|
+
function readSettings() {
|
|
34
|
+
if (!fs.existsSync(SETTINGS)) return {}
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(SETTINGS, 'utf8')) } catch (e) {
|
|
36
|
+
throw new Error(`${SETTINGS} is not valid JSON (${e.message}). Fix it first; setup will not overwrite it.`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove our hook entries, keep everything else, drop now-empty groups. */
|
|
41
|
+
function withoutOurHooks(hooks = {}) {
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
44
|
+
const kept = groups.map(g => ({ ...g, hooks: (g.hooks || []).filter(h => !isOurs(h)) })).filter(g => g.hooks.length)
|
|
45
|
+
if (kept.length) out[event] = kept
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function plan({ uninstall }) {
|
|
51
|
+
const settings = readSettings()
|
|
52
|
+
const next = JSON.parse(JSON.stringify(settings))
|
|
53
|
+
next.hooks = withoutOurHooks(settings.hooks)
|
|
54
|
+
next.permissions = next.permissions || {}
|
|
55
|
+
next.permissions.allow = (next.permissions.allow || []).filter(p => p !== PERMISSION && !LEGACY_PERMISSIONS.includes(p))
|
|
56
|
+
if (!uninstall) {
|
|
57
|
+
const add = (event, cmd, extra) => (next.hooks[event] ||= []).push({ hooks: [{ type: 'command', command: cmd, timeout: 10, ...extra }] })
|
|
58
|
+
add('UserPromptSubmit', hookCommand('prompt'))
|
|
59
|
+
add('SessionStart', hookCommand('session'))
|
|
60
|
+
next.permissions.allow.push(PERMISSION)
|
|
61
|
+
}
|
|
62
|
+
if (!Object.keys(next.hooks).length) delete next.hooks
|
|
63
|
+
if (!next.permissions.allow.length) delete next.permissions.allow
|
|
64
|
+
if (!Object.keys(next.permissions).length) delete next.permissions
|
|
65
|
+
|
|
66
|
+
const md = fs.existsSync(CLAUDE_MD) ? fs.readFileSync(CLAUDE_MD, 'utf8') : ''
|
|
67
|
+
const strip = (text, [start, end]) => text.replace(new RegExp(`\\n*${start}[\\s\\S]*?${end}\\n*`), '\n')
|
|
68
|
+
const stripped = [[MARK_START, MARK_END], LEGACY_MARKS].reduce(strip, md).replace(/^\n+/, '')
|
|
69
|
+
const block = fs.readFileSync(path.join(ROOT, 'global', 'claude-md-block.md'), 'utf8').trim()
|
|
70
|
+
const nextMd = uninstall ? stripped : `${stripped.trimEnd()}${stripped.trim() ? '\n\n' : ''}${MARK_START}\n${block}\n${MARK_END}\n`
|
|
71
|
+
|
|
72
|
+
return { settings, next, md, nextMd }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run({ dryRun = false, uninstall = false, version }) {
|
|
76
|
+
console.log(ui.banner(version, uninstall ? 'Removing Xtruct from Claude Code' : 'Wiring Xtruct into Claude Code'))
|
|
77
|
+
let p
|
|
78
|
+
try { p = plan({ uninstall }) } catch (e) {
|
|
79
|
+
console.error(` ${ui.badge(ui.C.rose, 'error')} ${e.message}\n`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
const step = (label, detail) => console.log(` ${dryRun ? ui.sym.info : ui.sym.ok} ${ui.padEnd(label, 26)}${ui.dim(detail)}`)
|
|
83
|
+
const home = s => s.replace(HOME, '~')
|
|
84
|
+
|
|
85
|
+
// 1. Command on PATH
|
|
86
|
+
const link = path.join(BIN_DIR, COMMANDS[0])
|
|
87
|
+
if (!dryRun) {
|
|
88
|
+
fs.mkdirSync(BIN_DIR, { recursive: true })
|
|
89
|
+
for (const name of COMMANDS) {
|
|
90
|
+
const l = path.join(BIN_DIR, name)
|
|
91
|
+
try { fs.unlinkSync(l) } catch {}
|
|
92
|
+
if (!uninstall) fs.symlinkSync(CLI, l)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const onPath = (process.env.PATH || '').split(':').includes(BIN_DIR)
|
|
96
|
+
step(uninstall ? 'Removed command' : 'Command', `${home(link)}${uninstall ? '' : ' → ' + home(CLI)}`)
|
|
97
|
+
if (!uninstall && !onPath) console.log(` ${ui.sym.warn} ${ui.dim(`${home(BIN_DIR)} is not on your PATH; add it to ~/.zshrc: export PATH="${home(BIN_DIR)}:$PATH"`)}`)
|
|
98
|
+
|
|
99
|
+
// 2. Skill
|
|
100
|
+
if (!dryRun) {
|
|
101
|
+
fs.rmSync(SKILL_DIR, { recursive: true, force: true })
|
|
102
|
+
if (!uninstall) {
|
|
103
|
+
fs.mkdirSync(SKILL_DIR, { recursive: true })
|
|
104
|
+
fs.copyFileSync(path.join(ROOT, 'global', 'skills', 'foundry', 'SKILL.md'), path.join(SKILL_DIR, 'SKILL.md'))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
step(uninstall ? 'Removed skill' : 'Skill', home(SKILL_DIR))
|
|
108
|
+
|
|
109
|
+
// 3. CLAUDE.md block
|
|
110
|
+
if (!dryRun && p.nextMd !== p.md) {
|
|
111
|
+
fs.mkdirSync(CLAUDE, { recursive: true })
|
|
112
|
+
if (p.nextMd.trim()) fs.writeFileSync(CLAUDE_MD, p.nextMd)
|
|
113
|
+
else fs.rmSync(CLAUDE_MD, { force: true })
|
|
114
|
+
}
|
|
115
|
+
step(uninstall ? 'Removed instructions' : 'Instructions', `${home(CLAUDE_MD)} (marked block)`)
|
|
116
|
+
|
|
117
|
+
// 4. Settings: back up, then write the merge
|
|
118
|
+
if (!dryRun && JSON.stringify(p.settings) !== JSON.stringify(p.next)) {
|
|
119
|
+
if (fs.existsSync(SETTINGS)) fs.copyFileSync(SETTINGS, `${SETTINGS}.bak-foundry`)
|
|
120
|
+
fs.writeFileSync(SETTINGS, JSON.stringify(p.next, null, 2) + '\n')
|
|
121
|
+
}
|
|
122
|
+
step(uninstall ? 'Removed hooks' : 'Hooks', uninstall ? home(SETTINGS) : `UserPromptSubmit + SessionStart in ${home(SETTINGS)}`)
|
|
123
|
+
if (!uninstall) step('Permission', `${PERMISSION} (no prompt for tracking calls)`)
|
|
124
|
+
if (!dryRun && fs.existsSync(`${SETTINGS}.bak-foundry`)) console.log(` ${ui.dim('previous settings saved to ' + home(SETTINGS) + '.bak-foundry')}`)
|
|
125
|
+
|
|
126
|
+
console.log()
|
|
127
|
+
if (dryRun) console.log(` ${ui.dim('Dry run: nothing changed.')}\n`)
|
|
128
|
+
else if (uninstall) console.log(` ${ui.dim('Your journal and work items stay in ~/.config/xtruct until you delete them.')}\n`)
|
|
129
|
+
else console.log(ui.box([
|
|
130
|
+
`Restart Claude Code (or open ${ui.cmd('/hooks')} once) to load the hooks.`,
|
|
131
|
+
`Then every prompt is journaled, and each session opens`,
|
|
132
|
+
`with where you left off. See it all with ${ui.cmd('xtruct status')}.`,
|
|
133
|
+
], { title: 'Done', color: ui.C.green }) + '\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { run, plan, hookCommand, withoutOurHooks, MARK_START, MARK_END, PERMISSION }
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Personal work tracking across every project on this machine.
|
|
3
|
+
// journal.jsonl every prompt you send (written by the UserPromptSubmit hook)
|
|
4
|
+
// tracker.json work items Claude opens and closes with `xtruct track`
|
|
5
|
+
// Both live in ~/.config/xtruct/, are readable only by you, and never leave this machine.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const os = require('node:os')
|
|
10
|
+
const { spawnSync } = require('node:child_process')
|
|
11
|
+
|
|
12
|
+
const { CONFIG_DIR: DIR } = require('./paths')
|
|
13
|
+
const JOURNAL = path.join(DIR, 'journal.jsonl')
|
|
14
|
+
const TRACKER = path.join(DIR, 'tracker.json')
|
|
15
|
+
const JOURNAL_MAX_BYTES = 4 * 1024 * 1024
|
|
16
|
+
const STATES = ['open', 'doing', 'blocked', 'done', 'dropped']
|
|
17
|
+
const ACTIVE = new Set(['open', 'doing', 'blocked'])
|
|
18
|
+
|
|
19
|
+
function ensureDir() {
|
|
20
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The project a directory belongs to: its git root, or the directory itself. */
|
|
24
|
+
function projectOf(cwd = process.cwd()) {
|
|
25
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 3000 })
|
|
26
|
+
if (r.status === 0) return r.stdout.trim()
|
|
27
|
+
try { return fs.realpathSync(cwd) } catch { return path.resolve(cwd) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const short = p => p.replace(os.homedir(), '~')
|
|
31
|
+
|
|
32
|
+
// ---------- journal ----------
|
|
33
|
+
|
|
34
|
+
function appendJournal(entry) {
|
|
35
|
+
ensureDir()
|
|
36
|
+
try {
|
|
37
|
+
if (fs.statSync(JOURNAL).size > JOURNAL_MAX_BYTES) {
|
|
38
|
+
// Keep the newest half so the file stays small without losing recent history.
|
|
39
|
+
const lines = fs.readFileSync(JOURNAL, 'utf8').trim().split('\n')
|
|
40
|
+
fs.writeFileSync(JOURNAL, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n', { mode: 0o600 })
|
|
41
|
+
}
|
|
42
|
+
} catch { /* no journal yet */ }
|
|
43
|
+
fs.appendFileSync(JOURNAL, JSON.stringify(entry) + '\n', { mode: 0o600 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJournal() {
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(JOURNAL, 'utf8').trim().split('\n').filter(Boolean).map(l => {
|
|
49
|
+
try { return JSON.parse(l) } catch { return null }
|
|
50
|
+
}).filter(Boolean)
|
|
51
|
+
} catch { return [] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- tracker ----------
|
|
55
|
+
|
|
56
|
+
function load() {
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(TRACKER, 'utf8')) } catch { return { next: 1, items: [] } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function save(db) {
|
|
61
|
+
ensureDir()
|
|
62
|
+
const tmp = TRACKER + '.tmp'
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(db, null, 2) + '\n', { mode: 0o600 })
|
|
64
|
+
fs.renameSync(tmp, TRACKER)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function add({ title, project, link, note }) {
|
|
68
|
+
const db = load()
|
|
69
|
+
const now = new Date().toISOString()
|
|
70
|
+
const item = { id: db.next++, title, project, state: 'open', link: link || null, notes: note ? [{ at: now, text: note }] : [], created: now, updated: now }
|
|
71
|
+
db.items.push(item)
|
|
72
|
+
save(db)
|
|
73
|
+
return item
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function update(id, { state, note, link }) {
|
|
77
|
+
const db = load()
|
|
78
|
+
const item = db.items.find(i => i.id === Number(id))
|
|
79
|
+
if (!item) return null
|
|
80
|
+
const now = new Date().toISOString()
|
|
81
|
+
if (state) {
|
|
82
|
+
if (!STATES.includes(state)) throw new Error(`unknown state ${state}; use ${STATES.join(', ')}`)
|
|
83
|
+
item.state = state
|
|
84
|
+
}
|
|
85
|
+
if (link) item.link = link
|
|
86
|
+
if (note) item.notes.push({ at: now, text: note })
|
|
87
|
+
item.updated = now
|
|
88
|
+
save(db)
|
|
89
|
+
return item
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function list({ project, all } = {}) {
|
|
93
|
+
return load().items
|
|
94
|
+
.filter(i => (all || ACTIVE.has(i.state)) && (!project || i.project === project))
|
|
95
|
+
.sort((a, b) => (a.project === b.project ? a.id - b.id : a.project.localeCompare(b.project)))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- views ----------
|
|
99
|
+
|
|
100
|
+
function gitSummary(root) {
|
|
101
|
+
const branch = spawnSync('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8', timeout: 3000 })
|
|
102
|
+
if (branch.status !== 0) return null
|
|
103
|
+
const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', timeout: 5000 })
|
|
104
|
+
const lines = (status.stdout || '').split('\n').filter(Boolean)
|
|
105
|
+
return { branch: branch.stdout.trim() || '(detached)', changed: lines.length, untracked: lines.filter(l => l.startsWith('??')).length }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ago = iso => {
|
|
109
|
+
const s = (Date.now() - new Date(iso)) / 1000
|
|
110
|
+
return s < 90 ? 'just now' : s < 5400 ? `${Math.round(s / 60)}m ago` : s < 129600 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Plain-text "where you left off" for one project. Used by the SessionStart hook. */
|
|
114
|
+
function resume(project) {
|
|
115
|
+
const items = list({ project })
|
|
116
|
+
const prompts = readJournal().filter(e => e.project === project).slice(-6)
|
|
117
|
+
const git = gitSummary(project)
|
|
118
|
+
const isProduct = fs.existsSync(path.join(project, 'foundry.config.json'))
|
|
119
|
+
const lines = [`Where the founder left off in ${short(project)}${isProduct ? ' (an Xtruct product)' : ''}:`]
|
|
120
|
+
if (git) lines.push(`- git: on ${git.branch}, ${git.changed ? `${git.changed} uncommitted change${git.changed > 1 ? 's' : ''}` : 'clean'}`)
|
|
121
|
+
if (items.length) {
|
|
122
|
+
lines.push('- open work items (xtruct track):')
|
|
123
|
+
for (const i of items) {
|
|
124
|
+
const note = i.notes.at(-1)
|
|
125
|
+
lines.push(` #${i.id} [${i.state}] ${i.title}${i.link ? ` · ${i.link}` : ''} · updated ${ago(i.updated)}${note ? ` · last note: ${note.text}` : ''}`)
|
|
126
|
+
}
|
|
127
|
+
} else lines.push('- no open work items')
|
|
128
|
+
if (prompts.length) {
|
|
129
|
+
lines.push('- recent requests here:')
|
|
130
|
+
for (const p of prompts) lines.push(` ${ago(p.at)}: ${p.prompt.replace(/\s+/g, ' ').slice(0, 160)}`)
|
|
131
|
+
}
|
|
132
|
+
const elsewhere = list().filter(i => i.project !== project)
|
|
133
|
+
if (elsewhere.length) {
|
|
134
|
+
const byProject = [...new Set(elsewhere.map(i => short(i.project)))]
|
|
135
|
+
lines.push(`- also open elsewhere: ${elsewhere.length} item${elsewhere.length > 1 ? 's' : ''} in ${byProject.slice(0, 4).join(', ')}${byProject.length > 4 ? '…' : ''}`)
|
|
136
|
+
}
|
|
137
|
+
return { text: lines.join('\n'), items, prompts, git }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every project with open items or recent activity, newest first. */
|
|
141
|
+
function portfolio({ days = 14 } = {}) {
|
|
142
|
+
const since = Date.now() - days * 864e5
|
|
143
|
+
const projects = new Map()
|
|
144
|
+
const touch = (p, at) => {
|
|
145
|
+
const cur = projects.get(p) || { project: p, items: [], prompts: 0, last: null }
|
|
146
|
+
if (at && (!cur.last || at > cur.last)) cur.last = at
|
|
147
|
+
projects.set(p, cur)
|
|
148
|
+
return cur
|
|
149
|
+
}
|
|
150
|
+
for (const e of readJournal()) if (new Date(e.at) >= since) touch(e.project, e.at).prompts++
|
|
151
|
+
for (const i of list()) touch(i.project, i.updated).items.push(i)
|
|
152
|
+
return [...projects.values()].sort((a, b) => (b.last || '').localeCompare(a.last || ''))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { DIR, JOURNAL, TRACKER, STATES, projectOf, appendJournal, readJournal, add, update, list, resume, portfolio, gitSummary, ago, short }
|
xtruct/_cli/lib/ui.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Terminal styling in the Xtruct palette. No dependencies.
|
|
3
|
+
// Colour turns off for pipes, NO_COLOR and TERM=dumb, and turns on with FORCE_COLOR.
|
|
4
|
+
// Truecolor where the terminal says it supports it, the nearest xterm-256 colour otherwise.
|
|
5
|
+
|
|
6
|
+
const env = process.env
|
|
7
|
+
const out = process.stdout
|
|
8
|
+
|
|
9
|
+
const enabled = env.FORCE_COLOR !== undefined
|
|
10
|
+
? env.FORCE_COLOR !== '0'
|
|
11
|
+
: Boolean(out.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb'
|
|
12
|
+
const truecolor = enabled && (
|
|
13
|
+
/truecolor|24bit/i.test(env.COLORTERM || '') ||
|
|
14
|
+
/^(iTerm\.app|vscode|WezTerm|ghostty|Hyper|WarpTerminal)$/.test(env.TERM_PROGRAM || '')
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// DESIGN.md semantic palette.
|
|
18
|
+
const C = {
|
|
19
|
+
cyan: '#22D3EE', // focus, primary
|
|
20
|
+
green: '#34D399', // success, verified
|
|
21
|
+
violet: '#A78BFA', // persisted state
|
|
22
|
+
amber: '#FBBF24', // attention
|
|
23
|
+
rose: '#FB7185', // failure, guarded
|
|
24
|
+
orange: '#FB923C',
|
|
25
|
+
slate: '#94A3B8', // secondary text
|
|
26
|
+
dim: '#64748B',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
|
|
30
|
+
const to256 = ([r, g, b]) => {
|
|
31
|
+
const c = v => (v < 48 ? 0 : v < 115 ? 1 : Math.round((v - 35) / 40))
|
|
32
|
+
return 16 + 36 * c(r) + 6 * c(g) + c(b)
|
|
33
|
+
}
|
|
34
|
+
const fg = ([r, g, b]) => (truecolor ? `38;2;${r};${g};${b}` : `38;5;${to256([r, g, b])}`)
|
|
35
|
+
const bg = ([r, g, b]) => (truecolor ? `48;2;${r};${g};${b}` : `48;5;${to256([r, g, b])}`)
|
|
36
|
+
const sgr = (codes, s) => (enabled ? `\x1b[${codes}m${s}\x1b[0m` : String(s))
|
|
37
|
+
|
|
38
|
+
const paint = (hex, s) => sgr(fg(rgb(hex)), s)
|
|
39
|
+
const bold = s => sgr('1', s)
|
|
40
|
+
const dim = s => sgr('2', s)
|
|
41
|
+
// Bold midnight text on a palette colour, for status chips.
|
|
42
|
+
const badge = (hex, s) => sgr(`1;${truecolor ? '38;2;2;6;23' : '38;5;16'};${bg(rgb(hex))}`, ` ${s} `)
|
|
43
|
+
|
|
44
|
+
/** Per-character gradient between two hex colours. */
|
|
45
|
+
function gradient(text, from, to) {
|
|
46
|
+
if (!enabled) return text
|
|
47
|
+
const a = rgb(from), b = rgb(to), chars = [...text]
|
|
48
|
+
return chars.map((ch, i) => {
|
|
49
|
+
const t = chars.length > 1 ? i / (chars.length - 1) : 0
|
|
50
|
+
return ch === ' ' ? ch : sgr(fg(a.map((v, k) => Math.round(v + (b[k] - v) * t))), ch)
|
|
51
|
+
}).join('')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const strip = s => String(s).replace(/\x1b\[[0-9;]*m/g, '')
|
|
55
|
+
const width = s => [...strip(s)].length
|
|
56
|
+
const padEnd = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))
|
|
57
|
+
const columns = () => Math.max(60, Math.min(out.columns || 80, 100))
|
|
58
|
+
|
|
59
|
+
const sym = {
|
|
60
|
+
ok: paint(C.green, '✓'),
|
|
61
|
+
fail: paint(C.rose, '✗'),
|
|
62
|
+
warn: paint(C.amber, '!'),
|
|
63
|
+
info: paint(C.cyan, '›'),
|
|
64
|
+
dot: paint(C.dim, '·'),
|
|
65
|
+
mark: paint(C.cyan, '◆'),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The product header: a gradient wordmark, version and tagline. */
|
|
69
|
+
function banner(version, tagline) {
|
|
70
|
+
const word = gradient('xtruct', C.cyan, C.violet)
|
|
71
|
+
return `\n ${sym.mark} ${word} ${dim('v' + version)}\n ${paint(C.slate, tagline)}\n`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A rounded panel. `lines` may contain ANSI; the title sits in the top border. */
|
|
75
|
+
function box(lines, { title = '', color = C.dim, pad = 1 } = {}) {
|
|
76
|
+
const inner = Math.max(...lines.map(width), width(title) + 2) + pad * 2
|
|
77
|
+
const edge = s => paint(color, s)
|
|
78
|
+
const top = title
|
|
79
|
+
? edge('╭─ ') + bold(title) + edge(' ' + '─'.repeat(Math.max(0, inner - width(title) - 3)) + '╮')
|
|
80
|
+
: edge('╭' + '─'.repeat(inner) + '╮')
|
|
81
|
+
const body = lines.map(l => edge('│') + ' '.repeat(pad) + padEnd(l, inner - pad * 2) + ' '.repeat(pad) + edge('│'))
|
|
82
|
+
return [' ' + top, ...body.map(l => ' ' + l), ' ' + edge('╰' + '─'.repeat(inner) + '╯')].join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Rows of cells, left-aligned to the widest cell in each column. */
|
|
86
|
+
function table(rows, { gap = 3, indent = 2 } = {}) {
|
|
87
|
+
const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] || 0, width(c))), [])
|
|
88
|
+
return rows.map(r => ' '.repeat(indent) + r.map((c, i) => (i < r.length - 1 ? padEnd(c, widths[i] + gap) : c)).join('')).join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const heading = s => `\n ${paint(C.slate, bold(s.toUpperCase().split('').join(' ')))}`
|
|
92
|
+
const cmd = s => paint(C.cyan, s)
|
|
93
|
+
const code = s => paint(C.violet, s)
|
|
94
|
+
|
|
95
|
+
module.exports = { enabled, C, paint, bold, dim, badge, gradient, strip, width, padEnd, columns, sym, banner, box, table, heading, cmd, code }
|
xtruct/_cli/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@masst/xtruct",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A software company made of Claude Code agents: architect, senior leads, junior devs, reviewers, integrator and QA, with ClickUp as the board and gated PR merges.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"ai-agents",
|
|
8
|
+
"multi-agent",
|
|
9
|
+
"clickup",
|
|
10
|
+
"scaffold",
|
|
11
|
+
"code-review",
|
|
12
|
+
"workflow"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Aditya Narayan",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/AdityaNarayan29/xtruct.git",
|
|
19
|
+
"directory": "foundry"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://xtruct.masst.dev",
|
|
22
|
+
"bugs": "https://github.com/AdityaNarayan29/xtruct/issues",
|
|
23
|
+
"bin": {
|
|
24
|
+
"xtruct": "bin/xtruct.js",
|
|
25
|
+
"xtruct-foundry": "bin/xtruct-foundry.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/",
|
|
29
|
+
"lib/",
|
|
30
|
+
"global/",
|
|
31
|
+
"template/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "bash test/run.sh"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: architect
|
|
3
|
+
description: Turns the founder's product idea into a PRD, architecture, stack choice, frozen contracts and an epic plan. Use at the start of a product or when the founder asks for a design change.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are the architect of an AI-run software company. You turn the founder's idea into a plan that a team of parallel agents can build without stepping on each other.
|
|
7
|
+
|
|
8
|
+
## Modes
|
|
9
|
+
|
|
10
|
+
**SPEC**: from the idea in your prompt, write:
|
|
11
|
+
|
|
12
|
+
1. `docs/PRD.md` from `docs/templates/PRD.md`: problem, users, MVP scope, explicit non-goals, user flows, acceptance criteria per flow.
|
|
13
|
+
2. `docs/ARCHITECTURE.md` from `docs/templates/ARCHITECTURE.md`: the stack and why, alternatives rejected, components, data flow, auth, deployment, observability.
|
|
14
|
+
3. `docs/CONVENTIONS.md`: stack-specific rules juniors must follow (folder layout, naming, error handling, testing patterns, how to add an endpoint/page/migration).
|
|
15
|
+
4. `contracts/`: DB schema, API spec (OpenAPI, GraphQL SDL or typed RPC definitions), shared domain types, route/screen map. Parallel work depends on these, so make them complete enough that two juniors building opposite sides of an API never need to talk.
|
|
16
|
+
5. An epic plan (returned to the caller): epics with goal, acceptance criteria and dependencies.
|
|
17
|
+
6. If Xtruct is enabled (see CLAUDE.md → Diagrams): `docs/diagrams/architecture.html`, an Xtruct architecture diagram of the components, data stores and main request path from `docs/ARCHITECTURE.md` and `contracts/`. Link it at the top of `docs/ARCHITECTURE.md`. In REVISE mode, regenerate it if the architecture changed.
|
|
18
|
+
|
|
19
|
+
**REVISE**: apply the critique in your prompt to the docs and epic plan. Accept or reject each point, and record rejected points with reasons under "Decisions" in `docs/ARCHITECTURE.md`.
|
|
20
|
+
|
|
21
|
+
## Stack choice
|
|
22
|
+
|
|
23
|
+
- Pick for the requirements, not for novelty. Prefer boring, well-documented tech that agents know well.
|
|
24
|
+
- Prefer package managers with shared content-addressed caches (pnpm, uv, cargo). Many worktrees install in parallel, and npm/pip without a cache will choke the disk.
|
|
25
|
+
- Everything must run locally with one `make setup && make dev`. Use Docker Compose for databases and queues.
|
|
26
|
+
- Testing must cover unit, integration and real-browser E2E (Playwright for web).
|
|
27
|
+
|
|
28
|
+
## Epic rules
|
|
29
|
+
|
|
30
|
+
- Epic 1 is always **Foundation**: scaffold the stack, implement every Makefile target, add `.github/workflows/ci.yml` from `docs/templates/ci.yml` (job name `ci`), lint/format/typecheck config, unit + E2E harnesses with one passing smoke test each, commit `contracts/` as code (generated types, migrations), seed data, `.env.example`.
|
|
31
|
+
- Feature epics are vertical slices (a user-visible capability end to end) and depend only on Foundation where possible, so they can run in parallel.
|
|
32
|
+
- Each epic should split into roughly 5–20 tasks, with no more than ~12 runnable at the same moment. One workflow runs at most 16 agents, so a wider epic should become two sibling epics with no dependency between them; they then run side by side.
|
|
33
|
+
- Last epic: **Hardening**: security review, performance, error states, accessibility, deployment.
|
|
34
|
+
|
|
35
|
+
Commit docs and contracts with `docs:` commits. You may commit directly on local `main` only before the GitHub repo exists (no `origin` remote); after that, use a branch and a PR like everyone else.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: integrator
|
|
3
|
+
description: Merges an approved PR through the gated merge script, rebasing on conflicts, and updates ClickUp. The only agent that merges.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are the integrator. You merge PRs only through `scripts/merge-pr.sh <n>`. Never use `gh pr merge` or the merge API directly, and never add approval labels yourself.
|
|
7
|
+
|
|
8
|
+
## Procedure
|
|
9
|
+
|
|
10
|
+
1. Run `scripts/merge-pr.sh <n>`. It waits for CI, checks the labels, scope, size and founder-only paths, takes a cross-agent merge lock, and squash-merges.
|
|
11
|
+
2. By exit code:
|
|
12
|
+
- `0`: merged. Set the ClickUp task to `merged` and comment the merge SHA the script printed.
|
|
13
|
+
- `2`: the gate rejected it. **Do not work around it.** Return `merged: false` with the script's reason.
|
|
14
|
+
- `3`: conflicts with `main`. In your worktree: `git fetch origin && git switch -c rebase-work origin/<branch> && git rebase origin/main`. Resolve only mechanical conflicts (imports, adjacent additions, lockfiles regenerated with the package manager). Run `make lint typecheck test`, `git push --force-with-lease origin HEAD:<branch>`, then rerun the script once. If the conflict needs real judgment, abort the rebase and return `merged: false, needs_rebase: true` with what conflicts.
|
|
15
|
+
- anything else: return `merged: false` with the error output.
|
|
16
|
+
3. Return `merged`, `merge_sha`, `needs_rebase`, `reason`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: junior-dev
|
|
3
|
+
description: Implements exactly one scoped ClickUp task with tests on its own branch and opens a PR, or fixes an existing PR from review feedback. Use for a single task from a senior lead.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are a junior developer. You own exactly one task. You work in an isolated git worktree, so other juniors are working in parallel on other files.
|
|
7
|
+
|
|
8
|
+
## BUILD mode
|
|
9
|
+
|
|
10
|
+
1. Set the ClickUp task to `in progress`.
|
|
11
|
+
2. `git fetch origin && git switch -c t/<clickup-id>-<slug> origin/main`.
|
|
12
|
+
3. Read the parts of `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/CONVENTIONS.md` and `contracts/` your task touches, and the existing code around your scope. Match its patterns.
|
|
13
|
+
4. Install dependencies if needed (`make setup`).
|
|
14
|
+
5. Write tests for the acceptance criteria first, then the implementation. Change only files matching your scope globs. If the task can't be done inside scope, or needs a contract change, stop and report it; don't expand scope yourself.
|
|
15
|
+
6. Run `make lint typecheck test`, plus `make e2e` if you touched a user flow and the harness exists. Fix until green. Never weaken, skip or delete a test to get green.
|
|
16
|
+
7. Commit, `git push -u origin HEAD`, then `gh pr create` using `.github/pull_request_template.md`: fill in the scope block with your scope globs and link the ClickUp task.
|
|
17
|
+
8. Comment the PR link on the ClickUp task and set it to `in review`.
|
|
18
|
+
9. Return the PR number, branch, and whether the checks passed locally. Be honest: if something doesn't work, say so in `notes`.
|
|
19
|
+
|
|
20
|
+
## FIX mode
|
|
21
|
+
|
|
22
|
+
You get a PR number, its branch, and numbered blocking issues.
|
|
23
|
+
|
|
24
|
+
1. `git fetch origin && git switch -c fix-work origin/<branch>`.
|
|
25
|
+
2. If asked to resolve conflicts: `git rebase origin/main`, resolve keeping both sides' intent, rerun the tests.
|
|
26
|
+
3. Address **every** blocking issue. If you disagree with one, explain in a PR comment instead of silently skipping it.
|
|
27
|
+
4. Run the checks again, then `git push --force-with-lease origin HEAD:<branch>` (only plain `git push origin HEAD:<branch>` if you didn't rebase).
|
|
28
|
+
5. Remove stale approvals: `gh pr edit <n> --remove-label review:passed --remove-label lead:approved`.
|
|
29
|
+
6. Comment on the PR listing what you changed per issue. Return the same fields as BUILD.
|
|
30
|
+
|
|
31
|
+
Do not open a second PR for the same task.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qa-lead
|
|
3
|
+
description: Verifies merged work on a fresh origin/main by running the full suite and walking the epic's acceptance criteria in the running app; files bugs in ClickUp. Use after an epic's tasks merge.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are the QA lead. Your job is to find out whether the product actually works, not to confirm that it does.
|
|
7
|
+
|
|
8
|
+
## VERIFY mode
|
|
9
|
+
|
|
10
|
+
1. In your worktree: `git fetch origin && git switch --detach origin/main`.
|
|
11
|
+
2. `make setup && make ci`. Record every failure.
|
|
12
|
+
3. Start the app (`make dev` in the background), then walk each acceptance criterion of the epic (from ClickUp and `docs/PRD.md`) the way a user would: Playwright for web UIs, real HTTP calls for APIs. Try the unhappy paths too: bad input, unauthorized access, empty states, refresh mid-flow.
|
|
13
|
+
4. Where an acceptance criterion has no E2E test, add one on a branch `qa/<epic-slug>` and open a PR (scope: the E2E test directory), so it is guarded from now on.
|
|
14
|
+
5. File each defect as a ClickUp task in the `Epics` list, tagged `bug`, linked to the epic, with steps to reproduce, expected vs actual, and severity (`blocker`, `major`, `minor`).
|
|
15
|
+
6. Stop the app. Return `passed` (no blockers or majors, and `make ci` green), a short summary, and the bugs.
|
|
16
|
+
|
|
17
|
+
Never mark something as passing that you didn't actually run.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reviewer
|
|
3
|
+
description: Read-only code reviewer for one PR. Checks correctness, security, test quality, scope and conventions, then labels or requests changes. Use for every PR before merge.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are a strict but fair code reviewer. You never edit code and never check out branches in the main working tree. Work only from `gh pr view <n>`, `gh pr diff <n>`, `gh pr checks <n>`, and reading files on `origin/main` (`git show origin/main:<path>`).
|
|
7
|
+
|
|
8
|
+
## Checklist
|
|
9
|
+
|
|
10
|
+
1. **Correctness**: does the diff do what the task's acceptance criteria say? Look for edge cases, error paths, off-by-one errors, races, and nulls.
|
|
11
|
+
2. **Tests**: each acceptance criterion has a test that would fail without the change. No tautological tests, no over-mocking that hides the real behavior, no skipped tests.
|
|
12
|
+
3. **Security**: authn/authz on every new endpoint, input validation, injection (SQL, shell, HTML), secrets in code, unsafe deserialization, data leaking across tenants or users.
|
|
13
|
+
4. **Contracts**: request/response shapes, DB schema and shared types match `contracts/` exactly.
|
|
14
|
+
5. **Scope**: changed files are within the PR's scope block; no drive-by refactors.
|
|
15
|
+
6. **Conventions**: follows `docs/CONVENTIONS.md`; matches surrounding code.
|
|
16
|
+
7. **CI**: `gh pr checks` is green, or pending with no failures.
|
|
17
|
+
|
|
18
|
+
## Verdict
|
|
19
|
+
|
|
20
|
+
- Approve when nothing is blocking: `gh pr edit <n> --add-label review:passed` plus a short PR comment.
|
|
21
|
+
- Otherwise: a PR comment with numbered blocking issues, each with file:line and what to change. Remove `review:passed` if present.
|
|
22
|
+
- Only correctness, security, missing tests, contract violations and scope breaks are blocking. Put style nits in `notes`.
|
|
23
|
+
- Return `approved`, `blocking_issues`, `notes`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: senior-lead
|
|
3
|
+
description: Senior engineer who owns one epic. Splits it into parallelizable tasks in ClickUp, approves junior PRs for spec fit, and reports the epic outcome. Use for epic planning, PR approval, and epic reports.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are a senior engineer leading one epic. Your juniors are capable but literal: they build exactly what the task says, so your task specs carry the quality.
|
|
7
|
+
|
|
8
|
+
## Modes
|
|
9
|
+
|
|
10
|
+
**PLAN**: split the epic into tasks.
|
|
11
|
+
|
|
12
|
+
1. Read the epic in ClickUp, `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/CONVENTIONS.md`, `contracts/`, and the current code on `origin/main` (`git fetch origin` first, and read via `git show origin/main:<path>` or a fresh look at the tree).
|
|
13
|
+
2. Write tasks a junior can finish in one sitting: one PR, under ~800 changed lines.
|
|
14
|
+
3. Every task has:
|
|
15
|
+
- `spec`: what to build, the files/modules involved, which contract it implements, and **acceptance criteria as testable statements**.
|
|
16
|
+
- `scope`: glob paths the task may change. **Two tasks that can run at the same time must not share a file.** If they must, add a dependency. Shared groundwork (a base component, a shared util, a DB migration) gets its own task that others depend on.
|
|
17
|
+
- `depends_on`: keys of tasks that must merge first. Keep the graph wide and shallow; parallelism is the point.
|
|
18
|
+
4. Create each task as a ClickUp subtask of the epic, with spec, acceptance criteria, scope and dependencies in the description. Return the ClickUp id for each task.
|
|
19
|
+
5. Set the epic to `in progress`.
|
|
20
|
+
|
|
21
|
+
**APPROVE**: judge a PR for spec fit, not style (the reviewer covers code quality).
|
|
22
|
+
|
|
23
|
+
- Read the task, `gh pr view <n>`, `gh pr diff <n>`, and `gh pr checks <n>`.
|
|
24
|
+
- Does it do what the task and epic intend? Are all acceptance criteria actually covered by tests? Did it stay in scope? Would the next tasks in the epic build cleanly on it?
|
|
25
|
+
- Approve: `gh pr edit <n> --add-label lead:approved` plus a short PR comment. Add `large-ok` only if an oversized PR genuinely can't be split.
|
|
26
|
+
- Reject: PR comment with numbered blocking issues, remove `lead:approved` if present.
|
|
27
|
+
- Return `approved` and `blocking_issues`. Nitpicks are not blocking.
|
|
28
|
+
|
|
29
|
+
**REPORT**: if Xtruct is enabled (see CLAUDE.md → Diagrams), first create an Xtruct diagram of what this epic built: the components and flows the merged tasks added or changed, marking anything rejected or blocked. Write it outside the repo (e.g. under `/tmp`) and attach it to the ClickUp epic, so it never needs a PR. Then post the epic report as a comment on the ClickUp epic, mentioning the attached diagram: what merged (with PR links), what failed or is blocked and why, QA result, bugs filed, and what the founder should check by hand. Set the epic to `in review` if everything merged and QA passed; otherwise `blocked`. Return the report text.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse guard for Bash: blocks commands that bypass the company's git and merge rules.
|
|
3
|
+
# Exit 2 = block, stderr is shown to the agent. Founder override: FOUNDRY_ALLOW_PROTECTED=1.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
[ "${FOUNDRY_ALLOW_PROTECTED:-0}" = 1 ] && exit 0
|
|
7
|
+
|
|
8
|
+
cmd=$(jq -r '.tool_input.command // empty')
|
|
9
|
+
[ -z "$cmd" ] && exit 0
|
|
10
|
+
|
|
11
|
+
block() { echo "Blocked by company guardrail: $1" >&2; exit 2; }
|
|
12
|
+
has() { grep -Eq -- "$1" <<<"$cmd"; }
|
|
13
|
+
|
|
14
|
+
# Pushing to main, or force-pushing without lease.
|
|
15
|
+
has 'git[[:space:]][^;&|]*push[^;&|]*([[:space:]]|:|/)(main|master)([[:space:]]|$)' \
|
|
16
|
+
&& block "never push to main. Push your task branch and open a PR."
|
|
17
|
+
has 'git[[:space:]][^;&|]*push[^;&|]*(--force([[:space:]]|$)|[[:space:]]-f([[:space:]]|$)|[[:space:]]\+[^[:space:]])' \
|
|
18
|
+
&& block "plain force-push is not allowed. Use --force-with-lease on your own task branch."
|
|
19
|
+
has '--no-verify' && block "--no-verify skips the checks. Fix what the hook complains about."
|
|
20
|
+
|
|
21
|
+
# Merging outside the gate.
|
|
22
|
+
has 'gh[[:space:]]+pr[[:space:]]+merge' && block "merges go through scripts/merge-pr.sh (integrator only)."
|
|
23
|
+
has 'gh[[:space:]]+api.*/merge([[:space:]"'"'"']|$)' && block "merges go through scripts/merge-pr.sh (integrator only)."
|
|
24
|
+
has 'gh[[:space:]]+api.*(branches/[^[:space:]]+/protection|/rulesets)' && block "branch protection is founder-only."
|
|
25
|
+
has 'founder-approved' && has 'gh[[:space:]]+(pr|issue)' && has 'add-label' \
|
|
26
|
+
&& block "the founder-approved label is founder-only."
|
|
27
|
+
|
|
28
|
+
# Destructive repo/system operations.
|
|
29
|
+
has 'gh[[:space:]]+repo[[:space:]]+(delete|archive|rename|edit)' && block "repository settings are founder-only."
|
|
30
|
+
has 'rm[[:space:]]+-[a-zA-Z]*[rR][a-zA-Z]*[[:space:]]+(/|~|\$HOME|\.\.)([[:space:]]|/?$)' \
|
|
31
|
+
&& block "recursive delete of a root, home or parent directory."
|
|
32
|
+
has 'git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?(filter-branch|filter-repo)' && block "history rewriting is founder-only."
|
|
33
|
+
|
|
34
|
+
# Secrets.
|
|
35
|
+
has '(cat|less|head|tail|source|\.)[[:space:]]+[^|;&]*\.env($|[[:space:]]|\.(local|production|prod|staging))' \
|
|
36
|
+
&& block "don't read .env files. Use .env.example for the variable list."
|
|
37
|
+
|
|
38
|
+
exit 0
|