@hjr15/blaze-board 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +141 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +38 -0
- package/scripts/cli.mjs +31 -0
- package/scripts/commit-or-queue.mjs +22 -0
- package/scripts/commit-runner.mjs +40 -0
- package/scripts/config.mjs +135 -0
- package/scripts/edit-runner.mjs +18 -0
- package/scripts/edit.mjs +85 -0
- package/scripts/event-bus.mjs +16 -0
- package/scripts/log-runner.mjs +38 -0
- package/scripts/log.mjs +44 -0
- package/scripts/loops/groomer.mjs +215 -0
- package/scripts/migrate/audit.mjs +92 -0
- package/scripts/migrate/jira-client.mjs +26 -0
- package/scripts/migrate/jira-import.mjs +96 -0
- package/scripts/migrate/map.mjs +110 -0
- package/scripts/migrate/merge.mjs +103 -0
- package/scripts/migrate/normalize.mjs +60 -0
- package/scripts/migrate/report.mjs +67 -0
- package/scripts/migrate/restructure.mjs +49 -0
- package/scripts/migrate-runner.mjs +51 -0
- package/scripts/model/.gitkeep +0 -0
- package/scripts/model/ids.mjs +32 -0
- package/scripts/model/index.mjs +63 -0
- package/scripts/model/move-plan.mjs +25 -0
- package/scripts/model/rollup.mjs +55 -0
- package/scripts/model/rules.mjs +64 -0
- package/scripts/model/schema.mjs +30 -0
- package/scripts/model/ticket.mjs +136 -0
- package/scripts/model/time.mjs +38 -0
- package/scripts/model/workflows.mjs +54 -0
- package/scripts/move-runner.mjs +18 -0
- package/scripts/move.mjs +56 -0
- package/scripts/new-runner.mjs +43 -0
- package/scripts/new.mjs +54 -0
- package/scripts/pending-ledger.mjs +36 -0
- package/scripts/reconcile.mjs +181 -0
- package/scripts/reindex.mjs +22 -0
- package/scripts/resolve-runner.mjs +17 -0
- package/scripts/resolve.mjs +21 -0
- package/scripts/rollup-runner.mjs +53 -0
- package/scripts/serve-commit.mjs +18 -0
- package/scripts/serve.mjs +658 -0
- package/scripts/supervisor.mjs +192 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Driving Blaze with an agent
|
|
2
|
+
|
|
3
|
+
Blaze is a file-based issue tracker. **A ticket's status is the directory it sits
|
|
4
|
+
in** — `projects/<KEY>/<status>/<KEY>-<n>-slug.md` — there is no `status:` field, so
|
|
5
|
+
it cannot drift. Any coding agent can drive it with ordinary file tools (`ls`,
|
|
6
|
+
`grep`, `git mv`), or with the `blaze` CLI, which is the recommended path since it
|
|
7
|
+
validates every write against the schema below and commits scoped to the files it
|
|
8
|
+
touched.
|
|
9
|
+
|
|
10
|
+
## Types & workflow
|
|
11
|
+
|
|
12
|
+
Every ticket has a `type`. Each type follows one of three workflows — its own
|
|
13
|
+
sequence of statuses:
|
|
14
|
+
|
|
15
|
+
| Type | Parent | Required fields | Workflow | Statuses (initial → terminal) |
|
|
16
|
+
|---|---|---|---|---|
|
|
17
|
+
| `goal` | — | title, description | `goal` | `defined → in-progress → achieved` |
|
|
18
|
+
| `epic` | goal | title, description | `delivery` | `defined → in-progress → in-review → done` |
|
|
19
|
+
| `story` / `task` / `bug` | epic | title, description, **estimate** | `delivery` | `defined → in-progress → in-review → done` |
|
|
20
|
+
| `subtask` | story/task/bug | title, description | `delivery` | `defined → in-progress → in-review → done` |
|
|
21
|
+
| `risk` | goal or epic | title, description, likelihood, impact | `risk` | `identified → mitigated` / `accepted` / `obsolete` |
|
|
22
|
+
|
|
23
|
+
A terminal move auto-sets `resolution` (`done` for `achieved`/`done`/`mitigated`/
|
|
24
|
+
`accepted`; `wont-do` for `obsolete`). Use `blaze resolve <id> <resolution>` for a
|
|
25
|
+
non-default resolution (`wont-do`, `duplicate`, `cannot-reproduce`) without moving
|
|
26
|
+
the file. These are the engine's **defaults**, defined once in
|
|
27
|
+
`scripts/model/schema.mjs` / `workflows.mjs`. A project's `project.json` can carry
|
|
28
|
+
a `workflowOverrides` field reserved for future per-project workflow
|
|
29
|
+
customisation (see Configuration below) — it is not yet consumed by the engine,
|
|
30
|
+
so every project uses the table above unconditionally today.
|
|
31
|
+
|
|
32
|
+
## The loop
|
|
33
|
+
|
|
34
|
+
1. **Create**: `blaze new --project <KEY> --type <type> "<title>" [--estimate m]
|
|
35
|
+
[--parent ID]`. It lands in the type's initial status (`defined` or
|
|
36
|
+
`identified`).
|
|
37
|
+
2. **You** move it forward by hand — `blaze move <id> <status>` — when you commit
|
|
38
|
+
to working it (intent is a human/agent decision, not automatic).
|
|
39
|
+
3. If the project has a `codeRepos` entry, `blaze reconcile` takes over for
|
|
40
|
+
**delivery-workflow tickets only** (epic/story/task/bug/subtask): a branch
|
|
41
|
+
embedding the ticket's key moves it to `in-progress`; opening its PR moves it to
|
|
42
|
+
`in-review`; merging moves it to `done`. Goals and risks are always manual.
|
|
43
|
+
Never hand-move a delivery ticket through the reconcile-owned statuses once a
|
|
44
|
+
branch/PR exists for it — let reconcile own it.
|
|
45
|
+
|
|
46
|
+
## The join key
|
|
47
|
+
|
|
48
|
+
The only coupling between the tracker and code is the branch name: it must embed
|
|
49
|
+
`<KEY>-<n>`, e.g. `KEY-12-add-export`. `reconcile` greps `KEY-12` out of every
|
|
50
|
+
branch/PR head ref in the project's `codeRepos` and matches it to
|
|
51
|
+
`projects/KEY/*/KEY-12-*.md`. No API, no webhook, no stored id.
|
|
52
|
+
|
|
53
|
+
## Frontmatter
|
|
54
|
+
|
|
55
|
+
Field order as written by the engine: `id`, `title`, `type`, `project`,
|
|
56
|
+
`priority`, `resolution`, `parent`, `assignee`, `labels`, `components`,
|
|
57
|
+
`estimate`, `worklog`, `links`, `likelihood`, `impact`, `branch`, `pr`, `created`,
|
|
58
|
+
`updated`.
|
|
59
|
+
|
|
60
|
+
- `id` — `<KEY>-<n>`, matches the filename, sequential, never reused.
|
|
61
|
+
- `priority` — one of `highest`/`high`/`medium`/`low`/`lowest`/`none`/`urgent`.
|
|
62
|
+
- `resolution` — `null` until terminal; one of `done`/`wont-do`/`duplicate`/
|
|
63
|
+
`cannot-reproduce`.
|
|
64
|
+
- `parent` — another ticket's `id`; must satisfy the parent-type rule in the table
|
|
65
|
+
above (validated, including cycle detection).
|
|
66
|
+
- `labels` / `components` — free-form; keep to whatever taxonomy the project sets
|
|
67
|
+
in `project.json` (`defaultLabels` in `blaze.config.json` is the tracker-wide
|
|
68
|
+
fallback).
|
|
69
|
+
- `estimate` — minutes, rounded to the nearest 5 (`blaze new --estimate`).
|
|
70
|
+
- `worklog` — list of `{ date, minutes, note? }`, appended by `blaze log`; minutes
|
|
71
|
+
round to the nearest whole minute.
|
|
72
|
+
- `likelihood` / `impact` — risk-only fields.
|
|
73
|
+
- `branch` / `pr` — filled by `reconcile`; don't hand-edit.
|
|
74
|
+
|
|
75
|
+
## Data-root ladder
|
|
76
|
+
|
|
77
|
+
The engine (this install) and the data (`blaze.config.json` + `projects/` + the git
|
|
78
|
+
history tickets commit into) can live in different trees. Every command resolves
|
|
79
|
+
roots the same way:
|
|
80
|
+
|
|
81
|
+
1. `BLAZE_PROJECTS_DIR` env — an explicit `projects/` directory; the data root is
|
|
82
|
+
its parent.
|
|
83
|
+
2. `./projects` under the current working directory — running from inside the
|
|
84
|
+
data repo.
|
|
85
|
+
3. The engine tree itself — single-tree back-compat only, and **only when the
|
|
86
|
+
engine isn't installed under `node_modules`**. For a global/npx install
|
|
87
|
+
(the normal packaged case), if neither rung 1 nor 2 matched, `resolveRoots`
|
|
88
|
+
throws `blaze: no data dir found — set BLAZE_PROJECTS_DIR or run from a
|
|
89
|
+
directory containing projects/` instead of silently falling back to the
|
|
90
|
+
engine's own tree.
|
|
91
|
+
|
|
92
|
+
## Commit modes
|
|
93
|
+
|
|
94
|
+
`blaze.config.json`'s `commitMode` decides how CLI verbs commit:
|
|
95
|
+
|
|
96
|
+
- `per-op` (default) — each `new`/`move`/`log`/`resolve`/`edit` commits immediately,
|
|
97
|
+
scoped to exactly the file(s) it touched (never a broad `git add -A`).
|
|
98
|
+
- `batch` — the op is appended to `.blaze/pending-commit.jsonl` instead; run
|
|
99
|
+
`blaze commit` to flush everything queued into one commit (subject = a per-op
|
|
100
|
+
count summary, body = one line per queued op).
|
|
101
|
+
|
|
102
|
+
## Querying the board
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
for s in defined in-progress in-review; do echo "## $s"; ls projects/*/$s/*.md 2>/dev/null; done
|
|
106
|
+
grep -rl '^priority: urgent' --include='*.md' projects/
|
|
107
|
+
blaze rollup # every goal/epic's rolled-up estimate + logged time
|
|
108
|
+
blaze rollup KEY-12 # one ticket's own vs. rolled totals, with child breakdown
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Grooming rules
|
|
112
|
+
|
|
113
|
+
When grooming a freshly-captured ticket (in its type's initial status), make these
|
|
114
|
+
and only these edits to its `.md` file, then stop:
|
|
115
|
+
|
|
116
|
+
- **Type & priority**: set `type` and `priority` from the ticket's content.
|
|
117
|
+
- **Labels**: add labels from the project's configured taxonomy that match the
|
|
118
|
+
area/intent. Do not invent new labels.
|
|
119
|
+
- **Acceptance criteria**: if the `## Acceptance Criteria` list is empty or a
|
|
120
|
+
placeholder, draft 2–4 concrete, testable checkboxes from the context.
|
|
121
|
+
- **Duplicates**: if the ticket clearly duplicates another, note it in `## Notes`
|
|
122
|
+
pointing at the surviving id (do not move or delete it — that stays a human
|
|
123
|
+
decision).
|
|
124
|
+
- **Links**: in `## Notes`, link closely related tickets by id.
|
|
125
|
+
|
|
126
|
+
Bump `updated:` to today on any edit. Never touch `id`, never change the
|
|
127
|
+
directory, never edit code or any file outside the tracker.
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
`blaze.config.json` (data-repo root): `key`, `projects` (array of project keys the
|
|
132
|
+
board renders), `commitMode`, `port`, and more.
|
|
133
|
+
|
|
134
|
+
`projects/<KEY>/project.json` (optional, per project): `labels`, `components`,
|
|
135
|
+
`codeRepos` (repos `reconcile` mirrors for this project),
|
|
136
|
+
`requireWorklogBeforeTerminal` (default `false` — when `true`, a leaf ticket
|
|
137
|
+
(story/task/bug/subtask) needs at least one `worklog` entry before it can enter a
|
|
138
|
+
terminal status; epics/goals/risks are exempt since their time rolls up from
|
|
139
|
+
children), and `workflowOverrides` (reserved for a future per-type
|
|
140
|
+
statuses/transitions override — currently stored but not read by the engine, so
|
|
141
|
+
it has no effect yet).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Lyons
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="brand/readme_graphic.jpg" alt="Blaze" width="420">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center"><b>Agentic AI for App Development</b><br>
|
|
6
|
+
A file-based, git-native issue tracker built for AI coding agents to drive.</p>
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Blaze is plain files, all the way down:
|
|
11
|
+
|
|
12
|
+
- **A ticket is a markdown file** — frontmatter + a body. No database, no login.
|
|
13
|
+
- **A ticket's status is the directory it sits in.** There is no `status:` field, so
|
|
14
|
+
it cannot drift out of sync with reality.
|
|
15
|
+
- **Git is the history.** `git log --follow` on a ticket file is its full audit trail;
|
|
16
|
+
every mutation is a small, revertable commit.
|
|
17
|
+
- **The board is a rendering, never a second source of truth** — `blaze board` reads
|
|
18
|
+
the same files you'd `ls` / `grep` / `git mv` by hand.
|
|
19
|
+
|
|
20
|
+
It's built AI-first: an agent drives the tracker with the file tools it already has,
|
|
21
|
+
or with the `blaze` CLI. No API client, no auth, no SDK required either way.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm i -g @hjr15/blaze-board
|
|
27
|
+
# or, without installing:
|
|
28
|
+
npx @hjr15/blaze-board <command>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Node 20+ and `git` on `PATH`.
|
|
32
|
+
|
|
33
|
+
## The engine ⟂ data split
|
|
34
|
+
|
|
35
|
+
This package is the **engine** — the `blaze` CLI and its web board. Your tickets
|
|
36
|
+
live in a separate **data repo**: a `blaze.config.json` plus a
|
|
37
|
+
`projects/<KEY>/<status>/` tree, versioned in its own git history.
|
|
38
|
+
|
|
39
|
+
Attach the engine to a data repo one of two ways:
|
|
40
|
+
|
|
41
|
+
- run `blaze` from inside the data repo (it looks for a `projects/` directory in
|
|
42
|
+
the current working directory), **or**
|
|
43
|
+
- set `BLAZE_PROJECTS_DIR` to the data repo's `projects/` directory, from anywhere.
|
|
44
|
+
|
|
45
|
+
One global `npm i -g @hjr15/blaze-board` install can drive any number of unrelated
|
|
46
|
+
data repos this way — upgrade the engine once, keep every board's ticket history in
|
|
47
|
+
its own repo.
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 1. a data repo — just a directory with its own git history
|
|
53
|
+
mkdir my-tracker && cd my-tracker && git init
|
|
54
|
+
|
|
55
|
+
# 2. the engine needs a key and at least one project
|
|
56
|
+
mkdir -p projects/ENG
|
|
57
|
+
cat > blaze.config.json <<'EOF'
|
|
58
|
+
{ "key": "ENG", "projects": ["ENG"] }
|
|
59
|
+
EOF
|
|
60
|
+
git add -A && git commit -m "init board"
|
|
61
|
+
|
|
62
|
+
# 3. create a ticket — task/story/bug require --estimate; every type gets a
|
|
63
|
+
# scaffolded description body
|
|
64
|
+
blaze new --project ENG --type task "Fix the export bug" --estimate 30
|
|
65
|
+
|
|
66
|
+
# 4. open the board
|
|
67
|
+
blaze board # → http://localhost:4321
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`blaze new` writes the ticket, validates it against the schema, and commits it — one
|
|
71
|
+
small commit per ticket, scoped to the files it actually touched.
|
|
72
|
+
|
|
73
|
+
## CLI verbs
|
|
74
|
+
|
|
75
|
+
| Command | Does |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `blaze new --project <KEY> --type <type> "<title>" [--estimate m] [--parent ID] [--priority p] [--labels a,b]` | Create a ticket in its type's initial status |
|
|
78
|
+
| `blaze move <id> <status>` | Change status (validates the transition; auto-sets `resolution` on a terminal status) |
|
|
79
|
+
| `blaze resolve <id> <done\|wont-do\|duplicate\|cannot-reproduce>` | Set a non-default resolution without moving the file |
|
|
80
|
+
| `blaze log <id> <minutes>` | Append a worklog entry |
|
|
81
|
+
| `blaze rollup [<id>]` | Print rolled-up estimate/logged time for one node, or a summary of every goal/epic |
|
|
82
|
+
| `blaze reconcile [--apply] [--fetch]` | Mirror a linked code repo's branch/PR state onto delivery-workflow tickets (dry-run by default) |
|
|
83
|
+
| `blaze edit <id> ...` | Edit ticket fields |
|
|
84
|
+
| `blaze reindex` | Rebuild/validate the on-disk index |
|
|
85
|
+
| `blaze commit` | Flush queued ops into one commit (`commitMode: batch`) |
|
|
86
|
+
| `blaze migrate [--dry-run\|--live] [--project <KEY>]` | Import tickets from an external tracker via a reviewed disposition ledger (`--project` optional — falls back to `blaze.config.json`'s `projects` list) |
|
|
87
|
+
| `blaze board` | Serve the read-only kanban view |
|
|
88
|
+
|
|
89
|
+
See [`AGENTS.md`](AGENTS.md) for the full contract — types, workflows, the git join
|
|
90
|
+
key, and how an agent should drive the board.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
`blaze.config.json` lives at the data repo's root. Minimally:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "key": "ENG", "projects": ["ENG"] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`key` is the ticket id prefix (`ENG-1`, `ENG-2`, ...); `projects` lists which
|
|
101
|
+
`projects/<KEY>/` directories the board renders. Per-project settings (labels,
|
|
102
|
+
`codeRepos` to mirror, `requireWorklogBeforeTerminal`, `workflowOverrides`) live in
|
|
103
|
+
`projects/<KEY>/project.json` — see [`AGENTS.md`](AGENTS.md#configuration).
|
|
104
|
+
|
|
105
|
+
## Origin
|
|
106
|
+
|
|
107
|
+
This is a public continuation of [`sychyoboN/blaze`](https://github.com/sychyoboN/blaze).
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hjr15/blaze-board",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A file-based, git-native issue board that AI coding agents can drive. Tickets are markdown; status is the directory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"blaze": "scripts/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"scripts/",
|
|
11
|
+
"AGENTS.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node scripts/supervisor.mjs",
|
|
17
|
+
"board": "node scripts/serve.mjs",
|
|
18
|
+
"reconcile": "node scripts/reconcile.mjs",
|
|
19
|
+
"groom": "node scripts/loops/groomer.mjs",
|
|
20
|
+
"commit": "node scripts/commit-runner.mjs",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"test:coverage": "c8 node --test --test-concurrency=1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/hjr15/blaze.git"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"c8": "11.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.mjs — the `blaze` command. Dispatches to the scripts.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
9
|
+
const node = (file, args = []) => spawnSync(process.execPath, [join(here, file), ...args], { stdio: "inherit" });
|
|
10
|
+
|
|
11
|
+
let r;
|
|
12
|
+
switch (cmd) {
|
|
13
|
+
case undefined:
|
|
14
|
+
case "start": r = node("supervisor.mjs"); break;
|
|
15
|
+
case "board": r = node("serve.mjs"); break;
|
|
16
|
+
case "reconcile": r = node("reconcile.mjs", rest); break;
|
|
17
|
+
case "groom": r = node("loops/groomer.mjs", rest); break;
|
|
18
|
+
case "new": r = node("new-runner.mjs", rest); break;
|
|
19
|
+
case "reindex": r = node("reindex.mjs", rest); break;
|
|
20
|
+
case "move": r = node("move-runner.mjs", rest); break;
|
|
21
|
+
case "edit": r = node("edit-runner.mjs", rest); break;
|
|
22
|
+
case "resolve": r = node("resolve-runner.mjs", rest); break;
|
|
23
|
+
case "log": r = node("log-runner.mjs", rest); break;
|
|
24
|
+
case "commit": r = node("commit-runner.mjs", rest); break;
|
|
25
|
+
case "rollup": r = node("rollup-runner.mjs", rest); break;
|
|
26
|
+
case "migrate": r = node("migrate-runner.mjs", rest); break;
|
|
27
|
+
default:
|
|
28
|
+
console.log("usage: blaze [start|board|reconcile|groom|new|reindex|move|edit|resolve|log|commit|rollup|migrate]");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
process.exit(r.status ?? 0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// scripts/commit-or-queue.mjs — single decision point for board-mutating CLI
|
|
2
|
+
// verbs: in `batch` mode queue the op onto the pending ledger; otherwise commit
|
|
3
|
+
// scoped to exactly the touched files (never `git add -A`).
|
|
4
|
+
import { relative } from "node:path";
|
|
5
|
+
import { commitFile } from "./serve-commit.mjs";
|
|
6
|
+
import { appendEntry } from "./pending-ledger.mjs";
|
|
7
|
+
|
|
8
|
+
export function commitOrQueue({ root, mode, op, id, message, files }) {
|
|
9
|
+
const unique = [...new Set(files)];
|
|
10
|
+
if (mode === "batch") {
|
|
11
|
+
appendEntry(root, {
|
|
12
|
+
id,
|
|
13
|
+
op,
|
|
14
|
+
message,
|
|
15
|
+
files: unique.map((f) => relative(root, f)),
|
|
16
|
+
ts: new Date().toISOString(),
|
|
17
|
+
});
|
|
18
|
+
return { ok: true, queued: true };
|
|
19
|
+
}
|
|
20
|
+
const [first, ...rest] = unique;
|
|
21
|
+
return commitFile(root, first, message, rest);
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// scripts/commit-runner.mjs — `blaze commit`: drain .blaze/pending-commit.jsonl
|
|
2
|
+
// into ONE commit (subject summary + per-op body), staging only recorded files.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readEntries, clearLedger } from "./pending-ledger.mjs";
|
|
5
|
+
import { resolveRoots } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const { dataRoot } = resolveRoots();
|
|
8
|
+
|
|
9
|
+
const entries = readEntries(dataRoot);
|
|
10
|
+
if (entries.length === 0) {
|
|
11
|
+
console.log("blaze commit: nothing to flush");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Counts by op → "2 new, 3 logged, 1 moved, 1 resolved"
|
|
16
|
+
const LABEL = { new: "new", log: "logged", move: "moved", resolve: "resolved" };
|
|
17
|
+
const counts = {};
|
|
18
|
+
for (const e of entries) counts[e.op] = (counts[e.op] || 0) + 1;
|
|
19
|
+
const summary = Object.entries(counts)
|
|
20
|
+
.map(([op, n]) => `${n} ${LABEL[op] || op}`)
|
|
21
|
+
.join(", ");
|
|
22
|
+
|
|
23
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
24
|
+
const subject = `blaze: ${date} board update (${summary})`;
|
|
25
|
+
const body = entries.map((e) => `- ${e.message}`).join("\n");
|
|
26
|
+
|
|
27
|
+
const files = [...new Set(entries.flatMap((e) => e.files))];
|
|
28
|
+
|
|
29
|
+
const add = spawnSync("git", ["-C", dataRoot, "add", "--", ...files], { stdio: "ignore" });
|
|
30
|
+
if (add.status !== 0) {
|
|
31
|
+
console.error(`blaze commit: git add failed (status ${add.status}) — ledger kept, resolve manually`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const commit = spawnSync("git", ["-C", dataRoot, "commit", "-m", subject, "-m", body, "--", ...files], { stdio: "inherit" });
|
|
35
|
+
if (commit.status !== 0) {
|
|
36
|
+
console.error(`blaze commit: git commit failed (status ${commit.status}) — ledger kept, resolve manually`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
clearLedger(dataRoot);
|
|
40
|
+
console.log(`blaze commit: flushed ${entries.length} op(s) → ${subject}`);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// config.mjs — load blaze.config.json with defaults + env overrides, and derive
|
|
2
|
+
// the key-based regexes that reconcile.mjs and new-runner.mjs share.
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname, isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
key: "TASK",
|
|
11
|
+
projects: [],
|
|
12
|
+
codeRepos: [],
|
|
13
|
+
boardTitle: "Blaze",
|
|
14
|
+
codeRepo: null,
|
|
15
|
+
provider: "github",
|
|
16
|
+
columns: ["backlog", "todo", "in-progress", "in-review", "done", "canceled", "duplicate"],
|
|
17
|
+
terminal: ["done", "canceled", "duplicate"],
|
|
18
|
+
defaultLabels: ["frontend", "backend", "infra", "docs", "bug", "chore"],
|
|
19
|
+
port: 4321,
|
|
20
|
+
agentCommand: "claude -p",
|
|
21
|
+
commitMode: "per-op",
|
|
22
|
+
loops: {
|
|
23
|
+
reconcile: { enabled: true, intervalSec: 60 },
|
|
24
|
+
groomer: { enabled: true, intervalSec: 300, columns: ["backlog"] },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function loadConfig({ root = ROOT, env = process.env, fileName = "blaze.config.json" } = {}) {
|
|
29
|
+
const path = join(root, fileName);
|
|
30
|
+
let file = {};
|
|
31
|
+
if (existsSync(path)) {
|
|
32
|
+
try {
|
|
33
|
+
file = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
} catch (e) {
|
|
35
|
+
throw new Error(`blaze: cannot parse ${fileName}: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cfg = { ...DEFAULTS, ...file };
|
|
40
|
+
cfg.loops = {
|
|
41
|
+
reconcile: { ...DEFAULTS.loops.reconcile, ...(file.loops && file.loops.reconcile) },
|
|
42
|
+
groomer: { ...DEFAULTS.loops.groomer, ...(file.loops && file.loops.groomer) },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Env overrides (highest precedence).
|
|
46
|
+
if (env.BLAZE_KEY) cfg.key = env.BLAZE_KEY;
|
|
47
|
+
if (env.BLAZE_PORT) cfg.port = Number(env.BLAZE_PORT);
|
|
48
|
+
if (env.BLAZE_AGENT_COMMAND) cfg.agentCommand = env.BLAZE_AGENT_COMMAND;
|
|
49
|
+
if (env.BLAZE_COMMIT_MODE) cfg.commitMode = env.BLAZE_COMMIT_MODE;
|
|
50
|
+
if (env.BLAZE_CODE_REPO !== undefined) cfg.codeRepo = env.BLAZE_CODE_REPO || null;
|
|
51
|
+
|
|
52
|
+
// Derived values.
|
|
53
|
+
cfg.codeRepoPath = cfg.codeRepo
|
|
54
|
+
? (isAbsolute(cfg.codeRepo) ? cfg.codeRepo : resolve(root, cfg.codeRepo))
|
|
55
|
+
: null;
|
|
56
|
+
cfg.idRegex = new RegExp("\\b" + cfg.key + "-(\\d+)", "i");
|
|
57
|
+
cfg.idFromRef = (ref) => {
|
|
58
|
+
const m = cfg.idRegex.exec(ref || "");
|
|
59
|
+
return m ? `${cfg.key}-${m[1]}` : null;
|
|
60
|
+
};
|
|
61
|
+
cfg.fileRegex = new RegExp("^" + cfg.key + "-\\d+.*\\.md$");
|
|
62
|
+
cfg.idLineRegex = new RegExp(`^id:\\s*(${cfg.key}-\\d+)`, "m");
|
|
63
|
+
|
|
64
|
+
return Object.freeze(cfg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- dataRoot resolution -----------------------------------------------------
|
|
68
|
+
// The engine (this install) and the data (blaze.config.json + projects/ +
|
|
69
|
+
// .blaze/ + the git repo commits land in) may live in different trees.
|
|
70
|
+
// Resolution ladder:
|
|
71
|
+
// 1. BLAZE_PROJECTS_DIR env — explicit projects dir; dataRoot is its parent
|
|
72
|
+
// 2. ./projects under CWD — running from a data repo checkout
|
|
73
|
+
// 3. the engine tree itself — single-tree back-compat (pre-split behaviour),
|
|
74
|
+
// but only when engineRoot isn't under node_modules; a packaged install
|
|
75
|
+
// with no data dir found throws instead of silently falling back
|
|
76
|
+
export function resolveRoots({ env = process.env, cwd = process.cwd(), engineRoot = ROOT } = {}) {
|
|
77
|
+
if (env.BLAZE_PROJECTS_DIR) {
|
|
78
|
+
const projectsDir = resolve(cwd, env.BLAZE_PROJECTS_DIR);
|
|
79
|
+
return Object.freeze({ engineRoot, dataRoot: dirname(projectsDir), projectsDir });
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(join(cwd, "projects"))) {
|
|
82
|
+
return Object.freeze({ engineRoot, dataRoot: cwd, projectsDir: join(cwd, "projects") });
|
|
83
|
+
}
|
|
84
|
+
if (engineRoot.includes("/node_modules/")) {
|
|
85
|
+
throw new Error("blaze: no data dir found — set BLAZE_PROJECTS_DIR or run from a directory containing projects/");
|
|
86
|
+
}
|
|
87
|
+
return Object.freeze({ engineRoot, dataRoot: engineRoot, projectsDir: join(engineRoot, "projects") });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// CLI: `node scripts/config.mjs --get <field>` prints one resolved config field —
|
|
91
|
+
// for scripts/tooling that need a config value directly in shell.
|
|
92
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
93
|
+
const i = process.argv.indexOf("--get");
|
|
94
|
+
if (i !== -1) {
|
|
95
|
+
const cfg = loadConfig({ root: resolveRoots().dataRoot });
|
|
96
|
+
const v = cfg[process.argv[i + 1]];
|
|
97
|
+
console.log(v === undefined || v === null ? "" : v);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- multi-project layer (Phase 3) -----------------------------------------
|
|
102
|
+
// The legacy single-board config above is retained as harmless defaults so the
|
|
103
|
+
// existing loops keep loading; the project API below is authoritative for the
|
|
104
|
+
// projects/<KEY>/<status>/ layout.
|
|
105
|
+
import { isAbsolute as _isAbsolute, resolve as _resolve } from "node:path";
|
|
106
|
+
|
|
107
|
+
const PROJECT_DEFAULTS = {
|
|
108
|
+
components: [],
|
|
109
|
+
labels: [],
|
|
110
|
+
codeRepos: [],
|
|
111
|
+
requireWorklogBeforeTerminal: false,
|
|
112
|
+
workflowOverrides: null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function listProjects(cfg, { root = ROOT } = {}) {
|
|
116
|
+
const c = cfg || loadConfig({ root });
|
|
117
|
+
return Array.isArray(c.projects) ? c.projects.slice() : [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadProject(key, { root = ROOT, projectsDir = join(root, "projects") } = {}) {
|
|
121
|
+
const cfg = loadConfig({ root });
|
|
122
|
+
const path = join(projectsDir, key, "project.json");
|
|
123
|
+
let file = {};
|
|
124
|
+
if (existsSync(path)) {
|
|
125
|
+
try { file = JSON.parse(readFileSync(path, "utf8")); }
|
|
126
|
+
catch (e) { throw new Error(`blaze: cannot parse projects/${key}/project.json: ${e.message}`); }
|
|
127
|
+
}
|
|
128
|
+
const merged = { ...PROJECT_DEFAULTS, ...file, key };
|
|
129
|
+
const repos = merged.codeRepos.length ? merged.codeRepos : (cfg.codeRepos || []);
|
|
130
|
+
merged.codeRepoPaths = repos.map((r) => (_isAbsolute(r) ? r : _resolve(root, r)));
|
|
131
|
+
merged.idRegex = new RegExp("\\b" + key + "-(\\d+)", "i");
|
|
132
|
+
merged.idFromRef = (ref) => { const m = merged.idRegex.exec(ref || ""); return m ? `${key}-${m[1]}` : null; };
|
|
133
|
+
merged.fileRegex = new RegExp("^" + key + "-\\d+.*\\.md$");
|
|
134
|
+
return Object.freeze(merged);
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// scripts/edit-runner.mjs — CLI entry for `blaze edit <id> <field> <value>`:
|
|
2
|
+
// applyEdit against the resolved data tree, then commit only the touched file.
|
|
3
|
+
import { applyEdit } from "./edit.mjs";
|
|
4
|
+
import { commitFile } from "./serve-commit.mjs";
|
|
5
|
+
import { resolveRoots } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const { dataRoot, projectsDir } = resolveRoots();
|
|
8
|
+
const [id, field, ...valueParts] = process.argv.slice(2);
|
|
9
|
+
if (!id || !field || valueParts.length === 0) {
|
|
10
|
+
console.error("usage: blaze edit <id> <field> <value>"); process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const value = valueParts.join(" ");
|
|
13
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
14
|
+
const r = applyEdit(projectsDir, id, { [field]: value }, { today });
|
|
15
|
+
if (!r.ok) { console.error(`blaze edit failed:\n ${r.errors.join("\n ")}`); process.exit(1); }
|
|
16
|
+
const c = commitFile(dataRoot, r.file, `${id}: edit ${field}`);
|
|
17
|
+
if (!c.ok) { console.error(`blaze edit: file written but commit failed (status ${c.status}) — commit manually`); process.exit(1); }
|
|
18
|
+
console.log(`${id}: ${field} = ${value}`);
|
package/scripts/edit.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// scripts/edit.mjs — validated in-place field edits and AC-checkbox toggling.
|
|
2
|
+
// fs-only (no git); the board/CLI wrappers commit. All business rules come from
|
|
3
|
+
// model/ — this file only marshals a patch through validateTicket before writing.
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname } from "node:path";
|
|
6
|
+
import { walkTickets } from "./model/index.mjs";
|
|
7
|
+
import { serializeTicket } from "./model/ticket.mjs";
|
|
8
|
+
import { validateTicket } from "./model/rules.mjs";
|
|
9
|
+
import { roundEstimate } from "./model/time.mjs";
|
|
10
|
+
|
|
11
|
+
const EDITABLE = new Set(["assignee", "priority", "labels", "components", "estimate", "parent", "likelihood", "impact"]);
|
|
12
|
+
|
|
13
|
+
// Same id resolution as move.mjs/log.mjs: prefer the project-dir-matching id.
|
|
14
|
+
function locate(projectsDir, id) {
|
|
15
|
+
let fallback = null;
|
|
16
|
+
for (const t of walkTickets(projectsDir)) {
|
|
17
|
+
if (t.frontmatter.id !== id) continue;
|
|
18
|
+
const projectKey = basename(dirname(dirname(t.file)));
|
|
19
|
+
if (id.startsWith(`${projectKey}-`)) return t;
|
|
20
|
+
fallback ??= t;
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asArray(v) {
|
|
26
|
+
if (Array.isArray(v)) return v;
|
|
27
|
+
if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
return v == null ? [] : [v];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyEdit(projectsDir, id, patch, opts = {}) {
|
|
32
|
+
const { today = null } = opts;
|
|
33
|
+
const bad = Object.keys(patch).filter((k) => !EDITABLE.has(k));
|
|
34
|
+
if (bad.length) return { ok: false, errors: [`field(s) not editable: ${bad.join(", ")}`] };
|
|
35
|
+
|
|
36
|
+
const found = locate(projectsDir, id);
|
|
37
|
+
if (!found) return { ok: false, errors: [`ticket not found: ${id}`] };
|
|
38
|
+
|
|
39
|
+
const fm = { ...found.frontmatter };
|
|
40
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
41
|
+
if (k === "estimate") fm.estimate = roundEstimate(v);
|
|
42
|
+
else if (k === "labels" || k === "components") fm[k] = asArray(v);
|
|
43
|
+
else fm[k] = v === "" ? null : v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate the merged ticket. lookup spans every ticket for parent-pair + cycle checks.
|
|
47
|
+
const all = new Map();
|
|
48
|
+
for (const t of walkTickets(projectsDir)) all.set(t.frontmatter.id, { frontmatter: t.frontmatter, body: t.body });
|
|
49
|
+
all.set(id, { frontmatter: fm, body: found.body });
|
|
50
|
+
const errors = validateTicket({ frontmatter: fm, body: found.body }, (pid) => all.get(pid) || null);
|
|
51
|
+
if (errors.length) return { ok: false, errors };
|
|
52
|
+
|
|
53
|
+
if (today) fm.updated = today;
|
|
54
|
+
writeFileSync(found.file, serializeTicket({ frontmatter: fm, body: found.body }));
|
|
55
|
+
return { ok: true, id, file: found.file };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Flip one checkbox under the `## Acceptance Criteria` heading, by ordinal.
|
|
59
|
+
// Only lines within that section count; a `- [ ]` elsewhere in the body is ignored.
|
|
60
|
+
export function applyToggleAc(projectsDir, id, { index, checked }, opts = {}) {
|
|
61
|
+
const { today = null } = opts;
|
|
62
|
+
const found = locate(projectsDir, id);
|
|
63
|
+
if (!found) return { ok: false, errors: [`ticket not found: ${id}`] };
|
|
64
|
+
|
|
65
|
+
const lines = found.body.split("\n");
|
|
66
|
+
const isHeading = (l) => /^\s{0,3}#{1,6}\s/.test(l);
|
|
67
|
+
const start = lines.findIndex((l) => /^\s{0,3}#{1,6}\s+acceptance criteria\s*$/i.test(l));
|
|
68
|
+
if (start === -1) return { ok: false, errors: ["ticket has no ## Acceptance Criteria section"] };
|
|
69
|
+
|
|
70
|
+
const acLineIdx = [];
|
|
71
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
72
|
+
if (isHeading(lines[i])) break; // next section ends AC
|
|
73
|
+
if (/^\s*- \[[ xX]\]\s/.test(lines[i])) acLineIdx.push(i);
|
|
74
|
+
}
|
|
75
|
+
if (index < 0 || index >= acLineIdx.length) {
|
|
76
|
+
return { ok: false, errors: [`AC index ${index} out of range (0..${acLineIdx.length - 1})`] };
|
|
77
|
+
}
|
|
78
|
+
const li = acLineIdx[index];
|
|
79
|
+
lines[li] = lines[li].replace(/^(\s*- )\[[ xX]\]/, `$1[${checked ? "x" : " "}]`);
|
|
80
|
+
|
|
81
|
+
const fm = { ...found.frontmatter };
|
|
82
|
+
if (today) fm.updated = today;
|
|
83
|
+
writeFileSync(found.file, serializeTicket({ frontmatter: fm, body: lines.join("\n") }));
|
|
84
|
+
return { ok: true, id, file: found.file };
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// event-bus.mjs — a tiny synchronous in-process pub/sub for the activity feed.
|
|
2
|
+
export function createBus() {
|
|
3
|
+
const subs = new Set();
|
|
4
|
+
return {
|
|
5
|
+
publish(evt) {
|
|
6
|
+
for (const fn of subs) {
|
|
7
|
+
try { fn(evt); } catch { /* one bad subscriber must not break the rest */ }
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
subscribe(fn) {
|
|
11
|
+
subs.add(fn);
|
|
12
|
+
return () => subs.delete(fn);
|
|
13
|
+
},
|
|
14
|
+
size() { return subs.size; },
|
|
15
|
+
};
|
|
16
|
+
}
|