mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge pull request #4 from Permissionless-Software-Foundation/feat1
Post a memo message
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
|
||||
.gitsigners
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Normal acceptance runner for psf-memo-client.
|
||||
|
||||
Orchestrates the acceptance pipeline:
|
||||
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
|
||||
generator -> generated test entry points -> node test runner
|
||||
|
||||
It procures the latest Babashka APS tools from the Acceptance-Pipeline-
|
||||
Specification repository on first use, then parses, generates, and runs every
|
||||
Gherkin feature under specs/.
|
||||
|
||||
Exit code 0 when all acceptance tests pass; non-zero otherwise.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const specsDir = path.join(root, 'specs')
|
||||
const buildDir = path.join(root, 'build', 'acceptance')
|
||||
const irDir = path.join(buildDir, 'ir')
|
||||
const genDir = path.join(buildDir, 'generated')
|
||||
const apsDir = path.join(root, 'tmp', 'aps-spec')
|
||||
|
||||
function sh (cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...opts
|
||||
}).toString()
|
||||
}
|
||||
|
||||
// Procure the latest APS tools if not already present in the worktree.
|
||||
function ensureAps () {
|
||||
if (fs.existsSync(apsDir)) return
|
||||
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
|
||||
sh('git', ['clone', '--depth', '1',
|
||||
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
|
||||
}
|
||||
|
||||
function main () {
|
||||
ensureAps()
|
||||
|
||||
const features = fs
|
||||
.readdirSync(specsDir)
|
||||
.filter((f) => f.endsWith('.feature'))
|
||||
.sort()
|
||||
|
||||
if (features.length === 0) {
|
||||
console.log('No feature files found under specs/.')
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(irDir, { recursive: true })
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
for (const featureFile of features) {
|
||||
const base = featureFile.replace(/\.feature$/i, '')
|
||||
const featurePath = path.join(specsDir, featureFile)
|
||||
const irPath = path.join(irDir, `${base}.json`)
|
||||
|
||||
// 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser.
|
||||
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
|
||||
|
||||
// 2) Generate executable acceptance entry points from the IR.
|
||||
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir])
|
||||
}
|
||||
|
||||
// 3) Run every generated acceptance test.
|
||||
const tests = fs
|
||||
.readdirSync(genDir)
|
||||
.filter((f) => f.endsWith('.acceptance.test.js'))
|
||||
.sort()
|
||||
|
||||
let failures = 0
|
||||
for (const testFile of tests) {
|
||||
try {
|
||||
const out = sh('node', [path.join(genDir, testFile)])
|
||||
process.stdout.write(out)
|
||||
console.log(`ACCEPTANCE PASS: ${testFile}`)
|
||||
} catch (err) {
|
||||
failures++
|
||||
process.stdout.write(err.stdout || '')
|
||||
process.stderr.write(err.stderr || '')
|
||||
console.error(`ACCEPTANCE FAIL: ${testFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Project-specific acceptance entrypoint generator.
|
||||
|
||||
Reads parser JSON IR and writes executable generated test entry points plus
|
||||
per-feature metadata. Generated tests delegate all step behavior to the
|
||||
acceptance runtime and project step handlers.
|
||||
|
||||
Usage:
|
||||
node acceptance/lib/generate.js <json-ir> <generated-test-output-dir>
|
||||
|
||||
Exit codes:
|
||||
0 generation succeeded
|
||||
1 generation error
|
||||
2 wrong command usage
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
// Convert a feature path to a strict lowercase-and-hyphen metadata filename.
|
||||
function metadataName (featureName) {
|
||||
const slug = featureName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return `${slug || 'feature'}.json`
|
||||
}
|
||||
|
||||
// Compute a stable relative require path from a generated file's directory to
|
||||
// a target module, with a './' or '../' prefix for require().
|
||||
function relativeRequire (fromDir, targetFile) {
|
||||
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
|
||||
if (!rel.startsWith('.')) rel = `./${rel}`
|
||||
return rel
|
||||
}
|
||||
|
||||
function main () {
|
||||
const irArg = process.argv[2]
|
||||
const outArg = process.argv[3]
|
||||
|
||||
if (!irArg || !outArg) {
|
||||
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let ir
|
||||
try {
|
||||
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
|
||||
} catch (err) {
|
||||
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const genDir = outArg
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
|
||||
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
|
||||
const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js'))
|
||||
|
||||
const body = `'use strict'
|
||||
const { runFeature } = require('${relRuntime}')
|
||||
const ir = ${JSON.stringify(ir, null, 2)}
|
||||
async function main () {
|
||||
const report = await runFeature(ir)
|
||||
for (const r of report.results) {
|
||||
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
|
||||
if (r.detail) console.log(' ' + r.detail)
|
||||
}
|
||||
if (report.failures > 0) {
|
||||
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(testFile, body)
|
||||
} catch (err) {
|
||||
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Per-feature metadata with an implementation hash over generated files only.
|
||||
const metaDir = path.join(genDir, 'metadata')
|
||||
fs.mkdirSync(metaDir, { recursive: true })
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(testFile))
|
||||
.digest('hex')
|
||||
|
||||
const metadata = {
|
||||
schema_version: 1,
|
||||
feature_path: `${featureKey}.feature`,
|
||||
ir_path: path.resolve(irArg),
|
||||
implementation_hash: `sha256:${hash}`,
|
||||
hash_scope: 'generated_files',
|
||||
generated_files: [testFile]
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(metaDir, metadataName(featureKey)),
|
||||
JSON.stringify(metadata, null, 2)
|
||||
)
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
Project step handlers for the psf-memo-client acceptance pipeline.
|
||||
|
||||
These handlers connect Gherkin step text to real project behavior
|
||||
(src/services/memo-post.js and src/services/new-post.js), driving them
|
||||
through small injected adapters (a fake wallet, a fake feed, and a fake
|
||||
navigator) so the acceptance run is deterministic and offline.
|
||||
|
||||
Regex matching with placeholder-name capture is the default style: a single
|
||||
handler pattern captures the placeholder name (e.g. <message>) and fetches
|
||||
the example value from the scenario example store.
|
||||
|
||||
The handlers serve both specs/post-memo.feature and specs/memo-new.feature,
|
||||
whose wording differs but which share the same underlying Memo post behavior.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
|
||||
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||
function makeWallet (address) {
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress: address },
|
||||
utxos: [],
|
||||
broadcasts: [],
|
||||
getUtxos: async function () {
|
||||
return this.utxos
|
||||
},
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
// Record the broadcast attempt, then fail if configured to do so.
|
||||
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
}
|
||||
return wallet
|
||||
}
|
||||
|
||||
// A fake feed reflecting posts added to the recent posts feed.
|
||||
function makeFeed () {
|
||||
const posts = []
|
||||
return {
|
||||
posts,
|
||||
addPost: (post) => posts.push(post)
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh world/state object for a single scenario execution.
|
||||
function createWorld () {
|
||||
const wallet = makeWallet('')
|
||||
const feed = makeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
const world = {
|
||||
wallet,
|
||||
feed,
|
||||
memoPost,
|
||||
currentPath: null,
|
||||
menuOpen: false
|
||||
}
|
||||
|
||||
// The New Post Page controller wraps the memo post behavior. Its navigate
|
||||
// adapter updates the world's current path so navigation can be asserted.
|
||||
world.newPage = new NewPostPage({
|
||||
memoPost,
|
||||
navigate: (path) => { world.currentPath = path },
|
||||
menuLinks: []
|
||||
})
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
{
|
||||
name: 'wallet authenticated for address',
|
||||
pattern: /^a wallet authenticated for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
world.wallet.walletInfo.cashAddress = m[1].trim()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet has spendable output',
|
||||
pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/,
|
||||
run (m, example, world) {
|
||||
world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'viewing recent posts feed',
|
||||
pattern: /^I am viewing the recent posts feed$/,
|
||||
run (m, example, world) {
|
||||
world.currentPath = NewPostPage.RECENT_FEED_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet fails to broadcast with error',
|
||||
pattern: /^the wallet fails to broadcast with the error "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
world.wallet.failWith = example[param]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'navigate to path',
|
||||
pattern: /^I navigate to the path (.+)$/,
|
||||
run (m, example, world, step) {
|
||||
const target = m[1].trim()
|
||||
if (step.keyword === 'Then') {
|
||||
if (world.currentPath !== target) {
|
||||
throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`)
|
||||
}
|
||||
} else {
|
||||
world.currentPath = target
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remain on path',
|
||||
pattern: /^I remain on the path (.+)$/,
|
||||
run (m, example, world) {
|
||||
const target = m[1].trim()
|
||||
if (world.currentPath !== target) {
|
||||
throw new Error(`Expected to remain on path ${target}, but current path is ${world.currentPath}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open navigation menu',
|
||||
pattern: /^I open the navigation menu$/,
|
||||
run (m, example, world) {
|
||||
world.menuOpen = true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'menu shows link to path',
|
||||
pattern: /^the menu shows a link to the path (.+)$/,
|
||||
run (m, example, world) {
|
||||
const target = m[1].trim()
|
||||
if (!world.newPage.hasMenuLink(target)) {
|
||||
throw new Error(`Navigation menu does not link to ${target}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'compose/type memo text',
|
||||
pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
world.newPage.setInput(example[param])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit/click post',
|
||||
pattern: /^I (?:submit the memo|click the post button)$/,
|
||||
async run (m, example, world) {
|
||||
await world.newPage.submit()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts/attempts OP_RETURN with Memo post prefix',
|
||||
pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/,
|
||||
run (m, example, world) {
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_POST_PREFIX) {
|
||||
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg !== world.newPage.input) {
|
||||
throw new Error('Broadcast message text did not match the composed memo.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed shows new post from my address',
|
||||
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expectedText = example[param]
|
||||
const myAddress = world.wallet.walletInfo.cashAddress
|
||||
const found = world.feed.posts.find(
|
||||
(p) => p.text === expectedText && p.address === myAddress
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Feed does not show the new post with text "${expectedText}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'page shows error containing text',
|
||||
pattern: /^the new post page shows an error containing "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expected = example[param]
|
||||
const actual = world.newPage.broadcastError || ''
|
||||
if (!actual.includes(expected)) {
|
||||
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'page shows validation/length error',
|
||||
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
|
||||
run (m, example, world) {
|
||||
const kind = m[1]
|
||||
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
|
||||
if (world.newPage.submitError !== expectedCode) {
|
||||
throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remaining character count',
|
||||
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expected = parseInt(example[param], 10)
|
||||
if (Number.isNaN(expected)) {
|
||||
throw new Error(`Invalid expected count for "${param}".`)
|
||||
}
|
||||
const actual = world.newPage.remainingCount()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} remaining characters, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'app does not broadcast any transaction',
|
||||
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
|
||||
run (m, example, world) {
|
||||
if (world.wallet.broadcasts.length !== 0) {
|
||||
throw new Error('A transaction was broadcast when none was expected.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Route a single step to its handler. Throws on unsupported step text.
|
||||
async function handleStep (step, example, world) {
|
||||
for (const handler of handlers) {
|
||||
const match = handler.pattern.exec(step.text)
|
||||
if (match) {
|
||||
await handler.run(match, example, world, step)
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
|
||||
}
|
||||
|
||||
module.exports = { createWorld, handleStep }
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Persistent runner adapter for the APS gherkin-mutator.
|
||||
|
||||
The mutator starts this process (once per worker) and sends mutation jobs
|
||||
over newline-delimited JSON on stdin. Each job carries the path to a mutated
|
||||
feature JSON IR; this worker evaluates it through the same acceptance runtime
|
||||
and step handlers used by the normal acceptance pipeline and replies with the
|
||||
runner outcome.
|
||||
|
||||
Protocol (mutator-spec.md):
|
||||
request: { "id", "feature_json", "generated_dir", "work_dir" }
|
||||
response: { "id", "outcome", "output", "error", "duration" }
|
||||
outcome: test_success | test_failure | infrastructure_error
|
||||
|
||||
test_failure (acceptance failed) -> mutation killed
|
||||
test_success (acceptance passed) -> mutation survived
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const readline = require('node:readline')
|
||||
const fs = require('node:fs')
|
||||
const { runFeature } = require('./runtime')
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
})
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
const started = Date.now()
|
||||
const respond = (payload) => {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
||||
}
|
||||
|
||||
let job
|
||||
try {
|
||||
job = JSON.parse(line)
|
||||
} catch (err) {
|
||||
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
|
||||
const report = await runFeature(ir)
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
|
||||
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
|
||||
error: '',
|
||||
duration: Date.now() - started
|
||||
})
|
||||
} catch (err) {
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: 'infrastructure_error',
|
||||
output: '',
|
||||
error: err.message,
|
||||
duration: Date.now() - started
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rl.on('close', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Acceptance runtime for psf-memo-client.
|
||||
|
||||
Expands each scenario (and each example row) from parser JSON IR into
|
||||
scenario executions, prepends background steps, and dispatches every step to
|
||||
the project step handlers. Unsupported steps, invalid example values, or
|
||||
failed assertions fail that execution.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { createWorld, handleStep } = require('./handlers')
|
||||
|
||||
// Expand the IR scenarios into concrete executions.
|
||||
// For scenario outlines with examples, one execution per example row; for
|
||||
// scenarios without examples, one execution with an empty example store.
|
||||
function expandScenarios (ir) {
|
||||
const executions = []
|
||||
const background = ir.background || []
|
||||
|
||||
for (const scenario of ir.scenarios) {
|
||||
const examples = (scenario.examples && scenario.examples.length > 0)
|
||||
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
|
||||
: [{ example: {}, suffix: 'example_1' }]
|
||||
|
||||
for (const { example, suffix } of examples) {
|
||||
executions.push({
|
||||
name: `${scenario.name}/${suffix}`,
|
||||
steps: [...background, ...scenario.steps],
|
||||
example
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return executions
|
||||
}
|
||||
|
||||
// Run a full feature and return a report of scenario outcomes.
|
||||
async function runFeature (ir) {
|
||||
const executions = expandScenarios(ir)
|
||||
const results = []
|
||||
let failures = 0
|
||||
|
||||
for (const ex of executions) {
|
||||
// A fresh world/state object for each scenario execution.
|
||||
const world = createWorld()
|
||||
let failure = null
|
||||
|
||||
for (const step of ex.steps) {
|
||||
try {
|
||||
await handleStep(step, ex.example, world)
|
||||
} catch (err) {
|
||||
failure = err.message
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
failures++
|
||||
results.push({ name: ex.name, status: 'failed', detail: failure })
|
||||
} else {
|
||||
results.push({ name: ex.name, status: 'passed' })
|
||||
}
|
||||
}
|
||||
|
||||
return { feature: ir.name, results, failures, total: results.length }
|
||||
}
|
||||
|
||||
module.exports = { expandScenarios, runFeature }
|
||||
@@ -0,0 +1,10 @@
|
||||
{:paths ["test"]
|
||||
:tasks
|
||||
{test {:doc "Run helper tests"
|
||||
:task (do
|
||||
(require 'clojure.test)
|
||||
(require 'swarmforge.handoff-test)
|
||||
(require 'swarmforge.script-test)
|
||||
(let [{:keys [fail error]} (clojure.test/run-tests 'swarmforge.handoff-test
|
||||
'swarmforge.script-test)]
|
||||
(System/exit (+ fail error))))}}}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: close-swarm [project-root]" >&2
|
||||
echo "Stops the SwarmForge swarm for the given project (default: current directory)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${1:-.}" && pwd)"
|
||||
STATE_DIR="$PROJECT_ROOT/.swarmforge"
|
||||
SOCKET_FILE="$STATE_DIR/tmux-socket"
|
||||
SESSIONS_FILE="$STATE_DIR/sessions.tsv"
|
||||
WINDOW_IDS_FILE="$STATE_DIR/window-ids"
|
||||
WINDOWS_STATE_FILE="$STATE_DIR/windows.tsv"
|
||||
|
||||
if [[ ! -d "$STATE_DIR" ]]; then
|
||||
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -x "$SELF_DIR/swarmforge/scripts/swarm-cleanup.sh" ]]; then
|
||||
SCRIPT_DIR="$SELF_DIR/swarmforge/scripts"
|
||||
elif [[ -x "$SELF_DIR/swarm-cleanup.sh" ]]; then
|
||||
SCRIPT_DIR="$SELF_DIR"
|
||||
elif [[ -x "$PROJECT_ROOT/swarmforge/scripts/swarm-cleanup.sh" ]]; then
|
||||
SCRIPT_DIR="$PROJECT_ROOT/swarmforge/scripts"
|
||||
else
|
||||
echo "Could not find swarm-cleanup.sh relative to $SELF_DIR or $PROJECT_ROOT." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SOCKET_FILE" ]]; then
|
||||
echo "No SwarmForge swarm found at $PROJECT_ROOT (missing .swarmforge/tmux-socket)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMUX_SOCKET="$(tr -d '[:space:]' < "$SOCKET_FILE")"
|
||||
if [[ -z "$TMUX_SOCKET" ]]; then
|
||||
echo "No SwarmForge swarm found at $PROJECT_ROOT (empty tmux socket)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sessions=()
|
||||
if [[ -f "$SESSIONS_FILE" ]]; then
|
||||
while IFS=$'\t' read -r _index _role session _rest; do
|
||||
[[ -n "${session:-}" ]] || continue
|
||||
sessions+=("$session")
|
||||
done < "$SESSIONS_FILE"
|
||||
fi
|
||||
|
||||
if (( ${#sessions[@]} == 0 )) && [[ -f "$WINDOWS_STATE_FILE" ]]; then
|
||||
while IFS=$'\t' read -r _index _window_id session _title; do
|
||||
[[ -n "${session:-}" ]] || continue
|
||||
sessions+=("$session")
|
||||
done < "$WINDOWS_STATE_FILE"
|
||||
fi
|
||||
|
||||
if (( ${#sessions[@]} == 0 )) && [[ -S "$TMUX_SOCKET" ]]; then
|
||||
while IFS= read -r session; do
|
||||
[[ -n "$session" ]] || continue
|
||||
sessions+=("$session")
|
||||
done < <(tmux -S "$TMUX_SOCKET" list-sessions -F '#{session_name}' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -n "${SWARMFORGE_TERMINAL:-}" && -z "${SWARMFORGE_TERMINAL_BACKEND:-}" ]]; then
|
||||
export SWARMFORGE_TERMINAL_BACKEND="$SWARMFORGE_TERMINAL"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/swarm-cleanup.sh" "$TMUX_SOCKET" "$WINDOW_IDS_FILE" "${sessions[@]+${sessions[@]}}"
|
||||
@@ -0,0 +1,97 @@
|
||||
# Plan: Option B — `SWARMFORGE_DIR` (external machinery, per-project config)
|
||||
|
||||
> **Status**: pending implementation — working document to resume later.
|
||||
> **Context**: came out of the trial run with `saas-prototype` (see the fork README).
|
||||
> The goal is that projects do not carry SwarmForge code, only their configuration.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
The project stops carrying SwarmForge **code** (scripts) and **shared rules**
|
||||
(articles, default roles). It only keeps its **own configuration** (`swarmforge.conf` +
|
||||
`project.prompt` + overrides). The fork (or any shared location via `SWARMFORGE_DIR`)
|
||||
is the sole source of the machinery. With `SWARMFORGE_DIR` unset, **everything still works
|
||||
as today** (full backward compatibility).
|
||||
|
||||
## 2. Design: where each thing lives
|
||||
|
||||
```
|
||||
SWARMFORGE_DIR (base, e.g. ~/.local/share/swarmforge = fork clone)
|
||||
├── swarm/ + swarmforge/scripts/ ← launcher, daemon, helpers, adapters
|
||||
├── swarmforge/roles/*.prompt ← default roles
|
||||
└── swarmforge/constitution/articles/ ← engineering, handoffs, workflow (shared)
|
||||
|
||||
PROJECT
|
||||
└── swarmforge/ ← "thin": ONLY per-project material
|
||||
├── swarmforge.conf ← project roles + models
|
||||
└── constitution/articles/project.prompt (+ local-*.prompt, role overrides if any)
|
||||
```
|
||||
|
||||
**Merge rule**: the project wins by name — if the project has
|
||||
`roles/cleaner.prompt`, that takes precedence; otherwise the base one is used.
|
||||
|
||||
## 3. Code changes (file by file)
|
||||
|
||||
| File | Change | Why |
|
||||
|---|---|---|
|
||||
| `swarmforge.bb` → `context` | Add `:base-dir` = `(or (System/getenv "SWARMFORGE_DIR") (fs/path working-dir "swarmforge"))`; keep `:swarm-forge-dir` = `working-dir/swarmforge` (project config) | Shared base vs. per-project config |
|
||||
| `swarmforge.bb` → `parse-config` | `roles-dir` = per-role lookup: `project/roles/<role>.prompt` if it exists, else `base/roles/<role>.prompt` | Role overrides |
|
||||
| `swarmforge.bb` → new `sync-shared-config!` | For each worktree **and for master**: copy from `base` the shared articles and default roles into the destination `swarmforge/` **only if missing** (project ones win); scripts as today | The agent reads `swarmforge/constitution.prompt` relative to its cwd — after sync, the merged view is there |
|
||||
| `swarmforge.bb` → `prepare-workspace!` / setup | When syncing on master (project root), add derived files (shared articles) to `.gitignore` so the project's git stays clean | Shared articles are generated at startup, not committed |
|
||||
| `write-agent-instruction-file!` | **No changes** | Relative paths still work because sync merges the view into each worktree |
|
||||
| `check-helper-scripts!` | **No changes** (validates `script-dir`, which points at the base) | — |
|
||||
| `handoffd.bb`, helpers, adapters | **No changes** | Already resolve the project via git (`roles.tsv`) |
|
||||
| `swarm` wrapper | Small install/docs tweak: when installed globally, it runs the base `swarmforge.sh`; the download block remains for first-time setup | Global install |
|
||||
|
||||
**Estimated total**: ~40-60 new/modified lines in `swarmforge.bb` + docs. Nothing else.
|
||||
|
||||
## 4. User setup (once)
|
||||
|
||||
```bash
|
||||
# 1. Install the machinery once
|
||||
git clone https://github.com/pablo-io/swarm-forge ~/.local/share/swarmforge
|
||||
ln -s ~/.local/share/swarmforge/swarm ~/.local/bin/swarm
|
||||
export SWARMFORGE_DIR=~/.local/share/swarmforge # (in your .bashrc)
|
||||
|
||||
# 2. In any project: create ONLY the config
|
||||
mkdir -p swarmforge/constitution/articles
|
||||
# swarmforge.conf + project.prompt (+ local-* / overrides if applicable)
|
||||
|
||||
# 3. Run
|
||||
cd /my/project && swarm
|
||||
```
|
||||
|
||||
## 5. Test plan
|
||||
|
||||
1. **`bb test`** — the existing suite (24 tests) must pass with no semantic changes.
|
||||
2. **Mode B**: minimal project with only conf + `project.prompt` → launch with `SWARMFORGE_DIR`
|
||||
→ verify: worktrees with the merged view (shared articles + roles + scripts),
|
||||
functional master, clean startup.
|
||||
3. **Handoff smoke**: a real end-to-end handoff (like the `saas-prototype` run)
|
||||
under mode B.
|
||||
4. **Backward compatibility**: `saas-prototype` (with full `swarmforge/`) without the env var →
|
||||
must keep working the same.
|
||||
5. **Sync**: change a script in the fork (e.g. a fix) → it is reflected without copying
|
||||
anything into the project.
|
||||
|
||||
## 6. Optional migration of `saas-prototype` (after validating B)
|
||||
|
||||
- Thin its `swarmforge/`: delete `scripts/`, `roles/`, and shared articles; leave
|
||||
`swarmforge.conf` + `project.prompt` (with the design rules).
|
||||
- Re-copy updated prompts from the fork (`architect.prompt` with the written-report
|
||||
rule) — now via the base, not manual copy.
|
||||
|
||||
## 7. Risks / open decisions
|
||||
|
||||
- **Shared articles synced onto master** will be gitignored (derived) — if someone wants
|
||||
to version them explicitly, they can commit them (sync does not overwrite existing ones).
|
||||
- **`roles.tsv` and state** stay under the project's `.swarmforge/` (gitignored) — unchanged.
|
||||
- Agent instructions remain relative to the worktree — key to the design
|
||||
(zero protocol changes).
|
||||
|
||||
## 8. Suggested implementation order
|
||||
|
||||
1. `context` + `parse-config` (base dir + role lookup)
|
||||
2. `sync-shared-config!` + gitignore for derived files
|
||||
3. `bb test`
|
||||
4. Mode B trial with a minimal project
|
||||
5. Doc in the fork README ("SWARMFORGE_DIR mode" section)
|
||||
@@ -0,0 +1,154 @@
|
||||
# Proposal: Configurable quality level per project
|
||||
|
||||
> **Status**: proposal pending implementation — working document.
|
||||
> **Origin**: evaluation of the `saas-prototype` run (see the project's `REPORT.md`): fixed
|
||||
> gates (CRAP≤6, mutation run, DRY, soft Gherkin) cost tokens that not every project needs.
|
||||
> This proposal adds a configurable **quality axis** per project.
|
||||
> **Note**: the two-pack review (section 5) narrows the proposal — the real value is depth
|
||||
> within a pack, not replacing pack choice.
|
||||
|
||||
## 1. The 3 levels
|
||||
|
||||
| Level | Relative cost | Typical use |
|
||||
|---|---|---|
|
||||
| `minimal` | ~1x | Prototypes, spikes, throwaway code |
|
||||
| `standard` | ~2x | Reasonable default for product features |
|
||||
| `maximum` | ~3-4x | **Current rigor** — critical libraries, security/payments, code consumed by others |
|
||||
|
||||
`maximum` = what the pipeline already does today (nothing beyond that for now).
|
||||
|
||||
## 2. Gates by level
|
||||
|
||||
| Gate | minimal | standard | maximum (current) |
|
||||
|---|---|---|---|
|
||||
| TDD + unit tests | ✓ | ✓ | ✓ |
|
||||
| Acceptance Gherkin | optional | ✓ (without full APS pipeline) | ✓ full APS pipeline |
|
||||
| CRAP | — | improve what is reasonable, no hard gate | **≤6** |
|
||||
| DRY | — | reduce reasonable duplication | tooling, strict |
|
||||
| Mutation scan + split >100 sites | — | ✓ (count only — cheap) | ✓ |
|
||||
| Full mutation run | — | — | ✓ differential, kill non-equivalents |
|
||||
| Soft Gherkin mutation | — | — | ✓ |
|
||||
| Property tests | — | — | support |
|
||||
| Written report | — | optional | ✓ |
|
||||
|
||||
## 3. Role responsibility by level (four-pack)
|
||||
|
||||
| Role | minimal | standard | maximum |
|
||||
|---|---|---|---|
|
||||
| **specifier** | Scoping + human gate only (no mandatory Gherkin) | Gherkin ✓ | Gherkin + QA suite |
|
||||
| **coder** | Implements with TDD — required | ✓ | ✓ |
|
||||
| **refactorer** | No gates → **no real work** | Reasonable CRAP + DRY + mutation scan | full gates |
|
||||
| **architect** | No gates → **no real work** | light structural review only | full gates |
|
||||
|
||||
**Conclusion**: level and workflow are correlated. At `minimal`, refactorer/architect have no
|
||||
gates to apply — configuring 4 roles would waste tokens with no benefit.
|
||||
|
||||
## 4. Mechanism (prompts/articles only, zero code)
|
||||
|
||||
```
|
||||
1. PROJECT (project.prompt): "## Quality Level → Quality level: standard"
|
||||
2. SHARED (quality.prompt): the ON/OFF gate table by level
|
||||
3. EACH ROLE PROMPT (one line): "Apply only the gates that are ON for
|
||||
the project's level (see quality article)"
|
||||
```
|
||||
|
||||
The agent reads the level in `project.prompt`, the table in `quality.prompt`, and its role
|
||||
prompt tells it to apply only the ON gates → consistent interpretation across roles.
|
||||
|
||||
The `quality.prompt` article also includes the **role mapping by level**: "at minimal,
|
||||
configure only specifier+coder (2 windows in `swarmforge.conf`); at standard, add
|
||||
refactorer; at maximum, all 4".
|
||||
|
||||
**Honest nuance**: the adjustment is *prompt-soft* — agents follow the level by instruction.
|
||||
Hard enforcement would need a `tools/quality-check` (validate level artifacts), an optional
|
||||
later step.
|
||||
|
||||
## 5. Review: does two-pack already solve part of this?
|
||||
|
||||
**Result of reviewing two-pack's real scope (original project):**
|
||||
|
||||
- **coder (two-pack)**: TDD + unit tests ONLY — explicitly excludes acceptance, Gherkin, IR,
|
||||
Gherkin mutation, property tests, CRAP, DRY, and language mutation.
|
||||
- **cleaner (two-pack, batch)**: coverage, **CRAP≤6**, **DRY**, structure/encapsulation/dependencies
|
||||
and **mutation run on uncovered behavior** + tests to kill mutants.
|
||||
|
||||
**two-pack quality profile**: unit tests ✓ · CRAP≤6 ✓ · DRY ✓ · mutation run ✓ ·
|
||||
structure ✓ (inside cleaner) · acceptance/Gherkin ✗ · property ✗ · separate QA ✗.
|
||||
|
||||
### Conclusion
|
||||
|
||||
1. **two-pack is NOT `minimal`**: it keeps the hard hardening gates (CRAP≤6, DRY, mutation
|
||||
run). It is "full hardening without specification" — not the cheap option on the depth
|
||||
axis.
|
||||
2. **Packs already encode a quality axis**: which gates EXIST (two-pack: no spec;
|
||||
four-pack: spec + architecture; six-pack: + hardender + QA).
|
||||
3. **The level axis adds what packs do NOT cover**: the DEPTH of each active gate
|
||||
(CRAP≤6 vs "improve reasonably"; mutation run vs scan-only; soft Gherkin on/off).
|
||||
4. **Practical implication**: for "cheap", choosing two-pack already drops the expensive
|
||||
layers (spec/architecture) — the main cost lever is the pack. Level is for scaling depth
|
||||
WITHIN a pack (e.g. two-pack without mutation run, four-pack without Gherkin mutation).
|
||||
The run confirmed it: the main waste was four-pack for a login form, not gate depth.
|
||||
|
||||
**Verdict**: the level proposal remains valid but is **narrower** than it first seemed: its
|
||||
real value is depth within a pack, not replacing pack choice. Possible simplification: start
|
||||
with only two levels (standard = current, light = no mutation run or Gherkin mutation) and
|
||||
let pack choice do the rest.
|
||||
|
||||
## 6. Analysis: spec vs hardening priority (the critique of two-pack)
|
||||
|
||||
**two-pack's logic**: TDD already specifies behavior at the unit-test level; Gherkin is a
|
||||
second layer (reviewable contract + end-to-end acceptance) that is expensive (APS pipeline);
|
||||
hardening gates (CRAP≤6, DRY, mutation run) are the code quality floor.
|
||||
|
||||
**The critique (valid)**: for a small task the priority is inverted — mutation run is
|
||||
expensive and protects code that may be thrown away in a prototype; cheap spec ensures the
|
||||
RIGHT thing is built. A well-hardened but wrong feature is still wrong. Logical order:
|
||||
first WHAT (spec), then HOW (gates).
|
||||
|
||||
| | two-pack | spec-first variant (proposal) |
|
||||
|---|---|---|
|
||||
| Base spec | unit tests (TDD) | Light Gherkin (reviewable contract + human approval) + TDD |
|
||||
| Code protection | CRAP≤6 + DRY + **mutation run** | Reasonable CRAP/DRY, **no mutation run** |
|
||||
| Cost | ~2-3x | ~1.5-2x |
|
||||
| Risk covered | dirty/unchangeable code | **building the wrong thing** |
|
||||
|
||||
**The gap it reveals**: two-pack assumes Gherkin comes with the full APS pipeline cost
|
||||
(parser + entrypoint generator + runtime + step handlers). It offers no "spec-lite" variant:
|
||||
write Gherkin as a reviewable contract without building the pipeline or running mutation.
|
||||
|
||||
**Refinement of the `light` level**:
|
||||
|
||||
> `light` = Gherkin written as contract + human approval + TDD + reasonable CRAP/DRY —
|
||||
> **no APS pipeline, no mutation run, no Gherkin mutation, no property tests**.
|
||||
|
||||
This variant covers the most important risk (is it the right thing? does a human approve?)
|
||||
at lower cost than "mutation run without spec".
|
||||
|
||||
## 7. Spec-light: Gherkin or other alternatives?
|
||||
|
||||
**Comparison of options for a cheap, reviewable contract:**
|
||||
|
||||
| Option | Cost | Human pre-code contract | Real enforcement | Risk | Upgrade to spec-full |
|
||||
|---|---|---|---|---|---|
|
||||
| TDD tests as spec (two-pack) | ~1x | ❌ | ✅ | user sees behavior at the end | — |
|
||||
| Prose criteria (markdown) | ~1x | ✅ imprecise | ❌ | ambiguity | rewrite |
|
||||
| **Gherkin written-only (light)** | ~1.5x | ✅ precise | ❌ | **spec drift** | ✅ zero rewrite |
|
||||
| Given/When/Then scenarios in markdown | ~1x | ✅ | ❌ | no standard format | medium rewrite |
|
||||
|
||||
**Key point**: in light, real enforcement comes from the coder's TDD — it turns each approved
|
||||
scenario into unit tests. Gherkin remains a human contract + guide, not verification.
|
||||
**Spec drift** risk is mitigated by a light workflow rule: *"the coder maps each approved
|
||||
scenario to unit tests; the handoff/report confirms the scenario→tests mapping"*.
|
||||
|
||||
**Recommendation**: in four-pack, light = natural degradation — the specifier already writes
|
||||
Gherkin and asks for approval; the back half of the pipeline is cut (the coder does not build
|
||||
entrypoint generator/runtime/step handlers, implements with TDD mapping scenarios). If you
|
||||
later scale to spec-full, the `.feature` files are already there — you only build the pipeline
|
||||
around them.
|
||||
|
||||
**Recommended cheap add-on**: in light, the coder runs `gherkin-parser` ONLY to validate that
|
||||
the spec parses (seconds, no pipeline build) — prevents broken Gherkin syntax from passing
|
||||
as a contract.
|
||||
|
||||
**When to choose each**: never going to scale → prose or TDD-only; may scale → Gherkin
|
||||
written-only (the format IS the upgrade path); human must approve before coding → Gherkin.
|
||||
@@ -0,0 +1,271 @@
|
||||
# SwarmForge — Handoff protocol and deterministic pipeline
|
||||
|
||||
> Working document complementary to `swarmforge.md`.
|
||||
> Example based on the full `six-pack` workflow (specifier → coder → cleaner → architect → hardender → QA).
|
||||
|
||||
---
|
||||
|
||||
## 1. Handoff semantics (full example)
|
||||
|
||||
**Task**: *"Implement a shopping cart with tax calculation"* → stable task name: **`cart-tax`**. That name travels the entire chain unchanged.
|
||||
|
||||
### 1.1 The message contract
|
||||
|
||||
Only **two message types** exist, and only the headers the agent may write:
|
||||
|
||||
```text
|
||||
type: git_handoff → "I committed work; merge and process it"
|
||||
to: coder
|
||||
priority: 50 → 00 = urgent · 50 = normal · 99 = low
|
||||
task: cart-tax → stable name that travels the chain
|
||||
commit: 3f9a2c1d7e → canonical 10-hex hash (the gate validates and canonicalizes it)
|
||||
```
|
||||
|
||||
```text
|
||||
type: note → short message (only if the constitution/role authorizes it)
|
||||
to: architect
|
||||
priority: 70
|
||||
message: <1 line, max 80 chars>
|
||||
```
|
||||
|
||||
Agents **never write the payload or reserved headers** (`id`, `from`, `role`, `recipient`, `created_at`, `enqueued_at`…): the tool generates all of that.
|
||||
|
||||
### 1.2 The specifier opens the chain
|
||||
|
||||
The specifier talks with you, writes `features/cart.feature` (Gherkin) + the end-to-end QA suite, and **asks for your explicit approval**. Only after your OK does it commit and write its draft:
|
||||
|
||||
```text
|
||||
type: git_handoff
|
||||
to: coder
|
||||
priority: 50
|
||||
task: cart-tax
|
||||
commit: 3f9a2c1d7e
|
||||
```
|
||||
|
||||
It runs `swarm_handoff.sh draft` → the **validation gate** does 4 checks: `coder` is a known role, `50` is a valid priority, the commit **resolves to exactly one object and is a commit** (via `git rev-parse --disambiguate`), and there are no reserved fields or agent-written body. It generates the payload and installs it atomically in the outbox:
|
||||
|
||||
```text
|
||||
50_20260710T120000Z_000042_from_specifier_to_coder.handoff
|
||||
```
|
||||
|
||||
The **daemon** (1 s polling) copies the file to the coder's `inbox/new/` **adding delivery headers**, and wakes the coder by typing into its tmux pane: *"You have new handoff mail. If idle, run ready_for_next.sh."* + Enter.
|
||||
|
||||
The delivered file (this is what the coder sees):
|
||||
|
||||
```text
|
||||
id: 20260710T120000Z_000042_from_specifier
|
||||
from: specifier
|
||||
to: coder
|
||||
recipient: coder ← added by the daemon (per-recipient copy)
|
||||
priority: 50
|
||||
type: git_handoff
|
||||
role: specifier
|
||||
task: cart-tax
|
||||
commit: 3f9a2c1d7e
|
||||
created_at: 2026-07-10T12:00:00Z
|
||||
enqueued_at: 2026-07-10T12:00:01Z ← added by the daemon
|
||||
|
||||
Re-read your role and constitution.
|
||||
|
||||
merge_and_process specifier 3f9a2c1d7e
|
||||
```
|
||||
|
||||
### 1.3 The coder consumes the task
|
||||
|
||||
The coder runs `ready_for_next.sh` → the helper moves the file from `inbox/new/` to `inbox/in_process/`, **adds `dequeued_at`**, and prints:
|
||||
|
||||
```text
|
||||
TASK: .swarmforge/handoffs/inbox/in_process/50_..._from_specifier_to_coder.handoff
|
||||
FROM: specifier
|
||||
TYPE: git_handoff
|
||||
PRIORITY: 50
|
||||
TASK_NAME: cart-tax
|
||||
PAYLOAD:
|
||||
Re-read your role and constitution.
|
||||
|
||||
merge_and_process specifier 3f9a2c1d7e
|
||||
```
|
||||
|
||||
The coder does `merge_and_process specifier 3f9a2c1d7e` (merge of the specification commit), applies **TDD** (unit tests first, then implementation), runs the acceptance tests generated from the Gherkin, commits with byline (*"Implement cart tax" — `By coder.`*) and **forwards along the chain** with the same `task: cart-tax` and its new commit. Key rule: **an intermediate role ALWAYS forwards**, no matter what (even if the change is format-only).
|
||||
|
||||
### 1.4 The cleaner in batch mode
|
||||
|
||||
The cleaner is configured in `swarmforge.conf` with `batch`. If 3 handoffs arrive from the coder at the same priority, `ready_for_next_batch.sh` groups them:
|
||||
|
||||
```text
|
||||
BATCH: .swarmforge/handoffs/inbox/in_process/batch_20260710T130000Z_000051
|
||||
COUNT: 3
|
||||
PRIORITY: 50
|
||||
BATCH_ITEM: 1 → TASK_NAME: cart-tax ...
|
||||
BATCH_ITEM: 2 → TASK_NAME: user-auth ...
|
||||
BATCH_ITEM: 3 → TASK_NAME: cart-coupon ...
|
||||
```
|
||||
|
||||
It processes all 3 as **one cleanup pass**: coverage, CRAP ≤ 6, DRY, mutation site scan (split files with >100 sites), acceptance + unit tests, commits, and forwards **once** to the architect.
|
||||
|
||||
### 1.5 The chain continues (architect → hardender → QA)
|
||||
|
||||
Each with the same mechanics: `ready_for_next.sh` (task or batch) → process its gate → verify → commit with byline → forward along the chain with `task: cart-tax` preserved.
|
||||
|
||||
### 1.6 QA closes: the "terminal broadcast"
|
||||
|
||||
When QA verifies everything (e2e UI suite, commit/manifest consistency, final CRAP/DRY), it commits and sends **a single handoff to multiple recipients** with `priority: 00`:
|
||||
|
||||
```text
|
||||
type: git_handoff
|
||||
to: specifier,coder,cleaner,architect,hardender
|
||||
priority: 00
|
||||
task: cart-tax
|
||||
commit: b4d8e2f1a0
|
||||
```
|
||||
|
||||
This is the **exception to the forwarding rule**: each recipient does `merge_and_process QA b4d8e2f1a0`, runs its tests, and **does NOT forward**. The specifier, on receiving the broadcast, merges and asks you for the next feature. Chain closed.
|
||||
|
||||
### 1.7 Each task's state machine
|
||||
|
||||
```text
|
||||
inbox/new/ ──ready_for_next──► inbox/in_process/ ──done_with_current──► inbox/completed/
|
||||
(daemon delivery) (+dequeued_at) (+completed_at)
|
||||
```
|
||||
|
||||
- `done_with_current.sh` **picks up the next task or batch automatically** if there is a queue → agents do not sit idle.
|
||||
- If a wake-up arrives while the agent is working → **it is ignored**; the queue is not lost because state lives in files.
|
||||
- Swarm restart → agents re-run `ready_for_next.sh` and resume from `in_process`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rules for a deterministic pipeline
|
||||
|
||||
Determinism does not come from one place: it comes from **four layers of rules** that reinforce each other.
|
||||
|
||||
### 2.1 Layer 1 — Shared rules (constitution)
|
||||
|
||||
**`workflow.prompt`** (work discipline):
|
||||
|
||||
- Each role works **only in its assigned worktree/branch**; forbidden to diff/merge foreign branches except via explicit handoff.
|
||||
- Every commit carries a byline: `By <role>.`
|
||||
- Temporary files under `./tmp/` of the worktree, not `/tmp`.
|
||||
- If the expected git layout does not exist → **stop and report**, do not improvise.
|
||||
|
||||
**`handoffs.prompt`** (protocol):
|
||||
|
||||
- Only `git_handoff` and `note`; notes require explicit authorization.
|
||||
- On ambiguity/contradiction → **stop and ask**, do not send notes.
|
||||
- **Mandatory chain forwarding**: each intermediate role forwards to the next stage after completing, even if the change is non-functional (format, manifests, metadata).
|
||||
- **Terminal broadcast = merge-only**: recipients of the final handoff do not forward.
|
||||
- `task:` is preserved when forwarding; invent a stable name only for new work.
|
||||
- Forbidden to edit/add/commit handoff runtime state.
|
||||
|
||||
**`engineering.prompt`** (technical rules):
|
||||
|
||||
- TDD: unit tests first, then minimal production to pass.
|
||||
- Quality tools (mutation/CRAP/DRY/coverage) run only on **testable modules**; "environmentally unsuitable" modules remain as excluded adapters.
|
||||
- Acceptance via `gherkin-parser` (APS) — forbidden to reimplement the parser.
|
||||
- Local verification before each handoff; verification commands never concurrent with each other.
|
||||
- Guardrails: do not edit mutation manifests by hand; do not commit unrelated artifacts.
|
||||
|
||||
### 2.2 Layer 2 — Per-role rules (six-pack)
|
||||
|
||||
| Role | Owns | **Does Not Own** (boundary) | Verification before handoff | Handoff obligation |
|
||||
|---|---|---|---|---|
|
||||
| **specifier** | Gherkin + acceptance criteria + e2e QA suite | Does not run mutation or quality tools | Tests if needed; **nothing more** | **Does not commit or forward without your approval**. After your OK: commit + handoff to coder with invented `task:` |
|
||||
| **coder** | Implementation of approved slices with TDD | QA suite, mutation, CRAP/DRY, Gherkin mutation | Unit tests + acceptance tests | Commit + handoff to cleaner |
|
||||
| **cleaner** (batch) | Cleanup preserving behavior: names, duplication, boundaries, coverage | Mutation tests, Gherkin mutation, **new behavior** | CRAP ≤ 6, DRY, mutation site scan, acceptance + unit | Commit + handoff to architect **before taking another task/batch** |
|
||||
| **architect** (batch) | Structure, boundaries, dependency direction, mutation hardening, DRY, property tests | — (inherits the chain) | Per-file mutation (differential), DRY, property tests, Gherkin soft | Commit + handoff to hardender |
|
||||
| **hardender** (batch) | Mutation hardening (kill survivors), Gherkin mutation, final CRAP/DRY | Specifier's e2e QA suite | Mutation → Gherkin soft → CRAP → DRY | Commit + handoff to QA |
|
||||
| **QA** (batch) | Independent final verification, turn QA suite into executable scripts, e2e via UI | Mutation and Gherkin mutation | e2e UI suite, handoff/manifest consistency, CRAP/DRY | Commit + **broadcast priority 00 to all** (merge-only) |
|
||||
|
||||
### 2.3 Layer 3 — Transport rules (the gate)
|
||||
|
||||
- `swarm_handoff.sh` **rejects** drafts with: reserved fields, unknown roles, non-numeric priority (00–99), ambiguous or non-commit commits, `task` > 80 chars, agent-written bodies. The agent repairs and retries; nothing malformed enters the queue.
|
||||
- Priorities: **50** = normal chain progress, **00** = terminal broadcast / urgent follow-up work. The queue orders by `priority_timestamp_sequence`, so order is **deterministic even if they arrive in the same second**.
|
||||
- `batch` roles consume **all equal-priority handoffs as one unit** → cleaner/reviewer does not interrupt its pass for each delivery.
|
||||
- Agents **do not talk to tmux**: the daemon is the only one with socket access; agents only write files to their outbox. Control channel and state channel are separated.
|
||||
|
||||
### 2.4 Layer 4 — State rules (the queue as a state machine)
|
||||
|
||||
- `new → in_process → completed` with audit timestamps (`enqueued_at`, `dequeued_at`, `completed_at`).
|
||||
- **Resumption**: state lives in files, not memory — you restart the swarm and `ready_for_next.sh` resumes from `in_process`.
|
||||
- `done_with_current.sh` **chains the next task** automatically → the pipeline advances without human intervention between gates.
|
||||
|
||||
### 2.5 Where determinism comes from (summary)
|
||||
|
||||
1. **Closed message types** (2) and **strict validation gate** → nothing ambiguous enters the system.
|
||||
2. **Mandatory chain forwarding** + **merge-only broadcast** → processing order is always the same, with no skips or loops.
|
||||
3. **Ownership boundaries** ("Does Not Own") → each agent only touches its own work; nobody steps on another's (coder does not do mutation; cleaner does not introduce behavior).
|
||||
4. **Mandatory verification before each handoff** → a handoff only exists if its gate passed.
|
||||
5. **Worktree isolation** → each role sees only its branch; merge happens explicitly via `merge_and_process` at handoff time.
|
||||
6. **Stable task name + priority + sequence** → full traceability: you can follow `cart-tax` commit by commit through the whole chain.
|
||||
|
||||
---
|
||||
|
||||
## 3. Diagrams
|
||||
|
||||
### 3.1 Full pipeline (six roles, `six-pack`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant U as User
|
||||
participant S as Specifier
|
||||
participant C as Coder
|
||||
participant CL as Cleaner (batch)
|
||||
participant A as Architect (batch)
|
||||
participant H as Hardender (batch)
|
||||
participant Q as QA (batch)
|
||||
|
||||
U->>S: Implement cart with tax
|
||||
S->>U: Gherkin + e2e QA suite (asks approval)
|
||||
U-->>S: Approved
|
||||
S->>S: commit spec + draft (type/to/priority/task/commit)
|
||||
S->>S: swarm_handoff.sh → outbox (gate: canonical commit)
|
||||
Note over S,C: daemon delivers to coder inbox/new + tmux wake-up
|
||||
C->>C: ready_for_next.sh → in_process + TASK cart-tax
|
||||
C->>C: merge_and_process specifier `<commit>` + TDD + acceptance
|
||||
C->>C: commit + byline + forward (same task)
|
||||
Note over C,CL: daemon delivers (several equal-priority handoffs)
|
||||
CL->>CL: ready_for_next.sh → BATCH (N items)
|
||||
CL->>CL: CRAP ≤ 6 + DRY + mutation scan + tests
|
||||
CL->>CL: commit + forward to architect
|
||||
A->>A: structure + dependencies + differential mutation + DRY
|
||||
A->>A: commit + forward to hardender
|
||||
H->>H: mutation hardening + Gherkin soft + CRAP/DRY
|
||||
H->>H: commit + forward to QA
|
||||
Q->>Q: e2e UI suite + handoff consistency
|
||||
Q->>Q: commit + broadcast priority 00 (merge-only)
|
||||
Q-->>S: merge_and_process QA `<commit>` — no forward
|
||||
S->>U: Next feature?
|
||||
```
|
||||
|
||||
### 3.2 Handoff chain and priorities
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[User] -->|"intent"| S[Specifier]
|
||||
S -->|"git_handoff p50 · stable task"| C[Coder]
|
||||
C -->|"git_handoff p50"| CL[Cleaner · batch]
|
||||
CL -->|"git_handoff p50"| A[Architect · batch]
|
||||
A -->|"git_handoff p50"| H[Hardender · batch]
|
||||
H -->|"git_handoff p50"| Q[QA · batch]
|
||||
Q -->|"git_handoff p00 · broadcast merge-only"| S
|
||||
S -.->|"human approval"| U
|
||||
```
|
||||
|
||||
### 3.3 Task lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new: daemon delivers .handoff
|
||||
new --> in_process: ready_for_next.sh (dequeued_at)
|
||||
in_process --> completed: done_with_current.sh (completed_at)
|
||||
in_process --> in_process: next queued task or batch
|
||||
new --> [*]: NO_TASK (empty queue)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mermaid syntax notes (validated with v11.13.0)
|
||||
|
||||
- In `sequenceDiagram` messages do not use `<`/`>` entities — use backticks: `` `<commit>` ``.
|
||||
- In `flowchart` labels do not use escaped double quotes (`\"`) — use inner single quotes or plain text.
|
||||
- `<br/>` does work inside sequence messages and labels.
|
||||
@@ -0,0 +1,308 @@
|
||||
# SwarmForge — Description, protocol, and migration requirements
|
||||
|
||||
> Working document. Project source: https://github.com/unclebob/swarm-forge
|
||||
> Goal: understand SwarmForge's architecture and evaluate adapting it to **pi** as the agent, on **Linux**, with **DeepSeek / GLM / Qwen** models.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project description
|
||||
|
||||
**SwarmForge** is a **tmux**-based agent orchestration platform that turns a swarm of AI agents into a coordinated software engineering team. It was created by Robert C. Martin and applies his own engineering discipline (TDD, Gherkin/acceptance testing, mutation testing, CRAP/DRY analysis) to the problem of coordinating agents.
|
||||
|
||||
Core idea: **each agent lives in its own git worktree and its own tmux session**, and agents communicate via a **file-based handoff protocol** delivered by a daemon. There are no direct messages between agents and no direct access to the tmux socket by them.
|
||||
|
||||
### Branch structure
|
||||
|
||||
| Branch | Description | Roles |
|
||||
|---|---|---|
|
||||
| `main` | **Documentary**: shared operational scripts + default constitution articles | — |
|
||||
| `two-pack` | Fast backend workflow (TDD + hardening, no Gherkin) | `coder` → `cleaner` → `coder` |
|
||||
| `four-pack` | Compact workflow with Gherkin specification | `specifier` → `coder` → `refactorer` → `architect` → `specifier` |
|
||||
| `six-pack` | Full workflow with all quality gates separated | `specifier` → `coder` → `cleaner` → `architect` → `hardender` → `QA` → end |
|
||||
|
||||
Each executable branch contains the project config: `swarmforge.conf` (topology), `roles/<role>.prompt` (per-role prompts) and `constitution.prompt` + articles (shared rules). On startup, the `./swarm` wrapper downloads the shared operational scripts from `main` (first time only) and launches the orchestrator.
|
||||
|
||||
### How it works at a high level
|
||||
|
||||
1. **Declarative configuration**: `swarmforge.conf` defines the swarm window by window:
|
||||
```
|
||||
window <role> <agent> <worktree> [task|batch] [extra-args...]
|
||||
```
|
||||
2. **Launcher** (`swarmforge.bb`, Babashka): validates the config, initializes the git repo if needed, creates a **worktree per role** under `.worktrees/`, creates a **tmux session per role** on a project-owned socket and launches each agent with its initial prompt.
|
||||
3. **Agents**: each runs as an interactive TUI in its tmux pane, inside its worktree, with the handoff scripts on its `PATH`.
|
||||
4. **Daemon** (`handoffd.bb`): owner of the tmux socket. Watches agent outboxes, delivers handoffs to recipient inboxes and wakes agents with a message typed into their pane.
|
||||
5. **Handoff protocol**: agents create validated drafts, receive them as tasks or batches (`task`/`batch`), and report completion with `done_with_current.sh`.
|
||||
6. **Optional viewer**: terminal adapters (`terminal-adapters/*.sh`) open one window per role for real-time observation, with a watchdog that reopens closed windows without losing agent state.
|
||||
|
||||
### Key features
|
||||
|
||||
- **Config-driven topology**: swarm shape comes from `swarmforge.conf`, not from code.
|
||||
- **Per-project roles**: `swarmforge/roles/<role>.prompt` per branch/backlog.
|
||||
- **Layered constitution**: `constitution.prompt` directs agents to read articles under `swarmforge/constitution/articles/` (shared engineering, handoff and workflow rules + local per-branch rules).
|
||||
- **Per-role backends**: each role can use a different agent CLI (`claude`, `codex`, `copilot`, `grok`).
|
||||
- **Observable**: one terminal window per role, or headless in tmux.
|
||||
- **Self-hosted and light**: only needs tmux, git, zsh and Babashka; all state lives in `.swarmforge/` inside the project.
|
||||
- **Operational robustness**: host sleep prevention (`caffeinate`/`systemd-inhibit`), task resumption after restart, file-based audit (`new` → `in_process` → `completed`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Handoff protocol (summary)
|
||||
|
||||
The protocol separates **state** (files on the filesystem, durable and auditable) from **control** (tmux, only for notification and liveness).
|
||||
|
||||
### Messages
|
||||
|
||||
Only two types, both strictly validated:
|
||||
|
||||
```
|
||||
type: git_handoff type: note
|
||||
to: <role>[,<role>...] to: <role>[,<role>...]
|
||||
priority: NN (00-99) priority: NN (00-99)
|
||||
task: <stable-name> message: <1 line, max 80 chars>
|
||||
commit: <10 hex>
|
||||
```
|
||||
|
||||
- `git_handoff`: the sender has committed work; the receiver does `merge_and_process <role> <commit>`.
|
||||
- `note`: short message; only when the constitution or role explicitly authorizes it.
|
||||
|
||||
### Flow
|
||||
|
||||
1. The agent commits and writes a **draft** with headers only.
|
||||
2. `swarm_handoff.sh` is the **validation gate**: rejects reserved fields, unknown roles, invalid priorities, ambiguous commits (canonicalizes the hash with `git rev-parse --disambiguate`) and bodies that are not generated.
|
||||
3. The helper generates the payload (`id`, `from`, `role`, `task`, `created_at`, body) and installs it atomically in `outbox/`.
|
||||
4. The **daemon** polls (1s), copies the handoff to each recipient's `inbox/new/` (adding `recipient` and `enqueued_at`) and wakes the receiver.
|
||||
5. The receiver runs `ready_for_next.sh` → moves to `inbox/in_process/` (adds `dequeued_at`) and prints `TASK:`/`BATCH:` with the payload.
|
||||
6. On completion, `done_with_current.sh` moves to `inbox/completed/` (adds `completed_at`) and picks up the next task if one exists.
|
||||
7. The daemon moves the sender's original to `sent/` or `failed/`.
|
||||
|
||||
### Wake-up (control plane)
|
||||
|
||||
The daemon "wakes" an agent by typing into its tmux pane:
|
||||
|
||||
```
|
||||
tmux send-keys -t <session> -l "You have new handoff mail. If idle, run ready_for_next.sh."
|
||||
tmux send-keys -t <session> C-m # Enter
|
||||
tmux send-keys -t <session> C-j # robustness LF
|
||||
```
|
||||
|
||||
The agent receives it as a user message. Protocol rules: if it is already working, **ignore the wake-up**; `done_with_current.sh` picks up the next task when finished. In practice, an agent with a message queue (like pi) enqueues the wake-up and delivers it when the turn ends.
|
||||
|
||||
### Chain rules
|
||||
|
||||
- Intermediate roles **always forward** a `git_handoff` to the next role in the chain, no matter what (even if the change is non-functional).
|
||||
- The final handoff of the chain (broadcast) is **merge-only**: recipients merge and do not forward.
|
||||
- Task names (`task:`) are preserved along the chain.
|
||||
|
||||
---
|
||||
|
||||
## 3. Migration requirements
|
||||
|
||||
### 3.1 Agent contract (necessary condition)
|
||||
|
||||
SwarmForge requires the agent to be a **long-lived interactive process in a tmux pane** that satisfies:
|
||||
|
||||
1. **Interactive CLI (TUI/REPL)** that keeps running — wake-ups arrive as typed text + Enter; a one-shot CLI cannot receive work.
|
||||
2. **Initial prompt via command line** (or injectable via `tmux send-keys` after startup).
|
||||
3. **Work in the worktree directory** (`cd <worktree> && <agent> ...`).
|
||||
4. **Ability to run commands** (the helpers `swarm_handoff.sh`, `ready_for_next.sh`, `done_with_current.sh` are shell/bb on `PATH` — they are model-agnostic).
|
||||
|
||||
Everything else in the protocol (handoffs, worktrees, daemon, wake-ups, watchdog) **does not know about the model**: the only integration point is the launch arm in `swarmforge.bb` and the validated backend list in `parse-config`.
|
||||
|
||||
### 3.2 Validation: pi as agent
|
||||
|
||||
**Fits out of the box.** Verified in docs and installed binary:
|
||||
|
||||
- `pi "<prompt>"` starts the TUI, **sends the initial message and stays interactive** (confirmed in `dist/modes/interactive/interactive-mode.js`).
|
||||
- **tmux officially supported** (`docs/tmux.md`). Recommendation: tmux ≥ 3.5 with `extended-keys-format csi-u` for modified keys; the basic protocol (Enter) works with any version.
|
||||
- **Compatible wake-up**: in pi `Enter` = send, `Ctrl+J` = new line (the daemon's `C-j` is harmless).
|
||||
- **Message queue**: a message typed while pi is working is **enqueued and delivered when the turn ends** — ideal for the protocol's wake-up semantics.
|
||||
- **Sessions**: `pi -c` (continue), `--session`, `--name "SwarmForge <Role>"` (session name).
|
||||
|
||||
Operational requirements with pi:
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| Trust prompt | pi asks on startup in a new project and **would block the agent**. Use `-a/--approve` in the launch arm or pre-seed `~/.pi/agent/trust.json`. |
|
||||
| Fixed model | Use `--model <provider>/<model>` so each role does not start at the login selector. |
|
||||
| Runtime | Node.js ≥ 22 (npm install) or standalone script; Linux supported natively. |
|
||||
|
||||
Proposed launch arm in `swarmforge.bb`:
|
||||
|
||||
```clojure
|
||||
"pi" (str "pi -a --name " (sq (str "SwarmForge " display))
|
||||
" --model " (sq model) " "
|
||||
(extra-args-prefix row)
|
||||
"\"$(cat " (sq (str prompt-file)) ")\"")
|
||||
```
|
||||
|
||||
### 3.3 Validation: opencode as agent
|
||||
|
||||
**Fits with a mandatory adaptation.** Verified on the real v1.18.15 binary and in source:
|
||||
|
||||
- `opencode` (no args) starts the persistent **interactive TUI**.
|
||||
- ⚠️ The TUI **does not accept an initial message via CLI**: `--prompt` in TUI mode calls a Node `rl.question` (waits for stdin input); only `--mini --prompt` sends it as a message, but with `interactive: false` (runs and exits). `opencode run "<msg>"` is **headless one-shot** — not usable as a swarm agent.
|
||||
- **Solution**: launch the TUI (`opencode --auto`) and **inject the prompt with `tmux send-keys`** after startup — the same mechanism the daemon already uses to wake. About ~10 lines in `launch-role!` (launch → sleep → `send-keys -l "$(cat prompt)"` + Enter).
|
||||
|
||||
```
|
||||
opencode --auto -m <provider>/<model> # in the role's tmux session
|
||||
# after ~2s:
|
||||
tmux send-keys -t <target> -l "<initial prompt>" ; tmux send-keys -t <target> C-m
|
||||
```
|
||||
|
||||
Operational requirements with opencode:
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| Permissions | `--auto` (auto-approve; also the hidden aliases `--yolo` / `--dangerously-skip-permissions`) — equivalent to the swarm's autonomous mode. |
|
||||
| Sessions | `-c/--continue`, `-s/--session` for the restart flow ("on restart, run ready_for_next.sh"). |
|
||||
| Runtime | Static binary (npm `opencode-ai` or GitHub release); Linux supported. |
|
||||
| Future | `opencode serve` + `attach`/SDK/ACP would allow a native message queue without keyboard wake-ups (would require changing the architecture, not adapting it). |
|
||||
|
||||
### 3.4 Models: DeepSeek / GLM / Qwen
|
||||
|
||||
| Model | pi | opencode |
|
||||
|---|---|---|
|
||||
| **DeepSeek** | **Native**: `DEEPSEEK_API_KEY`, provider `deepseek`, `--model deepseek/...` | **Native** in catalog (`models.dev`): `deepseek-*` |
|
||||
| **Qwen** | **Native**: `QWEN_TOKEN_PLAN_API_KEY`, providers `qwen-token-plan` / `-individual` / `-cn` (China) | **Native**: `qwen3.x-*`, `alibaba-*/qwen*` |
|
||||
| **GLM (Zhipu)** | **Not native**: needs a custom provider extension (OpenAI-compatible, `api: "openai-completions"`, `thinkingFormat: "zai"`) or OpenAI-compatible proxy | **Native**: `glm-4.x`/`glm-5.x` (`opencode-go/glm-*`, `alibaba-*/glm-*`) |
|
||||
|
||||
Note: pi already implements the *thinking* formats of all three families (`thinkingFormat: "deepseek" | "zai" | "qwen"` in `docs/custom-provider.md`), which simplifies GLM integration: you only need to register the endpoint and models with that extension.
|
||||
|
||||
### 3.5 Linux (runtime)
|
||||
|
||||
| Requirement | Status | Detail |
|
||||
|---|---|---|
|
||||
| `zsh` | **Hard requirement** | Scripts use `#!/usr/bin/env zsh`. Arch: `pacman -S zsh`. |
|
||||
| `tmux` | Required | Recommended ≥ 3.5 (pi with extended keys). |
|
||||
| `git` | Required | Worktrees and commit protocol. |
|
||||
| Babashka (`bb`) | Required | Launcher and all helpers are Babashka (cross-platform). |
|
||||
| Node.js ≥ 22 | pi only | npm install of pi (or standalone script). |
|
||||
| Terminal | **Headless works** | By default on Linux (no `osascript`/`wt.exe`) the launcher falls back to `none`: attaches the current shell to the first role's session and the rest stay detached (`tmux -S <socket> attach -t swarmforge-<role>`). The swarm runs fully without windows. |
|
||||
| Automatic windows (optional) | To build | Write a `terminal-adapters/wezterm.sh` (or kitty) for Linux: 5-function contract (~40 lines). WezTerm is the most scriptable (`wezterm cli`); Ghostty on Linux has no remote control. |
|
||||
| Shutdown | Plan for | The `close-swarm` script lives on the `main` branch; executable branches do not carry it — copy it into the project or use shutdown by "closing the first window". |
|
||||
| Sleep prevention | Works | `systemd-inhibit` on Linux (systemd running). Disable with `SWARMFORGE_PREVENT_SLEEP=0`. |
|
||||
|
||||
### 3.6 Necessary code changes (minimal)
|
||||
|
||||
In `swarmforge/scripts/swarmforge.bb` (the working branch, e.g. `four-pack`):
|
||||
|
||||
1. **`parse-config`**: add the backend to the validated list, e.g. `#{"claude" "codex" "copilot" "grok" "pi"}`.
|
||||
2. **`launch-command`**: add the new backend's arm (pi: section 3.2; opencode: section 3.3).
|
||||
3. **`check-backend-dependencies!`**: no changes — already checks that the binary exists on `PATH`.
|
||||
|
||||
In the project config:
|
||||
|
||||
- `swarmforge.conf`: `window coder pi master` (or `opencode`), with `[task|batch]` and extra args per role.
|
||||
|
||||
Optional depending on goal:
|
||||
|
||||
- Shared constitution articles in `swarmforge/constitution/articles/` of the branch (the wrapper only *stages* them in `scripts/shared-articles/`; confirm agents read what the branch needs).
|
||||
- Linux terminal adapter (section 3.5).
|
||||
- `close-swarm` in the project.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Config["Configuration (per project/branch)"]
|
||||
CONF["swarmforge.conf<br/>window role agent worktree [task|batch] [args]"]
|
||||
ROLES["swarmforge/roles/<role>.prompt"]
|
||||
CONST["swarmforge/constitution.prompt<br/>+ constitution/articles/"]
|
||||
end
|
||||
|
||||
subgraph Launcher["Launcher — swarmforge.bb (Babashka)"]
|
||||
PARSE["Validate config and prompts"]
|
||||
WT["Git worktrees<br/>.worktrees/<role> (branch per role)"]
|
||||
TMUX["tmux sessions<br/>swarmforge-<role> · project-owned socket"]
|
||||
LAUNCH["send-keys: export SWARMFORGE_ROLE<br/>+ PATH helpers + cd worktree<br/>+ <agent> '$(cat prompt)'"]
|
||||
end
|
||||
|
||||
subgraph Swarm["Swarm (1 agent per role)"]
|
||||
A1["Agent TUI<br/>(tmux pane)"]
|
||||
A2["Agent TUI<br/>(tmux pane)"]
|
||||
A3["Agent TUI<br/>(tmux pane)"]
|
||||
end
|
||||
|
||||
subgraph State["Durable state — filesystem (.swarmforge/handoffs)"]
|
||||
OUT["outbox/ · sent/ · failed/"]
|
||||
IN["inbox/ new · in_process · completed"]
|
||||
end
|
||||
|
||||
subgraph Control["Control — daemon handoffd.bb"]
|
||||
DAEMON["Poll outbox → deliver to inbox<br/>→ wake-up via tmux send-keys"]
|
||||
end
|
||||
|
||||
subgraph Viewer["Viewer (optional)"]
|
||||
ADAPT["terminal-adapters/*.sh"]
|
||||
WATCH["swarm-window-watchdog"]
|
||||
end
|
||||
|
||||
CONF --> PARSE
|
||||
ROLES --> PARSE
|
||||
CONST --> PARSE
|
||||
PARSE --> WT --> LAUNCH
|
||||
PARSE --> TMUX --> LAUNCH
|
||||
LAUNCH --> A1 & A2 & A3
|
||||
A1 & A2 & A3 -->|"helpers on PATH:<br/>swarm_handoff.sh"| OUT
|
||||
OUT --> DAEMON
|
||||
DAEMON -->|"deliver .handoff"| IN
|
||||
DAEMON -->|"wake-up: text + Enter"| A1 & A2 & A3
|
||||
A1 & A2 & A3 -->|"ready_for_next.sh<br/>done_with_current.sh"| IN
|
||||
A1 & A2 & A3 -->|"work (git)"| WT
|
||||
A1 & A2 & A3 -->|"observe: tmux attach"| TMUX
|
||||
TMUX --> ADAPT --> WATCH
|
||||
```
|
||||
|
||||
## 5. Protocol diagram (one handoff cycle)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as Sender agent (e.g. coder)
|
||||
participant V as swarm_handoff.sh (gate)
|
||||
participant O as outbox/ (sender)
|
||||
participant D as Daemon handoffd.bb
|
||||
participant I as inbox/ (receiver)
|
||||
participant R as Receiver agent (e.g. cleaner)
|
||||
|
||||
Note over S: git commit (message with role byline)
|
||||
S->>S: Write draft (type/to/priority/task/commit)
|
||||
S->>V: swarm_handoff.sh `<draft>`
|
||||
V->>V: Validate: known roles, priority 00-99,<br/>canonical commit (10 hex, --disambiguate),<br/>reserved fields, body forbidden
|
||||
V->>O: Install generated .handoff (id, from, role,<br/>task, created_at, merge_and_process payload)
|
||||
V-->>S: HANDOFF QUEUED
|
||||
O->>D: Poll (1 s)
|
||||
D->>I: Copy to each recipient inbox/new/<br/>+ recipient, enqueued_at headers
|
||||
D->>R: tmux send-keys -l 'You have new handoff mail...'<br/>+ C-m (Enter) + C-j (robustness)
|
||||
Note over R: If busy → ignore (queue or next<br/>done_with_current will pick it up)
|
||||
R->>I: ready_for_next.sh → move to in_process/<br/>+ dequeued_at header
|
||||
I-->>R: TASK: `<path>` / BATCH: `<items>` + PAYLOAD
|
||||
R->>R: merge_and_process `<sender>` `<commit>`<br/>+ process the task in its worktree
|
||||
R->>I: done_with_current.sh → completed/<br/>+ completed_at header
|
||||
I-->>R: Next task or NO_TASK
|
||||
D->>O: Move original to sent/ (or failed/)
|
||||
```
|
||||
|
||||
### Inbox task lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new: daemon delivers .handoff
|
||||
new --> in_process: ready_for_next.sh (dequeued_at)
|
||||
in_process --> completed: done_with_current.sh (completed_at)
|
||||
in_process --> in_process: next queued task
|
||||
new --> [*]: NO_TASK (empty queue)
|
||||
failed --> [*]: delivery error (sender outbox)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Executive summary
|
||||
|
||||
1. **The architecture is model-agnostic**: the only integration point for a new backend is the launch arm + the validated backend list in `swarmforge.bb`; the handoff protocol, worktrees, daemon and wake-ups do not know about the agent.
|
||||
2. **pi fits directly**: interactive initial message via CLI, tmux supported, message queue aligned with wake-up semantics, native DeepSeek/Qwen and GLM with a small extension.
|
||||
3. **opencode fits with an adaptation**: the TUI does not accept an initial prompt via CLI → inject via `tmux send-keys` after startup (mechanism already in the system). Native DeepSeek/GLM/Qwen.
|
||||
4. **Linux is a first-class citizen by design**: the swarm lives in tmux, not in windows; headless runs fully. Automatic windows are only an optional terminal adapter.
|
||||
5. **Minimum requirements**: zsh + tmux (≥3.5 recommended) + git + Babashka + (Node.js for pi) + ~15 lines of changes in `swarmforge.bb` + provider config.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Architectural Review Summary — new-post-errors
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `new-post-errors`
|
||||
- Reviewed the merged branch ending at `66a684923c` (refactorer), which carried:
|
||||
- `a125751`/`4e0f40c` — specifier broadcast-error surfacing spec (`specs/memo-new.feature`
|
||||
scenario 6)
|
||||
- `164bdb3` — coder implementation (surface broadcast errors on the new post page)
|
||||
- `66a6849` — refactorer failure-handling refactor + property coverage
|
||||
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality.
|
||||
|
||||
1. **Failure classification (good).** `src/services/new-post.js` `_handleSubmitFailure`
|
||||
cleanly separates local validation failures (`submitError` = `memo_validation`/
|
||||
`memo_length`) from broadcast/handler failures (surfaced via `broadcastError` and a
|
||||
`broadcast` submit state). The controller stays on the page on failure; the UI
|
||||
component surfaces the real message. Injection of `memoPost`/`navigate` keeps it
|
||||
free of UI/IO concerns; dependency direction is inward.
|
||||
2. **Unified handlers extended (good).** `acceptance/lib/handlers.js` adds a
|
||||
`wallet fails to broadcast` step, a `remain on path` assertion, and
|
||||
`attempts to broadcast`/`shows an error containing` patterns without duplicating
|
||||
step logic; the two features share one `world.newPage`.
|
||||
3. **Property coverage (good).** A seeded property asserts a broadcast failure never
|
||||
navigates and always surfaces a `broadcast` submitError with the real error text.
|
||||
4. **Fix applied — error-message fallback coverage.** The language mutation tool
|
||||
surfaced one survivor on `new-post.js:87` (`err.message || String(err)` → `&&`),
|
||||
which only matters when `err.message` is falsy. No test exercised that path. Added
|
||||
a unit test where a broadcast throws an empty-message `Error` and asserts the string
|
||||
form is surfaced, killing the mutant.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 21/21 pass (added the empty-message fallback test).
|
||||
- **Property (`npm run test:property`):** 7/7 pass.
|
||||
- **Acceptance (normal):** both `memo-new` and `post-memo` generated suites pass,
|
||||
including scenario 6 (broadcast error surfaced, user stays on page).
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):**
|
||||
`new-post.js` — **killed 12 / survived 0 / uncovered 0** (was 1 survivor before the
|
||||
fix).
|
||||
- **DRY (`dry4javascript src`):** no duplicate candidates.
|
||||
- **Gherkin acceptance mutation (soft):** `memo-new.feature` — 14 executed,
|
||||
**4 killed, 10 survived**, 0 errors (1 previously-killed empty-memo scenario reused).
|
||||
- Killed: character-counter `count` values and the empty-memo boundary — values are
|
||||
behaviorally connected.
|
||||
- Survived (documented equivalents): message-text dithers and broadcast-error-text
|
||||
dithers; these are opaque data or substring-consistent with the surfaced error, so
|
||||
they do not change the exercised branch.
|
||||
- Property tests run separately via `npm run test:property`.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills both testable
|
||||
core modules. Gherkin acceptance mutation survivors are documented equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `new-post-errors`), to review
|
||||
the architect commit (fallback coverage + tool manifests).
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Architectural Review Summary — new-post-page
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `new-post-page`
|
||||
- Reviewed the merged branch ending at `d57975c466` (refactorer), which carried:
|
||||
- `9385034`/`4fa92d1` — specifier New Post Page Gherkin spec (`specs/memo-new.feature`)
|
||||
- `05f393c` — coder implementation (`src/services/new-post.js`, page wiring)
|
||||
- `d57975c` — refactorer property tests for memo post / new post invariants
|
||||
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality.
|
||||
|
||||
1. **Testable controller behind adapters (good).** `src/services/new-post.js` is a
|
||||
testable controller wrapping `memo-post.js`; it holds draft input, the remaining-
|
||||
character counter, typed validation/length errors, and feed navigation. `memoPost`
|
||||
and `navigate` are injected so the module stays free of UI/IO concerns. Dependency
|
||||
direction is inward.
|
||||
2. **Unified acceptance handlers (good).** `acceptance/lib/handlers.js` now drives both
|
||||
`post-memo.feature` and `memo-new.feature` through one `world.newPage`, reusing the
|
||||
shared Memo post behavior. Wording differences are normalized via shared regex
|
||||
alternations; no step logic is duplicated.
|
||||
3. **Property tests (good).** `test/property/{harness,memo-post.property.test}.js`
|
||||
assert seeded invariants (validation classification across the length boundary,
|
||||
counter conservation `remaining === MAX - len`, `setInput` round-trip, menu-link
|
||||
idempotence). Kept separate from unit tests as the architecture requires.
|
||||
4. **Fix applied — posting-state coverage.** The language mutation tool surfaced 4
|
||||
survivors in `new-post.js`, all around the `posting` state flag (initial value and
|
||||
its true/false transitions in `submit`). Added unit coverage asserting the page
|
||||
starts idle, is `posting=true` while a submit is in flight (via a deferred wallet),
|
||||
and returns to `posting=false` on success and on error. This killed all 4 survivors.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 18/18 pass (added 2 posting-state tests).
|
||||
- **Property (`npm run test:property`):** 6/6 pass.
|
||||
- **Acceptance (normal):** both `memo-new` and `post-memo` generated suites pass.
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`):**
|
||||
- `memo-post.js`: differential reuse (module unchanged since its 7/7 kill).
|
||||
- `new-post.js`: after coverage fix, **killed 11 / survived 0 / uncovered 0**
|
||||
(was 4 survivors before the fix).
|
||||
- **DRY (`dry4javascript src`):** no duplicate candidates.
|
||||
- **Gherkin acceptance mutation (soft):**
|
||||
- `memo-new.feature`: 11 executed — **5 killed, 6 survived**, 0 errors.
|
||||
- Killed: empty-memo boundary; character-counter `count` example values (217/212/0)
|
||||
— proving those values are connected to behavior.
|
||||
- Survived (documented equivalents): message-content dithers (4) plus case/length-
|
||||
neutral dithers in the counter scenario — message text is opaque data; only length
|
||||
affects the counter, so these don't change observable behavior.
|
||||
- `post-memo.feature`: empty-memo scenario reused as killed; remaining 5 executed are
|
||||
the same documented message-content equivalents.
|
||||
- Property tests: run separately via `npm run test:property`.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills both testable
|
||||
core modules. Gherkin acceptance mutation survivors are documented equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `new-post-page`), to review the
|
||||
architect commit (posting-state coverage + tool manifests).
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Architectural Review Summary — post-memo
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `post-memo`
|
||||
- Reviewed the merged branch ending at `8e2693ad08` (refactorer), which carried:
|
||||
- `d071b21`/`c9556be` — specifier Post-a-Memo Gherkin spec + feature backlog
|
||||
- `514298b` — coder implementation with acceptance pipeline
|
||||
- `64a7f78` — refactorer merge of coder work
|
||||
- `8e2693a` — refactorer memo-post complexity reduction
|
||||
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality.
|
||||
|
||||
1. **Testable core behind small adapters (good).** `src/services/memo-post.js` is a
|
||||
testable module free of UI/IO concerns. It injects `wallet` and `feed` adapters
|
||||
(minimal-slp-wallet surface + feed reflection), so OP_RETURN/broadcast and UI
|
||||
concerns stay outside the core. Environmentally unsuitable I/O is confined to
|
||||
adapter boundaries. Dependency direction is inward.
|
||||
2. **Acceptance pipeline separation (good).** `acceptance/lib/{generate,runtime,handlers}`
|
||||
are the project-specific components the APS spec prescribes. The feature is parsed
|
||||
by the **APS-supplied Babashka `gherkin-parser`** (procured fresh from
|
||||
github.com/unclebob/Acceptance-Pipeline-Specification on first use), not
|
||||
reimplemented. `handlers.js` drives real core behavior through fake wallet/feed
|
||||
adapters so the run is deterministic and offline.
|
||||
3. **Information hiding (good).** memo-post hides the Memo `0x6d02` prefix and
|
||||
broadcast mechanics; handlers only assert observable outcomes (broadcast prefix,
|
||||
feed reflection, empty/length rejection).
|
||||
4. **Fix applied — runner adapter added.** Built the project-specific persistent
|
||||
runner adapter required by `gherkin-mutator` at
|
||||
`acceptance/lib/runner-worker.js` (newline-delimited JSON protocol; evaluates each
|
||||
mutated feature IR through the same runtime/handlers and reports
|
||||
`test_failure|test_success|infrastructure_error`).
|
||||
5. **Manifests updated by tools (permitted).** The language mutation tool embedded
|
||||
its differential footer manifest in `src/services/memo-post.js`; the APS mutator
|
||||
wrote the acceptance-mutation scenario manifest into `specs/post-memo.feature`.
|
||||
Both are normal tool output and were left for the tooling, not hand-edited.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 7/7 pass (`memo-post.test.js`).
|
||||
- **Acceptance (normal):** all 6 scenario executions pass.
|
||||
- **Mutation (`mutate4javascript` differential, `--max-workers 8`):**
|
||||
`memo-post.js` — **killed 7, survived 0, uncovered 0**. Full kill.
|
||||
- **DRY (`dry4javascript src`):** no duplicate candidates.
|
||||
- **Gherkin acceptance mutation (soft):** 6 discovered; **1 killed**, **5 survived**,
|
||||
0 errors.
|
||||
- Killed: the empty-memo boundary — dithering the `" "` example makes the
|
||||
validation branch fail the scenario, confirming the empty-rejection guardrail is
|
||||
connected to the example data.
|
||||
- Survived (documented equivalents): message-content dithers in the valid-memo
|
||||
scenario and the over-long scenarios. The tests treat the message as opaque data
|
||||
and assert the *same* (mutated) text is composed and reflected, so changing one
|
||||
character leaves the exercised branch identical. These are acceptable equivalent
|
||||
survivors, not missing guardrails.
|
||||
- **Property tests:** none present in this project.
|
||||
|
||||
## Suite status
|
||||
- Unit + acceptance suites pass; mutation fully kills source-level mutants for the
|
||||
testable core. Gherkin acceptance mutation has 5 documented-equivalent survivors.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `post-memo`), to review the
|
||||
architectural commit (runner adapter + tool manifests).
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Architectural Review Summary — reduce-dry-duplication
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `reduce-dry-duplication`
|
||||
- Source commit merged and reviewed: `eed9d70f6f` (`Reduce DRY duplication in services and components`, from refactorer)
|
||||
- Reviewed as a batch via `ready_for_next.sh`; merged into `swarmforge-architect` as a fast-forward and processed.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed the refactorer's DRY reduction for UI/Core separation, dependency rule,
|
||||
information hiding/encapsulation, and local code quality.
|
||||
|
||||
Findings (mostly sound, two notes):
|
||||
1. **MemoDb service consolidation** (`src/services/memo-db.js`) — `getRecentProfiles`/
|
||||
`getRecentPosts` → `getRecent`, and `getProfile`/`getProfilePic`/`getName` →
|
||||
`getLevelResource`. Preserves error semantics (recent → always throws; level → null
|
||||
on 404). Good cohesion, HTTP details stay in the service. **No change needed.**
|
||||
2. **AppUtil `pasteFromClipboard`** (`src/util/index.js`) — centralizes clipboard-paste
|
||||
into the shared utility; components no longer duplicate clipboard reading. Good
|
||||
information hiding. **No change needed.**
|
||||
3. **`PlaceholderView` extraction** — shared placeholder used by placeholder2/3.
|
||||
Cohesive. **No change needed.**
|
||||
4. **`wallet-summary` blur toggle generalization** — `toggleBlur(field)` via dynamic
|
||||
setter name reduces duplication. Functional and correct; the dynamic
|
||||
`set${Capitalized}` construction is a mild readability tradeoff but acceptable to
|
||||
preserve the DRY intent. **No change.**
|
||||
5. **Cross-feature dependency (fixed)** — `recent-profiles` imported the general-purpose
|
||||
`truncateAddr`/`truncateTxid` from the feed-specific `post-feed/post-display`.
|
||||
For cohesion and dependency direction, these pure string helpers belong in the
|
||||
shared utility layer. **Applied fix:** moved `truncateAddr`/`truncateTxid` to
|
||||
`src/util/index.js`; `post-display` now imports from util and re-exports them for
|
||||
its existing consumers; `recent-profiles` imports directly from `src/util`.
|
||||
This removes a sibling-feature → sibling-feature module coupling and keeps generic
|
||||
display helpers in the shared utility.
|
||||
|
||||
## Verification results
|
||||
- **Mutation** (`mutate4javascript --scan`): memo-db.js 4 sites, util/index.js 6 sites,
|
||||
post-display.js 8 sites. No JS unit test suite exists in this project
|
||||
(`npm test` → `echo 'no tests'`); there is no coverage harness, so all sites are
|
||||
uncovered and no survivors can be killed by tests. JSX component files cannot be
|
||||
parsed by the mutation tool (no `jsx` Babel plugin); they are UI adapters and are
|
||||
outside the currently testable boundary.
|
||||
- **DRY** (`dry4javascript src`): **no duplicate candidates found** — the reduction
|
||||
is effective and my fix introduced no duplication.
|
||||
- **Gherkin acceptance mutation (soft)**: not runnable — this project has no
|
||||
`.feature` files, no acceptance pipeline, and no runner adapter.
|
||||
- Property tests: none present in this project.
|
||||
|
||||
## Suite status
|
||||
- No JavaScript unit/acceptance suite exists (`npm test` echoes "no tests").
|
||||
- `bb.edn` test task covers the swarmforge helper scripts only (unrelated to this UI work).
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `reduce-dry-duplication`),
|
||||
to review the architectural commit.
|
||||
|
||||
By architect.
|
||||
+3
-1
@@ -25,7 +25,9 @@
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "echo 'no tests'",
|
||||
"test": "node --test \"test/unit/*.test.js\"",
|
||||
"test:property": "node --test \"test/property/*.test.js\"",
|
||||
"test:acceptance": "node acceptance/acceptance.js",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"pub": "node deploy/publish-main.js",
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Specifier Prompt — psf-memo-client
|
||||
|
||||
You are the **specifier** for the `psf-memo-client` SwarmForge swarm. This file is your
|
||||
standing briefing. You have no memory of prior sessions; this prompt (plus the
|
||||
repo state) is how you pick up the work. Read it fully, follow it, and update it at
|
||||
the end of each session when asked.
|
||||
|
||||
---
|
||||
|
||||
## 1. Role & startup (do these first)
|
||||
|
||||
1. Read `swarmforge/constitution.prompt`, then read every file it refers to
|
||||
recursively and obey them. Then read `swarmforge/roles/specifier.prompt` and
|
||||
follow it. (The constitution lives at `swarmforge/constitution.prompt`; articles
|
||||
are in `swarmforge/constitution/articles/`. Roles are in `swarmforge/roles/`.)
|
||||
2. Check for work: run `ready_for_next.sh`. If it prints `TASK`/`BATCH`, process it.
|
||||
If `NO_TASK`, ask the user for the next feature (from the backlog in §5).
|
||||
3. You are assigned to the `master` worktree = the **main checkout**, currently on
|
||||
branch **`feat1`**. That is where you commit specs and where the app the user runs
|
||||
lives. You work ONLY there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Project & architecture
|
||||
|
||||
- **psf-memo-client**: a React SPA (JavaScript) meant to be an open-source clone of
|
||||
memo.cash (https://memo.cash), a Twitter-like social network on Bitcoin Cash (BCH).
|
||||
- Every social action is a BCH `OP_RETURN` transaction: Memo protocol prefix `0x6d` +
|
||||
action byte + payload. It is **broadcast** to the chain, then crawled by
|
||||
`psf-memo-indexer` and stored in `psf-memo-db`.
|
||||
- **Write path**: use `minimal-slp-wallet.sendOpReturn()`. See §9 for the critical
|
||||
signature gotcha.
|
||||
- **Read path**: `psf-memo-db` LevelDB + REST API (default `http://localhost:5021`,
|
||||
prod live: `https://memo-api.fullstackcash.net`). Overridable via
|
||||
`REACT_APP_MEMO_DB_URL`.
|
||||
- **Identity/auth**: the React app auto-generates an HD wallet (12-word mnemonic)
|
||||
persisted to browser Local Storage on first load; the first derived key pair is the
|
||||
Memo identity. Posting broadcasts from that wallet.
|
||||
- Backends (separate repos, read-only reference): `psf-memo-indexer`
|
||||
(`/home/trout/work/psf-memo-indexer`), `psf-memo-db`
|
||||
(`/home/trout/work/psf-memo-db`). They MAY be changed to make the API more
|
||||
efficient/scalable; API changes are in scope for specs.
|
||||
- LLM wiki for BCH reference: `/home/trout/work/psf-llm-wiki` (read `AGENTS.md` and
|
||||
`wiki/index.md`).
|
||||
|
||||
---
|
||||
|
||||
## 3. The SwarmForge pipeline — READ THIS (gotchas)
|
||||
|
||||
This is the most important operational section.
|
||||
|
||||
- The swarm has 4 agents: **specifier** (you), **coder**, **refactorer**, **architect**.
|
||||
Each works in its own **git worktree on its own branch**:
|
||||
- specifier: main checkout, branch **`feat1`**
|
||||
- coder: `.worktrees/coder` on branch `swarmforge-coder`
|
||||
- refactorer: `.worktrees/refactorer` on `swarmforge-refactorer`
|
||||
- architect: `.worktrees/architect` on `swarmforge-architect`
|
||||
- Work flows: specifier → coder → refactorer → architect → back to specifier to merge.
|
||||
|
||||
### GOTCHA: the coder does NOT commit to `feat1`.
|
||||
The coder commits to its own `swarmforge-coder` branch. The finalized work is
|
||||
reviewed/merged through refactorer and architect and ends up on the
|
||||
`swarmforge-architect` branch. **The running app and your `feat1` branch do NOT see
|
||||
it until YOU merge the architect branch into `feat1`.** Do that when:
|
||||
- the architect completes a job (you may need to check, or the user asks), or
|
||||
- the user explicitly asks to see the feature.
|
||||
|
||||
Then **verify** with `npm run build` (must print `Compiled successfully.`). Remember
|
||||
the user runs `feat1` — a feature is "done" for them only after this merge.
|
||||
|
||||
### GOTCHA #2: the handoff daemon does not auto-start
|
||||
- Sending a handoff only queues it into the sender's `outbox`. A daemon
|
||||
(`handoffd.bb`) must be running to deliver it to the recipient's `inbox/new` and
|
||||
wake the agent. If the outbox file stays put after you send, start the daemon:
|
||||
```bash
|
||||
nohup bb swarmforge/scripts/handoffd.bb /home/trout/work/psf-memo-client >/dev/null 2>&1 &
|
||||
```
|
||||
- A harmless `Failed to inhibit: Access denied` line appears at startup; the daemon
|
||||
still works.
|
||||
|
||||
---
|
||||
|
||||
## 4. Specifier workflow (five phases) — from roles/specifier.prompt
|
||||
|
||||
For each feature:
|
||||
1. Write the Gherkin that specifies the feature (see §6/§7 for format & tooling).
|
||||
2. Prune: keep only parameters germane to acceptance mutation; drop identical
|
||||
example-table columns that don't improve mutation.
|
||||
3. Run `bb gherkin-ir-dry-checker` to normalize/prune.
|
||||
4. Move repeated scenario setup into a Gherkin `Background` when it preserves
|
||||
meaning.
|
||||
5. **Ask the user for approval** before handing off to the coder. After approval:
|
||||
commit with your byline (`By specifier.`), invent a short stable task name, and
|
||||
send the file-based `git_handoff` (see §8).
|
||||
|
||||
Also: do not run Gherkin acceptance mutation; run tests only when verification is
|
||||
needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Goal & feature backlog (memo.cash parity) with current status
|
||||
|
||||
Saved (and updated) at `specs/feature-backlog.md`. memo protocol action bytes below.
|
||||
|
||||
**Completed ✓ (merged to `feat1`):**
|
||||
- Post a Memo (`0x6d02`) — service + `/posts/new` page + broadcast fix. FULLY DONE.
|
||||
|
||||
**Tier P1 — Core social verbs (write + read) — do these next, in order:**
|
||||
1. ✅ Post a Memo (`0x6d02`) — DONE
|
||||
2. Set display name (`0x6d01`) — **NEXT** (breadcrumb: the feed already renders
|
||||
display names; the write action is missing)
|
||||
3. Reply to a Memo (`0x6d03`) — thread already renders; add reply broadcast
|
||||
4. Like / tip a Memo (`0x6d04`)
|
||||
5. Set profile text / bio (`0x6d05`)
|
||||
6. Set profile picture (`0x6d0a`)
|
||||
7. Follow a user (`0x6d06`)
|
||||
8. Unfollow a user (`0x6d07`)
|
||||
|
||||
**P2 — Topics:** topic post (`0x6d0c`), topic follow/unfollow (`0x6d0d`/`0x6d0e`),
|
||||
topic feed.
|
||||
**P3 — Polls:** create (`0x6d10`), add option (`0x6d13`), vote (`0x6d14`).
|
||||
**P4 — Moderation:** mute/unmute (`0x6d16`/`0x6d17`).
|
||||
**P5 — Money & tokens:** send money (`0x6d24`), token sell/buy/pin (MIP-0009).
|
||||
**P6 — Discovery/UX:** search, tags, notifications, ranked feed, repost (`0x6d0b`).
|
||||
|
||||
**Decisions to carry forward:**
|
||||
- Assume a broadcast succeeds and update the UI immediately (no "pending" state; no
|
||||
"my pending posts" concept).
|
||||
- Post memo length limit = **217 bytes** (memo.sv protocol), even though the indexer
|
||||
allows `MAX_POST_SIZE = 65000`. Use 217.
|
||||
- Keep the specs read-only-first for now, but always include the `sendOpReturn` write
|
||||
code paths.
|
||||
|
||||
---
|
||||
|
||||
## 6. Memo protocol reference (action bytes)
|
||||
|
||||
`OP_RETURN 6d<action><payload>`, UTF-8 payload. Table (from memo.sv/protocol):
|
||||
|
||||
| Action byte | Meaning |
|
||||
|-------------|---------|
|
||||
| `6d01` | Set name |
|
||||
| `6d02` | Post memo (msg max 217 bytes) |
|
||||
| `6d03` | Reply to memo (parent txid 32 bytes + msg) |
|
||||
| `6d04` | Like/tip memo (txid 32 bytes) |
|
||||
| `6d05` | Set profile text |
|
||||
| `6d06` / `6d07` | Follow / unfollow (address 20 bytes) |
|
||||
| `6d0a` | Set profile picture (url) |
|
||||
| `6d0b` | Repost (planned) |
|
||||
| `6d0c`/`6d0d`/`6d0e` | Topic post / follow / unfollow |
|
||||
| `6d10`/`6d13`/`6d14` | Create poll / add option / vote |
|
||||
| `6d16`/`6d17` | Mute / unmute |
|
||||
| `6d24` | Send money |
|
||||
| `6d30`–`6d35` | MIP-0009 token sell/buy/attach/pin |
|
||||
|
||||
Binary payloads (txid, address hash) are NOT plain UTF-8; keep encoding in mind when
|
||||
specing reply/like/follow.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gherkin & acceptance tooling
|
||||
|
||||
- Clone the Acceptance Pipeline Spec fresh (do NOT rely on cached/stale copies):
|
||||
```bash
|
||||
cd /home/trout/work/psf-memo-client
|
||||
mkdir -p tmp && cd tmp
|
||||
git clone https://github.com/unclebob/Acceptance-Pipeline-Specification.git aps
|
||||
```
|
||||
Temp files go in the worktree's `./tmp/`, never `/tmp`.
|
||||
- Commands (run from `tmp/aps`):
|
||||
```bash
|
||||
bb gherkin-parser <feature-file> <json-ir>
|
||||
bb gherkin-ir-dry-checker [--include-exact] <json-ir> <report>
|
||||
# optional: bb gherkin-mutator (you do not run acceptance mutation)
|
||||
```
|
||||
- Read `aps/parser-spec.md` and `aps/ir-dry-checker-spec.md` for the supported
|
||||
Gherkin subset and report format.
|
||||
- Rules: `Feature:`, one `Background:`, `Scenario Outline:` with `Examples:`. Name each
|
||||
scenario `Feature Name - N`. Put a `#` comment listing the scenario names immediately
|
||||
before the `Feature:` line. Use `<parameter>` placeholders for values that vary.
|
||||
- Store feature files under `specs/*.feature`; backlog under `specs/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Handoff mechanics
|
||||
|
||||
- Commit message must end with `By specifier.`
|
||||
- To hand off, write a draft file, then run the helper (it removes the draft on success):
|
||||
```text
|
||||
type: git_handoff
|
||||
to: coder
|
||||
priority: 10
|
||||
task: <short-stable-task-name>
|
||||
commit: <10-char-commit-abbrev>
|
||||
```
|
||||
```bash
|
||||
SWARMFORGE_ROLE=specifier swarm_handoff.sh tmp/<draft>
|
||||
```
|
||||
- After sending, check the handoff was delivered (daemon). If not, start the daemon
|
||||
(GOTCHA #2).
|
||||
- Do NOT commit/notify the coder until the user explicitly approves the handoff.
|
||||
- When the architect completes a job, **merge its branch into `feat1`** and verify
|
||||
the build (see §10).
|
||||
|
||||
---
|
||||
|
||||
## 9. Known gotchas & lessons learned (keep adding)
|
||||
|
||||
1. **Coder commits to its own branch, not `feat1`** — you must merge the architect's
|
||||
finalized branch into `feat1` for the running app to reflect changes.
|
||||
2. **Handoff daemon must be started** if the outbox file stays put after `swarm_handoff.sh`.
|
||||
3. **`sendOpReturn` public signature gotcha (real bug found):**
|
||||
- `minimal-slp-wallet` wallet instance exposes
|
||||
`sendOpReturn(msg='', prefix='6d02', bchOutput=[], satsPerByte=1.0)` — it resolves
|
||||
`walletInfo` and its own spendable UTXOs internally.
|
||||
- The low-level `lib/op-return.js` method has a different signature
|
||||
`sendOpReturn(wallet, bchUtxos, msg, prefix, ...)`.
|
||||
- Calling the wallet's public one with the low-level args makes `Buffer.from(msg)`
|
||||
receive an object → `"The first argument must be one of type string, Buffer..."`
|
||||
- **Correct usage:** `await this.wallet.getUtxos()` then
|
||||
`await this.wallet.sendOpReturn(message, MEMO_POST_PREFIX)`.
|
||||
4. **Unit/acceptance mocks can mask real API bugs** — the coder's tests mocked the buggy
|
||||
call signature, so the test suite passed while the live app broke. Live e2e (real BCH
|
||||
+ a live server) is what catches these. When adding/editing behavior, sanity-check the
|
||||
real `minimal-slp-wallet` API.
|
||||
5. **Error-masking bug fixed:** the New Post page once mapped every non-length error to
|
||||
"Memo must not be empty." Now broadcast failures surface the real error
|
||||
(`Failed to broadcast: <msg>`). Keep that behavior in specs.
|
||||
6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely
|
||||
on user-provided behavior details and the protocol spec.
|
||||
7. **Byte vs char:** the 217 limit and the counter currently count characters
|
||||
(`input.length`, UTF-16), not bytes. The user is aware; multi-byte unicode may
|
||||
diverge. Ask/decide per feature.
|
||||
8. **Live backend for e2e:** `https://memo-api.fullstackcash.net/` (prod memo-db). The
|
||||
user can provide BCH for real broadcasts.
|
||||
|
||||
---
|
||||
|
||||
## 10. Run / verify the app
|
||||
|
||||
```bash
|
||||
cd /home/trout/work/psf-memo-client
|
||||
npm start # dev server (CRA)
|
||||
npm run build # production build — verify after merges
|
||||
npm test # node --test "test/unit/*.test.js"
|
||||
npm run lint # standard --fix
|
||||
```
|
||||
|
||||
Backend default `http://localhost:5021`; live prod `https://memo-api.fullstackcash.net/`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Handoff to next session
|
||||
|
||||
At the end of each session, update this file:
|
||||
- Mark features completed in the backlog (§5).
|
||||
- Add any new gotchas to §10.
|
||||
- Note the current `feat1` HEAD commit.
|
||||
- State the next feature to work on (currently: **Set display name, `0x6d01`**).
|
||||
@@ -0,0 +1,151 @@
|
||||
# psf-memo-client — Prioritized Feature Backlog
|
||||
|
||||
**Status**: DRAFT — saved for future development cycles.
|
||||
**Owner**: specifier
|
||||
**Last updated**: 2026-05-25
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Make psf-memo-client feature-equivalent to [memo.cash](https://memo.cash). Memo is a
|
||||
Bitcoin Cash (BCH) social network built on `OP_RETURN` transactions. Every social
|
||||
action is a BCH transaction carrying a Memo protocol payload (`0x6d` + action byte)
|
||||
that is broadcast to the chain and later indexed by psf-memo-indexer into psf-memo-db.
|
||||
|
||||
## Architecture constraints
|
||||
|
||||
- **Identity/auth**: the auto-generated HD wallet (12-word mnemonic) already
|
||||
persisted in browser Local Storage by the existing React app is the Memo identity.
|
||||
The wallet's first derived key pair is the posting/identification key.
|
||||
- **Write path**: broadcasting is done via `minimal-slp-wallet.sendOpReturn(wallet, bchUtxos, msg, prefix, bchOutput, satsPerByte)`.
|
||||
- Default `prefix = '6d02'` posts a Memo.
|
||||
- `msg` carries the Memo payload for the selected action.
|
||||
- Reference tutorial: https://fullstack-agents.github.io/block-blog/#/education/11-write-text-blockchain
|
||||
- **Read path**: psf-memo-db REST API (`/posts/*`, `/profile/*`, `/level/*`). API may be
|
||||
refactored (in scope) to support a good UX.
|
||||
- The write path (broadcast) and read path (indexed) are asynchronous: a broadcasted
|
||||
action becomes visible only after confirmation + indexing.
|
||||
|
||||
## Memo protocol action codes
|
||||
|
||||
Reference: https://memo.sv/protocol
|
||||
|
||||
| Action byte | Meaning |
|
||||
|-------------|---------|
|
||||
| `0x6d01` | Set name |
|
||||
| `0x6d02` | Post memo |
|
||||
| `0x6d03` | Reply to memo |
|
||||
| `0x6d04` | Like / tip memo |
|
||||
| `0x6d05` | Set profile text |
|
||||
| `0x6d06` | Follow user |
|
||||
| `0x6d07` | Unfollow user |
|
||||
| `0x6d0a` | Set profile picture |
|
||||
| `0x6d0b` | Repost memo (planned) |
|
||||
| `0x6d0c` | Post topic message |
|
||||
| `0x6d0d` | Topic follow |
|
||||
| `0x6d0e` | Topic unfollow |
|
||||
| `0x6d10` | Create poll |
|
||||
| `0x6d13` | Add poll option |
|
||||
| `0x6d14` | Poll vote |
|
||||
| `0x6d16` | Mute user |
|
||||
| `0x6d17` | Unmute user |
|
||||
| `0x6d24` | Send money |
|
||||
| `0x6d30`–`0x6d35` | MIP-0009 token sell / buy / attach signature / pin |
|
||||
|
||||
---
|
||||
|
||||
## Tier P1 — Core social verbs (write + read)
|
||||
|
||||
These are the foundational posting and identity actions. Each is a broadcast
|
||||
action plus its read/display surface. This is the recommended first development slice.
|
||||
|
||||
| # | Feature | Memo action | Write | Read surface |
|
||||
|---|---------|-------------|-------|--------------|
|
||||
| 1 | Post a Memo | `0x6d02` | Compose + `sendOpReturn` | Appears in recent feed & own profile after indexing |
|
||||
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed |
|
||||
| 3 | Reply to a Memo | `0x6d03` | Broadcast reply to parent txid | Nested thread view |
|
||||
| 4 | Like a Memo | `0x6d04` | Broadcast like for a post txid | Like count + liked state on post |
|
||||
| 5 | Set profile text (bio) | `0x6d05` | Broadcast bio | Shown on profile page |
|
||||
| 6 | Set profile picture | `0x6d0a` | Broadcast avatar URL | Avatar on profile + posts |
|
||||
| 7 | Follow a user | `0x6d06` | Broadcast follow of address | Follow button state |
|
||||
| 8 | Unfollow a user | `0x6d07` | Broadcast unfollow | Follow button state; following list |
|
||||
|
||||
**API/DB needs (P1):** like counts + liked-state per post; my follow status per user;
|
||||
follower/following lists; name + profile + avatar joined into feed/profile responses
|
||||
(avoid N+1 lookups). Current `/posts/recent` omits name/avatar/likes.
|
||||
|
||||
## Priority order within P1
|
||||
|
||||
1. **Post a Memo** — the primary verb; unblocks all others.
|
||||
2. **Set display name** — makes the feed readable and gives identity.
|
||||
3. **Reply to a Memo** — core conversation; extends the existing thread modal.
|
||||
4. **Like a Memo** — social signal; needs like-count API.
|
||||
5. **Set profile text** — bio for the profile page.
|
||||
6. **Set profile picture** — avatar for posts/profiles.
|
||||
7. **Follow a user**.
|
||||
8. **Unfollow a user**.
|
||||
|
||||
## P2 — Topics
|
||||
|
||||
| # | Feature | Memo action |
|
||||
|---|---------|-------------|
|
||||
| 9 | Post a topic message | `0x6d0c` |
|
||||
| 10 | Follow a topic | `0x6d0d` |
|
||||
| 11 | Unfollow a topic | `0x6d0e` |
|
||||
| 12 | Topic feed page | read |
|
||||
|
||||
Needs: topics index in psf-memo-db, topic feed endpoint, topic follow state.
|
||||
|
||||
## P3 — Polls (later)
|
||||
|
||||
| # | Feature | Memo action |
|
||||
|---|---------|-------------|
|
||||
| 13 | Create a poll | `0x6d10` |
|
||||
| 14 | Add a poll option | `0x6d13` |
|
||||
| 15 | Vote in a poll | `0x6d14` |
|
||||
|
||||
Needs: poll data model + rendering + vote aggregation in psf-memo-db.
|
||||
|
||||
## P4 — Moderation (later)
|
||||
|
||||
| # | Feature | Memo action |
|
||||
|---|---------|-------------|
|
||||
| 16 | Mute a user | `0x6d16` |
|
||||
| 17 | Unmute a user | `0x6d17` |
|
||||
|
||||
Needs: per-wallet mute list applied to feed filtering.
|
||||
|
||||
## P5 — Money & tokens (later)
|
||||
|
||||
| # | Feature | Memo action |
|
||||
|---|---------|-------------|
|
||||
| 18 | Send money | `0x6d24` |
|
||||
| 19 | Token sell / buy / pin | `0x6d30`–`0x6d35` (MIP-0009) |
|
||||
|
||||
## P6 — Discovery & UX (later)
|
||||
|
||||
| # | Feature | Notes |
|
||||
|---|---------|-------|
|
||||
| 20 | Search (posts / profiles / topics / tags) | needs DB search index |
|
||||
| 21 | Tags / hashtags | link + filter by tag |
|
||||
| 22 | Notifications | replies / likes / follows to my posts |
|
||||
| 23 | Ranked feed | memo.cash "ranked" post ordering |
|
||||
| 24 | Repost | `0x6d0b` (planned in protocol) |
|
||||
|
||||
---
|
||||
|
||||
## Read-only vs write capability by cycle
|
||||
|
||||
- **Cycle 0 (current)**: read-only display of recent posts, profiles, post threads.
|
||||
- **Cycle 1 (P1)**: add write code paths (broadcast via `sendOpReturn`). UI is
|
||||
read-only until a broadcasted action is confirmed + indexed; then the feed/profile
|
||||
refresh.
|
||||
- **Later cycles**: topics, polls, moderation, money/tokens, discovery.
|
||||
|
||||
## Notes for future cycles
|
||||
|
||||
- Broadcast result (txid) is returned immediately; the action appears in the feed
|
||||
only after block confirmation + indexing. Specs must reflect this async visibility.
|
||||
- Mutations/specs are Gherkin feature files under `specs/` in the format defined by
|
||||
github.com/unclebob/Acceptance-Pipeline-Specification.
|
||||
@@ -0,0 +1,75 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T00:40:19.414904151Z","feature_name":"New Post Page","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T00:08:15.433121898Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5, New Post Page - 6
|
||||
Feature: New Post Page
|
||||
|
||||
Background:
|
||||
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
||||
Given the wallet has spendable output to pay the transaction fee
|
||||
|
||||
Scenario Outline: New Post Page - 1 a valid memo is posted and the user lands on the feed
|
||||
Given I navigate to the path /posts/new
|
||||
When I type a memo with the text "<message>"
|
||||
When I click the post button
|
||||
Then the app broadcasts an OP_RETURN transaction with the Memo post prefix
|
||||
Then I navigate to the path /posts/recent
|
||||
Then the feed shows a new post from my address with the text "<message>"
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| hello memo |
|
||||
| a longer memo with several words. |
|
||||
|
||||
Scenario Outline: New Post Page - 2 an empty memo is rejected on the new post page
|
||||
Given I navigate to the path /posts/new
|
||||
When I type a memo with the text "<message>"
|
||||
When I click the post button
|
||||
Then the new post page shows a validation error
|
||||
Then the app does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| |
|
||||
|
||||
Scenario Outline: New Post Page - 3 an over-long memo is rejected on the new post page
|
||||
Given I navigate to the path /posts/new
|
||||
When I type a memo with the text "<message>"
|
||||
When I click the post button
|
||||
Then the new post page shows a length error
|
||||
Then the app does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
|
||||
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
|
||||
|
||||
Scenario Outline: New Post Page - 4 the character counter counts down from the memo limit
|
||||
Given I navigate to the path /posts/new
|
||||
When I type a memo with the text "<message>"
|
||||
Then the new post page shows a remaining character count of <count>
|
||||
|
||||
Examples:
|
||||
| message | count |
|
||||
| | 217 |
|
||||
| hello | 212 |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
|
||||
|
||||
Scenario: New Post Page - 5 the navigation menu links to the new post page
|
||||
Given I open the navigation menu
|
||||
Then the menu shows a link to the path /posts/new
|
||||
|
||||
Scenario Outline: New Post Page - 6 a failed broadcast surfaces the real error and the user stays on the page
|
||||
Given I navigate to the path /posts/new
|
||||
And the wallet fails to broadcast with the error "<broadcast_error>"
|
||||
When I type a memo with the text "<message>"
|
||||
When I click the post button
|
||||
Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix
|
||||
Then the new post page shows an error containing "<broadcast_error>"
|
||||
Then I remain on the path /posts/new
|
||||
|
||||
Examples:
|
||||
| message | broadcast_error |
|
||||
| hello memo | BCH UTXO list is empty |
|
||||
| hello memo | Insufficient balance |
|
||||
@@ -0,0 +1,44 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T00:08:35.176401424Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
|
||||
Feature: Post a Memo
|
||||
|
||||
Background:
|
||||
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
||||
Given the wallet has a spendable output to pay the transaction fee
|
||||
|
||||
Scenario Outline: Post a Memo - 1 a valid memo is broadcast and shown in the feed
|
||||
Given I am viewing the recent posts feed
|
||||
When I compose a memo with the text "<message>"
|
||||
When I submit the memo
|
||||
Then the wallet broadcasts an OP_RETURN transaction with the Memo post prefix
|
||||
Then the feed shows a new post from my address with the text "<message>"
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| hello memo |
|
||||
| a longer memo with several words and punctuation. |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
|
||||
|
||||
Scenario Outline: Post a Memo - 2 an empty memo is rejected
|
||||
When I compose a memo with the text "<message>"
|
||||
When I submit the memo
|
||||
Then the app shows a validation error
|
||||
Then the wallet does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| |
|
||||
|
||||
Scenario Outline: Post a Memo - 3 an over-long memo is rejected
|
||||
When I compose a memo with the text "<message>"
|
||||
When I submit the memo
|
||||
Then the app shows a length error
|
||||
Then the wallet does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
|
||||
| cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc |
|
||||
@@ -14,14 +14,7 @@ const WalletImport = (props) => {
|
||||
const { appData } = props
|
||||
|
||||
// Load mnemonic from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const mnemonic = await appData.appUtil.readFromClipboard()
|
||||
setNewMnemonic(mnemonic)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setNewMnemonic)
|
||||
|
||||
// Handle input change for mnemonic
|
||||
const handleImportMnemonic = async (event) => {
|
||||
|
||||
@@ -38,29 +38,14 @@ function WalletSummary (props) {
|
||||
privateKey: blurredPrivateKey ? faEyeSlash : faEye
|
||||
}
|
||||
|
||||
// Toggle the state of blurring for the mnemonic
|
||||
const toggleMnemonicBlur = (inObj = {}) => {
|
||||
// Toggle the state of blurring for a field (mnemonic or private key)
|
||||
const toggleBlur = (field) => {
|
||||
try {
|
||||
const { walletSummaryData } = inObj
|
||||
|
||||
// toggle the state of blurring
|
||||
const blurredState = walletSummaryData.blurredMnemonic
|
||||
walletSummaryData.setBlurredMnemonic(!blurredState)
|
||||
const blurredState = walletSummaryData[field]
|
||||
const setterName = `set${field[0].toUpperCase()}${field.slice(1)}`
|
||||
walletSummaryData[setterName](!blurredState)
|
||||
} catch (error) {
|
||||
console.error('Error toggling mnemonic blur: ', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the state of blurring for the private key
|
||||
const togglePrivateKeyBlur = (inObj = {}) => {
|
||||
try {
|
||||
const { walletSummaryData } = inObj
|
||||
|
||||
// toggle the state of blurring
|
||||
const blurredState = walletSummaryData.blurredPrivateKey
|
||||
walletSummaryData.setBlurredPrivateKey(!blurredState)
|
||||
} catch (error) {
|
||||
console.error('Error toggling private key blur: ', error)
|
||||
console.error(`Error toggling ${field} blur: `, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +72,7 @@ function WalletSummary (props) {
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.mnemonic}
|
||||
size='lg'
|
||||
onClick={() => toggleMnemonicBlur({ walletSummaryData })}
|
||||
onClick={() => toggleBlur('blurredMnemonic')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
@@ -104,7 +89,7 @@ function WalletSummary (props) {
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.privateKey}
|
||||
size='lg'
|
||||
onClick={() => togglePrivateKeyBlur({ walletSummaryData })}
|
||||
onClick={() => toggleBlur('blurredPrivateKey')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import ServerSelectView from './configuration/select-server-view'
|
||||
import UserDataReview from './user-data-review'
|
||||
import RecentProfiles from './recent-profiles'
|
||||
import RecentPosts from './posts'
|
||||
import NewPost from './new-post'
|
||||
import Profile from './profile'
|
||||
|
||||
function AppBody (props) {
|
||||
@@ -42,6 +43,7 @@ function AppBody (props) {
|
||||
<Route path='/profile/recent' element={<RecentProfiles />} />
|
||||
<Route path='/profile/:addr' element={<Profile />} />
|
||||
<Route path='/posts/recent' element={<RecentPosts />} />
|
||||
<Route path='/posts/new' element={<NewPost appData={appData} />} />
|
||||
<Route path='/placeholder2' element={<Placeholder2 />} />
|
||||
<Route path='/placeholder3' element={<Placeholder3 />} />
|
||||
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
New Post view: compose and broadcast a Memo post, with a character counter
|
||||
that counts down from the memo limit. On success the user is navigated to the
|
||||
recent feed.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Local libraries
|
||||
import MemoPost from '../../../services/memo-post'
|
||||
import NewPostPage from '../../../services/new-post'
|
||||
|
||||
function NewPost (props) {
|
||||
const { appData } = props
|
||||
const navigate = useNavigate()
|
||||
|
||||
const maxChars = MemoPost.MAX_MEMO_CHARS
|
||||
const [input, setInput] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
const remaining = maxChars - input.length
|
||||
|
||||
async function handleSubmit (event) {
|
||||
event.preventDefault()
|
||||
setErr('')
|
||||
setPosting(true)
|
||||
|
||||
try {
|
||||
const memoPost = new MemoPost({ wallet: appData?.wallet })
|
||||
const page = new NewPostPage({ memoPost, navigate })
|
||||
page.setInput(input)
|
||||
|
||||
const result = await page.submit()
|
||||
if (!result.ok) {
|
||||
if (result.error === 'memo_length') {
|
||||
setErr(`Memo is too long. Maximum is ${maxChars} characters.`)
|
||||
} else if (result.error === 'memo_validation') {
|
||||
setErr('Memo must not be empty.')
|
||||
} else if (result.message) {
|
||||
setErr(`Failed to broadcast: ${result.message}`)
|
||||
} else {
|
||||
setErr('Failed to post memo.')
|
||||
}
|
||||
}
|
||||
// On success page.submit() navigated to the recent feed.
|
||||
} catch (submitErr) {
|
||||
setErr(submitErr.message)
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8} md={10} xs={12}>
|
||||
<header className='new-post-heading'>
|
||||
<h1>New Post</h1>
|
||||
<p>Compose a Memo message and publish it to Bitcoin Cash.</p>
|
||||
</header>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId='new-post-message' className='mb-3'>
|
||||
<Form.Label><b>Message</b></Form.Label>
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
rows={6}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder='Write your Memo here...'
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<p className='new-post-counter'>
|
||||
{remaining} characters remaining
|
||||
</p>
|
||||
|
||||
{err && <p className='new-post-error'>{err}</p>}
|
||||
|
||||
<Button type='submit' variant='primary' disabled={posting}>
|
||||
{posting ? 'Posting...' : 'Post'}
|
||||
</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPost
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
A placeholder view used for unreviewed routes.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
function PlaceholderView (props) {
|
||||
const { viewNumber } = props
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`Placeholder ${viewNumber} loaded.`)
|
||||
}, [viewNumber])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #{viewNumber}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlaceholderView
|
||||
@@ -2,19 +2,11 @@
|
||||
This is a placeholder View
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
// Local libraries
|
||||
import PlaceholderView from './placeholder-view'
|
||||
|
||||
function Placeholder2 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 2 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
|
||||
</>
|
||||
)
|
||||
return <PlaceholderView viewNumber={2} />
|
||||
}
|
||||
|
||||
export default Placeholder2
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
This is a placeholder View
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
// Local libraries
|
||||
import PlaceholderView from './placeholder-view'
|
||||
|
||||
function Placeholder3 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 3 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
|
||||
</>
|
||||
)
|
||||
return <PlaceholderView viewNumber={3} />
|
||||
}
|
||||
|
||||
export default Placeholder3
|
||||
|
||||
@@ -8,17 +8,11 @@ import { Container, Row, Col, Spinner, Table } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import AppUtil from '../../../util'
|
||||
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
|
||||
import '../../../App.css'
|
||||
|
||||
const appUtil = new AppUtil()
|
||||
|
||||
function truncate (str, maxLen = 16) {
|
||||
if (!str || str.length <= maxLen) return str
|
||||
const half = Math.floor((maxLen - 3) / 2)
|
||||
return `${str.slice(0, half)}...${str.slice(-half)}`
|
||||
}
|
||||
|
||||
function formatSeen (seen) {
|
||||
if (!seen) return ''
|
||||
const ms = seen > 1e12 ? seen : seen * 1000
|
||||
@@ -88,7 +82,7 @@ function RecentProfiles () {
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
title={profile.addr}
|
||||
>
|
||||
{truncate(profile.addr, 24)}
|
||||
{truncateAddr(profile.addr, 24)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{profile.text}</td>
|
||||
@@ -100,7 +94,7 @@ function RecentProfiles () {
|
||||
title={profile.txid}
|
||||
onClick={() => appUtil.copyToClipboard(profile.txid)}
|
||||
>
|
||||
{truncate(profile.txid, 20)}
|
||||
{truncateTxid(profile.txid, 20)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -105,14 +105,7 @@ function SendTokenButton ({ token, appData, refreshTokens }) {
|
||||
}
|
||||
|
||||
// Load address from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const address = await appData.appUtil.readFromClipboard()
|
||||
setSendToAddress(address)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setSendToAddress)
|
||||
|
||||
// Modal JSX
|
||||
const getModal = () => {
|
||||
|
||||
@@ -70,6 +70,14 @@ function NavMenu (props) {
|
||||
Posts
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/posts/new'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
New Post
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/wallet'
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Shared display helpers for post feed and thread views.
|
||||
*/
|
||||
|
||||
import { truncateAddr } from '../../util'
|
||||
|
||||
export { truncateAddr, truncateTxid } from '../../util'
|
||||
|
||||
export function formatRelativeSeen (seen) {
|
||||
if (!seen) return ''
|
||||
const ms = seen > 1e12 ? seen : seen * 1000
|
||||
@@ -26,16 +30,6 @@ export function formatRelativeSeen (seen) {
|
||||
return `${years}y`
|
||||
}
|
||||
|
||||
export function truncateAddr (addr, maxLen = 20) {
|
||||
if (!addr || addr.length <= maxLen) return addr
|
||||
const half = Math.floor((maxLen - 3) / 2)
|
||||
return `${addr.slice(0, half)}...${addr.slice(-half)}`
|
||||
}
|
||||
|
||||
export function truncateTxid (txid, maxLen = 20) {
|
||||
return truncateAddr(txid, maxLen)
|
||||
}
|
||||
|
||||
export function getDisplayName (addr, profiles) {
|
||||
const profile = profiles?.[addr]
|
||||
if (profile?.name) {
|
||||
|
||||
+25
-46
@@ -11,72 +11,51 @@ class MemoDb {
|
||||
}
|
||||
|
||||
async getRecentProfiles ({ limit = 100, offset = 0 } = {}) {
|
||||
try {
|
||||
const result = await this.axios.get(`${config.backend}/profile/recent`, {
|
||||
params: { limit, offset }
|
||||
})
|
||||
|
||||
return result.data
|
||||
} catch (err) {
|
||||
console.error('Error in getRecentProfiles()')
|
||||
throw err
|
||||
}
|
||||
return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset })
|
||||
}
|
||||
|
||||
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
||||
return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset })
|
||||
}
|
||||
|
||||
async getProfile (addr) {
|
||||
return this.getLevelResource('profile', addr, 'getProfile')
|
||||
}
|
||||
|
||||
async getProfilePic (addr) {
|
||||
return this.getLevelResource('profilepic', addr, 'getProfilePic')
|
||||
}
|
||||
|
||||
async getName (addr) {
|
||||
return this.getLevelResource('name', addr, 'getName')
|
||||
}
|
||||
|
||||
// GET a paginated 'recent' listing endpoint.
|
||||
async getRecent (path, name, params) {
|
||||
try {
|
||||
const result = await this.axios.get(`${config.backend}/posts/recent`, {
|
||||
params: { limit, offset }
|
||||
const result = await this.axios.get(`${config.backend}${path}`, {
|
||||
params
|
||||
})
|
||||
|
||||
return result.data
|
||||
} catch (err) {
|
||||
console.error('Error in getRecentPosts()')
|
||||
console.error(`Error in ${name}()`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile (addr) {
|
||||
// GET a level endpoint that resolves an address. Returns null on 404.
|
||||
async getLevelResource (endpoint, addr, name) {
|
||||
try {
|
||||
const result = await this.axios.get(
|
||||
`${config.backend}/level/profile/${encodeURIComponent(addr)}`
|
||||
`${config.backend}/level/${endpoint}/${encodeURIComponent(addr)}`
|
||||
)
|
||||
return result.data
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error('Error in getProfile()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async getProfilePic (addr) {
|
||||
try {
|
||||
const result = await this.axios.get(
|
||||
`${config.backend}/level/profilepic/${encodeURIComponent(addr)}`
|
||||
)
|
||||
return result.data
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error('Error in getProfilePic()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async getName (addr) {
|
||||
try {
|
||||
const result = await this.axios.get(
|
||||
`${config.backend}/level/name/${encodeURIComponent(addr)}`
|
||||
)
|
||||
return result.data
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error('Error in getName()')
|
||||
console.error(`Error in ${name}()`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Memo post behavior: compose, validate, and broadcast a Memo "post" message.
|
||||
|
||||
A Memo post is an OP_RETURN Bitcoin Cash transaction carrying the Memo post
|
||||
protocol prefix (0x6d02) followed by the message text. Broadcasting is done
|
||||
through a wallet that exposes the minimal-slp-wallet adapter surface
|
||||
(walletInfo, getUtxos(), sendOpReturn()).
|
||||
|
||||
The wallet and feed are injected so this module stays testable and free of
|
||||
network/UI concerns; environmentally unsuitable I/O lives behind those small
|
||||
adapter boundaries.
|
||||
|
||||
Constants
|
||||
MEMO_POST_PREFIX : hex prefix for the Memo "post" action (0x6d02)
|
||||
MAX_MEMO_CHARS : maximum allowed memo length (spec boundary: 217 valid,
|
||||
218 rejected)
|
||||
*/
|
||||
|
||||
const MEMO_POST_PREFIX = '6d02'
|
||||
const MAX_MEMO_CHARS = 217
|
||||
|
||||
class MemoPost {
|
||||
constructor (deps = {}) {
|
||||
this.wallet = deps.wallet
|
||||
this.feed = deps.feed
|
||||
}
|
||||
|
||||
// Validate a candidate memo message.
|
||||
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
||||
validate (message) {
|
||||
if (typeof message !== 'string' || message.trim().length === 0) {
|
||||
return { ok: false, type: 'validation' }
|
||||
}
|
||||
|
||||
if (message.length > MAX_MEMO_CHARS) {
|
||||
return { ok: false, type: 'length' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Compose and broadcast a Memo post for the given message.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async post (message) {
|
||||
const check = this.validate(message)
|
||||
this._throwIfInvalid(check)
|
||||
|
||||
if (!this.wallet) {
|
||||
throw new Error('Memo post requires a wallet.')
|
||||
}
|
||||
|
||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and
|
||||
// its own spendable UTXOs internally, so only the message and Memo post
|
||||
// prefix are passed here.
|
||||
const txid = await this.wallet.sendOpReturn(message, MEMO_POST_PREFIX)
|
||||
|
||||
// Reflect the new post in the feed once broadcast succeeds.
|
||||
this._reflectPost(txid, message)
|
||||
|
||||
return txid
|
||||
}
|
||||
|
||||
// Throw the appropriate typed error when a memo fails validation.
|
||||
_throwIfInvalid (check) {
|
||||
if (check.ok) return
|
||||
|
||||
const err = new Error(
|
||||
check.type === 'length'
|
||||
? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.`
|
||||
: 'Memo must not be empty.'
|
||||
)
|
||||
err.code = check.type === 'length' ? 'memo_length' : 'memo_validation'
|
||||
throw err
|
||||
}
|
||||
|
||||
// Record the new post on the injected feed when one is present.
|
||||
_reflectPost (txid, message) {
|
||||
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||
this.feed.addPost({
|
||||
txid,
|
||||
address: this.wallet.walletInfo.cashAddress,
|
||||
text: message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX
|
||||
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
||||
|
||||
module.exports = MemoPost
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
New Post Page behavior: compose and post a Memo, with a character counter
|
||||
that counts down from the memo limit.
|
||||
|
||||
This is the testable controller behind the React "New Post" page. It wraps
|
||||
the Memo post behavior (src/services/memo-post.js) and adds page-level
|
||||
concerns: holding the current input, computing the remaining character count,
|
||||
surfacing validation/length errors, and navigating to the recent feed after a
|
||||
successful post.
|
||||
|
||||
The memoPost and navigate concerns are injected so this module stays free of
|
||||
UI/network concerns; environmentally unsuitable I/O lives behind those small
|
||||
adapter boundaries.
|
||||
*/
|
||||
|
||||
const MemoPost = require('./memo-post')
|
||||
|
||||
const NEW_POST_PATH = '/posts/new'
|
||||
const RECENT_FEED_PATH = '/posts/recent'
|
||||
|
||||
class NewPostPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoPost = deps.memoPost || null
|
||||
this.navigate = deps.navigate || (() => {})
|
||||
this.menuLinks = deps.menuLinks || []
|
||||
|
||||
this.input = ''
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
this.posting = false
|
||||
|
||||
// The navigation menu links to the new post page.
|
||||
this.addMenuLink(NEW_POST_PATH)
|
||||
}
|
||||
|
||||
// Record a navigation menu link offered by the app.
|
||||
addMenuLink (path) {
|
||||
if (!this.menuLinks.includes(path)) this.menuLinks.push(path)
|
||||
return this
|
||||
}
|
||||
|
||||
// Whether the navigation menu exposes a link to the given path.
|
||||
hasMenuLink (path) {
|
||||
return this.menuLinks.includes(path)
|
||||
}
|
||||
|
||||
// Set the draft memo text and update the counter.
|
||||
setInput (text) {
|
||||
this.input = typeof text === 'string' ? text : ''
|
||||
return this
|
||||
}
|
||||
|
||||
// Characters remaining before the memo limit is reached.
|
||||
remainingCount () {
|
||||
return MemoPost.MAX_MEMO_CHARS - this.input.length
|
||||
}
|
||||
|
||||
// Validate and post the current draft. On success, navigate to the recent
|
||||
// feed. On failure, record the typed error and stay on the page. Resolves
|
||||
// with a result object.
|
||||
async submit () {
|
||||
this.posting = true
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
|
||||
try {
|
||||
if (!this.memoPost) {
|
||||
throw new Error('New post requires a memo post handler.')
|
||||
}
|
||||
|
||||
const txid = await this.memoPost.post(this.input)
|
||||
this.navigate(RECENT_FEED_PATH)
|
||||
this.posting = false
|
||||
return { ok: true, txid }
|
||||
} catch (err) {
|
||||
return this._handleSubmitFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify a submit failure, record the typed state, and return the failure
|
||||
// result. Local validation failures set submitError; broadcast or handler
|
||||
// failures surface the real error message via broadcastError.
|
||||
_handleSubmitFailure (err) {
|
||||
if (err.code === 'memo_validation' || err.code === 'memo_length') {
|
||||
this.submitError = err.code
|
||||
} else {
|
||||
this.broadcastError = err.message || String(err)
|
||||
this.submitError = 'broadcast'
|
||||
}
|
||||
this.posting = false
|
||||
return { ok: false, error: this.submitError, message: this.broadcastError }
|
||||
}
|
||||
}
|
||||
|
||||
NewPostPage.NEW_POST_PATH = NEW_POST_PATH
|
||||
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||
|
||||
module.exports = NewPostPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T00:39:54.163Z","module_hash":"8d50d002e9c6094a1bd2d6e764023c942b1eb0f085b019255dc80f0a72ab1ec6","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":34,"hash":"d61d01986c51dc4ed4185594fa3e35612924846db321a7107aaec191d17c419d"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":37,"end_line":40,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":43,"end_line":45,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":48,"end_line":51,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":54,"end_line":56,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":61,"end_line":78,"hash":"c280ee1244bcb80c3a9ffe4f52befc8a05629ec879ef9d86666ea11f493b3c5b"},{"id":"func/NewPostPage._handleSubmitFailure","name":"NewPostPage._handleSubmitFailure","line":83,"end_line":92,"hash":"b13d8cd6b48b1f42d72e0031cabcaa00bb78b813f42250517d711f0d6fb23126"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -32,6 +32,27 @@ class AppUtil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Read text from clipboard and pass it to a state setter.
|
||||
async pasteFromClipboard (setValue) {
|
||||
try {
|
||||
const text = await this.readFromClipboard()
|
||||
setValue(text)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate a long string (address, txid, etc.) for compact display.
|
||||
export function truncateAddr (addr, maxLen = 20) {
|
||||
if (!addr || addr.length <= maxLen) return addr
|
||||
const half = Math.floor((maxLen - 3) / 2)
|
||||
return `${addr.slice(0, half)}...${addr.slice(-half)}`
|
||||
}
|
||||
|
||||
export function truncateTxid (txid, maxLen = 20) {
|
||||
return truncateAddr(txid, maxLen)
|
||||
}
|
||||
|
||||
export default AppUtil
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MAIN_BRANCH="${SWARMFORGE_SCRIPTS_BRANCH:-main}"
|
||||
ARCHIVE_URL="${SWARMFORGE_SCRIPTS_URL:-https://github.com/unclebob/swarm-forge/archive/refs/heads/${MAIN_BRANCH}.tar.gz}"
|
||||
TMP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$TMP_DIR" ]]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -d "$SCRIPT_DIR/swarmforge/scripts" || ! -d "$SCRIPT_DIR/swarmforge/scripts/shared-articles" ]]; then
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
mkdir -p "$SCRIPT_DIR/swarmforge"
|
||||
curl -L "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$TMP_DIR"
|
||||
if [[ ! -d "$SCRIPT_DIR/swarmforge/scripts" ]]; then
|
||||
cp -R "$TMP_DIR/swarmforge/scripts" "$SCRIPT_DIR/swarmforge/scripts"
|
||||
fi
|
||||
if [[ -d "$TMP_DIR/swarmforge/constitution/articles" ]]; then
|
||||
mkdir -p "$SCRIPT_DIR/swarmforge/scripts/shared-articles"
|
||||
cp -R "$TMP_DIR/swarmforge/constitution/articles/." "$SCRIPT_DIR/swarmforge/scripts/shared-articles/"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$SCRIPT_DIR/swarmforge/scripts/swarmforge.sh" "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
# SwarmForge Constitution
|
||||
|
||||
This file takes precedence over article files.
|
||||
Read and obey every file in `swarmforge/constitution/articles/`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Engineering Rules
|
||||
|
||||
## Startup Tools
|
||||
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed `github.com/unclebob/...` repositories and get each one ready to run.
|
||||
- Resolve each listed repository at its latest available upstream version before installing or building it.
|
||||
- Do not rely on stale cached, vendored, or preinstalled copies when a fresh GitHub install/build is possible in the current environment.
|
||||
- Language tool table:
|
||||
- Go: install with `go install`; mutation `github.com/unclebob/mutate4go`, CRAP `github.com/unclebob/crap4go`, DRY `github.com/unclebob/dry4go`.
|
||||
- Clojure: install with Clojure CLI/deps.edn; mutation `github.com/unclebob/clj-mutate`, CRAP `github.com/unclebob/crap4clj`, DRY `github.com/unclebob/dry4clj`.
|
||||
- Java: install with Maven (`mvn`); mutation `github.com/unclebob/mutate4java`, CRAP `github.com/unclebob/crap4java`, DRY `github.com/unclebob/dry4java`.
|
||||
- Node.js JavaScript: install with `npm install`; mutation `github.com/FullStack-Agents/mutate4javascript`, CRAP `github.com/FullStack-Agents/crap4javascript`, DRY `github.com/FullStack-Agents/dry4javascript`.
|
||||
|
||||
## Language Defaults
|
||||
- For Clojure projects, prefer Babashka where possible.
|
||||
- For Clojure projects, prefer Speclj for unit and behavior tests.
|
||||
- For Clojure or Babashka projects using Speclj, use `github.com/unclebob/speclj-structure-check` to validate test syntax. If a Speclj spec file changed, run the structure check before executing the relevant test command.
|
||||
- For Java projects, avoid using Maven to run tests; build dedicated test runners and run those instead.
|
||||
|
||||
## Design And Testability
|
||||
- Work in small, reviewable increments.
|
||||
- Prefer the simplest design that supports the current behavior and leaves clear options for the next step.
|
||||
- Keep tests close to the behavior being changed.
|
||||
- Separate testable modules from environmentally unsuitable modules that open GUIs, depend on external devices, throw environment errors, emit system errors, or hang under automated tests. Maximize testable code and minimize the unsuitable boundary.
|
||||
- Only testable modules should participate in tools that run tests, including unit tests, acceptance tests, coverage, mutation testing, CRAP analysis, DRY analysis that invokes tests, and property tests.
|
||||
- Keep property tests separate from normal verification. Do not include property-test tags in normal unit coverage, Gherkin acceptance mutation, language mutation tools, CRAP, or coverage commands unless the role owns property-test verification or the user explicitly asks for property tests.
|
||||
|
||||
## Acceptance Pipeline
|
||||
- Use github.com/unclebob/Acceptance-Pipeline-Specification for Gherkin acceptance tests.
|
||||
- The Acceptance Pipeline Specification supplies `gherkin-parser` and `gherkin-mutator`; install or build those commands from that repository instead of reimplementing them in the project.
|
||||
- Prefer the Babashka APS tools for `gherkin-parser`, `gherkin-mutator`, and related APS support commands.
|
||||
- Use Go-based APS tools only if the Babashka APS tools do not work in the current project environment.
|
||||
- Project-specific acceptance pipeline components are the acceptance entrypoint generator, acceptance runtime, project step handlers, runner adapter, and convenience scripts.
|
||||
- Gherkin acceptance mutation means running `gherkin-mutator` to mutate Gherkin example values.
|
||||
- Gherkin acceptance mutation runs must report periodic progress/status so agents can distinguish normal long-running work from a hang.
|
||||
|
||||
## Verification
|
||||
- Before running language, build, or test commands, prefer project-local cache/configuration paths inside the assigned worktree. Avoid default cache locations that write outside the project and may trigger sandbox or permission restrictions.
|
||||
- Run acceptance generation and acceptance tests sequentially.
|
||||
- Avoid running whole-suite language test commands concurrently with acceptance generation.
|
||||
- Run the relevant local verification command before handoff whenever the project has one.
|
||||
|
||||
## Guardrails
|
||||
- Do not edit mutation testing or Gherkin acceptance mutation manifests by hand; allow approved mutation tools to update those manifests as part of their normal runs.
|
||||
- Do not commit unrelated local changes or generated artifacts unless required for the task.
|
||||
- Before relying on an unfamiliar command, inspect local help or project documentation.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Handoff Rules
|
||||
|
||||
## Sending Handoffs
|
||||
- Write a draft handoff file with only structured headers, then run `swarm_handoff.sh <draft-file>`.
|
||||
- Use only these message types:
|
||||
- `git_handoff`
|
||||
- `note`
|
||||
- Do not send `note` handoffs unless the user, role prompt, or constitution
|
||||
explicitly directs you to send one.
|
||||
- When blocked by ambiguity, contradiction, or test/specification conflict,
|
||||
stop and ask for clarification; do not send a `note` handoff unless one of
|
||||
the explicit authorities above directed that note.
|
||||
- For `git_handoff`, commit first, then write:
|
||||
|
||||
```text
|
||||
type: git_handoff
|
||||
to: <role>[,<role>...]
|
||||
priority: NN
|
||||
task: <short-stable-task-name>
|
||||
commit: <10-character-commit-abbrev>
|
||||
```
|
||||
|
||||
- When your role is an intermediate step in the pack pipeline, always forward
|
||||
a `git_handoff` to the next role in the chain after completing the inbound
|
||||
task, regardless of what changed. Formatting-only, manifest-only, audit-only,
|
||||
generated metadata, and other non-functional churn still require a forward
|
||||
down the chain.
|
||||
- When your role sends the end-of-chain handoff to multiple recipients, those
|
||||
recipients merge only (`merge_and_process`). They do not forward that handoff
|
||||
further. Only this terminal broadcast is merge-only without re-forwarding.
|
||||
- Preserve the received task name when forwarding work for the same task. If the
|
||||
handoff starts new work, invent a short stable task name.
|
||||
- For `note`, write:
|
||||
|
||||
```text
|
||||
type: note
|
||||
to: <role>[,<role>...]
|
||||
priority: NN
|
||||
message: <one line, max 80 chars>
|
||||
```
|
||||
|
||||
- If `swarm_handoff.sh` reports validation errors, repair the draft and rerun it.
|
||||
- After a successful send, the helper removes the draft file. If you need to
|
||||
remove a stale draft manually, use `rm <draft-file>`, not `rm -f`.
|
||||
- Do not write long handoff bodies. The helper generates the delivered payload.
|
||||
- Do not send tmux notifications directly.
|
||||
- Do not hand-edit, merge, stage, or commit handoff runtime state.
|
||||
|
||||
## Receiving Handoffs
|
||||
- When notified, run `ready_for_next.sh`.
|
||||
- `ready_for_next.sh` dispatches to the task or batch helper configured for
|
||||
your role.
|
||||
- If it prints `NO_TASK`, stop waiting for work.
|
||||
- If it prints `TASK: <path>`, treat the printed `PAYLOAD` as the task.
|
||||
- If it prints `TASK_NAME: <name>`, use that as the stable task name for any
|
||||
work you forward from that task.
|
||||
- If it prints `BATCH: <path>`, treat each printed `BATCH_ITEM` as part of the
|
||||
current batch in helper-delivered order.
|
||||
- Use only the task information printed by the helper scripts.
|
||||
- If a tmux wake-up arrives while already working on a task, ignore it.
|
||||
- When the task or batch is fully complete, run `done_with_current.sh`.
|
||||
- `note` handoffs are tasks too; after reading or acting on a note, run
|
||||
`done_with_current.sh` before accepting any other handoff.
|
||||
- If `done_with_current.sh` prints `TASK: <path>`, treat the printed `PAYLOAD`
|
||||
as the next task.
|
||||
- If `done_with_current.sh` prints `BATCH: <path>`, treat each printed
|
||||
`BATCH_ITEM` as part of the next batch in helper-delivered order.
|
||||
- If a done helper prints `NO_TASK`, stop waiting for work.
|
||||
- On restart, run `ready_for_next.sh` and follow its output.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Project Rules
|
||||
|
||||
## Project Shape
|
||||
- This project is configured for SwarmForge with four pi-backed agents: specifier, coder, refactorer, and architect.
|
||||
- Project language: JavaScript
|
||||
|
||||
## Local Configuration
|
||||
- Preserve project-local SwarmForge configuration under `swarmforge/`.
|
||||
- Keep swarm state local under `.swarmforge/`, worktrees under `.worktrees/`, and shared scripts under `swarmforge/scripts/`.
|
||||
|
||||
## Handoffs
|
||||
- Prefer terse, explicit handoffs that report state and request role-appropriate review. Do not include verifications or sender process narrative.
|
||||
|
||||
## Ownership
|
||||
- Do not change another role's prompt or workflow ownership without explicit user direction.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Workflow Rules
|
||||
|
||||
## Worktree Discipline
|
||||
- At startup, discover and remember the branch or worktree assigned to your role.
|
||||
- If your assigned worktree is `master`, work in the main project checkout on its current branch; do not expect or create a `.worktrees/<role>` directory for that role.
|
||||
- Work only in your assigned branch or worktree.
|
||||
- Do not inspect, diff, merge, or base work on another branch unless that branch is specifically named in a handoff or explicit user instruction.
|
||||
- Do not run `./swarm` from an agent worktree to repair helper scripts. If handoff helper scripts are missing from PATH, stop and report the startup failure.
|
||||
|
||||
## Announcements
|
||||
- Do not add role bylines to announcements or check-in comments.
|
||||
|
||||
## Commit Messages
|
||||
- Include your role byline in every git commit message in this form: `By <role>.`
|
||||
- Example:
|
||||
|
||||
```text
|
||||
Implement handoff validation
|
||||
|
||||
By coder.
|
||||
```
|
||||
|
||||
## Temporary Files
|
||||
- Use `./tmp/` in your assigned worktree for temporary files; do not use `/tmp`.
|
||||
|
||||
## Failure Conditions
|
||||
- If the expected git layout or assigned worktree is missing, stop and report instead of silently working in the wrong place.
|
||||
@@ -0,0 +1,59 @@
|
||||
You are the architect.
|
||||
|
||||
## Owns
|
||||
- Own the high-level design, module boundaries, dependency direction, and project structure.
|
||||
- Keep the architecture aligned with the current specification and implementation.
|
||||
- Decide when a design change is needed and when a simpler local change is enough.
|
||||
|
||||
## Architecture Rules
|
||||
- Inspect module structure and perform reasonable reorganizations that minimize coupling, maximize cohesion, and maintain information hiding. Split modules that mix unrelated behaviors or blur important technical boundaries.
|
||||
- Design boundaries that maximize testable modules and minimize environmentally unsuitable adapter shells.
|
||||
- Keep tests separate from test helpers.
|
||||
|
||||
## Architectural Review Phases
|
||||
- UI/Core Separation: review whether UI, framework, IO, and delivery details are separated from core rules and whether core behavior can be tested without UI or IO.
|
||||
- Dependency Rule: review dependency direction. High-level modules far from IO must not depend on low-level modules near IO; low-level modules should depend on high-level modules through stable abstractions or calls inward.
|
||||
- Information Hiding And Encapsulation: review whether modules expose only necessary concepts, hide representation and IO details, preserve invariants, and avoid leaking framework or persistence structures across boundaries.
|
||||
- Local Code Quality: review names, control flow, duplication, error handling, edge cases, and local readability as they affect architectural clarity.
|
||||
|
||||
## Startup Tools
|
||||
- At startup, install the language mutation tool from the constitution and make it ready for immediate use. Use it to cover the uncovered, and kill survivors.
|
||||
- At startup, install or build the APS-supplied commands `gherkin-parser` and `gherkin-mutator` from github.com/unclebob/Acceptance-Pipeline-Specification, and ensure `gherkin-mutator` reports periodic progress/status during long runs.
|
||||
- Prefer the Babashka APS `gherkin-parser` and `gherkin-mutator`; use Go-based APS tools only if the Babashka tools do not work in the current project environment.
|
||||
- Build the project-specific runner adapter required by `gherkin-mutator`.
|
||||
|
||||
## Mutation Work
|
||||
- Run the language mutation tool one file at a time in sequence.
|
||||
- Always use differential mutation against the manifest unless explicitly directed otherwise.
|
||||
- Time is of the essence during mutation work; keep mutation runs as efficient as reasonably possible while preserving meaningful coverage and manifest correctness.
|
||||
- Include property tests in the standard verification suite as a separate explicit command when the project has them.
|
||||
- When the language mutation tool supports worker limits, use `--max-workers 8`.
|
||||
- Run verification tools in verbose or progress-reporting mode when supported so long runs show normal progress.
|
||||
|
||||
## DRY Work
|
||||
- At startup, install the language DRY tool from the constitution and make it ready for immediate use. Use it to reduce duplication where reasonable.
|
||||
|
||||
## Boundaries
|
||||
- Keep mutation and hardening tests separate from unit and acceptance tests.
|
||||
|
||||
## Refactorer Handoffs
|
||||
- Process helper-delivered refactorer work in the shape delivered by `ready_for_next.sh`.
|
||||
- If `ready_for_next.sh` prints `BATCH`, process each `BATCH_ITEM` in helper-delivered order as one architectural review batch.
|
||||
- If `ready_for_next.sh` prints `TASK`, process that single task.
|
||||
- In every refactorer handoff, apply the module-structure rules for coupling, cohesion, information hiding, technical boundaries, and testable boundaries; implement reasonable structural fixes.
|
||||
|
||||
## Handoff
|
||||
- If a handoff contains no changes, do not hand it off to the other agents.
|
||||
- As the final verification sequence, run the language mutation tool, then the language DRY tool, then soft Gherkin acceptance mutation (`--level soft`) unless directed otherwise. Fix any issues each tool finds before running the next one.
|
||||
- **Written review report**: at the end of each task or batch, write a concise summary to
|
||||
`docs/reviews/<task>-summary.md` (create `docs/reviews/` if missing) and commit it with your
|
||||
byline, in the same commit as the review changes. The summary is the durable written record
|
||||
for the human and must include: the task(s) and commits reviewed, architectural findings and
|
||||
fixes applied, verification results (mutation kills, survivors with documented equivalents,
|
||||
DRY, cyclomatic complexity), suite status, and the handoffs sent. Do not send a `note` handoff
|
||||
for this — the file is the report.
|
||||
- When the current refactorer task or batch is complete, commit architectural changes and hand off functional work before taking another queued refactorer task or batch:
|
||||
- Send `git_handoff` files to the coder and refactorer using `priority: 00` when they have follow-up work to review.
|
||||
- Send a `git_handoff` file to the specifier only when there is a functional commit for the specifier to review.
|
||||
- Do not send completion notes or `note` handoffs to the specifier.
|
||||
- If the completed work produced no changes, or only manifest/non-functional changes, run `done_with_current.sh` and take the next queued task or batch without forwarding it.
|
||||
@@ -0,0 +1,29 @@
|
||||
You are the coder.
|
||||
|
||||
## Owns
|
||||
- Implement in the project language specified by the constitution.
|
||||
- Own implementation of approved behavior slices.
|
||||
- Start from the latest accepted specification and architecture guidance.
|
||||
|
||||
## Acceptance Pipeline
|
||||
- At startup, make sure the normal acceptance pipeline from github.com/unclebob/Acceptance-Pipeline-Specification is in place.
|
||||
- Use the APS-supplied command `gherkin-parser`; do not reimplement the parser in the project.
|
||||
- Prefer the Babashka APS `gherkin-parser`; use a Go-based APS parser only if the Babashka tool does not work in the current project environment.
|
||||
- Build project-specific acceptance entrypoint generator, runtime, step handlers, and normal acceptance scripts.
|
||||
- In acceptance step files, make regex-based parameter extraction the default for step definitions. Use one step handler with regular expression captures for repeated step shapes that vary only by example values; write separate literal handlers only when the wording represents genuinely different behavior.
|
||||
- Running acceptance tests means running `gherkin-parser`, running the project-specific acceptance entrypoint generator, and running the generated executable tests.
|
||||
- Keep generated acceptance tests separate from unit tests.
|
||||
|
||||
## Implementation
|
||||
- Keep new behavior in testable modules whenever possible. Put environmentally unsuitable code behind small adapter boundaries.
|
||||
- For each behavior slice, use TDD to specify behavior before implementation. First write focused unit tests that express the requested observable behavior and would fail for a plausible wrong implementation. Then write only enough production code to pass those tests.
|
||||
- Do not rely on generated acceptance tests as a substitute for unit tests.
|
||||
- Run property tests only when explicitly requested or when the task specifically calls for property-test coverage.
|
||||
- Keep implementation code understandable enough to hand off: use clear names, straightforward control flow, and no avoidable duplication in the touched code. Leave broad cleanup outside the behavior slice to the refactorer unless it blocks implementation.
|
||||
|
||||
## Does Not Own
|
||||
- Do not run language mutation, CRAP, or DRY checks; the refactorer and architect own those checks.
|
||||
- Do not run Gherkin acceptance mutation.
|
||||
|
||||
## Handoff
|
||||
- When all acceptance and unit tests pass, commit and notify the refactorer using the file-based handoff format.
|
||||
@@ -0,0 +1,29 @@
|
||||
You are the refactorer.
|
||||
|
||||
## Owns
|
||||
- Own structure-preserving cleanup after the coder's implementation.
|
||||
- Preserve behavior while improving names, duplication, boundaries, and testability.
|
||||
- Move behavior out of environmentally unsuitable modules into testable modules when that can be done without changing behavior. Keep unsuitable modules as small adapter shells excluded from tools that run tests.
|
||||
|
||||
## Coverage And Property Testing
|
||||
- Run coverage and increase where reasonable.
|
||||
- Own property testing support. Find an appropriate property testing framework for the project, or build a small one when no suitable framework fits.
|
||||
- Assess property-test coverage before verification. Improve existing property tests and add new ones where useful properties are undercovered: invariants, broad input ranges, round trips, conservation, idempotence, ordering, or parsing/formatting stability.
|
||||
- Include property tests in the standard verification suite as a separate explicit command when the project has them.
|
||||
|
||||
## Analysis Tools
|
||||
- At startup, install the language mutation, CRAP, and DRY tools from the constitution; make them ready for immediate use.
|
||||
- Run the language CRAP tool first and reduce CRAP to 6 or below. Then run the language DRY tool and reduce duplicate code where reasonable.
|
||||
- Use the language mutation tool's scan/count mode on changed and new source files to count mutation sites without running mutation tests.
|
||||
- If any changed or new source file has more than 100 mutation sites, perform a reasonable behavior-preserving split before handoff.
|
||||
- Preserve mutation manifests and any other project manifests across the split; do not discard manifest state or hand-edit mutation manifests.
|
||||
|
||||
## Does Not Own
|
||||
- Do not run mutation tests.
|
||||
- Do not run Gherkin acceptance mutation.
|
||||
- Do not introduce new behavior.
|
||||
|
||||
## Handoff
|
||||
- Keep refactors small enough to verify locally.
|
||||
- Verify by running acceptance and unit tests.
|
||||
- When complete, commit and notify the architect using the file-based handoff format.
|
||||
@@ -0,0 +1,30 @@
|
||||
You are the specifier.
|
||||
|
||||
## Owns
|
||||
- Own externally visible behavior specifications, acceptance criteria, and examples.
|
||||
- Ask questions to settle ambiguity.
|
||||
- Turn user intent into precise, testable behavior without prescribing unnecessary implementation details.
|
||||
|
||||
## Specification Rules
|
||||
- Keep specifications concise and deterministic.
|
||||
- Separate feature files by behavior and technology.
|
||||
- Name each scenario with the feature name and a stable index, and include that scenario name in a comment immediately preceding each feature.
|
||||
- Use the Gherkin format defined by github.com/unclebob/Acceptance-Pipeline-Specification.
|
||||
- Gherkin will be mutation tested; use Gherkin parameters for any fields that might vary.
|
||||
- Prune identical Gherkin example-table columns when every row has the same value and the column does not improve Gherkin acceptance mutation.
|
||||
|
||||
## Feature Workflow
|
||||
- For each feature, work in five phases:
|
||||
1. Write the Gherkin that specifies the feature.
|
||||
2. Prune the Gherkin so parameters are only values germane to Gherkin acceptance testing; remove redundant parameters and identical example-table columns that do not improve Gherkin acceptance mutation.
|
||||
3. Use `ir-dry-checker` to normalize and prune the Gherkin.
|
||||
4. Move repeated scenario setup into a Gherkin `Background` when doing so preserves scenario meaning.
|
||||
5. Ask the user for approval to hand off to the coder.
|
||||
|
||||
## Verification
|
||||
- Do not run Gherkin acceptance mutation.
|
||||
- Run tests when verification is needed; do not run other verification or quality tools.
|
||||
|
||||
## Handoff
|
||||
- Do not commit or notify coder until the user explicitly approves the handoff. After approval, commit the specification changes, invent a short stable task name, and notify coder using the file-based handoff format with that name in the `task:` header.
|
||||
- When the architect notifies you that the job is complete, merge the changes and ask the user for the next feature to add.
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(println message))
|
||||
(System/exit status))
|
||||
|
||||
(defn command [& args]
|
||||
(apply sh/sh args))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
path
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn role []
|
||||
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn receive-mode [role-name]
|
||||
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
|
||||
(or (some (fn [line]
|
||||
(let [fields (str/split line #"\t" -1)]
|
||||
(when (= role-name (first fields))
|
||||
(not-empty (get fields 6 "task")))))
|
||||
roles)
|
||||
(exit! 1 (str "Unknown role: " role-name)))))
|
||||
|
||||
(defn run-helper! [script]
|
||||
(process/exec (str (fs/path script-dir script))))
|
||||
|
||||
(defn -main []
|
||||
(case (receive-mode (role))
|
||||
"batch" (run-helper! "done_with_current_batch.sh")
|
||||
"task" (run-helper! "done_with_current_task.sh")
|
||||
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current.bb" "$@"
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current-batch
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn run-ready! []
|
||||
(process/exec (str (fs/path script-dir "ready_for_next_batch.sh"))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-files)
|
||||
(fail! 2
|
||||
"CURRENT_WORK_IS_SINGLE_TASK: use done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(when (empty? in-process-batches)
|
||||
(fail! 1 "NO_CURRENT_BATCH"))
|
||||
(when (> (count in-process-batches) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple batches are in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(let [source-dir (first in-process-batches)
|
||||
batch-files (handoff-files source-dir)
|
||||
target-dir (fs/path completed-dir (fs/file-name source-dir))
|
||||
completed-at (timestamp)]
|
||||
(when (empty? batch-files)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " source-dir)))
|
||||
(when (fs/exists? target-dir)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch already exists: " target-dir)))
|
||||
(fs/create-dir target-dir)
|
||||
(doseq [source-file batch-files]
|
||||
(set-header! source-file "completed_at" completed-at)
|
||||
(let [target-file (fs/path target-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed batch file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(println "COMPLETED:" (str target-file))))
|
||||
(fs/delete source-dir)
|
||||
(println "COMPLETED_BATCH:" (str target-dir))
|
||||
(run-ready!)))))
|
||||
|
||||
(-main)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current_batch.bb" "$@"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns done-with-current-task
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn run-ready! []
|
||||
(process/exec (str (fs/path script-dir "ready_for_next_task.sh"))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-batches)
|
||||
(fail! 2
|
||||
"CURRENT_WORK_IS_BATCH: use done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(when (empty? in-process-files)
|
||||
(fail! 1 "NO_CURRENT_TASK"))
|
||||
(when (> (count in-process-files) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple tasks are in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(let [source-file (first in-process-files)
|
||||
target-file (fs/path completed-dir (fs/file-name source-file))]
|
||||
(set-header! source-file "completed_at" (timestamp))
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: completed file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(println "COMPLETED:" (str target-file))
|
||||
(run-ready!)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/done_with_current_task.bb" "$@"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns handoff-lib
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn role []
|
||||
(or (System/getenv "SWARMFORGE_ROLE")
|
||||
(throw (ex-info "Set SWARMFORGE_ROLE." {:exit 1}))))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (state-dir) "inbox"))
|
||||
|
||||
(defn project-root []
|
||||
(let [cwd (fs/cwd)
|
||||
direct (fs/path cwd ".swarmforge" "roles.tsv")]
|
||||
(if (fs/exists? direct)
|
||||
cwd
|
||||
(let [git-root (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--show-toplevel"))
|
||||
root (when-not (str/blank? git-root) (fs/path (str/trim git-root)))]
|
||||
(if (and root (fs/exists? (fs/path root ".swarmforge" "roles.tsv")))
|
||||
root
|
||||
(let [common (:out (babashka.process/sh {:continue true} "git" "rev-parse" "--git-common-dir"))
|
||||
common-path (when-not (str/blank? common)
|
||||
(let [path (fs/path (str/trim common))]
|
||||
(if (fs/absolute? path) path (fs/absolutize path))))
|
||||
common-parent (some-> common-path fs/parent)]
|
||||
(if (and common-parent (fs/exists? (fs/path common-parent ".swarmforge" "roles.tsv")))
|
||||
common-parent
|
||||
(throw (ex-info "Cannot find SwarmForge project root" {:exit 1})))))))))
|
||||
|
||||
(defn roles-file []
|
||||
(fs/path (project-root) ".swarmforge" "roles.tsv"))
|
||||
|
||||
(defn role-rows []
|
||||
(->> (str/split-lines (slurp (str (roles-file))))
|
||||
(map #(str/split % #"\t" -1))))
|
||||
|
||||
(defn role-row [role-name]
|
||||
(or (some #(when (= role-name (first %)) %) (role-rows))
|
||||
(throw (ex-info (str "Unknown role: " role-name) {:exit 1}))))
|
||||
|
||||
(defn role-known? [role-name]
|
||||
(boolean (some #(= role-name (first %)) (role-rows))))
|
||||
|
||||
(defn role-worktree-name [role-name]
|
||||
(second (role-row role-name)))
|
||||
|
||||
(defn role-receive-mode [role-name]
|
||||
(let [mode (nth (role-row role-name) 6 "")]
|
||||
(if (str/blank? mode) "task" mode)))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn valid-priority? [value]
|
||||
(boolean (re-matches #"[0-9][0-9]" value)))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?)
|
||||
(str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [file (fs/path file)
|
||||
lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (or (header-field file "from") "unknown"))
|
||||
(println "TYPE:" (or (header-field file "type") "unknown"))
|
||||
(println "PRIORITY:" (or (header-field file "priority") "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn print-batch [batch-dir]
|
||||
(let [files (handoff-files batch-dir)]
|
||||
(when (empty? files)
|
||||
(throw (ex-info (str "AMBIGUOUS_TASK_STATE: batch contains no tasks: " batch-dir) {:exit 2})))
|
||||
(println "BATCH:" (str batch-dir))
|
||||
(println "COUNT:" (count files))
|
||||
(println "PRIORITY:" (or (header-field (first files) "priority") "50"))
|
||||
(doseq [[index file] (map-indexed vector files)]
|
||||
(println)
|
||||
(println "BATCH_ITEM:" (inc index))
|
||||
(print-task file))))
|
||||
|
||||
(defn next-sequence []
|
||||
(let [dir (state-dir)
|
||||
seq-file (fs/path dir "sequence")
|
||||
lock-dir (fs/path dir "sequence.lock")]
|
||||
(fs/create-dirs dir)
|
||||
(loop []
|
||||
(when-not (try (fs/create-dir lock-dir) true (catch Exception _ false))
|
||||
(Thread/sleep 50)
|
||||
(recur)))
|
||||
(try
|
||||
(let [last-value (if (fs/exists? seq-file)
|
||||
(str/trim (slurp (str seq-file)))
|
||||
"0")
|
||||
last-number (if (re-matches #"[0-9]+" last-value)
|
||||
(Long/parseLong last-value)
|
||||
0)
|
||||
next-number (inc last-number)]
|
||||
(spit (str seq-file) (format "%06d\n" next-number))
|
||||
(format "%06d" next-number))
|
||||
(finally
|
||||
(fs/delete-tree lock-dir)))))
|
||||
|
||||
(defn -main [& args]
|
||||
(try
|
||||
(case (first args)
|
||||
"role" (println (role))
|
||||
"state-dir" (println (state-dir))
|
||||
"inbox-dir" (println (inbox-dir))
|
||||
"project-root" (println (project-root))
|
||||
"role-known" (System/exit (if (role-known? (second args)) 0 1))
|
||||
"role-worktree-name" (println (role-worktree-name (second args)))
|
||||
"role-receive-mode" (println (role-receive-mode (second args)))
|
||||
"timestamp" (println (timestamp))
|
||||
"id-timestamp" (println (id-timestamp))
|
||||
"valid-priority" (System/exit (if (valid-priority? (second args)) 0 1))
|
||||
"header-field" (if-let [value (header-field (second args) (nth args 2))]
|
||||
(println value)
|
||||
(System/exit 1))
|
||||
"body" (print (body (second args)))
|
||||
"set-header" (set-header! (second args) (nth args 2) (nth args 3))
|
||||
"print-task" (print-task (second args))
|
||||
"print-batch" (print-batch (second args))
|
||||
"next-sequence" (println (next-sequence))
|
||||
(do
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: handoff_lib.bb <command> [args...]"))
|
||||
(System/exit 2)))
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(binding [*out* *err*]
|
||||
(println (ex-message e)))
|
||||
(System/exit (or (:exit (ex-data e)) 1)))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns handoffd
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :refer [sh]]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def poll-ms 1000)
|
||||
(def wake-message
|
||||
"You have new handoff mail. If idle, run ready_for_next.sh.")
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: handoffd.bb <project-root>"))
|
||||
(System/exit 1))
|
||||
|
||||
(def project-root
|
||||
(or (first *command-line-args*) (usage)))
|
||||
|
||||
(def state-dir (fs/path project-root ".swarmforge"))
|
||||
(def daemon-dir (fs/path state-dir "daemon"))
|
||||
(def roles-file (fs/path state-dir "roles.tsv"))
|
||||
(def socket-file (fs/path state-dir "tmux-socket"))
|
||||
(def pid-file (fs/path daemon-dir "handoffd.pid"))
|
||||
(def stop-file (fs/path daemon-dir "stop"))
|
||||
(def log-file (fs/path daemon-dir "handoffd.log"))
|
||||
(def stopping-flag (atom false))
|
||||
|
||||
(defn now []
|
||||
(.format (java.time.format.DateTimeFormatter/ISO_INSTANT)
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn log! [& parts]
|
||||
(fs/create-dirs daemon-dir)
|
||||
(spit (str log-file)
|
||||
(str (now) " " (str/join " " parts) "\n")
|
||||
:append true))
|
||||
|
||||
(defn read-lines [path]
|
||||
(when (fs/exists? path)
|
||||
(str/split-lines (slurp (str path)))))
|
||||
|
||||
(defn load-roles []
|
||||
(into {}
|
||||
(for [line (read-lines roles-file)
|
||||
:when (not (str/blank? line))
|
||||
:let [[role worktree-name worktree-path session display agent receive-mode]
|
||||
(str/split line #"\t")]]
|
||||
[role {:role role
|
||||
:worktree-name worktree-name
|
||||
:worktree-path worktree-path
|
||||
:session session
|
||||
:display display
|
||||
:agent agent
|
||||
:receive-mode (or receive-mode "task")}])))
|
||||
|
||||
(defn parse-message [path]
|
||||
(let [content (slurp (str path))
|
||||
[header body] (str/split content #"\n\n" 2)
|
||||
headers (into {}
|
||||
(for [line (str/split-lines header)
|
||||
:let [[k v] (str/split line #": " 2)]
|
||||
:when (and k v)]
|
||||
[k v]))]
|
||||
{:headers headers
|
||||
:body (or body "")
|
||||
:content content}))
|
||||
|
||||
(defn render-message [headers body]
|
||||
(let [preferred ["id" "from" "to" "recipient" "priority" "type" "role" "commit"
|
||||
"message" "created_at" "enqueued_at" "dequeued_at" "completed_at"]
|
||||
remaining (->> (keys headers)
|
||||
(remove (set preferred))
|
||||
sort)
|
||||
ordered (concat preferred remaining)]
|
||||
(str (str/join "\n"
|
||||
(for [k ordered
|
||||
:let [v (get headers k)]
|
||||
:when v]
|
||||
(str k ": " v)))
|
||||
"\n\n"
|
||||
body)))
|
||||
|
||||
(defn add-delivery-headers [message recipient]
|
||||
(-> message
|
||||
(assoc-in [:headers "recipient"] recipient)
|
||||
(assoc-in [:headers "enqueued_at"] (now))))
|
||||
|
||||
(defn target-path [role-info filename]
|
||||
(fs/path (:worktree-path role-info)
|
||||
".swarmforge" "handoffs" "inbox" "new" filename))
|
||||
|
||||
(defn notify! [socket session]
|
||||
;; Wake-up universal: el CR va EMBEBIDO en el mismo write que el texto
|
||||
;; (pi-tui descarta un C-m suelto; texto+\r en un solo send-keys -l
|
||||
;; dispara el submit en pi). El C-j final es la "robustez" original de
|
||||
;; upstream para otros TUIs (claude/codex/grok); en pi es inofensivo
|
||||
;; (nueva línea en el editor vacío tras el submit).
|
||||
(let [send-text (sh "tmux" "-S" socket "send-keys" "-t" session "-l" (str wake-message "\r"))
|
||||
_ (Thread/sleep 300)
|
||||
send-line-feed (sh "tmux" "-S" socket "send-keys" "-t" session "C-j")]
|
||||
(when-not (zero? (:exit send-text))
|
||||
(throw (ex-info "tmux send text failed" send-text)))
|
||||
(when-not (zero? (:exit send-line-feed))
|
||||
(throw (ex-info "tmux send line feed failed" send-line-feed)))))
|
||||
|
||||
(defn move-with-collision [source target-dir]
|
||||
(fs/create-dirs target-dir)
|
||||
(let [base (fs/file-name source)
|
||||
target (fs/path target-dir base)]
|
||||
(if (fs/exists? target)
|
||||
(fs/move source
|
||||
(fs/path target-dir (str (now) "_" base))
|
||||
{:replace-existing false})
|
||||
(fs/move source target {:replace-existing false}))))
|
||||
|
||||
(defn fail! [path reason]
|
||||
(let [failed-dir (fs/path (fs/parent (fs/parent path)) "failed")]
|
||||
(log! "failed" (str path) reason)
|
||||
(spit (str path ".error") (str reason "\n"))
|
||||
(move-with-collision path failed-dir)))
|
||||
|
||||
(defn deliver! [roles socket sender-role path]
|
||||
(let [filename (fs/file-name path)
|
||||
message (parse-message path)
|
||||
headers (:headers message)
|
||||
recipients (some-> (get headers "to") (str/split #",") seq)]
|
||||
(if-not recipients
|
||||
(fail! path "missing to header")
|
||||
(do
|
||||
(doseq [recipient recipients]
|
||||
(let [role-info (get roles recipient)]
|
||||
(when-not role-info
|
||||
(throw (ex-info (str "unknown recipient " recipient) {:recipient recipient})))
|
||||
(let [target (target-path role-info filename)
|
||||
delivered (add-delivery-headers message recipient)]
|
||||
(fs/create-dirs (fs/parent target))
|
||||
(when-not (fs/exists? target)
|
||||
(spit (str target) (render-message (:headers delivered) (:body delivered))))
|
||||
(notify! socket (:session role-info)))))
|
||||
(move-with-collision path
|
||||
(fs/path (get-in roles [sender-role :worktree-path])
|
||||
".swarmforge" "handoffs" "sent"))
|
||||
(log! "delivered" (str path))))))
|
||||
|
||||
(defn outbox-files [role-info]
|
||||
(let [outbox (fs/path (:worktree-path role-info) ".swarmforge" "handoffs" "outbox")]
|
||||
(when (fs/exists? outbox)
|
||||
(->> (fs/list-dir outbox)
|
||||
(filter #(and (fs/regular-file? %)
|
||||
(str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))))))
|
||||
|
||||
(defn should-stop? []
|
||||
(or @stopping-flag (fs/exists? stop-file)))
|
||||
|
||||
(defn sleep-poll! [ms]
|
||||
(loop [remaining ms]
|
||||
(when (and (pos? remaining) (not (should-stop?)))
|
||||
(let [step (min remaining 100)]
|
||||
(Thread/sleep step)
|
||||
(recur (- remaining step))))))
|
||||
|
||||
(defn poll-once! []
|
||||
(when-not (should-stop?)
|
||||
(let [roles (load-roles)
|
||||
socket (str/trim (slurp (str socket-file)))]
|
||||
(doseq [[role role-info] roles
|
||||
path (or (outbox-files role-info) [])
|
||||
:while (not (should-stop?))]
|
||||
(try
|
||||
(deliver! roles socket role path)
|
||||
(catch Exception e
|
||||
(log! "error" (str path) (.getMessage e))
|
||||
(try
|
||||
(fail! path (.getMessage e))
|
||||
(catch Exception nested
|
||||
(log! "failed-to-archive" (str path) (.getMessage nested))))))))))
|
||||
|
||||
(defn shutdown! []
|
||||
(reset! stopping-flag true)
|
||||
(try
|
||||
(fs/delete-if-exists pid-file)
|
||||
(log! "stopped")
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn -main []
|
||||
(fs/create-dirs daemon-dir)
|
||||
(fs/delete-if-exists stop-file)
|
||||
(spit (str pid-file) (str (.pid (java.lang.ProcessHandle/current)) "\n"))
|
||||
(.addShutdownHook (Runtime/getRuntime) (Thread. shutdown!))
|
||||
(log! "started")
|
||||
(try
|
||||
(while (not (should-stop?))
|
||||
(poll-once!)
|
||||
(sleep-poll! poll-ms))
|
||||
(finally
|
||||
(fs/delete-if-exists pid-file)
|
||||
(log! "stopped"))))
|
||||
|
||||
(-main)
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def script-dir (fs/parent *file*))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(println message))
|
||||
(System/exit status))
|
||||
|
||||
(defn command [& args]
|
||||
(apply sh/sh args))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
path
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn role []
|
||||
(or (not-empty (System/getenv "SWARMFORGE_ROLE"))
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn receive-mode [role-name]
|
||||
(let [roles (str/split-lines (slurp (str (fs/path (project-root) ".swarmforge" "roles.tsv"))))]
|
||||
(or (some (fn [line]
|
||||
(let [fields (str/split line #"\t" -1)]
|
||||
(when (= role-name (first fields))
|
||||
(not-empty (get fields 6 "task")))))
|
||||
roles)
|
||||
(exit! 1 (str "Unknown role: " role-name)))))
|
||||
|
||||
(defn run-helper! [script]
|
||||
(process/exec (str (fs/path script-dir script))))
|
||||
|
||||
(defn -main []
|
||||
(case (receive-mode (role))
|
||||
"batch" (run-helper! "ready_for_next_batch.sh")
|
||||
"task" (run-helper! "ready_for_next_task.sh")
|
||||
(exit! 2 (str "INVALID_RECEIVE_MODE: " (receive-mode (role)) " for role " (role)))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next.bb" "$@"
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next-batch
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs" "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn header-value [file field default]
|
||||
(or (header-field file field) default))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (header-value file "from" "unknown"))
|
||||
(println "TYPE:" (header-value file "type" "unknown"))
|
||||
(println "PRIORITY:" (header-value file "priority" "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn print-batch [batch-dir]
|
||||
(let [files (handoff-files batch-dir)]
|
||||
(when (empty? files)
|
||||
(binding [*out* *err*]
|
||||
(println "AMBIGUOUS_TASK_STATE: batch contains no tasks:" (str batch-dir)))
|
||||
(System/exit 2))
|
||||
(println "BATCH:" (str batch-dir))
|
||||
(println "COUNT:" (count files))
|
||||
(println "PRIORITY:" (header-value (first files) "priority" "50"))
|
||||
(doseq [[index file] (map-indexed vector files)]
|
||||
(println)
|
||||
(println "BATCH_ITEM:" (inc index))
|
||||
(print-task file))))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn new-batch-dir [in-process-dir]
|
||||
(loop [suffix 1]
|
||||
(let [dir (fs/path in-process-dir (format "batch_%s_%06d" (id-timestamp) suffix))]
|
||||
(if (fs/exists? dir)
|
||||
(recur (inc suffix))
|
||||
dir))))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
new-dir (fs/path inbox "new")
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [new-dir in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-files)
|
||||
(fail! 2
|
||||
"TASK_IN_PROCESS_IS_SINGLE: use ready_for_next.sh or done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(when (> (count in-process-batches) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple batches are already in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(if (= 1 (count in-process-batches))
|
||||
(print-batch (first in-process-batches))
|
||||
(let [new-files (handoff-files new-dir)]
|
||||
(if (empty? new-files)
|
||||
(println "NO_TASK")
|
||||
(let [batch-priority (header-value (first new-files) "priority" "50")
|
||||
batch-dir (new-batch-dir in-process-dir)
|
||||
selected-files (filter #(= batch-priority (header-value % "priority" "50")) new-files)]
|
||||
(fs/create-dir batch-dir)
|
||||
(doseq [source-file selected-files]
|
||||
(let [target-file (fs/path batch-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target batch file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(set-header! target-file "dequeued_at" (timestamp))))
|
||||
(when (empty? selected-files)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: no tasks selected for batch priority " batch-priority ".")))
|
||||
(print-batch batch-dir))))))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next_batch.bb" "$@"
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns ready-for-next-task
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn inbox-dir []
|
||||
(fs/path (state-dir) "inbox"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn handoff-files [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/regular-file? %) (str/ends-with? (fs/file-name %) ".handoff")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn batch-dirs [dir]
|
||||
(if (fs/exists? dir)
|
||||
(->> (fs/list-dir dir)
|
||||
(filter #(and (fs/directory? %) (str/starts-with? (fs/file-name %) "batch_")))
|
||||
(sort-by #(fs/file-name %))
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn header-field [file field]
|
||||
(let [prefix (str field ": ")]
|
||||
(some (fn [line]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))
|
||||
(take-while (complement str/blank?) (str/split-lines (slurp (str file)))))))
|
||||
|
||||
(defn header-value [file field default]
|
||||
(or (header-field file field) default))
|
||||
|
||||
(defn body [file]
|
||||
(let [[_ body] (str/split (slurp (str file)) #"\n\n" 2)]
|
||||
(or body "")))
|
||||
|
||||
(defn set-header! [file field value]
|
||||
(let [lines (str/split-lines (slurp (str file)))
|
||||
prefix (str field ": ")
|
||||
tmp (fs/create-temp-file {:dir (fs/parent file) :prefix ".headers."})
|
||||
result (loop [remaining lines
|
||||
out []
|
||||
inserted? false
|
||||
replaced? false]
|
||||
(if-let [line (first remaining)]
|
||||
(cond
|
||||
(and (not inserted?) (str/blank? line))
|
||||
(recur (next remaining)
|
||||
(conj (cond-> out (not replaced?) (conj (str prefix value))) line)
|
||||
true
|
||||
replaced?)
|
||||
|
||||
(and (not inserted?) (str/starts-with? line prefix))
|
||||
(recur (next remaining) (conj out (str prefix value)) inserted? true)
|
||||
|
||||
:else
|
||||
(recur (next remaining) (conj out line) inserted? replaced?))
|
||||
(cond-> out
|
||||
(and (not inserted?) (not replaced?)) (conj (str prefix value)))))]
|
||||
(spit (str tmp) (str (str/join "\n" result) "\n"))
|
||||
(fs/move tmp file {:replace-existing true})))
|
||||
|
||||
(defn print-task [file]
|
||||
(let [task-name (header-field file "task")]
|
||||
(println "TASK:" (str file))
|
||||
(println "FROM:" (header-value file "from" "unknown"))
|
||||
(println "TYPE:" (header-value file "type" "unknown"))
|
||||
(println "PRIORITY:" (header-value file "priority" "50"))
|
||||
(when task-name
|
||||
(println "TASK_NAME:" task-name))
|
||||
(println "PAYLOAD:")
|
||||
(print (body file))))
|
||||
|
||||
(defn fail! [status & lines]
|
||||
(binding [*out* *err*]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
(System/exit status))
|
||||
|
||||
(defn -main []
|
||||
(let [inbox (inbox-dir)
|
||||
new-dir (fs/path inbox "new")
|
||||
in-process-dir (fs/path inbox "in_process")
|
||||
completed-dir (fs/path inbox "completed")]
|
||||
(doseq [dir [new-dir in-process-dir completed-dir]]
|
||||
(fs/create-dirs dir))
|
||||
(let [in-process-batches (batch-dirs in-process-dir)
|
||||
in-process-files (handoff-files in-process-dir)]
|
||||
(when (seq in-process-batches)
|
||||
(fail! 2
|
||||
"TASK_IN_PROCESS_IS_BATCH: use ready_for_next.sh or done_with_current.sh."
|
||||
(str/join "\n" (map #(str "- " %) in-process-batches))))
|
||||
(when (> (count in-process-files) 1)
|
||||
(fail! 2
|
||||
"AMBIGUOUS_TASK_STATE: multiple tasks are already in process."
|
||||
(str/join "\n" (map #(str "- " %) in-process-files))))
|
||||
(if (= 1 (count in-process-files))
|
||||
(print-task (first in-process-files))
|
||||
(let [new-files (handoff-files new-dir)]
|
||||
(if (empty? new-files)
|
||||
(println "NO_TASK")
|
||||
(let [source-file (first new-files)
|
||||
target-file (fs/path in-process-dir (fs/file-name source-file))]
|
||||
(when (fs/exists? target-file)
|
||||
(fail! 2 (str "AMBIGUOUS_TASK_STATE: target in-process file already exists: " target-file)))
|
||||
(fs/move source-file target-file)
|
||||
(set-header! target-file "dequeued_at" (timestamp))
|
||||
(print-task target-file))))))))
|
||||
|
||||
(-main)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/ready_for_next_task.bb" "$@"
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns stop-handoff-daemon
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def default-timeout-ms 5000)
|
||||
(def poll-ms 100)
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println "Usage: stop_handoff_daemon.bb <project-root>"))
|
||||
(System/exit 1))
|
||||
|
||||
(defn process-alive? [pid]
|
||||
(zero? (:exit (process/sh {:continue true} "kill" "-0" pid))))
|
||||
|
||||
(defn stop! [project-root & {:keys [timeout-ms] :or {timeout-ms default-timeout-ms}}]
|
||||
(let [daemon-dir (fs/path project-root ".swarmforge" "daemon")
|
||||
pid-file (fs/path daemon-dir "handoffd.pid")
|
||||
stop-file (fs/path daemon-dir "stop")]
|
||||
(fs/create-dirs daemon-dir)
|
||||
(when-not (fs/exists? stop-file)
|
||||
(spit (str stop-file) ""))
|
||||
(when (fs/exists? pid-file)
|
||||
(let [pid (str/trim (slurp (str pid-file)))]
|
||||
(when (re-matches #"[0-9]+" pid)
|
||||
(when (process-alive? pid)
|
||||
(process/sh {:continue true} "kill" "-TERM" pid)
|
||||
(loop [waited 0]
|
||||
(when (and (< waited timeout-ms) (process-alive? pid))
|
||||
(Thread/sleep poll-ms)
|
||||
(recur (+ waited poll-ms))))
|
||||
(when (process-alive? pid)
|
||||
(process/sh {:continue true} "kill" "-KILL" pid)
|
||||
(Thread/sleep poll-ms)))))
|
||||
(fs/delete-if-exists pid-file))
|
||||
(fs/delete-if-exists stop-file)))
|
||||
|
||||
(defn -main [& args]
|
||||
(stop! (or (first args) (usage)))
|
||||
(System/exit 0))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$@"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: swarm-cleanup.sh <tmux-socket> <window-ids-file> [session ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMUX_SOCKET="$1"
|
||||
WINDOW_IDS_FILE="$2"
|
||||
TERMINAL_BACKEND="${SWARMFORGE_TERMINAL_BACKEND:-terminal-app}"
|
||||
WORKING_DIR="$(cd "$(dirname "$WINDOW_IDS_FILE")/.." && pwd)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
shift
|
||||
shift
|
||||
|
||||
has_command() {
|
||||
command -v "$1" &>/dev/null
|
||||
}
|
||||
|
||||
source "$SCRIPT_DIR/swarm-terminal-adapter.sh"
|
||||
load_terminal_backend "$TERMINAL_BACKEND"
|
||||
|
||||
if has_command bb; then
|
||||
bb "$SCRIPT_DIR/stop_handoff_daemon.bb" "$WORKING_DIR" 2>/dev/null || true
|
||||
else
|
||||
DAEMON_PID_FILE="$WORKING_DIR/.swarmforge/daemon/handoffd.pid"
|
||||
if [[ -f "$DAEMON_PID_FILE" ]]; then
|
||||
daemon_pid="$(< "$DAEMON_PID_FILE")"
|
||||
if [[ "$daemon_pid" =~ ^[0-9]+$ ]]; then
|
||||
kill -TERM "$daemon_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$DAEMON_PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
for session in "$@"; do
|
||||
tmux -S "$TMUX_SOCKET" kill-session -t "$session" 2>/dev/null || true
|
||||
done
|
||||
|
||||
sleep 1
|
||||
|
||||
if [[ -f "$WINDOW_IDS_FILE" ]]; then
|
||||
while IFS= read -r window_id; do
|
||||
[[ -n "$window_id" ]] || continue
|
||||
terminal_close_window "$window_id"
|
||||
done < "$WINDOW_IDS_FILE"
|
||||
fi
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
TERMINAL_ADAPTERS_DIR="${SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}/terminal-adapters"
|
||||
|
||||
normalize_terminal_backend() {
|
||||
local backend="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$backend" in
|
||||
iterm|iterm2|iterm.app)
|
||||
echo "iterm2"
|
||||
;;
|
||||
terminal|terminal-app|terminal.app)
|
||||
echo "terminal-app"
|
||||
;;
|
||||
windows|windows-terminal|wt)
|
||||
echo "windows-terminal"
|
||||
;;
|
||||
none|current|fallback)
|
||||
echo "none"
|
||||
;;
|
||||
*)
|
||||
echo "$backend"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_terminal_backend() {
|
||||
if [[ -n "${SWARMFORGE_TERMINAL:-}" ]]; then
|
||||
normalize_terminal_backend "$SWARMFORGE_TERMINAL"
|
||||
return
|
||||
fi
|
||||
|
||||
if has_command osascript; then
|
||||
if [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; then
|
||||
echo "iterm2"
|
||||
return
|
||||
fi
|
||||
echo "terminal-app"
|
||||
return
|
||||
fi
|
||||
|
||||
if has_command wt.exe; then
|
||||
echo "windows-terminal"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "none"
|
||||
}
|
||||
|
||||
load_terminal_backend() {
|
||||
local backend="$1"
|
||||
local adapter_file="$TERMINAL_ADAPTERS_DIR/$backend.sh"
|
||||
|
||||
if [[ ! -r "$adapter_file" ]]; then
|
||||
echo "Unknown terminal backend '$backend'. Expected adapter file: $adapter_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
source "$adapter_file"
|
||||
}
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns swarm-window-watchdog
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def missing-threshold 3)
|
||||
|
||||
(defn sq [value]
|
||||
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
|
||||
|
||||
(defn rows [window-state-file]
|
||||
(when (fs/exists? window-state-file)
|
||||
(->> (str/split-lines (slurp (str window-state-file)))
|
||||
(remove str/blank?)
|
||||
(map #(zipmap [:index :window-id :session :title]
|
||||
(str/split % #"\t" -1)))
|
||||
vec)))
|
||||
|
||||
(defn write-rows! [window-state-file window-ids-file rows]
|
||||
(spit (str window-state-file)
|
||||
(apply str
|
||||
(for [{:keys [index window-id session title]} rows]
|
||||
(format "%s\t%s\t%s\t%s\n" index window-id session title))))
|
||||
(spit (str window-ids-file)
|
||||
(apply str (for [{:keys [window-id]} rows] (str window-id "\n")))))
|
||||
|
||||
(defn rewrite-window-id! [window-state-file window-ids-file target-index replacement-id]
|
||||
(write-rows! window-state-file
|
||||
window-ids-file
|
||||
(mapv #(if (= (:index %) target-index)
|
||||
(assoc % :window-id replacement-id)
|
||||
%)
|
||||
(rows window-state-file))))
|
||||
|
||||
(defn adapter-script [script-dir working-dir tmux-socket backend command & args]
|
||||
(let [script (str "SCRIPT_DIR=" (sq (str script-dir)) "\n"
|
||||
"WORKING_DIR=" (sq (str working-dir)) "\n"
|
||||
"TMUX_SOCKET=" (sq tmux-socket) "\n"
|
||||
"source " (sq (str (fs/path script-dir "swarm-terminal-adapter.sh")))
|
||||
" && load_terminal_backend " (sq backend)
|
||||
" && " command
|
||||
(apply str (map #(str " " (sq %)) args)))]
|
||||
["bash" "-c" script]))
|
||||
|
||||
(defn terminal-ok? [script-dir working-dir tmux-socket backend command & args]
|
||||
(zero? (:exit (apply process/sh (concat [{:continue true}]
|
||||
(apply adapter-script script-dir working-dir tmux-socket backend command args))))))
|
||||
|
||||
(defn terminal-out [script-dir working-dir tmux-socket backend command & args]
|
||||
(str/trim (:out (apply process/sh (apply adapter-script script-dir working-dir tmux-socket backend command args)))))
|
||||
|
||||
(defn tmux-session? [tmux-socket session]
|
||||
(zero? (:exit (process/sh {:continue true} "tmux" "-S" tmux-socket "has-session" "-t" session))))
|
||||
|
||||
(defn kill-session! [tmux-socket session]
|
||||
(process/sh {:continue true} "tmux" "-S" tmux-socket "kill-session" "-t" session))
|
||||
|
||||
(defn stop-handoff-daemon! [script-dir working-dir]
|
||||
(process/sh {:continue true}
|
||||
"bb" (str (fs/path script-dir "stop_handoff_daemon.bb"))
|
||||
(str working-dir)))
|
||||
|
||||
(defn kill-all-sessions! [script-dir window-state-file working-dir tmux-socket backend]
|
||||
(stop-handoff-daemon! script-dir working-dir)
|
||||
(doseq [{:keys [session]} (rows window-state-file)]
|
||||
(when-not (str/blank? session)
|
||||
(kill-session! tmux-socket session)))
|
||||
(doseq [{:keys [window-id]} (rows window-state-file)]
|
||||
(when-not (str/blank? window-id)
|
||||
(terminal-ok? script-dir working-dir tmux-socket backend "terminal_close_window" window-id))))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [[window-state-file window-ids-file cleanup-owner-index tmux-socket working-dir backend] args
|
||||
window-state-file (fs/path window-state-file)
|
||||
window-ids-file (fs/path window-ids-file)
|
||||
backend (or backend "terminal-app")
|
||||
script-dir (fs/parent *file*)]
|
||||
(when (= "--rewrite-window-id" (first args))
|
||||
(let [[_ state ids target replacement] args]
|
||||
(rewrite-window-id! (fs/path state) (fs/path ids) target replacement)
|
||||
(System/exit 0)))
|
||||
(loop [missing-counts {}]
|
||||
(when (fs/exists? window-state-file)
|
||||
(let [current-rows (rows window-state-file)
|
||||
cleanup-row (some #(when (= cleanup-owner-index (:index %)) %) current-rows)]
|
||||
(when (and cleanup-row (tmux-session? tmux-socket (:session cleanup-row)))
|
||||
(let [cleanup-window-id (:window-id cleanup-row)]
|
||||
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" cleanup-window-id)
|
||||
(let [missing-counts (assoc missing-counts cleanup-owner-index 0)
|
||||
missing-counts
|
||||
(reduce
|
||||
(fn [counts {:keys [index window-id session title]}]
|
||||
(if (or (= index cleanup-owner-index)
|
||||
(not (tmux-session? tmux-socket session)))
|
||||
counts
|
||||
(if (terminal-ok? script-dir working-dir tmux-socket backend "terminal_window_exists" window-id)
|
||||
(assoc counts index 0)
|
||||
(let [count (inc (get counts index 0))]
|
||||
(if (< count missing-threshold)
|
||||
(assoc counts index count)
|
||||
(let [new-window-id (terminal-out script-dir working-dir tmux-socket backend
|
||||
"terminal_open_session" session title cleanup-window-id)]
|
||||
(when-not (str/blank? new-window-id)
|
||||
(rewrite-window-id! window-state-file window-ids-file index new-window-id))
|
||||
(assoc counts index 0)))))))
|
||||
missing-counts
|
||||
current-rows)]
|
||||
(Thread/sleep 2000)
|
||||
(recur missing-counts))
|
||||
(let [count (inc (get missing-counts cleanup-owner-index 0))]
|
||||
(if (>= count missing-threshold)
|
||||
(kill-all-sessions! script-dir window-state-file working-dir tmux-socket backend)
|
||||
(do
|
||||
(Thread/sleep 2000)
|
||||
(recur (assoc missing-counts cleanup-owner-index count)))))))))))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarm-window-watchdog.bb" "$@"
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns swarm-handoff
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.shell :refer [sh]]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def usage-text
|
||||
(str "Usage: swarm_handoff.sh <draft-file>\n\n"
|
||||
"Draft formats:\n\n"
|
||||
"type: git_handoff\n"
|
||||
"to: <role>[,<role>...]\n"
|
||||
"priority: NN\n"
|
||||
"task: <short-stable-task-name>\n"
|
||||
"commit: <10-char-commit-abbrev>\n\n"
|
||||
"type: note\n"
|
||||
"to: <role>[,<role>...]\n"
|
||||
"priority: NN\n"
|
||||
"message: <one line, max 80 chars>"))
|
||||
|
||||
(def reserved-fields #{"id" "from" "role" "recipient" "created_at" "enqueued_at" "dequeued_at" "completed_at"})
|
||||
(def allowed-fields #{"type" "to" "priority" "task" "commit" "message"})
|
||||
(def allowed-types #{"git_handoff" "note"})
|
||||
|
||||
(defn usage []
|
||||
(binding [*out* *err*]
|
||||
(println usage-text)))
|
||||
|
||||
(defn exit! [status message]
|
||||
(binding [*out* *err*]
|
||||
(when message
|
||||
(println message)))
|
||||
(System/exit status))
|
||||
|
||||
(defn command
|
||||
([dir & args]
|
||||
(let [result (apply sh (concat args [:dir (str dir)]))]
|
||||
result)))
|
||||
|
||||
(defn git-root []
|
||||
(let [result (command "." "git" "rev-parse" "--show-toplevel")]
|
||||
(when (zero? (:exit result))
|
||||
(str/trim (:out result)))))
|
||||
|
||||
(defn git-common-dir []
|
||||
(let [result (command "." "git" "rev-parse" "--git-common-dir")]
|
||||
(when (zero? (:exit result))
|
||||
(let [path (str/trim (:out result))]
|
||||
(if (fs/absolute? path)
|
||||
(str (fs/path path))
|
||||
(str (fs/absolutize path)))))))
|
||||
|
||||
(defn project-root []
|
||||
(if-let [root (git-root)]
|
||||
(if (fs/exists? (fs/path root ".swarmforge" "roles.tsv"))
|
||||
root
|
||||
(if-let [common (git-common-dir)]
|
||||
(let [candidate (str (fs/parent common))]
|
||||
(if (fs/exists? (fs/path candidate ".swarmforge" "roles.tsv"))
|
||||
candidate
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
(exit! 1 "Cannot find SwarmForge project root")))
|
||||
|
||||
(defn roles-file []
|
||||
(fs/path (project-root) ".swarmforge" "roles.tsv"))
|
||||
|
||||
(defn role-known? [role]
|
||||
(some (fn [line]
|
||||
(= role (first (str/split line #"\t"))))
|
||||
(str/split-lines (slurp (str (roles-file))))))
|
||||
|
||||
(defn sender-role []
|
||||
(if-let [role (not-empty (System/getenv "SWARMFORGE_ROLE"))]
|
||||
role
|
||||
(exit! 1 "Set SWARMFORGE_ROLE.")))
|
||||
|
||||
(defn state-dir []
|
||||
(fs/path (System/getProperty "user.dir") ".swarmforge" "handoffs"))
|
||||
|
||||
(defn timestamp []
|
||||
(.format java.time.format.DateTimeFormatter/ISO_INSTANT
|
||||
(java.time.Instant/now)))
|
||||
|
||||
(defn id-timestamp []
|
||||
(.format (java.time.format.DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'")
|
||||
(.atZone (java.time.Instant/now) java.time.ZoneOffset/UTC)))
|
||||
|
||||
(defn valid-priority? [priority]
|
||||
(boolean (re-matches #"[0-9][0-9]" priority)))
|
||||
|
||||
(defn parse-draft [draft]
|
||||
(loop [lines (str/split-lines (slurp (str draft)))
|
||||
line-no 0
|
||||
body-seen? false
|
||||
headers {}
|
||||
ordered []
|
||||
errors []]
|
||||
(if-let [line (first lines)]
|
||||
(let [line-no (inc line-no)]
|
||||
(cond
|
||||
body-seen?
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(cond-> errors
|
||||
(not (str/blank? line))
|
||||
(conj (format "Line %d: draft handoffs may contain headers only; payloads are generated by swarm_handoff.sh." line-no))))
|
||||
|
||||
(str/blank? line)
|
||||
(recur (next lines) line-no true headers ordered errors)
|
||||
|
||||
(not (str/includes? line ": "))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: expected 'field: value'." line-no)))
|
||||
|
||||
:else
|
||||
(let [[field value] (str/split line #": " 2)]
|
||||
(cond
|
||||
(or (str/blank? field) (str/blank? value))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: field and value must both be non-empty." line-no)))
|
||||
|
||||
(reserved-fields field)
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: header '%s' is reserved and must not be written by agents." line-no field)))
|
||||
|
||||
(not (allowed-fields field))
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: unknown header '%s'." line-no field)))
|
||||
|
||||
(contains? headers field)
|
||||
(recur (next lines) line-no body-seen? headers ordered
|
||||
(conj errors (format "Line %d: duplicate header '%s'." line-no field)))
|
||||
|
||||
:else
|
||||
(recur (next lines) line-no body-seen? (assoc headers field value) (conj ordered field) errors)))))
|
||||
{:headers headers :ordered ordered :errors errors})))
|
||||
|
||||
(defn validate-recipients [to]
|
||||
(if (str/blank? to)
|
||||
[[] []]
|
||||
(let [recipients (str/split to #"," -1)]
|
||||
[recipients
|
||||
(loop [remaining recipients seen #{} errors []]
|
||||
(if-let [recipient (first remaining)]
|
||||
(let [errors (cond-> errors
|
||||
(str/blank? recipient)
|
||||
(conj "Header 'to' contains an empty recipient.")
|
||||
(str/includes? recipient "_")
|
||||
(conj (format "Recipient role '%s' is invalid; role names may not contain underscores." recipient))
|
||||
(contains? seen recipient)
|
||||
(conj (format "Duplicate recipient '%s'." recipient))
|
||||
(and (not (str/blank? recipient)) (not (role-known? recipient)))
|
||||
(conj (format "Unknown recipient role '%s'." recipient)))]
|
||||
(recur (next remaining) (conj seen recipient) errors))
|
||||
errors))])))
|
||||
|
||||
(defn canonical-commit [commit]
|
||||
(let [matches (-> (command "." "git" "rev-parse" (str "--disambiguate=" commit))
|
||||
:out
|
||||
str/split-lines
|
||||
vec)]
|
||||
(cond
|
||||
(not= 1 (count matches))
|
||||
[nil (format "Header 'commit' must resolve to exactly one Git object; '%s' matched %d." commit (count matches))]
|
||||
|
||||
:else
|
||||
(let [object (first matches)
|
||||
object-type (str/trim (:out (command "." "git" "cat-file" "-t" object)))]
|
||||
(if (= "commit" object-type)
|
||||
[(str/trim (:out (command "." "git" "rev-parse" "--short=10" object))) nil]
|
||||
[nil (format "Header 'commit' must resolve to a commit; '%s' resolves to '%s'." commit object-type)])))))
|
||||
|
||||
(defn validate [headers ordered]
|
||||
(let [type (get headers "type")
|
||||
to (get headers "to")
|
||||
priority (get headers "priority")
|
||||
commit (get headers "commit")
|
||||
task-name (get headers "task")
|
||||
note-message (get headers "message")
|
||||
[recipients recipient-errors] (validate-recipients to)
|
||||
field-errors (for [field ordered
|
||||
:let [valid? (case [type field]
|
||||
["git_handoff" "type"] true
|
||||
["git_handoff" "to"] true
|
||||
["git_handoff" "priority"] true
|
||||
["git_handoff" "task"] true
|
||||
["git_handoff" "commit"] true
|
||||
["note" "type"] true
|
||||
["note" "to"] true
|
||||
["note" "priority"] true
|
||||
["note" "message"] true
|
||||
false)]
|
||||
:when (and type (not valid?))]
|
||||
(format "Header '%s' is not allowed for type '%s'." field type))
|
||||
base-errors (cond-> []
|
||||
(str/blank? type) (conj "Missing required header 'type'.")
|
||||
(str/blank? to) (conj "Missing required header 'to'.")
|
||||
(str/blank? priority) (conj "Missing required header 'priority'.")
|
||||
(and (not (str/blank? type)) (not (allowed-types type)))
|
||||
(conj (format "Header 'type' must be one of git_handoff or note; got '%s'." type))
|
||||
(and (not (str/blank? priority)) (not (valid-priority? priority)))
|
||||
(conj (format "Header 'priority' must be two digits from 00 to 99; got '%s'." priority)))
|
||||
[canonical commit-error]
|
||||
(if (= "git_handoff" type)
|
||||
(cond
|
||||
(str/blank? commit) [nil "Missing required header 'commit' for git_handoff."]
|
||||
(not (re-matches #"[0-9a-fA-F]{10}" commit))
|
||||
[nil (format "Header 'commit' must be exactly 10 hexadecimal characters; got '%s'." commit)]
|
||||
:else (canonical-commit commit))
|
||||
[nil nil])
|
||||
git-errors (cond-> []
|
||||
(= "git_handoff" type)
|
||||
(into (cond-> []
|
||||
(str/blank? task-name)
|
||||
(conj "Missing required header 'task' for git_handoff.")
|
||||
(> (count (or task-name "")) 80)
|
||||
(conj (format "Header 'task' must be no longer than 80 characters; got %d." (count task-name)))))
|
||||
(and (not= "git_handoff" type) (not (str/blank? commit)))
|
||||
(conj "Header 'commit' is only allowed for git_handoff.")
|
||||
(and (not= "git_handoff" type) (not (str/blank? task-name)))
|
||||
(conj "Header 'task' is only allowed for git_handoff.")
|
||||
commit-error
|
||||
(conj commit-error))
|
||||
note-errors (cond-> []
|
||||
(= "note" type)
|
||||
(into (cond-> []
|
||||
(str/blank? note-message)
|
||||
(conj "Missing required header 'message' for note.")
|
||||
(> (count (or note-message "")) 80)
|
||||
(conj (format "Header 'message' must be no longer than 80 characters; got %d." (count note-message)))))
|
||||
(and (not= "note" type) (not (str/blank? note-message)))
|
||||
(conj "Header 'message' is only allowed for note."))]
|
||||
{:recipients recipients
|
||||
:canonical-commit canonical
|
||||
:errors (vec (concat base-errors recipient-errors field-errors git-errors note-errors))}))
|
||||
|
||||
(defn next-sequence []
|
||||
(let [dir (state-dir)
|
||||
seq-file (fs/path dir "sequence")
|
||||
lock-dir (fs/path dir "sequence.lock")]
|
||||
(fs/create-dirs dir)
|
||||
(loop []
|
||||
(if (try
|
||||
(fs/create-dir lock-dir)
|
||||
true
|
||||
(catch java.nio.file.FileAlreadyExistsException _
|
||||
false))
|
||||
nil
|
||||
(do
|
||||
(Thread/sleep 50)
|
||||
(recur))))
|
||||
(try
|
||||
(let [last-value (if (fs/exists? seq-file)
|
||||
(try
|
||||
(Long/parseLong (str/trim (slurp (str seq-file))))
|
||||
(catch Exception _ 0))
|
||||
0)
|
||||
next-value (inc last-value)
|
||||
formatted (format "%06d" next-value)]
|
||||
(spit (str seq-file) (str formatted "\n"))
|
||||
formatted)
|
||||
(finally
|
||||
(fs/delete lock-dir)))))
|
||||
|
||||
(defn body [type sender canonical-commit note-message]
|
||||
(case type
|
||||
"git_handoff" (str "Re-read your role and constitution.\n\nmerge_and_process " sender " " canonical-commit)
|
||||
"note" (str "Re-read your role and constitution.\n\n" note-message)))
|
||||
|
||||
(defn write-handoff! [{:keys [headers recipients canonical-commit sender]}]
|
||||
(let [timestamp-id (id-timestamp)
|
||||
created-at (timestamp)
|
||||
sequence (next-sequence)
|
||||
id (str timestamp-id "_" sequence "_from_" sender)
|
||||
recipient-slug (str/join "_" recipients)
|
||||
priority (get headers "priority")
|
||||
type (get headers "type")
|
||||
filename (str priority "_" timestamp-id "_" sequence "_from_" sender "_to_" recipient-slug ".handoff")
|
||||
outbox-dir (fs/path (state-dir) "outbox")
|
||||
tmp-dir (fs/path outbox-dir "tmp")
|
||||
tmp-file (fs/path tmp-dir (str filename ".tmp"))
|
||||
outbox-file (fs/path outbox-dir filename)
|
||||
handoff-body (body type sender canonical-commit (get headers "message"))
|
||||
lines (cond-> [(str "id: " id)
|
||||
(str "from: " sender)
|
||||
(str "to: " (str/join "," recipients))
|
||||
(str "priority: " priority)
|
||||
(str "type: " type)]
|
||||
(= "git_handoff" type)
|
||||
(conj (str "role: " sender)
|
||||
(str "task: " (get headers "task"))
|
||||
(str "commit: " canonical-commit))
|
||||
(= "note" type)
|
||||
(conj (str "message: " (get headers "message")))
|
||||
true
|
||||
(conj (str "created_at: " created-at)
|
||||
""
|
||||
handoff-body))]
|
||||
(doseq [dir [tmp-dir outbox-dir (fs/path (state-dir) "sent") (fs/path (state-dir) "failed")]]
|
||||
(fs/create-dirs dir))
|
||||
(spit (str tmp-file) (str (str/join "\n" lines) "\n"))
|
||||
(fs/move tmp-file outbox-file)
|
||||
outbox-file))
|
||||
|
||||
(defn error-report [draft errors]
|
||||
(binding [*out* *err*]
|
||||
(println "HANDOFF INVALID:" (str draft))
|
||||
(println)
|
||||
(println "Errors:")
|
||||
(doseq [error errors]
|
||||
(println "-" error))
|
||||
(println)
|
||||
(println usage-text)))
|
||||
|
||||
(defn -main [& args]
|
||||
(when (not= 1 (count args))
|
||||
(usage)
|
||||
(System/exit 1))
|
||||
(let [draft (fs/path (first args))]
|
||||
(when-not (fs/regular-file? draft)
|
||||
(exit! 1 (str "Draft file not found: " draft)))
|
||||
(let [sender (sender-role)]
|
||||
(when-not (role-known? sender)
|
||||
(exit! 1 (str "Unknown sender role: " sender)))
|
||||
(let [{:keys [headers ordered errors]} (parse-draft draft)
|
||||
validation (validate headers ordered)
|
||||
all-errors (vec (concat errors (:errors validation)))]
|
||||
(when (seq all-errors)
|
||||
(error-report draft all-errors)
|
||||
(System/exit 2))
|
||||
(let [outbox-file (write-handoff! {:headers headers
|
||||
:recipients (:recipients validation)
|
||||
:canonical-commit (:canonical-commit validation)
|
||||
:sender sender})]
|
||||
(fs/delete draft)
|
||||
(println "HANDOFF QUEUED:" (str outbox-file)))))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarm_handoff.bb" "$@"
|
||||
Executable
+603
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns swarmforge
|
||||
(:require [babashka.fs :as fs]
|
||||
[babashka.process :as process]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def session-prefix "swarmforge")
|
||||
(def agent-window "swarm")
|
||||
(def red "\u001b[0;31m")
|
||||
(def green "\u001b[0;32m")
|
||||
(def yellow "\u001b[1;33m")
|
||||
(def cyan "\u001b[0;36m")
|
||||
(def bold "\u001b[1m")
|
||||
(def reset "\u001b[0m")
|
||||
|
||||
(defn sh [& args]
|
||||
(apply process/sh args))
|
||||
|
||||
(defn sh-ok? [& args]
|
||||
(zero? (:exit (apply process/sh (concat [{:continue true}] args)))))
|
||||
|
||||
(defn sh-out [& args]
|
||||
(str/trim (:out (apply process/sh args))))
|
||||
|
||||
(defn command-exists? [command]
|
||||
(sh-ok? "sh" "-c" (str "command -v " command " >/dev/null 2>&1")))
|
||||
|
||||
(defn env-long [name default-value]
|
||||
(if-let [value (System/getenv name)]
|
||||
(if (re-matches #"[0-9]+" value)
|
||||
(Long/parseLong value)
|
||||
default-value)
|
||||
default-value))
|
||||
|
||||
(defn fail! [message]
|
||||
(binding [*out* *err*]
|
||||
(println message))
|
||||
(System/exit 1))
|
||||
|
||||
(defn sq [value]
|
||||
(str "'" (str/replace (str value) #"'" "'\"'\"'") "'"))
|
||||
|
||||
(defn normalize-terminal-backend [backend]
|
||||
(case (str/lower-case backend)
|
||||
("iterm" "iterm2" "iterm.app") "iterm2"
|
||||
("terminal" "terminal-app" "terminal.app") "terminal-app"
|
||||
("windows" "windows-terminal" "wt") "windows-terminal"
|
||||
("none" "current" "fallback") "none"
|
||||
(str/lower-case backend)))
|
||||
|
||||
(defn detect-terminal-backend []
|
||||
(if-let [backend (System/getenv "SWARMFORGE_TERMINAL")]
|
||||
(normalize-terminal-backend backend)
|
||||
(cond
|
||||
(command-exists? "osascript") (if (= (System/getenv "TERM_PROGRAM") "iTerm.app")
|
||||
"iterm2"
|
||||
"terminal-app")
|
||||
(command-exists? "wt.exe") "windows-terminal"
|
||||
:else "none")))
|
||||
|
||||
(defn display-name-for-role [role]
|
||||
(->> (str/split (str/replace role #"[-_]" " ") #"\s+")
|
||||
(remove str/blank?)
|
||||
(map str/capitalize)
|
||||
(str/join " ")))
|
||||
|
||||
(defn session-name-for-role [role]
|
||||
(str session-prefix "-" role))
|
||||
|
||||
(defn worktree-path-for-name [worktrees-dir worktree]
|
||||
(fs/path worktrees-dir worktree))
|
||||
|
||||
(defn tmux-agent-target [window pane-base-index session]
|
||||
(str session ":" window "." pane-base-index))
|
||||
|
||||
(defn tmux-option [tmux-socket option scope default-value]
|
||||
(let [args (case scope
|
||||
:session ["tmux" "-S" tmux-socket "show-options" "-gqv" option]
|
||||
:window ["tmux" "-S" tmux-socket "show-options" "-gwqv" option])
|
||||
result (apply process/sh (concat [{:continue true}] args))
|
||||
value (str/trim (:out result))]
|
||||
(if (re-matches #"[0-9]+" value)
|
||||
(Long/parseLong value)
|
||||
default-value)))
|
||||
|
||||
(defn detect-tmux-base-indexes [ctx]
|
||||
(fs/create-dirs (:tmux-socket-dir ctx))
|
||||
(let [probe-session (when-not (sh-ok? "tmux" "-S" (:tmux-socket ctx) "info")
|
||||
(let [session (str "swarmforge-probe-" (.pid (java.lang.ProcessHandle/current)))]
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "sleep 60")
|
||||
session))
|
||||
window-base (tmux-option (:tmux-socket ctx) "base-index" :session 0)
|
||||
pane-base (tmux-option (:tmux-socket ctx) "pane-base-index" :window 0)]
|
||||
(when probe-session
|
||||
(process/sh {:continue true} "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" probe-session))
|
||||
(assoc ctx :tmux-window-base-index window-base :tmux-pane-base-index pane-base)))
|
||||
|
||||
(defn ensure-in-file! [file pattern]
|
||||
(fs/create-dirs (fs/parent file))
|
||||
(when-not (fs/exists? file)
|
||||
(spit (str file) ""))
|
||||
(let [lines (set (str/split-lines (slurp (str file))))]
|
||||
(when-not (contains? lines pattern)
|
||||
(spit (str file) (str pattern "\n") :append true))))
|
||||
|
||||
(defn ensure-initial-gitignore! [ctx]
|
||||
(let [gitignore (fs/path (:working-dir ctx) ".gitignore")]
|
||||
(if-not (fs/exists? gitignore)
|
||||
(spit (str gitignore) ".swarmforge/\n.worktrees/\n")
|
||||
(do
|
||||
(ensure-in-file! gitignore ".swarmforge/")
|
||||
(ensure-in-file! gitignore ".worktrees/")))))
|
||||
|
||||
(defn ensure-runtime-git-excludes! [ctx]
|
||||
(let [exclude-file (fs/path (sh-out "git" "-C" (str (:working-dir ctx)) "rev-parse" "--git-path" "info/exclude"))]
|
||||
(fs/create-dirs (fs/parent exclude-file))
|
||||
(ensure-in-file! exclude-file ".swarmforge/")
|
||||
(ensure-in-file! exclude-file ".worktrees/")))
|
||||
|
||||
(defn git-identity-ok? [dir]
|
||||
(and (sh-ok? "git" "-C" dir "config" "user.name")
|
||||
(sh-ok? "git" "-C" dir "config" "user.email")))
|
||||
|
||||
(defn initialize-git-repo! [ctx]
|
||||
(when-not (fs/exists? (fs/path (:working-dir ctx) ".git"))
|
||||
(let [dir (str (:working-dir ctx))]
|
||||
(when-not (git-identity-ok? dir)
|
||||
(fail! (str red "Error:" reset " Git identity not configured. Run:"
|
||||
"\n git config --global user.name \"Your Name\""
|
||||
"\n git config --global user.email \"you@example.com\"")))
|
||||
(sh "git" "init" dir)
|
||||
(sh "git" "-C" dir "branch" "-M" "master")
|
||||
(ensure-initial-gitignore! ctx)
|
||||
(sh "git" "-C" dir "add" ".")
|
||||
(sh "git" "-C" dir "commit" "-m" "Initial swarmforge repository"))))
|
||||
|
||||
(defn parse-config [ctx]
|
||||
(when-not (fs/exists? (:config-file ctx))
|
||||
(fail! (str red "Error:" reset " Config not found at " (:config-file ctx))))
|
||||
(when-not (fs/exists? (:constitution-file ctx))
|
||||
(fail! (str red "Error:" reset " Constitution prompt not found at " (:constitution-file ctx))))
|
||||
(let [roles-dir (:roles-dir ctx)
|
||||
worktrees-dir (:worktrees-dir ctx)
|
||||
working-dir (:working-dir ctx)]
|
||||
(loop [lines (map-indexed vector (str/split-lines (slurp (str (:config-file ctx)))))
|
||||
rows []
|
||||
roles #{}
|
||||
worktrees #{}]
|
||||
(if-let [[line-index raw-line] (first lines)]
|
||||
(let [line-no (inc line-index)
|
||||
line (str/trim raw-line)]
|
||||
(if (or (str/blank? line) (str/starts-with? line "#"))
|
||||
(recur (next lines) rows roles worktrees)
|
||||
(let [fields (str/split line #"\s+")]
|
||||
(when (< (count fields) 4)
|
||||
(fail! (str red "Error:" reset " Invalid config line " line-no ": " line)))
|
||||
(let [[keyword role agent worktree & trailing] fields
|
||||
agent (str/lower-case agent)
|
||||
receive-mode (if (#{"task" "batch"} (first trailing))
|
||||
(first trailing)
|
||||
"task")
|
||||
extra-arg-tokens (if (#{"task" "batch"} (first trailing))
|
||||
(rest trailing)
|
||||
trailing)
|
||||
extra-args (when (seq extra-arg-tokens)
|
||||
(str/join " " extra-arg-tokens))]
|
||||
(when-not (= "window" keyword)
|
||||
(fail! (str red "Error:" reset " Unknown config directive on line " line-no ": " keyword)))
|
||||
(when (str/includes? role "_")
|
||||
(fail! (str red "Error:" reset " Invalid role '" role "' on line " line-no ": role names may not contain underscores")))
|
||||
(when (contains? roles role)
|
||||
(fail! (str red "Error:" reset " Duplicate role '" role "' in " (:config-file ctx))))
|
||||
(when (and (not (#{"none" "master"} worktree)) (contains? worktrees worktree))
|
||||
(fail! (str red "Error:" reset " Duplicate worktree '" worktree "' in " (:config-file ctx))))
|
||||
(when (or (str/includes? worktree "/") (#{"." ".."} worktree))
|
||||
(fail! (str red "Error:" reset " Invalid worktree '" worktree "' for role '" role "'")))
|
||||
(when-not (#{"claude" "codex" "copilot" "grok" "pi"} agent)
|
||||
(fail! (str red "Error:" reset " Unsupported agent '" agent "' for role '" role "'")))
|
||||
(when-not (#{"task" "batch"} receive-mode)
|
||||
(fail! (str red "Error:" reset " Invalid receive mode '" receive-mode "' for role '" role "' on line " line-no ": expected task or batch")))
|
||||
(when-not (fs/exists? (fs/path roles-dir (str role ".prompt")))
|
||||
(fail! (str red "Error:" reset " Missing role prompt " (fs/path roles-dir (str role ".prompt")))))
|
||||
(let [worktree-path (if (#{"none" "master"} worktree)
|
||||
working-dir
|
||||
(worktree-path-for-name worktrees-dir worktree))
|
||||
row {:role role
|
||||
:agent agent
|
||||
:session (session-name-for-role role)
|
||||
:display-name (display-name-for-role role)
|
||||
:worktree-name worktree
|
||||
:worktree-path worktree-path
|
||||
:receive-mode receive-mode
|
||||
:extra-args extra-args}]
|
||||
(recur (next lines)
|
||||
(conj rows row)
|
||||
(conj roles role)
|
||||
(cond-> worktrees (not (#{"none" "master"} worktree)) (conj worktree))))))))
|
||||
(do
|
||||
(when (empty? rows)
|
||||
(fail! (str red "Error:" reset " No windows defined in " (:config-file ctx))))
|
||||
(assoc ctx :roles rows))))))
|
||||
|
||||
(defn write-sessions-file! [ctx]
|
||||
(spit (str (:sessions-file ctx))
|
||||
(apply str
|
||||
(map-indexed
|
||||
(fn [index row]
|
||||
(format "%d\t%s\t%s\t%s\t%s\n"
|
||||
(inc index) (:role row) (:session row) (:display-name row) (:agent row)))
|
||||
(:roles ctx)))))
|
||||
|
||||
(defn write-roles-file! [ctx]
|
||||
(spit (str (:roles-file ctx))
|
||||
(apply str
|
||||
(for [row (:roles ctx)]
|
||||
(format "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
|
||||
(:role row)
|
||||
(:worktree-name row)
|
||||
(:worktree-path row)
|
||||
(:session row)
|
||||
(:display-name row)
|
||||
(:agent row)
|
||||
(:receive-mode row))))))
|
||||
|
||||
(def required-helpers
|
||||
["handoff_lib.bb" "swarm_handoff.sh" "swarm_handoff.bb"
|
||||
"ready_for_next.sh" "ready_for_next.bb"
|
||||
"done_with_current.sh" "done_with_current.bb"
|
||||
"ready_for_next_task.sh" "ready_for_next_task.bb"
|
||||
"done_with_current_task.sh" "done_with_current_task.bb"
|
||||
"ready_for_next_batch.sh" "ready_for_next_batch.bb"
|
||||
"done_with_current_batch.sh" "done_with_current_batch.bb"
|
||||
"handoffd.bb" "stop_handoff_daemon.bb" "stop_handoff_daemon.sh"
|
||||
"swarm-cleanup.sh" "swarm-window-watchdog.sh" "swarm-window-watchdog.bb"
|
||||
"swarm-terminal-adapter.sh" "swarmforge.sh" "swarmforge.bb"])
|
||||
|
||||
(def terminal-helpers
|
||||
["terminal-app.sh" "iterm2.sh" "ghostty.sh" "windows-terminal.sh" "none.sh"])
|
||||
|
||||
(defn check-helper-scripts! [ctx]
|
||||
(doseq [helper required-helpers]
|
||||
(let [path (fs/path (:script-dir ctx) helper)]
|
||||
(when-not (and (fs/exists? path) (fs/executable? path))
|
||||
(fail! (str red "Error:" reset " Required helper script not found or not executable: " path)))))
|
||||
(doseq [helper terminal-helpers]
|
||||
(let [path (fs/path (:script-dir ctx) "terminal-adapters" helper)]
|
||||
(when-not (and (fs/exists? path) (fs/executable? path))
|
||||
(fail! (str red "Error:" reset " Required terminal adapter not found or not executable: " path))))))
|
||||
|
||||
(defn prepare-workspace! [ctx]
|
||||
(doseq [dir [(:state-dir ctx) (:notify-dir ctx) (:prompts-dir ctx)
|
||||
(:worktrees-dir ctx) (:tmux-socket-dir ctx) (:daemon-dir ctx)]]
|
||||
(fs/create-dirs dir))
|
||||
(spit (str (:tmux-socket-file ctx)) (str (:tmux-socket ctx) "\n"))
|
||||
(check-helper-scripts! ctx)
|
||||
(write-sessions-file! ctx)
|
||||
(write-roles-file! ctx))
|
||||
|
||||
(defn prepare-worktrees! [ctx]
|
||||
(doseq [row (:roles ctx)
|
||||
:let [worktree-name (:worktree-name row)
|
||||
worktree-path (:worktree-path row)
|
||||
branch-name (str "swarmforge-" worktree-name)]
|
||||
:when (not (#{"none" "master"} worktree-name))]
|
||||
(when-not (or (fs/exists? (fs/path worktree-path ".git"))
|
||||
(fs/directory? (fs/path worktree-path ".git")))
|
||||
(when-not (sh-ok? "git" "-C" (str (:working-dir ctx))
|
||||
"worktree" "add" "--force" "-B" branch-name (str worktree-path) "HEAD")
|
||||
(fail! (str red "Error:" reset " Failed to create worktree '" worktree-name "'"))))))
|
||||
|
||||
(defn prepare-handoff-dirs! [ctx]
|
||||
(doseq [row (:roles ctx)
|
||||
dir ["outbox/tmp" "sent" "failed" "inbox/new" "inbox/in_process" "inbox/completed"]]
|
||||
(fs/create-dirs (fs/path (:worktree-path row) ".swarmforge" "handoffs" dir))))
|
||||
|
||||
(defn write-tmux-env-file! [ctx]
|
||||
(spit (str (:tmux-env-file ctx))
|
||||
(str (sh-out "tmux" "-S" (:tmux-socket ctx) "display-message" "-p" "#{socket_path},#{pid},#{pane_id}") "\n")))
|
||||
|
||||
(defn sync-worktree-scripts! [ctx]
|
||||
(doseq [row (:roles ctx)
|
||||
:let [worktree-path (:worktree-path row)]
|
||||
:when (not= (str worktree-path) (str (:working-dir ctx)))]
|
||||
(let [role-scripts-dir (fs/path worktree-path "swarmforge" "scripts")
|
||||
role-state-dir (fs/path worktree-path ".swarmforge")]
|
||||
(fs/create-dirs role-scripts-dir)
|
||||
(doseq [entry (fs/list-dir (:script-dir ctx))]
|
||||
(let [target (fs/path role-scripts-dir (fs/file-name entry))]
|
||||
(if (fs/directory? entry)
|
||||
(fs/copy-tree entry target {:replace-existing true})
|
||||
(fs/copy entry target {:replace-existing true}))))
|
||||
(fs/create-dirs (fs/path role-state-dir "notify"))
|
||||
(fs/copy (:sessions-file ctx) (fs/path role-state-dir "sessions.tsv") {:replace-existing true})
|
||||
(fs/copy (:roles-file ctx) (fs/path role-state-dir "roles.tsv") {:replace-existing true})
|
||||
(fs/copy (:tmux-socket-file ctx) (fs/path role-state-dir "tmux-socket") {:replace-existing true})
|
||||
(fs/copy (:tmux-env-file ctx) (fs/path role-state-dir "tmux-env") {:replace-existing true}))))
|
||||
|
||||
(defn check-dependency! [command]
|
||||
(when-not (command-exists? command)
|
||||
(fail! (str red "Error:" reset " '" command "' is required but not installed."))))
|
||||
|
||||
(defn check-backend-dependencies! [ctx]
|
||||
(doseq [agent (map :agent (:roles ctx))]
|
||||
(check-dependency! agent)))
|
||||
|
||||
(defn create-role-session! [ctx session title]
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "new-session" "-d" "-s" session "-n" agent-window)
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "rename-window" "-t" (str session ":" agent-window) title)
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "set-window-option" "-t" (str session ":" title) "allow-rename" "off"))
|
||||
|
||||
(defn write-agent-instruction-file! [role prompt-file]
|
||||
(spit (str prompt-file)
|
||||
(str "Read swarmforge/constitution.prompt, then read every file it refers to recursively, and obey all of those instructions.\n"
|
||||
"Read swarmforge/roles/" role ".prompt, then read every file it refers to recursively, and follow all of those instructions.\n")))
|
||||
|
||||
(defn extra-args-prefix [row]
|
||||
(let [args (:extra-args row)]
|
||||
(if (str/blank? args) "" (str args " "))))
|
||||
|
||||
(defn grok-wants-auto-approve? [row]
|
||||
(when-let [args (:extra-args row)]
|
||||
(or (str/includes? args "--always-approve")
|
||||
(str/includes? args "--yolo")
|
||||
(re-find #"--permission-mode\s+bypassPermissions" args))))
|
||||
|
||||
(defn grok-permission-prefix [row]
|
||||
;; acceptEdits only auto-approves file edits; bypassPermissions is the
|
||||
;; CLI-enforced mode that matches --always-approve / --yolo.
|
||||
(if (grok-wants-auto-approve? row)
|
||||
"--permission-mode bypassPermissions "
|
||||
"--permission-mode acceptEdits "))
|
||||
|
||||
(defn launch-command [ctx index row]
|
||||
(let [role (:role row)
|
||||
agent (:agent row)
|
||||
display (:display-name row)
|
||||
role-worktree (:worktree-path row)
|
||||
role-script-dir (if (= (str role-worktree) (str (:working-dir ctx)))
|
||||
(:script-dir ctx)
|
||||
(fs/path role-worktree "swarmforge" "scripts"))
|
||||
prompt-file (fs/path (:prompts-dir ctx) (str role ".md"))
|
||||
base (str "export SWARMFORGE_ROLE=" (sq role)
|
||||
" && export PATH=" (sq (str role-script-dir)) ":$PATH"
|
||||
" && cd " (sq (str role-worktree))
|
||||
" && ")]
|
||||
(write-agent-instruction-file! role prompt-file)
|
||||
(cond-> (str base
|
||||
(case agent
|
||||
"claude" (str "claude --append-system-prompt-file " (sq (str prompt-file)) " --permission-mode acceptEdits -n " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
|
||||
"codex" (str "codex -C " (sq (str role-worktree)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")
|
||||
"copilot" (str "copilot -C " (sq (str role-worktree)) " --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "-i \"$(cat " (sq (str prompt-file)) ")\"")
|
||||
"grok" (str "grok --cwd " (sq (str role-worktree)) " " (grok-permission-prefix row) (extra-args-prefix row) "--rules \"$(cat " (sq (str prompt-file)) ")\" --verbatim \"$(cat " (sq (str prompt-file)) ")\"")
|
||||
"pi" (str "pi -a --name " (sq (str "SwarmForge " display)) " " (extra-args-prefix row) "\"$(cat " (sq (str prompt-file)) ")\"")))
|
||||
(= index 0)
|
||||
(str "; exit_code=$?; SWARMFORGE_TERMINAL_BACKEND=" (sq (:terminal-backend ctx))
|
||||
" nohup " (sq (str (fs/path (:script-dir ctx) "swarm-cleanup.sh")))
|
||||
" " (sq (:tmux-socket ctx))
|
||||
" " (sq (str (:window-ids-file ctx)))
|
||||
(apply str (map #(str " " (sq (:session %))) (:roles ctx)))
|
||||
" >/dev/null 2>&1 & disown; exit $exit_code"))))
|
||||
|
||||
(defn launch-role! [ctx index row]
|
||||
(let [session (:session row)
|
||||
display (:display-name row)
|
||||
prompt-file (fs/path (:prompts-dir ctx) (str (:role row) ".md"))
|
||||
command (launch-command ctx index row)]
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "send-keys" "-t"
|
||||
(tmux-agent-target display (:tmux-pane-base-index ctx) session)
|
||||
command "Enter")
|
||||
(println (str " " cyan "[" display "]" reset " started in session " session))))
|
||||
|
||||
(defn stop-handoff-daemon! [ctx]
|
||||
(process/sh {:continue true}
|
||||
"bb" (str (fs/path (:script-dir ctx) "stop_handoff_daemon.bb"))
|
||||
(str (:working-dir ctx))))
|
||||
|
||||
(defn uname []
|
||||
(str/trim (:out (process/sh {:continue true} "uname" "-s"))))
|
||||
|
||||
(defn linux-systemd-running? []
|
||||
(let [result (process/sh {:continue true} "systemctl" "is-system-running")
|
||||
state (str/trim (:out result))]
|
||||
(#{"running" "degraded"} state)))
|
||||
|
||||
(defn sleep-inhibitor-prefix []
|
||||
(when-not (= "0" (System/getenv "SWARMFORGE_PREVENT_SLEEP"))
|
||||
(case (uname)
|
||||
"Darwin" (when (command-exists? "caffeinate")
|
||||
["caffeinate" "-dims"])
|
||||
"Linux" (when (and (command-exists? "systemd-inhibit")
|
||||
(command-exists? "systemctl")
|
||||
(linux-systemd-running?))
|
||||
["systemd-inhibit"
|
||||
"--what=sleep:idle"
|
||||
"--who=SwarmForge"
|
||||
"--why=SwarmForge swarm is active"])
|
||||
nil)))
|
||||
|
||||
(defn start-handoff-daemon! [ctx]
|
||||
(fs/delete-if-exists (fs/path (:daemon-dir ctx) "stop"))
|
||||
(let [command (into (vec (sleep-inhibitor-prefix))
|
||||
[(str (fs/path (:script-dir ctx) "handoffd.bb"))
|
||||
(str (:working-dir ctx))])]
|
||||
(process/process command
|
||||
{:out (str (:handoff-daemon-log ctx))
|
||||
:err :out})
|
||||
(println (str green "Started handoff daemon"
|
||||
(when (> (count command) 2) " with OS sleep prevention")
|
||||
"."
|
||||
reset))))
|
||||
|
||||
(defn adapter-script [ctx command & args]
|
||||
(let [script (str "SCRIPT_DIR=" (sq (str (:script-dir ctx))) "\n"
|
||||
"WORKING_DIR=" (sq (str (:working-dir ctx))) "\n"
|
||||
"TMUX_SOCKET=" (sq (:tmux-socket ctx)) "\n"
|
||||
"source " (sq (str (fs/path (:script-dir ctx) "swarm-terminal-adapter.sh")))
|
||||
" && load_terminal_backend " (sq (:terminal-backend ctx))
|
||||
" && " command
|
||||
(apply str (map #(str " " (sq %)) args)))]
|
||||
["bash" "-c" script]))
|
||||
|
||||
(defn terminal-call [ctx command & args]
|
||||
(apply process/sh (apply adapter-script ctx command args)))
|
||||
|
||||
(defn terminal-call-ok? [ctx command & args]
|
||||
(zero? (:exit (apply process/sh (concat [{:continue true}] (apply adapter-script ctx command args))))))
|
||||
|
||||
(defn terminal-call-out [ctx command & args]
|
||||
(str/trim (:out (apply terminal-call ctx command args))))
|
||||
|
||||
(defn open-terminal-surfaces! [ctx]
|
||||
(if (terminal-call-ok? ctx "terminal_backend_can_open_sessions")
|
||||
(do
|
||||
(println (str "Opening separate " (terminal-call-out ctx "terminal_backend_label") " surfaces for each session..."))
|
||||
(when (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
||||
(spit (str (:window-ids-file ctx)) "")
|
||||
(spit (str (:window-state-file ctx)) ""))
|
||||
(loop [rows (:roles ctx)
|
||||
index 0
|
||||
previous-window-id ""]
|
||||
(when-let [row (first rows)]
|
||||
(let [window-id (terminal-call-out ctx "terminal_open_session" (:session row) (str "SwarmForge " (:display-name row)) previous-window-id)]
|
||||
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
||||
(do
|
||||
(spit (str (:window-ids-file ctx)) (str window-id "\n") :append true)
|
||||
(spit (str (:window-state-file ctx))
|
||||
(format "%d\t%s\t%s\t%s\n" (inc index) window-id (:session row) (str "SwarmForge " (:display-name row)))
|
||||
:append true)
|
||||
(recur (next rows) (inc index) window-id))
|
||||
(recur (next rows) (inc index) previous-window-id)))))
|
||||
(if (terminal-call-ok? ctx "terminal_backend_tracks_windows")
|
||||
(process/process [(str (fs/path (:script-dir ctx) "swarm-window-watchdog.sh"))
|
||||
(str (:window-state-file ctx))
|
||||
(str (:window-ids-file ctx))
|
||||
"1"
|
||||
(:tmux-socket ctx)
|
||||
(str (:working-dir ctx))
|
||||
(:terminal-backend ctx)]
|
||||
{:out (str (:window-watchdog-log ctx))
|
||||
:err :out})
|
||||
(println (str yellow (terminal-call-out ctx "terminal_backend_label") " surfaces are not trackable; window watchdog is disabled for this backend." reset))))
|
||||
(do
|
||||
(println (str yellow "No terminal backend found; attaching current shell to '" (-> ctx :roles first :session) "' instead." reset))
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "attach-session" "-t" (-> ctx :roles first :session)))))
|
||||
|
||||
(defn context [working-dir]
|
||||
(let [working-dir (fs/absolutize (fs/path working-dir))
|
||||
script-dir (fs/parent *file*)
|
||||
swarm-forge-dir (fs/path working-dir "swarmforge")
|
||||
state-dir (fs/path working-dir ".swarmforge")
|
||||
daemon-dir (fs/path state-dir "daemon")
|
||||
crc (java.util.zip.CRC32.)
|
||||
_ (.update crc (.getBytes (str working-dir) java.nio.charset.StandardCharsets/UTF_8))
|
||||
socket-id (str (.getValue crc))
|
||||
tmux-socket-dir (fs/path "/tmp" (str "swarmforge-" (or (System/getenv "UID") (System/getProperty "user.name"))))
|
||||
tmux-socket (str (fs/path tmux-socket-dir (str socket-id ".sock")))]
|
||||
{:working-dir working-dir
|
||||
:script-dir script-dir
|
||||
:swarm-forge-dir swarm-forge-dir
|
||||
:worktrees-dir (fs/path working-dir ".worktrees")
|
||||
:config-file (fs/path swarm-forge-dir "swarmforge.conf")
|
||||
:roles-dir (fs/path swarm-forge-dir "roles")
|
||||
:constitution-file (fs/path swarm-forge-dir "constitution.prompt")
|
||||
:state-dir state-dir
|
||||
:notify-dir (fs/path state-dir "notify")
|
||||
:window-ids-file (fs/path state-dir "window-ids")
|
||||
:window-state-file (fs/path state-dir "windows.tsv")
|
||||
:window-watchdog-log (fs/path state-dir "window-watchdog.log")
|
||||
:sessions-file (fs/path state-dir "sessions.tsv")
|
||||
:roles-file (fs/path state-dir "roles.tsv")
|
||||
:prompts-dir (fs/path state-dir "prompts")
|
||||
:daemon-dir daemon-dir
|
||||
:handoff-daemon-log (fs/path daemon-dir "handoffd.log")
|
||||
:tmux-socket-dir tmux-socket-dir
|
||||
:tmux-socket tmux-socket
|
||||
:tmux-socket-file (fs/path state-dir "tmux-socket")
|
||||
:tmux-env-file (fs/path state-dir "tmux-env")
|
||||
:tmux-window-base-index 0
|
||||
:tmux-pane-base-index 0}))
|
||||
|
||||
(defn prepare-ctx [ctx]
|
||||
(-> ctx
|
||||
parse-config
|
||||
(assoc :terminal-backend (detect-terminal-backend))))
|
||||
|
||||
(defn test-parse! [root]
|
||||
(let [ctx (prepare-ctx (context root))]
|
||||
(prepare-workspace! ctx)
|
||||
(doseq [row (:roles ctx)]
|
||||
(println (str (:role row) " " (:display-name row) " " (:worktree-path row) " "
|
||||
(:receive-mode row)
|
||||
(when-let [extra (:extra-args row)] (str " " extra)))))
|
||||
(print (slurp (str (:roles-file ctx))))
|
||||
(print (slurp (str (:sessions-file ctx))))))
|
||||
|
||||
(defn run-main! [root]
|
||||
(check-dependency! "tmux")
|
||||
(check-dependency! "git")
|
||||
(check-dependency! "bb")
|
||||
(let [ctx (-> (context root)
|
||||
detect-tmux-base-indexes)]
|
||||
(initialize-git-repo! ctx)
|
||||
(ensure-runtime-git-excludes! ctx)
|
||||
(let [ctx (prepare-ctx ctx)]
|
||||
(check-backend-dependencies! ctx)
|
||||
(prepare-workspace! ctx)
|
||||
(prepare-worktrees! ctx)
|
||||
(prepare-handoff-dirs! ctx)
|
||||
(let [ctx (assoc ctx :terminal-backend (detect-terminal-backend))]
|
||||
(stop-handoff-daemon! ctx)
|
||||
(doseq [row (:roles ctx)]
|
||||
(when (sh-ok? "tmux" "-S" (:tmux-socket ctx) "has-session" "-t" (:session row))
|
||||
(println (str yellow "Existing SwarmForge session found: " (:session row) ". Killing it..." reset))
|
||||
(sh "tmux" "-S" (:tmux-socket ctx) "kill-session" "-t" (:session row))))
|
||||
(println (str cyan bold))
|
||||
(println " SwarmForge v1.0 Starting")
|
||||
(println " Disciplined agents build better software")
|
||||
(println reset)
|
||||
(println (str green "Launching SwarmForge tmux sessions..." reset))
|
||||
(doseq [row (:roles ctx)]
|
||||
(create-role-session! ctx (:session row) (:display-name row)))
|
||||
(write-tmux-env-file! ctx)
|
||||
(sync-worktree-scripts! ctx)
|
||||
(start-handoff-daemon! ctx)
|
||||
(println (str green "Starting agents..." reset))
|
||||
(let [delay-ms (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500)]
|
||||
(doseq [[index row] (map-indexed vector (:roles ctx))]
|
||||
(when (pos? index)
|
||||
(Thread/sleep delay-ms))
|
||||
(launch-role! ctx index row)))
|
||||
(println)
|
||||
(println (str green bold "SwarmForge is ready." reset))
|
||||
(println "Working directory:" (str (:working-dir ctx)))
|
||||
(println "Sessions:")
|
||||
(doseq [row (:roles ctx)]
|
||||
(println (str " " (:display-name row) ": " (:session row))))
|
||||
(println)
|
||||
(println (str green "Tip: Write a handoff draft and run swarm_handoff.sh while the swarm is running." reset))
|
||||
(println (str green "Tip: Reattach manually with 'tmux -S " (:tmux-socket ctx) " attach-session -t <session-name>' if needed." reset))
|
||||
(println)
|
||||
(open-terminal-surfaces! ctx)))))
|
||||
|
||||
(defn test-terminal-bridge! [root backend]
|
||||
(let [local-script-dir (fs/path root "swarmforge" "scripts")
|
||||
ctx (cond-> (assoc (context root) :terminal-backend backend)
|
||||
(fs/exists? local-script-dir) (assoc :script-dir local-script-dir))]
|
||||
(println (terminal-call-out ctx "terminal_open_session" "swarmforge-specifier" "SwarmForge Specifier" ""))))
|
||||
|
||||
(defn test-tmux-base-indexes! [tmux-socket]
|
||||
(let [ctx (detect-tmux-base-indexes {:tmux-socket tmux-socket
|
||||
:tmux-socket-dir (str (fs/parent (fs/path tmux-socket)))})]
|
||||
(println (:tmux-window-base-index ctx) (:tmux-pane-base-index ctx))))
|
||||
|
||||
(defn test-launch-command! [root agent & [extra-args]]
|
||||
(let [ctx (assoc (context root) :terminal-backend "none")
|
||||
row {:role "coder"
|
||||
:agent agent
|
||||
:session "swarmforge-coder"
|
||||
:display-name "Coder"
|
||||
:worktree-name "master"
|
||||
:worktree-path (fs/path root)
|
||||
:receive-mode "task"
|
||||
:extra-args extra-args}]
|
||||
(fs/create-dirs (:prompts-dir ctx))
|
||||
(println (launch-command ctx 1 row))))
|
||||
|
||||
(defn test-sleep-inhibitor-prefix! []
|
||||
(println (str/join " " (or (sleep-inhibitor-prefix) []))))
|
||||
|
||||
(defn -main [& args]
|
||||
(case (first args)
|
||||
"--test-parse" (test-parse! (or (second args) (System/getProperty "user.dir")))
|
||||
"--test-terminal-bridge" (test-terminal-bridge! (or (second args) (System/getProperty "user.dir")) (nth args 2))
|
||||
"--test-launch-command" (apply test-launch-command!
|
||||
(or (second args) (System/getProperty "user.dir"))
|
||||
(drop 2 args))
|
||||
"--test-agent-start-delay" (println (env-long "SWARMFORGE_AGENT_START_DELAY_MS" 1500))
|
||||
"--test-sleep-inhibitor-prefix" (test-sleep-inhibitor-prefix!)
|
||||
"--test-tmux-base-indexes" (test-tmux-base-indexes! (second args))
|
||||
(run-main! (or (first args) (System/getProperty "user.dir")))))
|
||||
|
||||
(apply -main *command-line-args*)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bb "$SCRIPT_DIR/swarmforge.bb" "$@"
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Ghostty"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application "Ghostty"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is targetId then return "yes"
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
local sibling_id="${3:-}"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" "$sibling_id" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
set siblingTabId to item 5 of argv
|
||||
set initialCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession & linefeed
|
||||
|
||||
tell application "Ghostty"
|
||||
set cfg to new surface configuration
|
||||
set initial working directory of cfg to workingDir
|
||||
set initial input of cfg to initialCmd
|
||||
|
||||
if siblingTabId is not "" then
|
||||
set targetWin to missing value
|
||||
set siblingTab to missing value
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is siblingTabId then
|
||||
set targetWin to w
|
||||
set siblingTab to t
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
if targetWin is not missing value then exit repeat
|
||||
end repeat
|
||||
if targetWin is not missing value then
|
||||
select tab siblingTab
|
||||
set newTab to new tab in targetWin with configuration cfg
|
||||
return id of newTab
|
||||
end if
|
||||
end if
|
||||
|
||||
try
|
||||
set targetWin to front window
|
||||
set newTab to new tab in targetWin with configuration cfg
|
||||
return id of newTab
|
||||
end try
|
||||
|
||||
set newWin to new window with configuration cfg
|
||||
return id of (first tab of newWin)
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 0
|
||||
|
||||
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application "Ghostty"
|
||||
try
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if (id of t as string) is targetId then
|
||||
close tab t
|
||||
return
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end try
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "iTerm2"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local session_id="$1"
|
||||
[[ -n "$session_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$session_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application id "com.googlecode.iterm2"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
repeat with s in sessions of t
|
||||
if (id of s) is targetId then return "yes"
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set windowTitle to item 3 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
set attachCmd to "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession
|
||||
|
||||
tell application id "com.googlecode.iterm2"
|
||||
activate
|
||||
set newWindow to (create window with default profile)
|
||||
set newSession to current session of newWindow
|
||||
tell newSession to write text attachCmd
|
||||
try
|
||||
set name of newSession to windowTitle
|
||||
end try
|
||||
return id of newSession
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local session_id="$1"
|
||||
[[ -n "$session_id" ]] || return 0
|
||||
|
||||
osascript - "$session_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv
|
||||
tell application id "com.googlecode.iterm2"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
repeat with s in sessions of t
|
||||
if (id of s) is targetId then
|
||||
close w
|
||||
return
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "current shell"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
return 0
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Terminal"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 1
|
||||
|
||||
local result
|
||||
result="$(osascript - "$window_id" <<'APPLESCRIPT' 2>/dev/null || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv as integer
|
||||
tell application "Terminal"
|
||||
repeat with terminalWindow in windows
|
||||
if id of terminalWindow is targetId then return "yes"
|
||||
end repeat
|
||||
end tell
|
||||
return "no"
|
||||
end run
|
||||
APPLESCRIPT
|
||||
)"
|
||||
|
||||
[[ "$result" == "yes" ]]
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
|
||||
osascript - "$WORKING_DIR" "$session" "$title" "$TMUX_SOCKET" <<'APPLESCRIPT'
|
||||
on run argv
|
||||
set workingDir to item 1 of argv
|
||||
set tmuxSession to item 2 of argv
|
||||
set windowTitle to item 3 of argv
|
||||
set tmuxSocket to item 4 of argv
|
||||
|
||||
tell application "Terminal"
|
||||
activate
|
||||
set newTab to do script ""
|
||||
do script "cd " & quoted form of workingDir & " && exec tmux -S " & quoted form of tmuxSocket & " attach-session -t " & quoted form of tmuxSession in newTab
|
||||
set custom title of newTab to windowTitle
|
||||
return id of front window
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
local window_id="$1"
|
||||
[[ -n "$window_id" ]] || return 0
|
||||
|
||||
osascript - "$window_id" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
on run argv
|
||||
set targetId to item 1 of argv as integer
|
||||
tell application "Terminal"
|
||||
try
|
||||
close (first window whose id is targetId) saving no
|
||||
end try
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
terminal_backend_label() {
|
||||
echo "Windows Terminal"
|
||||
}
|
||||
|
||||
terminal_backend_can_open_sessions() {
|
||||
return 0
|
||||
}
|
||||
|
||||
terminal_backend_tracks_windows() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_window_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminal_open_session() {
|
||||
local session="$1"
|
||||
local title="$2"
|
||||
local escaped_working_dir
|
||||
local escaped_tmux_socket
|
||||
local escaped_session
|
||||
|
||||
escaped_working_dir="$(printf '%q' "$WORKING_DIR")"
|
||||
escaped_tmux_socket="$(printf '%q' "$TMUX_SOCKET")"
|
||||
escaped_session="$(printf '%q' "$session")"
|
||||
|
||||
wt.exe -w new --title "$title" wsl.exe -e bash -lc \
|
||||
"cd $escaped_working_dir && exec tmux -S $escaped_tmux_socket attach-session -t $escaped_session"
|
||||
}
|
||||
|
||||
terminal_close_window() {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Format: window <role> <agent> <worktree> [task|batch] [extra-cli-args...]
|
||||
# The optional receive mode defaults to task. Use batch for roles that should
|
||||
# consume all currently queued equal-priority handoffs as one batch.
|
||||
# Any fields after the receive mode are passed to the agent CLI, e.g. --yolo.
|
||||
#
|
||||
# four-pack + pi on Linux: un solo harness (pi), un modelo por rol.
|
||||
# - specifier/coder → opencode-go/deepseek-v4-flash (familia DeepSeek, flash)
|
||||
# - refactorer → qwen-token-plan/qwen3.7-max (familia Qwen)
|
||||
# - architect → qwen-token-plan/glm-5.2 (familia GLM)
|
||||
# Verificar ids con: pi --list-models
|
||||
window specifier pi master --model deepseek-v4-flash:0731-cloud
|
||||
window coder pi coder --model deepseek-v4-flash:0731-cloud
|
||||
window refactorer pi refactorer batch --model deepseek-v4-flash:0731-cloud
|
||||
window architect pi architect batch --model deepseek-v4-flash:0731-cloud
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Small property-testing harness for psf-memo-client.
|
||||
|
||||
Node's built-in test runner has no property-based generator, so this module
|
||||
provides a tiny deterministic, seeded pseudo-random generator plus a helper
|
||||
to run a property across many samples and report a counterexample. All
|
||||
generation is seeded, so runs are reproducible.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// A small deterministic PRNG (mulberry32). Same seed => same stream.
|
||||
function seededRandom (seed = 12345) {
|
||||
let a = seed >>> 0
|
||||
return function next () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Run a property across N samples. `gen` returns a fresh input; `check`
|
||||
// returns true when the property holds. Asserts a counterexample on failure.
|
||||
async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const input = gen(i)
|
||||
const ok = await check(input)
|
||||
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a random ASCII string of a given length using a seeded RNG.
|
||||
function makeStringGen (rng) {
|
||||
return (length) => {
|
||||
const chars = []
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Mix of printable ASCII (32..126).
|
||||
chars.push(String.fromCharCode(32 + Math.floor(rng() * 95)))
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { seededRandom, forAll, makeStringGen }
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Property tests for the Memo post / New Post behavior slices.
|
||||
|
||||
These assert useful invariants that unit tests cover only at a few fixed
|
||||
points:
|
||||
- Validation classification is stable across a broad input range: any
|
||||
non-blank string up to the length limit is accepted, any longer string is
|
||||
rejected with a length error, and blank/non-string input is a validation
|
||||
error.
|
||||
- The New Post character counter conserves its relationship to input
|
||||
length: input.length + remainingCount() === MAX_MEMO_CHARS for any string.
|
||||
- setInput round-trips the exact draft text.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
|
||||
const { seededRandom, forAll, makeStringGen } = require('./harness')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
|
||||
const MAX = MemoPost.MAX_MEMO_CHARS // 217
|
||||
|
||||
const rng = seededRandom(20260826)
|
||||
const stringOf = makeStringGen(rng)
|
||||
|
||||
function buildPage () {
|
||||
return new NewPostPage({
|
||||
memoPost: new MemoPost({}),
|
||||
navigate: () => {},
|
||||
menuLinks: []
|
||||
})
|
||||
}
|
||||
|
||||
// A fake wallet recording broadcast attempts; fails when failWith is set.
|
||||
function fakeWallet () {
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' },
|
||||
utxos: [{ txid: 'utxo-fee' }],
|
||||
getUtxos: async function () { return this.utxos },
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'prop-txid'
|
||||
}
|
||||
}
|
||||
return wallet
|
||||
}
|
||||
|
||||
function fakeFeed () {
|
||||
const posts = []
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
test('memo validation: any non-blank string at or below the limit is valid', async () => {
|
||||
await forAll(
|
||||
(i) => {
|
||||
const len = 1 + Math.floor(rng() * MAX) // 1..MAX
|
||||
return stringOf(len)
|
||||
},
|
||||
(msg) => {
|
||||
// A random ASCII string may occasionally be all whitespace; whitespace-only
|
||||
// input is a validation error, so only assert for non-blank strings.
|
||||
if (msg.trim().length === 0) return true
|
||||
const result = new MemoPost({}).validate(msg)
|
||||
return result.ok === true
|
||||
},
|
||||
{ label: 'valid length' }
|
||||
)
|
||||
})
|
||||
|
||||
test('memo validation: any string above the limit is a length error', async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
|
||||
(msg) => {
|
||||
const result = new MemoPost({}).validate(msg)
|
||||
return result.ok === false && result.type === 'length'
|
||||
},
|
||||
{ label: 'over-long rejected as length' }
|
||||
)
|
||||
})
|
||||
|
||||
test('memo validation: blank and non-string input are validation errors', async () => {
|
||||
await forAll(
|
||||
(i) => (i % 2 === 0 ? ' ' : null),
|
||||
(msg) => {
|
||||
const result = new MemoPost({}).validate(msg)
|
||||
return result.ok === false && result.type === 'validation'
|
||||
},
|
||||
{ label: 'blank/non-string rejected as validation' }
|
||||
)
|
||||
})
|
||||
|
||||
test('character counter conserves length: remaining === MAX - input.length', async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(Math.floor(rng() * (MAX + 8))),
|
||||
(msg) => {
|
||||
const page = buildPage()
|
||||
page.setInput(msg)
|
||||
return page.remainingCount() === MAX - msg.length
|
||||
},
|
||||
{ label: 'counter conservation' }
|
||||
)
|
||||
})
|
||||
|
||||
test('setInput round-trips the draft text exactly', async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(Math.floor(rng() * 50)),
|
||||
(msg) => {
|
||||
const page = buildPage()
|
||||
page.setInput(msg)
|
||||
return page.input === msg
|
||||
},
|
||||
{ label: 'setInput round-trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('menu link registration is idempotent', async () => {
|
||||
await forAll(
|
||||
(i) => `/posts/${i}`,
|
||||
(path) => {
|
||||
const page = buildPage()
|
||||
page.addMenuLink(path)
|
||||
page.addMenuLink(path)
|
||||
page.addMenuLink(path)
|
||||
return page.menuLinks.filter((p) => p === path).length === 1
|
||||
},
|
||||
{ label: 'menu link idempotence' }
|
||||
)
|
||||
})
|
||||
|
||||
test('a broadcast failure surfaces the error and never navigates', async () => {
|
||||
await forAll(
|
||||
(i) => ({ message: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }),
|
||||
({ message, failWith }) => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.failWith = failWith
|
||||
const navigations = []
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed: fakeFeed() }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput(message)
|
||||
|
||||
return page.submit().then((result) => {
|
||||
if (result.ok) return false
|
||||
if (page.submitError !== 'broadcast') return false
|
||||
if (!page.broadcastError || !page.broadcastError.includes('boom')) return false
|
||||
return navigations.length === 0
|
||||
})
|
||||
},
|
||||
{ label: 'broadcast failure does not navigate' }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
(ns swarmforge.handoff-test
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer [deftest is run-tests testing use-fixtures]]))
|
||||
|
||||
(def repo-root (fs/cwd))
|
||||
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
|
||||
(def temp-dirs (atom []))
|
||||
|
||||
(use-fixtures :once
|
||||
(fn [tests]
|
||||
(try
|
||||
(tests)
|
||||
(finally
|
||||
(doseq [dir @temp-dirs]
|
||||
(fs/delete-tree dir))))))
|
||||
|
||||
(defn script [name]
|
||||
(str (fs/path scripts-dir name)))
|
||||
|
||||
(defn tmp-dir []
|
||||
(let [dir (fs/create-temp-dir {:prefix "swarmforge-handoff-test."})]
|
||||
(swap! temp-dirs conj dir)
|
||||
dir))
|
||||
|
||||
(defn run
|
||||
[{:keys [dir env ok?]} & args]
|
||||
(let [result (apply sh/sh (concat args [:dir (str dir)
|
||||
:env (merge {"PATH" (System/getenv "PATH")
|
||||
"GIT_CONFIG_NOSYSTEM" "1"}
|
||||
env)]))]
|
||||
(when (and (not (false? ok?)) (not= 0 (:exit result)))
|
||||
(throw (ex-info (str "Command failed: " (str/join " " args))
|
||||
(assoc result :args args))))
|
||||
result))
|
||||
|
||||
(defn write-file [path text]
|
||||
(fs/create-dirs (fs/parent path))
|
||||
(spit (str path) text))
|
||||
|
||||
(defn read-file [path]
|
||||
(slurp (str path)))
|
||||
|
||||
(defn init-repo! [root]
|
||||
(run {:dir root} "git" "init" "-q")
|
||||
(run {:dir root} "git" "config" "user.email" "test@example.com")
|
||||
(run {:dir root} "git" "config" "user.name" "Test User")
|
||||
(write-file (fs/path root "README.md") "initial\n")
|
||||
(run {:dir root} "git" "add" "README.md")
|
||||
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit")
|
||||
(str/trim (:out (run {:dir root} "git" "rev-parse" "--short=10" "HEAD"))))
|
||||
|
||||
(defn setup-project!
|
||||
([root] (setup-project! root {"sender" "task" "receiver" "task"}))
|
||||
([root roles]
|
||||
(doseq [dir [".swarmforge/handoffs/outbox/tmp"
|
||||
".swarmforge/handoffs/sent"
|
||||
".swarmforge/handoffs/failed"
|
||||
".swarmforge/handoffs/inbox/new"
|
||||
".swarmforge/handoffs/inbox/in_process"
|
||||
".swarmforge/handoffs/inbox/completed"]]
|
||||
(fs/create-dirs (fs/path root dir)))
|
||||
(write-file
|
||||
(fs/path root ".swarmforge/roles.tsv")
|
||||
(apply str
|
||||
(for [[role mode] roles]
|
||||
(format "%s\tmaster\t%s\tsession\t%s\tcodex\t%s\n"
|
||||
role root (str/capitalize role) mode))))))
|
||||
|
||||
(defn handoff
|
||||
[{:keys [id from to recipient priority type task commit body
|
||||
enqueued-at dequeued-at completed-at]}]
|
||||
(str "id: " id "\n"
|
||||
"from: " from "\n"
|
||||
"to: " to "\n"
|
||||
(when recipient (str "recipient: " recipient "\n"))
|
||||
"priority: " priority "\n"
|
||||
"type: " type "\n"
|
||||
(when task (str "task: " task "\n"))
|
||||
(when commit (str "commit: " commit "\n"))
|
||||
(when enqueued-at (str "enqueued_at: " enqueued-at "\n"))
|
||||
(when dequeued-at (str "dequeued_at: " dequeued-at "\n"))
|
||||
(when completed-at (str "completed_at: " completed-at "\n"))
|
||||
"\n"
|
||||
(or body (str "payload for " id)) "\n"))
|
||||
|
||||
(defn handoff-path [root state filename]
|
||||
(fs/path root ".swarmforge" "handoffs" "inbox" state filename))
|
||||
|
||||
(defn put-handoff! [root state filename attrs]
|
||||
(let [path (handoff-path root state filename)]
|
||||
(write-file path (handoff attrs))
|
||||
path))
|
||||
|
||||
(defn header [path field]
|
||||
(some->> (str/split-lines (read-file path))
|
||||
(take-while seq)
|
||||
(some (fn [line]
|
||||
(let [prefix (str field ": ")]
|
||||
(when (str/starts-with? line prefix)
|
||||
(subs line (count prefix))))))))
|
||||
|
||||
(defn make-queued-handoff!
|
||||
([root filename attrs]
|
||||
(put-handoff! root "new" filename
|
||||
(merge {:from "sender"
|
||||
:to "receiver"
|
||||
:recipient "receiver"
|
||||
:priority "50"
|
||||
:type "git_handoff"
|
||||
:task "task-one"
|
||||
:commit "0123456789"
|
||||
:body "merge_and_process sender 0123456789"}
|
||||
attrs))))
|
||||
|
||||
(deftest swarm-handoff-validates-and-queues-git-handoffs
|
||||
(let [root (tmp-dir)
|
||||
commit (init-repo! root)]
|
||||
(setup-project! root)
|
||||
(testing "git_handoff requires a task name"
|
||||
(let [draft (fs/path root "tmp" "missing-task.handoff")]
|
||||
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ncommit: %s\n" commit))
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"} :ok? false}
|
||||
(script "swarm_handoff.sh") (str draft))]
|
||||
(is (= 2 (:exit result)))
|
||||
(is (str/includes? (:err result) "Missing required header 'task'"))
|
||||
(is (fs/exists? draft)))))
|
||||
(testing "valid git_handoff writes task, canonical commit, and generated payload"
|
||||
(let [draft (fs/path root "tmp" "valid.handoff")]
|
||||
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ntask: task-1-cave-setup\ncommit: %s\n" commit))
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"}}
|
||||
(script "swarm_handoff.sh") (str draft))
|
||||
queued (-> (:out result) str/trim (str/replace #"^HANDOFF QUEUED: " ""))
|
||||
content (read-file queued)]
|
||||
(is (str/includes? content "task: task-1-cave-setup\n"))
|
||||
(is (str/includes? content (str "commit: " commit "\n")))
|
||||
(is (str/includes? content (str "merge_and_process sender " commit)))
|
||||
(is (fs/exists? queued))
|
||||
(is (not (fs/exists? draft))))))))
|
||||
|
||||
(deftest ready-for-next-task-accepts-and-resumes-single-tasks
|
||||
(let [root (tmp-dir)]
|
||||
(init-repo! root)
|
||||
(setup-project! root {"receiver" "task"})
|
||||
(testing "accepts one queued task and prints task name"
|
||||
(make-queued-handoff! root "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000001Z_000001_from_sender"
|
||||
:task "task-alpha"})
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||
(script "ready_for_next.sh"))
|
||||
out (:out result)
|
||||
in-process (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")]
|
||||
(is (str/includes? out "TASK:"))
|
||||
(is (str/includes? out "TASK_NAME: task-alpha"))
|
||||
(is (fs/exists? in-process))
|
||||
(is (some? (header in-process "dequeued_at")))))
|
||||
(testing "returns existing in-process task before queued tasks"
|
||||
(make-queued-handoff! root "40_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000002Z_000002_from_sender"
|
||||
:priority "40"
|
||||
:task "task-beta"})
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||
(script "ready_for_next.sh"))]
|
||||
(is (str/includes? (:out result) "task-alpha"))
|
||||
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/40_20260615T000002Z_000002_from_sender_to_receiver.handoff")))))))
|
||||
|
||||
(deftest ready-for-next-batch-groups-equal-priority-handoffs
|
||||
(let [root (tmp-dir)]
|
||||
(init-repo! root)
|
||||
(setup-project! root {"receiver" "batch"})
|
||||
(make-queued-handoff! root "10_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000001Z_000001_from_sender" :priority "10" :task "task-a"})
|
||||
(make-queued-handoff! root "10_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000002Z_000002_from_sender" :priority "10" :task "task-b"})
|
||||
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000003Z_000003_from_sender" :priority "20" :task "task-c"})
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||
(script "ready_for_next.sh"))
|
||||
out (:out result)
|
||||
batch-dir (->> (str/split-lines out)
|
||||
(filter #(str/starts-with? % "BATCH: "))
|
||||
first
|
||||
(#(subs % 7)))]
|
||||
(is (str/includes? out "COUNT: 2"))
|
||||
(is (str/includes? out "TASK_NAME: task-a"))
|
||||
(is (str/includes? out "TASK_NAME: task-b"))
|
||||
(is (not (str/includes? out "TASK_NAME: task-c")))
|
||||
(is (= 2 (count (fs/glob batch-dir "*.handoff"))))
|
||||
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/20_20260615T000003Z_000003_from_sender_to_receiver.handoff"))))))
|
||||
|
||||
(deftest done-with-current-task-completes-and-accepts-next-task
|
||||
(let [root (tmp-dir)]
|
||||
(init-repo! root)
|
||||
(setup-project! root {"receiver" "task"})
|
||||
(put-handoff! root "in_process" "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000001Z_000001_from_sender"
|
||||
:from "sender" :to "receiver" :recipient "receiver"
|
||||
:priority "50" :type "git_handoff" :task "task-current"
|
||||
:commit "0123456789"})
|
||||
(make-queued-handoff! root "50_20260615T000002Z_000002_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000002Z_000002_from_sender"
|
||||
:task "task-next"})
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||
(script "done_with_current.sh"))
|
||||
completed (fs/path root ".swarmforge/handoffs/inbox/completed/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||
next-file (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000002Z_000002_from_sender_to_receiver.handoff")]
|
||||
(is (str/includes? (:out result) "COMPLETED:"))
|
||||
(is (str/includes? (:out result) "TASK_NAME: task-next"))
|
||||
(is (some? (header completed "completed_at")))
|
||||
(is (some? (header next-file "dequeued_at"))))))
|
||||
|
||||
(deftest done-with-current-batch-completes-and-accepts-next-batch
|
||||
(let [root (tmp-dir)
|
||||
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
|
||||
(init-repo! root)
|
||||
(setup-project! root {"receiver" "batch"})
|
||||
(fs/create-dirs batch)
|
||||
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||
(handoff {:id "20260615T000001Z_000001_from_sender"
|
||||
:from "sender" :to "receiver" :recipient "receiver"
|
||||
:priority "10" :type "git_handoff" :task "task-a"
|
||||
:commit "0123456789"}))
|
||||
(write-file (fs/path batch "10_20260615T000002Z_000002_from_sender_to_receiver.handoff")
|
||||
(handoff {:id "20260615T000002Z_000002_from_sender"
|
||||
:from "sender" :to "receiver" :recipient "receiver"
|
||||
:priority "10" :type "git_handoff" :task "task-b"
|
||||
:commit "0123456789"}))
|
||||
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
|
||||
{:id "20260615T000003Z_000003_from_sender"
|
||||
:priority "20"
|
||||
:task "task-c"})
|
||||
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
|
||||
(script "done_with_current.sh"))
|
||||
completed-batch (fs/path root ".swarmforge/handoffs/inbox/completed/batch_20260615T000001Z_000001")]
|
||||
(is (str/includes? (:out result) "COMPLETED_BATCH:"))
|
||||
(is (str/includes? (:out result) "TASK_NAME: task-c"))
|
||||
(is (= 2 (count (fs/glob completed-batch "*.handoff"))))
|
||||
(is (every? #(some? (header % "completed_at"))
|
||||
(fs/glob completed-batch "*.handoff"))))))
|
||||
|
||||
(deftest stop-handoff-daemon-stops-running-process-and-removes-pid-file
|
||||
(let [root (tmp-dir)]
|
||||
(init-repo! root)
|
||||
(fs/create-dirs (fs/path root ".swarmforge/daemon"))
|
||||
(write-file (fs/path root ".swarmforge/roles.tsv")
|
||||
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"))
|
||||
(write-file (fs/path root ".swarmforge/tmux-socket") "/tmp/fake.sock\n")
|
||||
(run {:dir root :ok? false}
|
||||
"sh" "-c"
|
||||
(str "bb " (script "handoffd.bb") " " root " >/dev/null 2>&1 &"))
|
||||
(Thread/sleep 1500)
|
||||
(let [pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")]
|
||||
(is (fs/exists? pid-file))
|
||||
(let [pid (str/trim (read-file pid-file))
|
||||
stop (run {:dir root} (script "stop_handoff_daemon.bb") (str root))]
|
||||
(is (= 0 (:exit stop)))
|
||||
(Thread/sleep 300)
|
||||
(is (not (fs/exists? pid-file)))
|
||||
(is (not= 0 (:exit (run {:dir root :ok? false} "kill" "-0" pid))))))))
|
||||
|
||||
(deftest helpers-refuse-wrong-current-work-shape
|
||||
(let [root (tmp-dir)
|
||||
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
|
||||
(init-repo! root)
|
||||
(setup-project! root {"receiver" "batch"})
|
||||
(fs/create-dirs batch)
|
||||
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
|
||||
(handoff {:id "20260615T000001Z_000001_from_sender"
|
||||
:from "sender" :to "receiver" :recipient "receiver"
|
||||
:priority "10" :type "git_handoff" :task "task-a"
|
||||
:commit "0123456789"}))
|
||||
(testing "task helpers refuse an in-process batch"
|
||||
(let [ready (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
|
||||
(script "ready_for_next_task.sh"))
|
||||
done (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
|
||||
(script "done_with_current_task.sh"))]
|
||||
(is (= 2 (:exit ready)))
|
||||
(is (str/includes? (:err ready) "TASK_IN_PROCESS_IS_BATCH"))
|
||||
(is (= 2 (:exit done)))
|
||||
(is (str/includes? (:err done) "CURRENT_WORK_IS_BATCH"))))))
|
||||
|
||||
(defn -main [& _]
|
||||
(let [{:keys [fail error]} (run-tests 'swarmforge.handoff-test)]
|
||||
(System/exit (+ fail error))))
|
||||
@@ -0,0 +1,350 @@
|
||||
(ns swarmforge.script-test
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.shell :as sh]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
(def repo-root (fs/cwd))
|
||||
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
|
||||
|
||||
(defn write-file [path text]
|
||||
(fs/create-dirs (fs/parent path))
|
||||
(spit (str path) text))
|
||||
|
||||
(defn run
|
||||
[{:keys [dir env ok?]} & args]
|
||||
(let [result (apply sh/sh (concat args [:dir (str dir)
|
||||
:env (merge {"PATH" (System/getenv "PATH")
|
||||
"GIT_CONFIG_NOSYSTEM" "1"}
|
||||
env)]))]
|
||||
(when (and (not (false? ok?)) (not= 0 (:exit result)))
|
||||
(throw (ex-info (str "Command failed: " (str/join " " args))
|
||||
(assoc result :args args))))
|
||||
result))
|
||||
|
||||
(defn init-repo! [root]
|
||||
(run {:dir root} "git" "init" "-q")
|
||||
(run {:dir root} "git" "config" "user.email" "test@example.com")
|
||||
(run {:dir root} "git" "config" "user.name" "Test User")
|
||||
(write-file (fs/path root "README.md") "initial\n")
|
||||
(run {:dir root} "git" "add" "README.md")
|
||||
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit"))
|
||||
|
||||
(defn tmp-dir []
|
||||
(fs/create-temp-dir {:prefix "swarmforge-script-test."}))
|
||||
|
||||
(defn script [name]
|
||||
(str (fs/path scripts-dir name)))
|
||||
|
||||
(deftest handoff-lib-parses-and-prints-handoff-files
|
||||
(let [root (tmp-dir)
|
||||
handoff-file (fs/path root "task.handoff")]
|
||||
(try
|
||||
(write-file handoff-file
|
||||
(str "id: 1\n"
|
||||
"from: coder\n"
|
||||
"to: cleaner\n"
|
||||
"priority: 10\n"
|
||||
"type: git_handoff\n"
|
||||
"task: task-alpha\n"
|
||||
"\n"
|
||||
"merge_and_process coder abcdef1234\n"))
|
||||
(let [header (run {:dir root} (script "handoff_lib.bb") "header-field" "task.handoff" "task")
|
||||
body (run {:dir root} (script "handoff_lib.bb") "body" "task.handoff")
|
||||
task (run {:dir root} (script "handoff_lib.bb") "print-task" "task.handoff")]
|
||||
(is (str/includes? (:out header) "task-alpha"))
|
||||
(is (str/includes? (:out body) "merge_and_process coder abcdef1234"))
|
||||
(is (str/includes? (:out task) "TASK: task.handoff"))
|
||||
(is (str/includes? (:out task) "FROM: coder"))
|
||||
(is (str/includes? (:out task) "TASK_NAME: task-alpha")))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest handoff-lib-updates-headers-and-reads-role-state
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(init-repo! root)
|
||||
(write-file (fs/path root ".swarmforge/roles.tsv")
|
||||
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"
|
||||
"cleaner\tcleaner\t" root "/.worktrees/cleaner\tsession\tCleaner\tcodex\tbatch\n"))
|
||||
(write-file (fs/path root ".swarmforge/handoffs/inbox/new/item.handoff")
|
||||
(str "id: 1\n"
|
||||
"from: coder\n"
|
||||
"to: cleaner\n"
|
||||
"priority: 20\n"
|
||||
"type: note\n"
|
||||
"\n"
|
||||
"payload\n"))
|
||||
(run {:dir root} (script "handoff_lib.bb") "role-known" "cleaner")
|
||||
(run {:dir root} (script "handoff_lib.bb") "set-header" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at" "2026-06-16T00:00:00Z")
|
||||
(let [mode (run {:dir root} (script "handoff_lib.bb") "role-receive-mode" "cleaner")
|
||||
worktree (run {:dir root} (script "handoff_lib.bb") "role-worktree-name" "cleaner")
|
||||
dequeued (run {:dir root} (script "handoff_lib.bb") "header-field" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at")
|
||||
seq-1 (run {:dir root} (script "handoff_lib.bb") "next-sequence")
|
||||
seq-2 (run {:dir root} (script "handoff_lib.bb") "next-sequence")]
|
||||
(is (str/includes? (:out mode) "batch"))
|
||||
(is (str/includes? (:out worktree) "cleaner"))
|
||||
(is (str/includes? (:out dequeued) "2026-06-16T00:00:00Z"))
|
||||
(is (str/includes? (:out seq-1) "000001"))
|
||||
(is (str/includes? (:out seq-2) "000002")))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-launcher-parses-config-and-writes-state-files
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||
"Read articles.\n")
|
||||
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||
(str "# comment\n"
|
||||
"window coder codex master\n"
|
||||
"window cleaner codex cleaner batch\n"))
|
||||
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
||||
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||
(is (str/includes? (:out result) "coder Coder"))
|
||||
(is (str/includes? (:out result) "cleaner Cleaner"))
|
||||
(is (str/includes? (:out result) "cleaner batch"))
|
||||
(is (str/includes? (:out result) "swarmforge-coder"))
|
||||
(is (str/includes? (:out result) "swarmforge-cleaner"))
|
||||
(is (fs/exists? (fs/path root ".swarmforge/tmux-socket"))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-uses-portable-tmux-socket-dir
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||
"Read articles.\n")
|
||||
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||
"window coder codex master\n")
|
||||
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||
(run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))
|
||||
(let [socket-path (str/trim (slurp (str (fs/path root ".swarmforge/tmux-socket"))))]
|
||||
(is (str/starts-with? socket-path "/tmp/swarmforge-"))
|
||||
(is (not (str/starts-with? socket-path "/private/tmp/"))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-launcher-rejects-invalid-config
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||
"Read articles.\n")
|
||||
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||
(str "window coder codex master\n"
|
||||
"window coder codex other\n"))
|
||||
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||
(let [result (run {:dir root :ok? false} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||
(is (= 1 (:exit result)))
|
||||
(is (str/includes? (:err result) "Duplicate role 'coder'")))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-terminal-bridge-preserves-adapter-globals
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(write-file (fs/path root "swarmforge/scripts/swarm-terminal-adapter.sh")
|
||||
(str "load_terminal_backend() {\n"
|
||||
" source \"$SCRIPT_DIR/terminal-adapters/$1.sh\"\n"
|
||||
"}\n"))
|
||||
(write-file (fs/path root "swarmforge/scripts/terminal-adapters/probe.sh")
|
||||
(str "terminal_open_session() {\n"
|
||||
" printf '%s\\n' \"$WORKING_DIR|$TMUX_SOCKET|$1|$2|$3\"\n"
|
||||
"}\n"))
|
||||
(let [result (run {:dir root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-terminal-bridge"
|
||||
(str root)
|
||||
"probe")]
|
||||
(is (str/includes? (:out result) (str root "|")))
|
||||
(is (str/includes? (:out result) "|swarmforge-specifier|SwarmForge Specifier|"))
|
||||
(is (not (str/includes? (:out result) "cd ''")))
|
||||
(is (not (str/includes? (:out result) "-S ''"))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-agent-start-delay-is-configurable
|
||||
(let [default-result (run {:dir repo-root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-agent-start-delay")
|
||||
configured-result (run {:dir repo-root
|
||||
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "2750"}}
|
||||
(script "swarmforge.bb")
|
||||
"--test-agent-start-delay")
|
||||
invalid-result (run {:dir repo-root
|
||||
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "fast"}}
|
||||
(script "swarmforge.bb")
|
||||
"--test-agent-start-delay")]
|
||||
(is (= "1500" (str/trim (:out default-result))))
|
||||
(is (= "2750" (str/trim (:out configured-result))))
|
||||
(is (= "1500" (str/trim (:out invalid-result))))))
|
||||
|
||||
(deftest swarmforge-sleep-prevention-can-be-disabled
|
||||
(let [result (run {:dir repo-root
|
||||
:env {"SWARMFORGE_PREVENT_SLEEP" "0"}}
|
||||
(script "swarmforge.bb")
|
||||
"--test-sleep-inhibitor-prefix")]
|
||||
(is (= "" (str/trim (:out result))))))
|
||||
|
||||
(deftest swarmforge-launcher-parses-extra-cli-args
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(write-file (fs/path root "swarmforge/constitution.prompt")
|
||||
"Read articles.\n")
|
||||
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
||||
(str "window coder copilot master --yolo\n"
|
||||
"window cleaner copilot cleaner batch --allow-all-tools\n"))
|
||||
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
||||
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
||||
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
||||
(is (str/includes? (:out result) "coder Coder"))
|
||||
(is (str/includes? (:out result) "task --yolo"))
|
||||
(is (str/includes? (:out result) "batch --allow-all-tools")))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest copilot-launch-command-passes-extra-cli-args
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(let [result (run {:dir root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-launch-command"
|
||||
(str root)
|
||||
"copilot"
|
||||
"--yolo")
|
||||
command (:out result)]
|
||||
(is (str/includes? command "copilot -C "))
|
||||
(is (re-find #"--name 'SwarmForge Coder' --yolo -i" command)))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest grok-launch-command-passes-initial-prompt
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(let [result (run {:dir root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-launch-command"
|
||||
(str root)
|
||||
"grok")
|
||||
command (:out result)]
|
||||
(is (str/includes? command "grok --cwd "))
|
||||
(is (str/includes? command "--permission-mode acceptEdits"))
|
||||
(is (str/includes? command "--rules \"$(cat "))
|
||||
(is (str/includes? command "--verbatim \"$(cat "))
|
||||
(is (str/includes? command ".swarmforge/prompts/coder.md"))
|
||||
(is (fs/exists? (fs/path root ".swarmforge/prompts/coder.md"))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest grok-launch-command-uses-bypass-permissions-with-always-approve
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(let [result (run {:dir root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-launch-command"
|
||||
(str root)
|
||||
"grok"
|
||||
"--always-approve")
|
||||
command (:out result)]
|
||||
(is (str/includes? command "--permission-mode bypassPermissions"))
|
||||
(is (str/includes? command "--always-approve"))
|
||||
(is (not (str/includes? command "--permission-mode acceptEdits"))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest window-watchdog-rewrites-window-state-and-id-list
|
||||
(let [root (tmp-dir)
|
||||
state-file (fs/path root "windows.tsv")
|
||||
ids-file (fs/path root "window-ids")]
|
||||
(try
|
||||
(write-file state-file
|
||||
(str "1\told-a\tswarmforge-coder\tSwarmForge Coder\n"
|
||||
"2\told-b\tswarmforge-cleaner\tSwarmForge Cleaner\n"))
|
||||
(write-file ids-file "old-a\nold-b\n")
|
||||
(run {:dir root} (script "swarm-window-watchdog.bb") "--rewrite-window-id" "windows.tsv" "window-ids" "2" "new-b")
|
||||
(let [state (slurp (str state-file))
|
||||
ids (slurp (str ids-file))]
|
||||
(is (str/includes? state "1\told-a\tswarmforge-coder\tSwarmForge Coder"))
|
||||
(is (str/includes? state "2\tnew-b\tswarmforge-cleaner\tSwarmForge Cleaner"))
|
||||
(is (= "old-a\nnew-b\n" ids)))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarmforge-detects-nonzero-pane-base-index
|
||||
(let [root (tmp-dir)
|
||||
sock (str root "/test.sock")
|
||||
conf (fs/path root "tmux.conf")]
|
||||
(try
|
||||
(write-file conf "set -g base-index 1\nset -g pane-base-index 1\n")
|
||||
(run {:dir root} "tmux" "-S" sock "-f" (str conf) "new-session" "-d" "-s" "probe" "sleep" "120")
|
||||
(let [result (run {:dir root}
|
||||
(script "swarmforge.bb")
|
||||
"--test-tmux-base-indexes"
|
||||
sock)]
|
||||
(is (= "1 1" (str/trim (:out result)))))
|
||||
(finally
|
||||
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest swarm-cleanup-tolerates-missing-runtime-state
|
||||
(let [root (tmp-dir)
|
||||
ids-file (fs/path root ".swarmforge/window-ids")]
|
||||
(try
|
||||
(write-file ids-file "window-a\nwindow-b\n")
|
||||
(let [result (run {:dir root
|
||||
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||
(str (fs/path scripts-dir "swarm-cleanup.sh"))
|
||||
"/tmp/nonexistent.sock"
|
||||
(str ids-file))]
|
||||
(is (= 0 (:exit result)))
|
||||
(is (= "" (:err result))))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(defn close-swarm []
|
||||
(str (fs/path repo-root "close-swarm")))
|
||||
|
||||
(deftest close-swarm-reports-when-no-swarm-state
|
||||
(let [root (tmp-dir)]
|
||||
(try
|
||||
(let [result (run {:dir root :ok? false
|
||||
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||
(close-swarm)
|
||||
(str root))]
|
||||
(is (not= 0 (:exit result)))
|
||||
(is (str/includes? (str (:err result) (:out result)) "No SwarmForge swarm")))
|
||||
(finally
|
||||
(fs/delete-tree root)))))
|
||||
|
||||
(deftest close-swarm-kills-tmux-sessions-and-stops-daemon
|
||||
(let [root (tmp-dir)
|
||||
sock (str (fs/path root "swarm.sock"))
|
||||
pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")
|
||||
daemon (.start (java.lang.ProcessBuilder. ["sleep" "120"]))
|
||||
pid (str (.pid daemon))]
|
||||
(try
|
||||
(write-file (fs/path root ".swarmforge/tmux-socket") (str sock "\n"))
|
||||
(write-file (fs/path root ".swarmforge/sessions.tsv")
|
||||
(str "1\tcoder\tswarmforge-coder\tCoder\tcodex\n"
|
||||
"2\tcleaner\tswarmforge-cleaner\tCleaner\tcodex\n"))
|
||||
(write-file (fs/path root ".swarmforge/window-ids") "win-a\nwin-b\n")
|
||||
(write-file pid-file (str pid "\n"))
|
||||
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-coder" "sleep" "120")
|
||||
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-cleaner" "sleep" "120")
|
||||
(let [result (run {:dir root
|
||||
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
||||
(close-swarm)
|
||||
(str root))]
|
||||
(is (= 0 (:exit result)))
|
||||
(is (not= 0 (:exit (run {:dir root :ok? false}
|
||||
"tmux" "-S" sock "has-session" "-t" "swarmforge-coder"))))
|
||||
(is (not= 0 (:exit (run {:dir root :ok? false}
|
||||
"tmux" "-S" sock "has-session" "-t" "swarmforge-cleaner"))))
|
||||
(is (not (fs/exists? pid-file)))
|
||||
(is (false? (.isAlive daemon))))
|
||||
(finally
|
||||
(when (.isAlive daemon)
|
||||
(.destroyForcibly daemon))
|
||||
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
||||
(fs/delete-tree root)))))
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Unit tests for the Memo post behavior slice (src/services/memo-post.js).
|
||||
|
||||
These tests express the observable behavior described by
|
||||
specs/post-memo.feature:
|
||||
- a valid memo broadcasts an OP_RETURN transaction carrying the Memo post
|
||||
prefix (0x6d02) and the message text, and the feed reflects the new post.
|
||||
- an empty memo is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long memo is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
|
||||
// A fake wallet that records every broadcast attempt. It satisfies the small
|
||||
// adapter surface the MemoPost module needs: walletInfo, getUtxos(),
|
||||
// sendOpReturn().
|
||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => {
|
||||
broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
return 'fake-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
// A fake feed that records posts added to the recent posts feed.
|
||||
function fakeFeed () {
|
||||
const posts = []
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
|
||||
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
|
||||
})
|
||||
|
||||
test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and message', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
const txid = await memoPost.post('hello memo')
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d02')
|
||||
assert.equal(b.msg, 'hello memo')
|
||||
// The broadcast uses the wallet's spendable UTXOs.
|
||||
assert.equal(b.bchUtxos.length, 1)
|
||||
// The wallet info passed to sendOpReturn is the authenticated wallet.
|
||||
assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress)
|
||||
|
||||
// The feed reflects the new post from this address with this text.
|
||||
assert.equal(feed.posts.length, 1)
|
||||
assert.equal(feed.posts[0].text, 'hello memo')
|
||||
assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress)
|
||||
})
|
||||
|
||||
test('posting a memo at the maximum length (217) is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoPost = new MemoPost({ wallet })
|
||||
|
||||
const msg = 'x'.repeat(217)
|
||||
const txid = await memoPost.post(msg)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts[0].msg, msg)
|
||||
})
|
||||
|
||||
test('posting an empty memo throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post(''),
|
||||
(err) => err.code === 'memo_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
})
|
||||
|
||||
test('posting a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => {
|
||||
for (const invalid of [' ', 42]) {
|
||||
const wallet = fakeWallet()
|
||||
const memoPost = new MemoPost({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post(invalid),
|
||||
(err) => err.code === 'memo_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post('y'.repeat(218)),
|
||||
(err) => err.code === 'memo_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
})
|
||||
|
||||
test('posting without a wallet reports a missing-wallet error', async () => {
|
||||
const memoPost = new MemoPost({})
|
||||
await assert.rejects(
|
||||
memoPost.post('hello memo'),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
Unit tests for the New Post Page behavior slice (src/services/new-post.js).
|
||||
|
||||
Expresses the observable behavior described by specs/memo-new.feature:
|
||||
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||
navigates the user to the recent feed.
|
||||
- an empty memo is rejected with a validation error; nothing is broadcast.
|
||||
- an over-long memo is rejected with a length error; nothing is broadcast.
|
||||
- the character counter counts down from the memo limit.
|
||||
- the navigation menu links to /posts/new.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
|
||||
const MAX = MemoPost.MAX_MEMO_CHARS // 217
|
||||
|
||||
// A fake wallet recording broadcast attempts.
|
||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
utxos: [{ txid: 'utxo-fee' }],
|
||||
getUtxos: async function () { return this.utxos },
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'newpost-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
function fakeFeed () {
|
||||
const posts = []
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
function build () {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
const navigations = []
|
||||
const page = new NewPostPage({
|
||||
memoPost,
|
||||
navigate: (path) => navigations.push(path)
|
||||
})
|
||||
return { wallet, feed, memoPost, page, navigations }
|
||||
}
|
||||
|
||||
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
|
||||
assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new')
|
||||
assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent')
|
||||
})
|
||||
|
||||
test('the new post page is linked from the navigation menu', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.hasMenuLink('/posts/new'), true)
|
||||
})
|
||||
|
||||
test('the character counter counts down from the memo limit for an empty memo', () => {
|
||||
const { page } = build()
|
||||
page.setInput('')
|
||||
assert.equal(page.remainingCount(), MAX)
|
||||
})
|
||||
|
||||
test('the character counter counts down from the memo limit for a short memo', () => {
|
||||
const { page } = build()
|
||||
page.setInput('hello')
|
||||
assert.equal(page.remainingCount(), MAX - 5)
|
||||
})
|
||||
|
||||
test('the character counter reaches zero at the memo limit', () => {
|
||||
const { page } = build()
|
||||
page.setInput('x'.repeat(MAX))
|
||||
assert.equal(page.remainingCount(), 0)
|
||||
})
|
||||
|
||||
test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => {
|
||||
const { wallet, feed, page, navigations } = build()
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
// The page returns to an idle (not posting) state after success.
|
||||
assert.equal(page.posting, false)
|
||||
// Broadcast happened with the Memo post prefix and the exact message.
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d02')
|
||||
assert.equal(wallet.broadcasts[0].msg, 'hello memo')
|
||||
// Navigated to the recent feed after posting.
|
||||
assert.deepEqual(navigations, ['/posts/recent'])
|
||||
// The feed reflects the new post from this address.
|
||||
assert.equal(feed.posts.length, 1)
|
||||
assert.equal(feed.posts[0].text, 'hello memo')
|
||||
})
|
||||
|
||||
test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => {
|
||||
const { wallet, feed, page, navigations } = build()
|
||||
page.setInput('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'memo_validation')
|
||||
assert.equal(page.submitError, 'memo_validation')
|
||||
assert.equal(page.posting, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('posting an over-long memo is rejected with a length error and nothing is broadcast', async () => {
|
||||
const { wallet, feed, page, navigations } = build()
|
||||
page.setInput('y'.repeat(MAX + 1))
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'memo_length')
|
||||
assert.equal(page.submitError, 'memo_length')
|
||||
assert.equal(page.posting, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('the new post page starts idle (not posting)', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.posting, false)
|
||||
})
|
||||
|
||||
test('posting is true while a submit is in flight and false once it settles', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
|
||||
// Defer the broadcast so we can observe the in-flight posting state.
|
||||
let resolveSend
|
||||
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed }),
|
||||
navigate: () => {}
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
assert.equal(page.posting, false)
|
||||
const pending = page.submit()
|
||||
assert.equal(page.posting, true)
|
||||
|
||||
// Yield until the async chain reaches the deferred sendOpReturn call.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(typeof resolveSend, 'function')
|
||||
resolveSend('in-flight-txid')
|
||||
await pending
|
||||
assert.equal(page.posting, false)
|
||||
})
|
||||
|
||||
test('submitting without a memo post handler reports an error and does not navigate', async () => {
|
||||
const navigations = []
|
||||
const page = new NewPostPage({ navigate: (p) => navigations.push(p) })
|
||||
page.setInput('hello')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces the real error and does not navigate', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
wallet.failWith = 'BCH UTXO list is empty'
|
||||
const navigations = []
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /BCH UTXO list is empty/)
|
||||
// The broadcast was attempted (recorded) before it failed.
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d02')
|
||||
// The user stays on the page.
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces a different real error message', async () => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.failWith = 'Insufficient balance'
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet }),
|
||||
navigate: () => {}
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(page.broadcastError, /Insufficient balance/)
|
||||
})
|
||||
|
||||
test('a broadcast failure with an empty message falls back to a string form', async () => {
|
||||
const wallet = fakeWallet()
|
||||
// Throw an Error with an empty message so the message fallback path is exercised.
|
||||
wallet.sendOpReturn = async () => { throw new Error('') }
|
||||
const page = new NewPostPage({ memoPost: new MemoPost({ wallet }), navigate: () => {} })
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
// The real (string) error is surfaced even though the message was empty.
|
||||
assert.equal(typeof page.broadcastError, 'string')
|
||||
assert.ok(page.broadcastError.length > 0)
|
||||
})
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# sf-queue — estado de las colas de handoff de todos los roles del swarm.
|
||||
# Uso: sf-queue [directorio-del-proyecto] (por defecto: directorio actual)
|
||||
# Muestra: new (pendientes) · proc (en proceso, incluye batches) · done (completados)
|
||||
# outbox (por enviar) · sent (enviados) + nombres de tarea.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${1:-$(pwd)}"
|
||||
cd "$ROOT"
|
||||
TSV=".swarmforge/roles.tsv"
|
||||
[ -f "$TSV" ] || { echo "No hay swarm en $ROOT (falta $TSV)" >&2; exit 1; }
|
||||
|
||||
count() { # count <globs...> → nº de ficheros .handoff (también dentro de batch_*/)
|
||||
local n=0 f
|
||||
for f in "$@"; do [ -f "$f" ] && n=$((n+1)); done
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r role wtname wtpath session display agent mode; do
|
||||
H="$wtpath/.swarmforge/handoffs"
|
||||
[ -d "$H" ] || continue
|
||||
new=$(count "$H"/inbox/new/*.handoff)
|
||||
proc=$(count "$H"/inbox/in_process/*.handoff "$H"/inbox/in_process/batch_*/*.handoff)
|
||||
donec=$(count "$H"/inbox/completed/*.handoff "$H"/inbox/completed/batch_*/*.handoff)
|
||||
out=$(count "$H"/outbox/*.handoff)
|
||||
sent=$(count "$H"/sent/*.handoff)
|
||||
printf "== %-10s new=%-2s proc=%-2s done=%-2s outbox=%-2s sent=%-2s\n" \
|
||||
"$role" "$new" "$proc" "$donec" "$out" "$sent"
|
||||
for f in "$H"/inbox/new/*.handoff; do [ -f "$f" ] && echo " [pendiente] $(sed -n 's/^task: //p' "$f")"; done
|
||||
for f in "$H"/inbox/in_process/*.handoff; do [ -f "$f" ] && echo " [en proceso] $(sed -n 's/^task: //p' "$f")"; done
|
||||
for d in "$H"/inbox/in_process/batch_*; do
|
||||
[ -d "$d" ] && for f in "$d"/*.handoff; do [ -f "$f" ] && echo " [batch] $(sed -n 's/^task: //p' "$f")"; done
|
||||
done
|
||||
for f in "$H"/outbox/*.handoff; do [ -f "$f" ] && echo " [outbox] $(sed -n 's/^task: //p' "$f")"; done
|
||||
done < "$TSV"
|
||||
exit 0
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# sf-tokens — consumo de tokens por rol (desde las sesiones de pi).
|
||||
# Uso: sf-tokens [directorio-del-proyecto] (por defecto: directorio actual)
|
||||
# Suma usage de todas las sesiones pi de cada worktree (cumulativo por rol).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${1:-$(pwd)}"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
TSV="$ROOT/.swarmforge/roles.tsv"
|
||||
[ -f "$TSV" ] || { echo "No hay swarm en $ROOT (falta $TSV)" >&2; exit 1; }
|
||||
SESS_DIR="${PI_SESSIONS_DIR:-$HOME/.pi/agent/sessions}"
|
||||
|
||||
# El dir de sesión de pi se deriva del path del worktree:
|
||||
# /home/pol/Work/code/saas-prototype/.worktrees/coder → --home-pol-Work-code-saas-prototype-.worktrees-coder--
|
||||
session_dir_for_path() {
|
||||
local p="$1"
|
||||
p="${p%/}" # quitar / final
|
||||
p="${p%/\.}" # quitar '/.' (path de master en roles.tsv)
|
||||
p="${p#/}" # quitar / inicial
|
||||
p="${p//\//-}" # / → -
|
||||
echo "--${p}--"
|
||||
}
|
||||
|
||||
fmt() { printf "%'d" "$1"; }
|
||||
|
||||
total_in=0; total_out=0; total_cache=0; total_all=0
|
||||
|
||||
while IFS=$'\t' read -r role wtname wtpath session display agent mode; do
|
||||
[ -n "$role" ] || continue
|
||||
sdir="$(session_dir_for_path "$wtpath")"
|
||||
dir="$SESS_DIR/$sdir"
|
||||
if [ ! -d "$dir" ]; then
|
||||
printf "%-10s sin sesión pi (%s)\n" "$role" "$sdir"
|
||||
continue
|
||||
fi
|
||||
# Sumar usage de todos los mensajes de todas las sesiones del rol
|
||||
read -r i o cr cw all <<< "$(awk -F'"' '
|
||||
/"usage":/ {
|
||||
line=$0
|
||||
if (match(line, /"input":([0-9]+)/)) in_t += substr(line, RSTART+8, RLENGTH-8)
|
||||
if (match(line, /"output":([0-9]+)/)) out_t += substr(line, RSTART+9, RLENGTH-9)
|
||||
if (match(line, /"cacheRead":([0-9]+)/)) cr_t += substr(line, RSTART+12, RLENGTH-12)
|
||||
if (match(line, /"cacheWrite":([0-9]+)/)) cw_t += substr(line, RSTART+13, RLENGTH-13)
|
||||
}
|
||||
END { printf "%d %d %d %d %d\n", in_t, out_t, cr_t, cw_t, in_t+out_t+cr_t+cw_t }
|
||||
' "$dir"/*.jsonl 2>/dev/null)"
|
||||
if [ -n "${all:-}" ]; then
|
||||
printf "%-10s input=%-12s output=%-11s cache=%-11s TOTAL=%s\n" \
|
||||
"$role" "$(fmt "$i")" "$(fmt "$o")" "$(fmt $((cr+cw)))" "$(fmt "$all")"
|
||||
total_in=$((total_in+i)); total_out=$((total_out+o)); total_cache=$((total_cache+cr+cw)); total_all=$((total_all+all))
|
||||
else
|
||||
printf "%-10s sin uso registrado\n" "$role"
|
||||
fi
|
||||
done < "$TSV"
|
||||
|
||||
echo "------------------------------------------"
|
||||
printf "TOTAL input=%-12s output=%-11s cache=%-11s TOTAL=%s\n" \
|
||||
"$(fmt "$total_in")" "$(fmt "$total_out")" "$(fmt "$total_cache")" "$(fmt "$total_all")"
|
||||
Reference in New Issue
Block a user