@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.
Files changed (46) hide show
  1. package/AGENTS.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/package.json +38 -0
  5. package/scripts/cli.mjs +31 -0
  6. package/scripts/commit-or-queue.mjs +22 -0
  7. package/scripts/commit-runner.mjs +40 -0
  8. package/scripts/config.mjs +135 -0
  9. package/scripts/edit-runner.mjs +18 -0
  10. package/scripts/edit.mjs +85 -0
  11. package/scripts/event-bus.mjs +16 -0
  12. package/scripts/log-runner.mjs +38 -0
  13. package/scripts/log.mjs +44 -0
  14. package/scripts/loops/groomer.mjs +215 -0
  15. package/scripts/migrate/audit.mjs +92 -0
  16. package/scripts/migrate/jira-client.mjs +26 -0
  17. package/scripts/migrate/jira-import.mjs +96 -0
  18. package/scripts/migrate/map.mjs +110 -0
  19. package/scripts/migrate/merge.mjs +103 -0
  20. package/scripts/migrate/normalize.mjs +60 -0
  21. package/scripts/migrate/report.mjs +67 -0
  22. package/scripts/migrate/restructure.mjs +49 -0
  23. package/scripts/migrate-runner.mjs +51 -0
  24. package/scripts/model/.gitkeep +0 -0
  25. package/scripts/model/ids.mjs +32 -0
  26. package/scripts/model/index.mjs +63 -0
  27. package/scripts/model/move-plan.mjs +25 -0
  28. package/scripts/model/rollup.mjs +55 -0
  29. package/scripts/model/rules.mjs +64 -0
  30. package/scripts/model/schema.mjs +30 -0
  31. package/scripts/model/ticket.mjs +136 -0
  32. package/scripts/model/time.mjs +38 -0
  33. package/scripts/model/workflows.mjs +54 -0
  34. package/scripts/move-runner.mjs +18 -0
  35. package/scripts/move.mjs +56 -0
  36. package/scripts/new-runner.mjs +43 -0
  37. package/scripts/new.mjs +54 -0
  38. package/scripts/pending-ledger.mjs +36 -0
  39. package/scripts/reconcile.mjs +181 -0
  40. package/scripts/reindex.mjs +22 -0
  41. package/scripts/resolve-runner.mjs +17 -0
  42. package/scripts/resolve.mjs +21 -0
  43. package/scripts/rollup-runner.mjs +53 -0
  44. package/scripts/serve-commit.mjs +18 -0
  45. package/scripts/serve.mjs +658 -0
  46. 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
+ }
@@ -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}`);
@@ -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
+ }