bullswarm 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +33 -4
- package/package.json +1 -1
- package/skill/SKILL.md +24 -1
- package/src/cli.js +16 -3
- package/src/integrate.js +265 -0
- package/src/setup.js +29 -54
- package/src/workflow/runs-cli.js +116 -10
package/AGENTS.md
CHANGED
|
@@ -37,7 +37,7 @@ content. Published as `bullswarm` on npm.
|
|
|
37
37
|
## Development
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
npm test #
|
|
40
|
+
npm test # 232 tests, no network needed (meters read from cache)
|
|
41
41
|
node bin/bullswarm.js doctor --json # readiness report
|
|
42
42
|
node bin/bullswarm.js workflow list # discover workflows
|
|
43
43
|
node bin/bullswarm.js workflow runs # ongoing workflow instances
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.2 — cross-agent skill integration
|
|
4
|
+
|
|
5
|
+
- Added explicit `bullswarm integrate status|install|remove` support for Codex,
|
|
6
|
+
Claude, and Grok. Installation registers one packaged `bullswarm` skill with
|
|
7
|
+
all selected agents and writes concise marker-delimited global awareness
|
|
8
|
+
rules; removal touches only Bullswarm-managed links and blocks.
|
|
9
|
+
- Added recoverable `integrate retire-legacy --yes` migration for the retired
|
|
10
|
+
Claude `offload` skill. Detection is read-only and retirement always moves the
|
|
11
|
+
old skill into `~/.claude/skills-archive/`.
|
|
12
|
+
- Added recursion-aware global guidance: a worker with `BULLSWARM_DEPTH` set
|
|
13
|
+
performs its assigned task directly instead of casually spawning another
|
|
14
|
+
swarm.
|
|
15
|
+
- Renamed the published agent skill from `bullswarm-setup` to `bullswarm` and
|
|
16
|
+
documented single-task, zero-graph goal, fixed-workflow, observation, and
|
|
17
|
+
integration paths together.
|
|
18
|
+
- Corrected README language so agent/time targets are advisory while graph
|
|
19
|
+
expansion limits remain hard safeguards.
|
|
20
|
+
|
|
21
|
+
## 0.10.1 — initiated-time workflow history search
|
|
22
|
+
|
|
23
|
+
- Added `workflow runs --since <time> --until <time>` filtering against the
|
|
24
|
+
workflow's initiation timestamp (`startedAt`), with an inclusive lower bound
|
|
25
|
+
and exclusive upper bound.
|
|
26
|
+
- Added `--from`/`--to` and `--started-after`/`--started-before` aliases plus
|
|
27
|
+
ISO timestamps, local dates, calendar keywords, and relative durations such
|
|
28
|
+
as `7d`.
|
|
29
|
+
- Historical listing now falls back to `report.startedAt` when an older state
|
|
30
|
+
record lacks its initiation timestamp, and JSON output reports the normalized
|
|
31
|
+
range used for the audit.
|
|
32
|
+
|
|
3
33
|
## 0.10.0 — battle-tested advisory orchestration and agent activity
|
|
4
34
|
|
|
5
35
|
- Made `maxAgents` and `maxWorkflowSeconds` advisory planning targets instead
|
package/README.md
CHANGED
|
@@ -23,8 +23,24 @@ Every delegate output is judged by content before it counts.
|
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npm install -g bullswarm # or: node bin/bullswarm.js directly from a checkout
|
|
26
|
+
bullswarm integrate install --agents codex,claude,grok --yes
|
|
26
27
|
```
|
|
27
28
|
|
|
29
|
+
The integration command registers Bullswarm's packaged `bullswarm` skill with
|
|
30
|
+
Codex, Claude, and Grok and appends a concise, marker-delimited awareness rule
|
|
31
|
+
to each agent's global instructions. It is explicit, idempotent, and reversible:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bullswarm integrate status --json
|
|
35
|
+
bullswarm integrate remove --agents codex,claude,grok --yes
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If the retired pre-Bullswarm Claude `offload` skill is detected, status reports
|
|
39
|
+
it without changing it. Archive it recoverably with
|
|
40
|
+
`bullswarm integrate retire-legacy --yes`. The awareness rule prevents workers
|
|
41
|
+
already launched by Bullswarm (`BULLSWARM_DEPTH` is set) from casually
|
|
42
|
+
re-delegating and creating recursive swarms.
|
|
43
|
+
|
|
28
44
|
## Quick start
|
|
29
45
|
|
|
30
46
|
```bash
|
|
@@ -42,6 +58,7 @@ bullswarm health # re-judge saved outputs; catch gate failures
|
|
|
42
58
|
| Verb | Purpose |
|
|
43
59
|
|---|---|
|
|
44
60
|
| `setup` | Discover installed agent CLIs, show quota state, toggle pools, suggest a routing table, write config. Approval-gated, idempotent. |
|
|
61
|
+
| `integrate` | Register or remove the canonical Bullswarm skill and global awareness rules for Codex, Claude, and Grok. |
|
|
45
62
|
| `run` | route → dispatch → watch → verify → one JSON verdict |
|
|
46
63
|
| `health` | Re-judge saved outputs against their verdicts; surface verify-gate failures and quarantine clusters |
|
|
47
64
|
| `pools` | Show each pool's meter state, pace position, quarantine status |
|
|
@@ -107,7 +124,7 @@ orchestrator by live quota surplus. The orchestrator observes durable evidence,
|
|
|
107
124
|
and decides when another expansion or verification is necessary. Bullswarm
|
|
108
125
|
validates the proposal, owns agent/process selection, routes workers, and calls
|
|
109
126
|
the orchestrator again until completion, cancellation, failure, approval, or a
|
|
110
|
-
|
|
127
|
+
hard graph-growth safeguard. No initial phases, prompts, JSON schema, or agent choice are
|
|
111
128
|
required from the user.
|
|
112
129
|
|
|
113
130
|
The detached response includes a short ID and exact observation commands:
|
|
@@ -128,9 +145,10 @@ bullswarm workflow goal --resume <shortId> --json
|
|
|
128
145
|
```
|
|
129
146
|
|
|
130
147
|
`--orchestrator <pool>` exists for controlled testing; ordinary use should
|
|
131
|
-
leave selection on `auto`.
|
|
132
|
-
|
|
133
|
-
`--max-
|
|
148
|
+
leave selection on `auto`. `--max-agents` and `--max-workflow-seconds` are
|
|
149
|
+
advisory planning targets; hard graph-growth safeguards are adjusted with
|
|
150
|
+
`--max-expansion-rounds`, `--max-actions`, and `--max-items-per-expansion`.
|
|
151
|
+
Interactive setup also records a worktree-isolation
|
|
134
152
|
preference (`agent-decides`, `off`, or `required`); Bullswarm communicates that
|
|
135
153
|
policy to the orchestrator without imposing repository topology itself.
|
|
136
154
|
|
|
@@ -173,6 +191,9 @@ bullswarm workflow runs # ongoing only (default)
|
|
|
173
191
|
bullswarm workflow runs --all # ongoing + historical
|
|
174
192
|
bullswarm workflow runs --historical # only historical
|
|
175
193
|
bullswarm workflow runs --name audit-code # filter by workflow
|
|
194
|
+
bullswarm workflow runs --all --since 7d # initiated in the last 7 days
|
|
195
|
+
bullswarm workflow runs --historical --since yesterday --until today
|
|
196
|
+
bullswarm workflow runs --all --from 2026-08-20 --to 2026-08-27
|
|
176
197
|
bullswarm workflow runs --limit 20 # cap the result count
|
|
177
198
|
bullswarm workflow runs show <shortId> # state + report + summary
|
|
178
199
|
bullswarm runs show <shortId> # top-level shorthand
|
|
@@ -182,6 +203,14 @@ bullswarm workflow runs delete <shortId> --yes # remove the run dir
|
|
|
182
203
|
bullswarm workflow run audit-code --resume <shortId>
|
|
183
204
|
```
|
|
184
205
|
|
|
206
|
+
Run-history time filters always compare when the workflow was initiated
|
|
207
|
+
(`startedAt`), never when it finished. `--since` is inclusive and `--until` is
|
|
208
|
+
exclusive; `--started-after`/`--from` and `--started-before`/`--to` are aliases.
|
|
209
|
+
Values accept ISO timestamps, local `YYYY-MM-DD` dates, `today`, `yesterday`,
|
|
210
|
+
`tomorrow`, `now`, or relative durations such as `30m`, `24h`, `7d`, and `2w`.
|
|
211
|
+
Time filters preserve the existing scope, so use `--all` or `--historical` when
|
|
212
|
+
auditing completed runs.
|
|
213
|
+
|
|
185
214
|
### Live workflow dashboard
|
|
186
215
|
|
|
187
216
|
For ordinary observation, use the non-interactive watcher. It prints only when
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: bullswarm
|
|
2
|
+
name: bullswarm
|
|
3
3
|
description: Use when you want to offload work to coding-agent subscriptions or run a self-contained goal across heterogeneous providers. bullswarm can accept one goal, select an orchestrator, expand and execute a bounded plan, verify the outcome, and expose the whole run through CLI state and events. Use `bullswarm workflow goal` for ordinary multi-step work, an explicit workflow draft when the graph itself is the contract, and `bullswarm run` for one bounded task. Every verb self-initializes.
|
|
4
4
|
---
|
|
5
5
|
|
|
@@ -10,6 +10,16 @@ CLI subscription has the most quota headroom. Every delegate output is
|
|
|
10
10
|
judged by **content**, not exit code. A non-zero exit is never a success; a
|
|
11
11
|
`verified` output is.
|
|
12
12
|
|
|
13
|
+
This skill is registered globally by:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bullswarm integrate install --agents codex,claude,grok --yes
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If `BULLSWARM_DEPTH` is already set, you are a Bullswarm delegate. Complete the
|
|
20
|
+
assigned task directly; do not recursively invoke Bullswarm unless the task
|
|
21
|
+
explicitly requires another bounded delegation.
|
|
22
|
+
|
|
13
23
|
## When to reach for it
|
|
14
24
|
|
|
15
25
|
You should consider bullswarm when **any** of the following apply:
|
|
@@ -166,10 +176,19 @@ Every run gets a 6-character shortId (Crockford-style alphabet, no
|
|
|
166
176
|
bullswarm workflow runs # ongoing only (default)
|
|
167
177
|
bullswarm workflow runs --all # ongoing + historical
|
|
168
178
|
bullswarm workflow runs --name <workflow> # filter by workflow name
|
|
179
|
+
bullswarm workflow runs --all --since 7d # initiated in the last 7 days
|
|
180
|
+
bullswarm workflow runs --historical --since yesterday --until today
|
|
169
181
|
bullswarm workflow runs show <shortId> # state + report + summary
|
|
170
182
|
bullswarm workflow runs delete <shortId> --yes
|
|
171
183
|
```
|
|
172
184
|
|
|
185
|
+
Historical time ranges filter the workflow's initiation time (`startedAt`),
|
|
186
|
+
not completion time. `--since` is inclusive and `--until` is exclusive, with
|
|
187
|
+
`--from`/`--to` and `--started-after`/`--started-before` aliases. Bounds accept
|
|
188
|
+
ISO timestamps, local dates, today/yesterday/tomorrow/now, or durations such as
|
|
189
|
+
`7d`. Add `--all` or `--historical`; time filters do not silently change the
|
|
190
|
+
normal ongoing-only scope.
|
|
191
|
+
|
|
173
192
|
Before authoring or choosing a workflow, agents can inspect the live execution
|
|
174
193
|
fabric and the workflow document itself:
|
|
175
194
|
|
|
@@ -297,6 +316,10 @@ You do NOT need to set up bullswarm. Every verb self-initializes:
|
|
|
297
316
|
bullswarm doctor --json
|
|
298
317
|
```
|
|
299
318
|
|
|
319
|
+
Self-initialization prepares routing and connectors; it does not silently edit
|
|
320
|
+
global agent instructions. Inspect or explicitly install agent awareness with
|
|
321
|
+
`bullswarm integrate status --json` and `bullswarm integrate install --yes`.
|
|
322
|
+
|
|
300
323
|
The output has a `checks[]` array with one entry per readiness concern
|
|
301
324
|
(config, connectors, meters, offload-capable) and a `nextActions[]` list
|
|
302
325
|
of exact commands to fix anything missing.
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getVersion } from './lib/version.js';
|
|
|
16
16
|
import { release } from './lib/release.js';
|
|
17
17
|
import { cmdWorkflow } from './workflow/cli.js';
|
|
18
18
|
import { cmdStrategy, maybeRefreshStrategy } from './strategy-cli.js';
|
|
19
|
+
import { cmdIntegrate, installIntegration } from './integrate.js';
|
|
19
20
|
|
|
20
21
|
export function getBullswarmDir() {
|
|
21
22
|
const h = process.env.BULLSWARM_HOME?.trim();
|
|
@@ -31,8 +32,10 @@ function parseArgs(argv) {
|
|
|
31
32
|
const rest = [];
|
|
32
33
|
for (let i = 0; i < argv.length; i++) {
|
|
33
34
|
if (argv[i].startsWith('--')) {
|
|
34
|
-
const
|
|
35
|
+
const eq = argv[i].indexOf('=');
|
|
36
|
+
const key = argv[i].slice(2, eq > 0 ? eq : undefined);
|
|
35
37
|
if (key === 'json') args.json = true;
|
|
38
|
+
else if (eq > 0) args[key] = argv[i].slice(eq + 1);
|
|
36
39
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) args[key] = argv[++i];
|
|
37
40
|
else args[key] = true;
|
|
38
41
|
} else rest.push(argv[i]);
|
|
@@ -295,12 +298,20 @@ async function cmdSetup(opts) {
|
|
|
295
298
|
const report = await refreshStrategy(getBullswarmDir());
|
|
296
299
|
strategy = applyStrategyRecommendations(getBullswarmDir(), report);
|
|
297
300
|
}
|
|
298
|
-
|
|
301
|
+
let integration = null;
|
|
302
|
+
if (opts.yes && opts.integrate) {
|
|
303
|
+
integration = installIntegration({
|
|
304
|
+
agents: opts.agents,
|
|
305
|
+
approved: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, mode: 'auto', ...r, strategy, integration }, null, 2));
|
|
299
309
|
else {
|
|
300
310
|
console.log(`setup complete (${r.reason}): enabled ${r.enabledPools.join(', ')}`);
|
|
301
311
|
if (r.repaired.length) console.log(`repaired connector files: ${r.repaired.join(', ')}`);
|
|
302
312
|
console.log(`model strategy: ${r.strategyCommand} (discovers models and refreshes tier suggestions)`);
|
|
303
313
|
if (strategy) console.log(`strategy autopilot: applied ${Object.keys(strategy.applied).join(', ')} tiers; refresh every ${strategy.policy.refreshHours}h`);
|
|
314
|
+
if (integration) console.log('agent integration: installed (inspect with bullswarm integrate status)');
|
|
304
315
|
}
|
|
305
316
|
return 0;
|
|
306
317
|
}
|
|
@@ -413,6 +424,8 @@ export async function main(argv) {
|
|
|
413
424
|
return cmdWorkflow(['runs', ...rest]);
|
|
414
425
|
case 'strategy':
|
|
415
426
|
return cmdStrategy(rest, { bullswarmDir: getBullswarmDir() });
|
|
427
|
+
case 'integrate':
|
|
428
|
+
return cmdIntegrate(opts);
|
|
416
429
|
case 'version':
|
|
417
430
|
case '--version':
|
|
418
431
|
console.log(getVersion());
|
|
@@ -421,7 +434,7 @@ export async function main(argv) {
|
|
|
421
434
|
return cmdRelease(opts);
|
|
422
435
|
default:
|
|
423
436
|
console.error(
|
|
424
|
-
`unknown verb "${verb}". try: setup | run | health | pools | strategy | doctor | workflow | runs | version | release`,
|
|
437
|
+
`unknown verb "${verb}". try: setup | integrate | run | health | pools | strategy | doctor | workflow | runs | version | release`,
|
|
425
438
|
);
|
|
426
439
|
return 2;
|
|
427
440
|
}
|
package/src/integrate.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// bullswarm integrate — make the published agent guide discoverable by coding CLIs.
|
|
2
|
+
//
|
|
3
|
+
// Integration is explicit, idempotent, and reversible. The full operating
|
|
4
|
+
// procedure remains in skill/SKILL.md; global instruction files receive only a
|
|
5
|
+
// compact trigger policy so CLI documentation cannot drift in three places.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync,
|
|
9
|
+
symlinkSync, unlinkSync, writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const SKILL_SOURCE = fileURLToPath(new URL('../skill', import.meta.url));
|
|
15
|
+
const MARKER_BEGIN = '<!-- bullswarm:begin v2 -->';
|
|
16
|
+
const MARKER_END = '<!-- bullswarm:end -->';
|
|
17
|
+
const MARKER_RE = /<!-- bullswarm:begin v\d+ -->[\s\S]*?<!-- bullswarm:end -->\n?/;
|
|
18
|
+
|
|
19
|
+
const AGENT_LAYOUT = {
|
|
20
|
+
codex: { skill: ['.codex', 'skills', 'bullswarm'], instructions: ['.codex', 'AGENTS.md'] },
|
|
21
|
+
claude: { skill: ['.claude', 'skills', 'bullswarm'], instructions: ['.claude', 'CLAUDE.md'] },
|
|
22
|
+
grok: { skill: ['.grok', 'skills', 'bullswarm'], instructions: ['.grok', 'AGENTS.md'] },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const INTEGRATION_AGENTS = Object.freeze(Object.keys(AGENT_LAYOUT));
|
|
26
|
+
|
|
27
|
+
export function awarenessBlock() {
|
|
28
|
+
return `${MARKER_BEGIN}
|
|
29
|
+
## Bullswarm delegation
|
|
30
|
+
|
|
31
|
+
Bullswarm is available for bounded external delegation. When delegation,
|
|
32
|
+
offloading, independent verification, or autonomous multi-step execution is
|
|
33
|
+
requested, read the \`bullswarm\` skill before acting. Use \`bullswarm run\` for
|
|
34
|
+
one bounded task, \`bullswarm workflow goal\` for ordinary autonomous multi-step
|
|
35
|
+
work, and workflow drafts only when the graph itself is the contract. Treat
|
|
36
|
+
returned artifacts and verification as evidence, not authority. This policy
|
|
37
|
+
supersedes retired pre-Bullswarm \`offload\` routing instructions. If
|
|
38
|
+
\`BULLSWARM_DEPTH\` is already set, perform the assigned task directly and do
|
|
39
|
+
not recursively invoke Bullswarm unless the task explicitly requires it.
|
|
40
|
+
${MARKER_END}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function applyAwarenessBlock(filePath, { approved }) {
|
|
44
|
+
if (!approved) return { changed: false, reason: 'not approved' };
|
|
45
|
+
const existing = readOptional(filePath);
|
|
46
|
+
const stripped = existing.replace(MARKER_RE, '').trimEnd();
|
|
47
|
+
const next = stripped ? `${stripped}\n\n${awarenessBlock()}\n` : `${awarenessBlock()}\n`;
|
|
48
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
49
|
+
if (next === existing) return { changed: false, reason: 'already current' };
|
|
50
|
+
writeFileSync(filePath, next);
|
|
51
|
+
return { changed: true, reason: existing.match(MARKER_RE) ? 'updated' : 'installed' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function removeAwarenessBlock(filePath, { approved }) {
|
|
55
|
+
if (!approved) return { changed: false, reason: 'not approved' };
|
|
56
|
+
if (!existsSync(filePath)) return { changed: false, reason: 'not installed' };
|
|
57
|
+
const existing = readFileSync(filePath, 'utf8');
|
|
58
|
+
const next = existing.replace(MARKER_RE, '').trimEnd();
|
|
59
|
+
if (next === existing.trimEnd()) return { changed: false, reason: 'not installed' };
|
|
60
|
+
writeFileSync(filePath, next ? `${next}\n` : '');
|
|
61
|
+
return { changed: true, reason: 'removed' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function awarenessBlockPresent(filePath) {
|
|
65
|
+
return MARKER_RE.test(readOptional(filePath));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseIntegrationAgents(value) {
|
|
69
|
+
const requested = value == null || value === true
|
|
70
|
+
? INTEGRATION_AGENTS
|
|
71
|
+
: String(value).split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
72
|
+
const unique = [...new Set(requested)];
|
|
73
|
+
const invalid = unique.filter((agent) => !INTEGRATION_AGENTS.includes(agent));
|
|
74
|
+
if (invalid.length) {
|
|
75
|
+
throw new Error(`unknown integration agent(s): ${invalid.join(', ')}; use ${INTEGRATION_AGENTS.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
if (!unique.length) throw new Error('--agents must name at least one agent');
|
|
78
|
+
return unique;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function integrationStatus({
|
|
82
|
+
homeDir = process.env.HOME ?? '', agents = INTEGRATION_AGENTS, skillSource = SKILL_SOURCE,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const selected = parseIntegrationAgents(agents);
|
|
85
|
+
const entries = selected.map((agent) => {
|
|
86
|
+
const paths = pathsFor(homeDir, agent);
|
|
87
|
+
return {
|
|
88
|
+
agent,
|
|
89
|
+
skillPath: paths.skillPath,
|
|
90
|
+
skill: skillLinkStatus(paths.skillPath, skillSource),
|
|
91
|
+
instructionsPath: paths.instructionsPath,
|
|
92
|
+
awareness: awarenessBlockPresent(paths.instructionsPath),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
const legacyPath = join(homeDir, '.claude', 'skills', 'offload');
|
|
96
|
+
return {
|
|
97
|
+
ok: entries.every((entry) => entry.skill.status === 'installed' && entry.awareness),
|
|
98
|
+
skillSource,
|
|
99
|
+
agents: entries,
|
|
100
|
+
legacyOffload: {
|
|
101
|
+
path: legacyPath,
|
|
102
|
+
detected: existsSync(legacyPath),
|
|
103
|
+
action: existsSync(legacyPath)
|
|
104
|
+
? 'bullswarm integrate retire-legacy --yes'
|
|
105
|
+
: null,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function installIntegration({
|
|
111
|
+
homeDir = process.env.HOME ?? '', agents = INTEGRATION_AGENTS,
|
|
112
|
+
skillSource = SKILL_SOURCE, approved = false,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!approved) throw new Error('integration changes global agent configuration; pass --yes to approve');
|
|
115
|
+
const selected = parseIntegrationAgents(agents);
|
|
116
|
+
if (!existsSync(join(skillSource, 'SKILL.md'))) {
|
|
117
|
+
throw new Error(`packaged Bullswarm skill is missing: ${join(skillSource, 'SKILL.md')}`);
|
|
118
|
+
}
|
|
119
|
+
for (const agent of selected) {
|
|
120
|
+
const { skillPath } = pathsFor(homeDir, agent);
|
|
121
|
+
if (skillLinkStatus(skillPath, skillSource).status === 'conflict') {
|
|
122
|
+
throw new Error(`refusing to replace non-Bullswarm skill path: ${skillPath}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const changes = [];
|
|
126
|
+
for (const agent of selected) {
|
|
127
|
+
const paths = pathsFor(homeDir, agent);
|
|
128
|
+
const skill = installSkillLink(paths.skillPath, skillSource);
|
|
129
|
+
const awareness = applyAwarenessBlock(paths.instructionsPath, { approved: true });
|
|
130
|
+
changes.push({ agent, skill, awareness });
|
|
131
|
+
}
|
|
132
|
+
return { action: 'install', changes, status: integrationStatus({ homeDir, agents: selected, skillSource }) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function removeIntegration({
|
|
136
|
+
homeDir = process.env.HOME ?? '', agents = INTEGRATION_AGENTS,
|
|
137
|
+
skillSource = SKILL_SOURCE, approved = false,
|
|
138
|
+
} = {}) {
|
|
139
|
+
if (!approved) throw new Error('integration removal changes global agent configuration; pass --yes to approve');
|
|
140
|
+
const selected = parseIntegrationAgents(agents);
|
|
141
|
+
const changes = [];
|
|
142
|
+
for (const agent of selected) {
|
|
143
|
+
const paths = pathsFor(homeDir, agent);
|
|
144
|
+
const skill = removeSkillLink(paths.skillPath, skillSource);
|
|
145
|
+
const awareness = removeAwarenessBlock(paths.instructionsPath, { approved: true });
|
|
146
|
+
changes.push({ agent, skill, awareness });
|
|
147
|
+
}
|
|
148
|
+
return { action: 'remove', changes, status: integrationStatus({ homeDir, agents: selected, skillSource }) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function retireLegacyOffload({ homeDir = process.env.HOME ?? '', approved = false, now = new Date() } = {}) {
|
|
152
|
+
if (!approved) throw new Error('legacy offload retirement moves a user skill; pass --yes to approve');
|
|
153
|
+
const source = join(homeDir, '.claude', 'skills', 'offload');
|
|
154
|
+
if (!existsSync(source)) return { action: 'retire-legacy', changed: false, reason: 'not installed' };
|
|
155
|
+
const archiveRoot = join(homeDir, '.claude', 'skills-archive');
|
|
156
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
157
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
158
|
+
const destination = join(archiveRoot, `offload-before-bullswarm-${stamp}`);
|
|
159
|
+
renameSync(source, destination);
|
|
160
|
+
return { action: 'retire-legacy', changed: true, source, destination, recoverable: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function integrateUsage() {
|
|
164
|
+
return `usage: bullswarm integrate [status] [--agents codex,claude,grok] [--json]
|
|
165
|
+
bullswarm integrate install --agents codex,claude,grok --yes [--json]
|
|
166
|
+
bullswarm integrate remove --agents codex,claude,grok --yes [--json]
|
|
167
|
+
bullswarm integrate retire-legacy --yes [--json]
|
|
168
|
+
|
|
169
|
+
Install registers the packaged Bullswarm skill and a concise recursion-safe
|
|
170
|
+
awareness rule. Removal only deletes Bullswarm-managed symlinks and marker
|
|
171
|
+
blocks. retire-legacy moves ~/.claude/skills/offload into skills-archive.`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function cmdIntegrate(opts) {
|
|
175
|
+
const subcommand = opts.rest[0] ?? 'status';
|
|
176
|
+
if (opts.help || subcommand === 'help') {
|
|
177
|
+
console.log(integrateUsage());
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
let agents;
|
|
181
|
+
try {
|
|
182
|
+
agents = parseIntegrationAgents(opts.agents);
|
|
183
|
+
let result;
|
|
184
|
+
if (subcommand === 'status') result = { action: 'status', ...integrationStatus({ agents }) };
|
|
185
|
+
else if (subcommand === 'install') result = installIntegration({ agents, approved: opts.yes === true });
|
|
186
|
+
else if (subcommand === 'remove') result = removeIntegration({ agents, approved: opts.yes === true });
|
|
187
|
+
else if (subcommand === 'retire-legacy') result = retireLegacyOffload({ approved: opts.yes === true });
|
|
188
|
+
else {
|
|
189
|
+
console.error(integrateUsage());
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
193
|
+
else printIntegrationResult(result);
|
|
194
|
+
const ok = result.status?.ok ?? result.ok;
|
|
195
|
+
return ok === false && subcommand === 'status' ? 1 : 0;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error(`✗ ${error.message}`);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function pathsFor(homeDir, agent) {
|
|
203
|
+
const layout = AGENT_LAYOUT[agent];
|
|
204
|
+
return {
|
|
205
|
+
skillPath: join(homeDir, ...layout.skill),
|
|
206
|
+
instructionsPath: join(homeDir, ...layout.instructions),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function installSkillLink(skillPath, skillSource) {
|
|
211
|
+
const current = skillLinkStatus(skillPath, skillSource);
|
|
212
|
+
if (current.status === 'installed') return { changed: false, ...current };
|
|
213
|
+
if (current.status === 'conflict') {
|
|
214
|
+
throw new Error(`refusing to replace non-Bullswarm skill path: ${skillPath}`);
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(skillPath), { recursive: true });
|
|
217
|
+
symlinkSync(skillSource, skillPath, 'dir');
|
|
218
|
+
return { changed: true, status: 'installed', path: skillPath, target: skillSource };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function removeSkillLink(skillPath, skillSource) {
|
|
222
|
+
const current = skillLinkStatus(skillPath, skillSource);
|
|
223
|
+
if (current.status === 'missing') return { changed: false, ...current };
|
|
224
|
+
if (current.status === 'conflict') {
|
|
225
|
+
return { changed: false, ...current, reason: 'left conflict untouched' };
|
|
226
|
+
}
|
|
227
|
+
unlinkSync(skillPath);
|
|
228
|
+
return { changed: true, status: 'missing', path: skillPath, target: skillSource };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function skillLinkStatus(skillPath, skillSource) {
|
|
232
|
+
let stat;
|
|
233
|
+
try { stat = lstatSync(skillPath); } catch { return { status: 'missing', path: skillPath, target: null }; }
|
|
234
|
+
if (!stat.isSymbolicLink()) return { status: 'conflict', path: skillPath, target: null };
|
|
235
|
+
const rawTarget = readlinkSync(skillPath);
|
|
236
|
+
const target = resolve(dirname(skillPath), rawTarget);
|
|
237
|
+
return {
|
|
238
|
+
status: target === resolve(skillSource) ? 'installed' : 'conflict',
|
|
239
|
+
path: skillPath,
|
|
240
|
+
target,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readOptional(filePath) {
|
|
245
|
+
try { return readFileSync(filePath, 'utf8'); } catch { return ''; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function printIntegrationResult(result) {
|
|
249
|
+
if (result.action === 'retire-legacy') {
|
|
250
|
+
console.log(result.changed
|
|
251
|
+
? `✓ archived retired offload skill at ${result.destination}`
|
|
252
|
+
: 'retired offload skill is not installed');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const status = result.status ?? result;
|
|
256
|
+
for (const entry of status.agents ?? []) {
|
|
257
|
+
const skill = entry.skill.status === 'installed' ? 'skill ✓' : `skill ${entry.skill.status}`;
|
|
258
|
+
const awareness = entry.awareness ? 'awareness ✓' : 'awareness missing';
|
|
259
|
+
console.log(`${entry.agent.padEnd(8)} ${skill}; ${awareness}`);
|
|
260
|
+
}
|
|
261
|
+
if (status.legacyOffload?.detected) {
|
|
262
|
+
console.log(`⚠ retired Claude offload skill detected: ${status.legacyOffload.path}`);
|
|
263
|
+
console.log(` recoverably archive it with: ${status.legacyOffload.action}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
// entry. Burn rate starts EMPTY and is labeled "learning".
|
|
6
6
|
// U2. The wizard suggests a routing table as an EDITABLE ARTIFACT, never a
|
|
7
7
|
// questionnaire.
|
|
8
|
-
// U3.
|
|
9
|
-
//
|
|
10
|
-
// markers, idempotent on re-run.
|
|
8
|
+
// U3. Cross-agent skill/instruction integration requires explicit approval,
|
|
9
|
+
// uses versioned bullswarm:begin/end markers, and is idempotent.
|
|
11
10
|
// U4. `bullswarm setup` on a configured machine reports state and repairs
|
|
12
11
|
// broken connector files.
|
|
13
12
|
|
|
@@ -15,14 +14,16 @@ import { execFileSync } from 'node:child_process';
|
|
|
15
14
|
import {
|
|
16
15
|
existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync,
|
|
17
16
|
} from 'node:fs';
|
|
18
|
-
import { join
|
|
17
|
+
import { join } from 'node:path';
|
|
19
18
|
import { fileURLToPath } from 'node:url';
|
|
20
19
|
import { stdin as input } from 'node:process';
|
|
21
20
|
import { loadState, saveState } from './lib/state.js';
|
|
21
|
+
import {
|
|
22
|
+
awarenessBlock, applyAwarenessBlock, awarenessBlockPresent,
|
|
23
|
+
installIntegration, retireLegacyOffload,
|
|
24
|
+
} from './integrate.js';
|
|
22
25
|
|
|
23
26
|
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
24
|
-
const MARKER_BEGIN = '<!-- bullswarm:begin v1 -->';
|
|
25
|
-
const MARKER_END = '<!-- bullswarm:end -->';
|
|
26
27
|
|
|
27
28
|
// --- prompting ------------------------------------------------------------
|
|
28
29
|
// Sequential prompts that work identically on a TTY and with piped answers.
|
|
@@ -121,45 +122,15 @@ export function suggestRoutingTable(enabledPools) {
|
|
|
121
122
|
// --- integration block ------------------------------------------------------------
|
|
122
123
|
|
|
123
124
|
export function integrationBlock() {
|
|
124
|
-
return
|
|
125
|
-
## bullswarm offload policy
|
|
126
|
-
|
|
127
|
-
When a task fits a bounded lane, prefer offloading it:
|
|
128
|
-
|
|
129
|
-
bullswarm run --lane <analyze|build|chore> --add-dir <repo-dir> --task-file <file> --json
|
|
130
|
-
|
|
131
|
-
Read the verdict JSON: ok:true -> use outFile; keepOnClaude:true -> do it in-session;
|
|
132
|
-
ok:false -> the why field names the failed gate. Delegate output is INPUT you verify,
|
|
133
|
-
never the answer. Final synthesis, architecture decisions, and live-context work stay
|
|
134
|
-
with you. Run \`bullswarm health\` after every offload round.
|
|
135
|
-
${MARKER_END}`;
|
|
125
|
+
return awarenessBlock();
|
|
136
126
|
}
|
|
137
127
|
|
|
138
128
|
export function applyIntegrationBlock(filePath, { approved }) {
|
|
139
|
-
|
|
140
|
-
let existing = '';
|
|
141
|
-
try {
|
|
142
|
-
existing = readFileSync(filePath, 'utf8');
|
|
143
|
-
} catch {
|
|
144
|
-
/* new file */
|
|
145
|
-
}
|
|
146
|
-
const stripped = existing
|
|
147
|
-
.replace(new RegExp(`${MARKER_BEGIN}[\\s\\S]*?${MARKER_END}\n?`), '')
|
|
148
|
-
.trimEnd();
|
|
149
|
-
const next = stripped
|
|
150
|
-
? `${stripped}\n\n${integrationBlock()}\n`
|
|
151
|
-
: `${integrationBlock()}\n`;
|
|
152
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
153
|
-
writeFileSync(filePath, next);
|
|
154
|
-
return { changed: true };
|
|
129
|
+
return applyAwarenessBlock(filePath, { approved });
|
|
155
130
|
}
|
|
156
131
|
|
|
157
132
|
export function integrationBlockPresent(filePath) {
|
|
158
|
-
|
|
159
|
-
return readFileSync(filePath, 'utf8').includes(MARKER_BEGIN);
|
|
160
|
-
} catch {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
133
|
+
return awarenessBlockPresent(filePath);
|
|
163
134
|
}
|
|
164
135
|
|
|
165
136
|
// --- repair ---------------------------------------------------------------
|
|
@@ -398,21 +369,25 @@ export async function runWizard(bullswarmDir, opts = {}) {
|
|
|
398
369
|
console.log(' strategy autopilot: off (enable later with bullswarm strategy apply --yes)');
|
|
399
370
|
}
|
|
400
371
|
|
|
401
|
-
// 6.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
372
|
+
// 6. Cross-agent integration — one canonical skill plus concise global
|
|
373
|
+
// awareness rules. Nothing is written without this explicit answer.
|
|
374
|
+
const integrateAnswer = (
|
|
375
|
+
await rl.question('install Bullswarm skill for Codex, Claude, and Grok? [y/N] ')
|
|
376
|
+
).trim().toLowerCase();
|
|
377
|
+
if (integrateAnswer === 'y' || integrateAnswer === 'yes') {
|
|
378
|
+
const integrated = installIntegration({ approved: true });
|
|
379
|
+
console.log(` agent integration: ${integrated.status.ok ? 'ready' : 'incomplete'}`);
|
|
380
|
+
if (integrated.status.legacyOffload.detected) {
|
|
381
|
+
const retireAnswer = (
|
|
382
|
+
await rl.question('archive the retired Claude offload skill? [y/N] ')
|
|
383
|
+
).trim().toLowerCase();
|
|
384
|
+
if (retireAnswer === 'y' || retireAnswer === 'yes') {
|
|
385
|
+
const retired = retireLegacyOffload({ approved: true });
|
|
386
|
+
console.log(` retired offload: ${retired.changed ? `archived at ${retired.destination}` : retired.reason}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
console.log(' agent integration: skipped (install later with bullswarm integrate install --yes)');
|
|
416
391
|
}
|
|
417
392
|
|
|
418
393
|
rl.close();
|
package/src/workflow/runs-cli.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// bullswarm workflow runs --all # list ongoing + historical
|
|
5
5
|
// bullswarm workflow runs --historical # only historical
|
|
6
6
|
// bullswarm workflow runs --name <workflow> # filter by workflow name
|
|
7
|
+
// bullswarm workflow runs --all --since 7d # initiated in the last 7 days
|
|
8
|
+
// bullswarm workflow runs --all --since yesterday --until today
|
|
7
9
|
// bullswarm workflow runs --limit N # cap result count
|
|
8
10
|
// bullswarm workflow runs show <id> # dump state + report
|
|
9
11
|
// bullswarm workflow runs delete <id> --yes # remove the run directory
|
|
@@ -32,29 +34,60 @@ export function cmdRuns(args) {
|
|
|
32
34
|
export function runsUsage() {
|
|
33
35
|
return `usage:
|
|
34
36
|
bullswarm workflow runs # list ongoing runs
|
|
35
|
-
bullswarm workflow runs [--all | --historical] [--name <workflow>]
|
|
37
|
+
bullswarm workflow runs [--all | --historical] [--name <workflow>]
|
|
38
|
+
[--since <time>] [--until <time>] [--limit N] [--json]
|
|
36
39
|
bullswarm workflow runs show <id> # <id> = shortId (6 chars) or full runId
|
|
37
|
-
bullswarm workflow runs delete <id> --yes # remove the run directory
|
|
40
|
+
bullswarm workflow runs delete <id> --yes # remove the run directory
|
|
41
|
+
|
|
42
|
+
Time filters compare the workflow initiation timestamp (startedAt):
|
|
43
|
+
--since / --started-after / --from inclusive lower bound
|
|
44
|
+
--until / --started-before / --to exclusive upper bound
|
|
45
|
+
Values accept ISO timestamps, local dates, today/yesterday/tomorrow/now,
|
|
46
|
+
or relative durations such as 30m, 24h, 7d, and 2w.`;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
function parseRunsFlags(argv) {
|
|
41
50
|
const out = { _positional: [] };
|
|
51
|
+
const valueFlags = new Map([
|
|
52
|
+
['name', 'name'],
|
|
53
|
+
['limit', 'limit'],
|
|
54
|
+
['since', 'since'],
|
|
55
|
+
['started-after', 'since'],
|
|
56
|
+
['from', 'since'],
|
|
57
|
+
['until', 'until'],
|
|
58
|
+
['started-before', 'until'],
|
|
59
|
+
['to', 'until'],
|
|
60
|
+
]);
|
|
42
61
|
for (let i = 0; i < argv.length; i++) {
|
|
43
62
|
const a = argv[i];
|
|
44
63
|
if (a === '--json') out.json = true;
|
|
45
64
|
else if (a === '--all') out.all = true;
|
|
46
65
|
else if (a === '--historical') out.historical = true;
|
|
47
66
|
else if (a === '--yes' || a === '-y') out.yes = true;
|
|
48
|
-
else if (a === '--name') out.name = argv[++i];
|
|
49
|
-
else if (a === '--limit') out.limit = Number(argv[++i]);
|
|
50
67
|
else if (a === '--force') out.force = true;
|
|
51
|
-
else if (a.startsWith('--'))
|
|
68
|
+
else if (a.startsWith('--')) {
|
|
69
|
+
const eq = a.indexOf('=');
|
|
70
|
+
const key = a.slice(2, eq > 0 ? eq : undefined);
|
|
71
|
+
const target = valueFlags.get(key);
|
|
72
|
+
if (target) {
|
|
73
|
+
const value = eq > 0 ? a.slice(eq + 1) : argv[++i];
|
|
74
|
+
out[target] = target === 'limit' ? Number(value) : value;
|
|
75
|
+
} else {
|
|
76
|
+
out[key] = eq > 0 ? a.slice(eq + 1) : true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
52
79
|
else out._positional.push(a);
|
|
53
80
|
}
|
|
54
81
|
return out;
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
function runsList(opts) {
|
|
85
|
+
let initiatedRange;
|
|
86
|
+
try {
|
|
87
|
+
initiatedRange = resolveInitiatedRange(opts);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return err(error.message);
|
|
90
|
+
}
|
|
58
91
|
const all = listRuns(BULLSWARM_DIR());
|
|
59
92
|
let filtered = all;
|
|
60
93
|
if (opts.name) filtered = filtered.filter((r) => r.state?.workflow === opts.name);
|
|
@@ -64,10 +97,22 @@ function runsList(opts) {
|
|
|
64
97
|
} else if (opts.historical) {
|
|
65
98
|
filtered = filtered.filter((r) => !r.ongoing);
|
|
66
99
|
}
|
|
100
|
+
if (initiatedRange.sinceMs != null) {
|
|
101
|
+
filtered = filtered.filter((r) => {
|
|
102
|
+
const startedMs = Date.parse(runStartedAt(r) ?? '');
|
|
103
|
+
return Number.isFinite(startedMs) && startedMs >= initiatedRange.sinceMs;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (initiatedRange.untilMs != null) {
|
|
107
|
+
filtered = filtered.filter((r) => {
|
|
108
|
+
const startedMs = Date.parse(runStartedAt(r) ?? '');
|
|
109
|
+
return Number.isFinite(startedMs) && startedMs < initiatedRange.untilMs;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
67
112
|
// Newest first.
|
|
68
113
|
filtered.sort((a, b) => {
|
|
69
|
-
const ta = a
|
|
70
|
-
const tb = b
|
|
114
|
+
const ta = runStartedAt(a) ?? '';
|
|
115
|
+
const tb = runStartedAt(b) ?? '';
|
|
71
116
|
return tb.localeCompare(ta);
|
|
72
117
|
});
|
|
73
118
|
if (opts.limit && opts.limit > 0) filtered = filtered.slice(0, opts.limit);
|
|
@@ -77,6 +122,15 @@ function runsList(opts) {
|
|
|
77
122
|
ongoing: opts.all || !opts.historical,
|
|
78
123
|
historical: opts.all || opts.historical,
|
|
79
124
|
name: opts.name ?? null,
|
|
125
|
+
initiatedRange: {
|
|
126
|
+
field: 'startedAt',
|
|
127
|
+
sinceInclusive: initiatedRange.sinceMs == null
|
|
128
|
+
? null
|
|
129
|
+
: new Date(initiatedRange.sinceMs).toISOString(),
|
|
130
|
+
untilExclusive: initiatedRange.untilMs == null
|
|
131
|
+
? null
|
|
132
|
+
: new Date(initiatedRange.untilMs).toISOString(),
|
|
133
|
+
},
|
|
80
134
|
count: filtered.length,
|
|
81
135
|
runs: filtered.map(summarize),
|
|
82
136
|
}, opts);
|
|
@@ -96,7 +150,7 @@ function runsList(opts) {
|
|
|
96
150
|
for (const r of filtered) {
|
|
97
151
|
const wf = r.state?.workflow ?? '?';
|
|
98
152
|
const status = r.state?.status ?? (r.ongoing ? 'running' : 'unknown');
|
|
99
|
-
const age = humanAge(r
|
|
153
|
+
const age = humanAge(runStartedAt(r));
|
|
100
154
|
const phases = `${r.state?.steps?.filter((s) => s.ok).length ?? 0}/${r.state?.steps?.length ?? 0}`;
|
|
101
155
|
console.log(
|
|
102
156
|
`${r.ongoing ? '●' : '○'} ${(r.shortId ?? '------').padEnd(8)} ` +
|
|
@@ -167,8 +221,8 @@ function summarize(r) {
|
|
|
167
221
|
shortId: r.shortId,
|
|
168
222
|
workflow: r.state?.workflow ?? null,
|
|
169
223
|
status: r.state?.status ?? null,
|
|
170
|
-
startedAt: r
|
|
171
|
-
finishedAt: r.state?.finishedAt ?? null,
|
|
224
|
+
startedAt: runStartedAt(r),
|
|
225
|
+
finishedAt: r.state?.finishedAt ?? r.report?.finishedAt ?? null,
|
|
172
226
|
ongoing: r.ongoing,
|
|
173
227
|
stepsOk: r.state?.steps?.filter((s) => s.ok).length ?? 0,
|
|
174
228
|
stepsFailed: r.state?.steps?.filter((s) => s.ok === false).length ?? 0,
|
|
@@ -177,6 +231,58 @@ function summarize(r) {
|
|
|
177
231
|
};
|
|
178
232
|
}
|
|
179
233
|
|
|
234
|
+
function runStartedAt(run) {
|
|
235
|
+
return run.state?.startedAt ?? run.report?.startedAt ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveInitiatedRange(opts, nowMs = Date.now()) {
|
|
239
|
+
const sinceMs = opts.since == null ? null : parseTimeBound(opts.since, nowMs, '--since');
|
|
240
|
+
const untilMs = opts.until == null ? null : parseTimeBound(opts.until, nowMs, '--until');
|
|
241
|
+
if (sinceMs != null && untilMs != null && sinceMs >= untilMs) {
|
|
242
|
+
throw new Error('initiated-time range is empty: --since must be earlier than --until');
|
|
243
|
+
}
|
|
244
|
+
return { sinceMs, untilMs };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseTimeBound(value, nowMs, flag) {
|
|
248
|
+
const raw = String(value ?? '').trim();
|
|
249
|
+
if (!raw) throw new Error(`${flag} requires a time value`);
|
|
250
|
+
const normalized = raw.toLowerCase();
|
|
251
|
+
|
|
252
|
+
if (normalized === 'now') return nowMs;
|
|
253
|
+
if (['yesterday', 'today', 'tomorrow'].includes(normalized)) {
|
|
254
|
+
const date = new Date(nowMs);
|
|
255
|
+
date.setHours(0, 0, 0, 0);
|
|
256
|
+
if (normalized === 'yesterday') date.setDate(date.getDate() - 1);
|
|
257
|
+
if (normalized === 'tomorrow') date.setDate(date.getDate() + 1);
|
|
258
|
+
return date.getTime();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const duration = normalized.match(/^(\d+(?:\.\d+)?)(m|h|d|w)$/);
|
|
262
|
+
if (duration) {
|
|
263
|
+
const unitMs = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[duration[2]];
|
|
264
|
+
return nowMs - Number(duration[1]) * unitMs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const localDate = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
268
|
+
if (localDate) {
|
|
269
|
+
const [, year, month, day] = localDate;
|
|
270
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
271
|
+
if (date.getFullYear() !== Number(year)
|
|
272
|
+
|| date.getMonth() !== Number(month) - 1
|
|
273
|
+
|| date.getDate() !== Number(day)) {
|
|
274
|
+
throw new Error(`${flag} has an invalid calendar date: "${raw}"`);
|
|
275
|
+
}
|
|
276
|
+
return date.getTime();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parsed = Date.parse(raw);
|
|
280
|
+
if (!Number.isFinite(parsed)) {
|
|
281
|
+
throw new Error(`${flag} has an invalid time: "${raw}"`);
|
|
282
|
+
}
|
|
283
|
+
return parsed;
|
|
284
|
+
}
|
|
285
|
+
|
|
180
286
|
function humanAge(iso) {
|
|
181
287
|
if (!iso) return '—';
|
|
182
288
|
const ms = Date.now() - Date.parse(iso);
|