maqcli 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 (88) hide show
  1. package/README.md +223 -0
  2. package/dist/core/audit.d.ts +43 -0
  3. package/dist/core/audit.js +77 -0
  4. package/dist/core/board.d.ts +78 -0
  5. package/dist/core/board.js +256 -0
  6. package/dist/core/catalog.d.ts +50 -0
  7. package/dist/core/catalog.js +103 -0
  8. package/dist/core/command-catalog.d.ts +44 -0
  9. package/dist/core/command-catalog.js +86 -0
  10. package/dist/core/completion.d.ts +24 -0
  11. package/dist/core/completion.js +309 -0
  12. package/dist/core/complexity.d.ts +17 -0
  13. package/dist/core/complexity.js +87 -0
  14. package/dist/core/config-store.d.ts +33 -0
  15. package/dist/core/config-store.js +61 -0
  16. package/dist/core/connectivity.d.ts +34 -0
  17. package/dist/core/connectivity.js +49 -0
  18. package/dist/core/cost-tracker.d.ts +89 -0
  19. package/dist/core/cost-tracker.js +189 -0
  20. package/dist/core/cost.d.ts +35 -0
  21. package/dist/core/cost.js +89 -0
  22. package/dist/core/exec.d.ts +43 -0
  23. package/dist/core/exec.js +154 -0
  24. package/dist/core/flows.d.ts +36 -0
  25. package/dist/core/flows.js +96 -0
  26. package/dist/core/headroom.d.ts +36 -0
  27. package/dist/core/headroom.js +88 -0
  28. package/dist/core/help-topics.d.ts +26 -0
  29. package/dist/core/help-topics.js +294 -0
  30. package/dist/core/init-wizard.d.ts +26 -0
  31. package/dist/core/init-wizard.js +168 -0
  32. package/dist/core/interactive-registry.d.ts +50 -0
  33. package/dist/core/interactive-registry.js +86 -0
  34. package/dist/core/interactive.d.ts +48 -0
  35. package/dist/core/interactive.js +137 -0
  36. package/dist/core/logger.d.ts +16 -0
  37. package/dist/core/logger.js +46 -0
  38. package/dist/core/memory.d.ts +28 -0
  39. package/dist/core/memory.js +70 -0
  40. package/dist/core/metered.d.ts +9 -0
  41. package/dist/core/metered.js +16 -0
  42. package/dist/core/model.d.ts +74 -0
  43. package/dist/core/model.js +199 -0
  44. package/dist/core/pipeline.d.ts +33 -0
  45. package/dist/core/pipeline.js +223 -0
  46. package/dist/core/plugins.d.ts +21 -0
  47. package/dist/core/plugins.js +38 -0
  48. package/dist/core/probe.d.ts +48 -0
  49. package/dist/core/probe.js +156 -0
  50. package/dist/core/profiles.d.ts +42 -0
  51. package/dist/core/profiles.js +153 -0
  52. package/dist/core/providers.d.ts +84 -0
  53. package/dist/core/providers.js +275 -0
  54. package/dist/core/recall.d.ts +29 -0
  55. package/dist/core/recall.js +83 -0
  56. package/dist/core/registry.d.ts +41 -0
  57. package/dist/core/registry.js +162 -0
  58. package/dist/core/router.d.ts +33 -0
  59. package/dist/core/router.js +40 -0
  60. package/dist/core/sandbox.d.ts +78 -0
  61. package/dist/core/sandbox.js +268 -0
  62. package/dist/core/session.d.ts +105 -0
  63. package/dist/core/session.js +252 -0
  64. package/dist/core/skills.d.ts +56 -0
  65. package/dist/core/skills.js +289 -0
  66. package/dist/core/subagent.d.ts +40 -0
  67. package/dist/core/subagent.js +55 -0
  68. package/dist/core/supervisor.d.ts +37 -0
  69. package/dist/core/supervisor.js +40 -0
  70. package/dist/core/tools.d.ts +39 -0
  71. package/dist/core/tools.js +159 -0
  72. package/dist/core/types.d.ts +87 -0
  73. package/dist/core/types.js +10 -0
  74. package/dist/index.d.ts +11 -0
  75. package/dist/index.js +1032 -0
  76. package/dist/phases/execute.d.ts +39 -0
  77. package/dist/phases/execute.js +166 -0
  78. package/dist/phases/plan.d.ts +11 -0
  79. package/dist/phases/plan.js +118 -0
  80. package/dist/phases/scout.d.ts +10 -0
  81. package/dist/phases/scout.js +113 -0
  82. package/dist/phases/verify.d.ts +22 -0
  83. package/dist/phases/verify.js +81 -0
  84. package/dist/server/daemon.d.ts +50 -0
  85. package/dist/server/daemon.js +377 -0
  86. package/dist/server/relay-bridge.d.ts +44 -0
  87. package/dist/server/relay-bridge.js +175 -0
  88. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ shu# maqcli
2
+
3
+ The **MAQ master orchestrator** — a token-efficient, agent-agnostic supervisor
4
+ CLI that sits *on top of* any worker CLI (AI or not) and runs every task through
5
+ a **Scout → Plan → Execute → Verify** pipeline. It also runs as a secure
6
+ **daemon** so a phone/app can drive it over one normalized event stream.
7
+
8
+ It does not replace your coding agent. It organizes, supports, and verifies its
9
+ work — and when no AI is involved, it runs raw commands safely instead.
10
+
11
+ - **Zero runtime dependencies** — Node built-ins + global `fetch` only.
12
+ - **Runs fully offline** via a deterministic heuristic provider (no API key), and
13
+ connects to **real providers** (OpenAI / Anthropic / Ollama / Groq / any
14
+ OpenAI-compatible or LiteLLM proxy) when you configure one.
15
+ - **Safe execution** — every command runs via `spawn` with an argument array and
16
+ `shell: false`; user/model values are never shell-interpreted.
17
+ - **Secure daemon** — HTTP + SSE, bearer-token auth, loopback-only by default.
18
+
19
+ ## Install / build
20
+
21
+ ```bash
22
+ cd cli
23
+ npm install # installs the TypeScript toolchain (dev only)
24
+ npm run build # tsc -> dist/
25
+ npm test # build + node --test (60 tests)
26
+ ```
27
+
28
+ Then run it:
29
+
30
+ ```bash
31
+ node dist/index.js <command>
32
+ # or, after `npm link` / global install:
33
+ maq <command> # (also aliased as `maqcli`)
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ |---|---|
40
+ | `maq detect` | Scan `PATH` + each tool's auth dir for worker CLIs; report installed/authenticated/json-stream and the auto target. |
41
+ | `maq scout "<task>"` | Deterministic, read-only recon (files, README, manifest, git history, complexity). Zero token cost. |
42
+ | `maq plan "<task>"` | Scout + verifier-gated candidate plan (Best-of-N with early exit). |
43
+ | `maq run "<task>" [opts]` | Full pipeline; dispatch the winning plan to a worker CLI (streamed) or run raw (`--target none`). |
44
+ | `maq verify [--cwd <dir>]` | Run the project's tests (or cross-model review) as the completion signal. |
45
+ | `maq serve [opts]` | Start the HTTP+SSE daemon (the app seam). Prints a bearer token. |
46
+ | `maq models` | Show the configured provider, tier models, prices, and availability. |
47
+ | `maq probe` | Probe the connectivity tier (real STUN/UDP + TCP + LAN interfaces). |
48
+ | `maq skills [init\|path] [--tier]` | List/scaffold the tier-aware skills/rules layer injected into planning. |
49
+ | `maq subagent "<task>"` | Run an isolated sub-agent (fresh context) that returns a concise summary. |
50
+ | `maq tools [<name>] [--args JSON]` | List the safe tool registry, or run one (read_file/list_dir/grep_text/…). |
51
+ | `maq sessions [<id>] [pause\|resume\|cancel]` | List/inspect/control daemon sessions (needs `MAQ_TOKEN`). |
52
+ | `maq config [get\|set\|path\|reset]` | Read/update `~/.maqcli/config.json`. |
53
+ | `maq version` / `maq help` | — |
54
+
55
+ ### `run` options
56
+ ```
57
+ -t, --target <t> auto | claude-code | codex | gemini | opencode | aider | amazon-q | none
58
+ --dry-run plan and show the commands without executing
59
+ --json stream normalized events (one JSON object per line)
60
+ --cwd <dir> working directory
61
+ ```
62
+
63
+ ### `serve` options
64
+ ```
65
+ --host <h> bind address (default 127.0.0.1; env MAQ_HOST)
66
+ --port <p> port (default 7717; env MAQ_PORT)
67
+ --token <t> bearer token (default env MAQ_TOKEN, else generated + printed)
68
+ --cors <o> allow a browser origin (default off; env MAQ_CORS_ORIGIN)
69
+ ```
70
+
71
+ ## Model providers (LiteLLM-style, one interface)
72
+
73
+ All master-model calls go through one `ModelProvider` interface. Select a
74
+ provider with `maq config set provider <name>`. **API keys come from the
75
+ environment only — never config files.** Missing keys fall back to the offline
76
+ heuristic provider so the pipeline always runs.
77
+
78
+ | provider | env | notes |
79
+ |---|---|---|
80
+ | `heuristic` | — | offline, deterministic, $0 (default) |
81
+ | `openai` | `OPENAI_API_KEY` (`OPENAI_BASE_URL?`) | `/v1/chat/completions` |
82
+ | `anthropic` | `ANTHROPIC_API_KEY` | Messages API, `anthropic-version: 2023-06-01` |
83
+ | `ollama` | `OLLAMA_HOST?` | local, native `/api/chat`, free |
84
+ | `groq` | `GROQ_API_KEY` | OpenAI-compatible, free tier |
85
+ | `openai-compatible` / `litellm` | `MAQ_PROVIDER_BASE_URL` (`MAQ_PROVIDER_API_KEY?`) | any OpenAI-compatible endpoint / LiteLLM proxy |
86
+ | `cli:<agent>` | — | reuse an authenticated worker CLI (e.g. `cli:gemini`, `cli:codex`) as the master's own model — **$0 marginal** (the user's existing subscription pays) |
87
+
88
+ ### $0 intelligence layer (`maq models auto`)
89
+
90
+ The master's own thinking (Scout/Plan/Verify) should cost ~$0. `maq models`
91
+ inspects the environment and ranks the cheapest capable master model:
92
+
93
+ ```bash
94
+ maq models list # ranked catalog with availability + reasons
95
+ maq models cheapest # the single best $0-or-cheapest option right now
96
+ maq models auto # write that choice into config (provider + cheapModel)
97
+ ```
98
+
99
+ Ranking (best first): an **authenticated worker CLI** (reuse the user's
100
+ subscription, $0 marginal) → a **free-tier key** (Groq / Gemini Flash) → a
101
+ **local Ollama** model → the **cheapest paid** key (gpt-4o-mini / haiku) → the
102
+ **offline heuristic** ($0 floor). Heavy Worker executions stay BYO-key; only the
103
+ light master loop is auto-optimized. This is the PRODUCTION_GUIDE "$0 daemon
104
+ thinking" strategy, implemented.
105
+
106
+ Every call has an `AbortController` timeout (`MAQ_MODEL_TIMEOUT_MS`) and bounded
107
+ retries with backoff (`MAQ_MODEL_RETRIES`) on 429/5xx/network errors. Real
108
+ `usage` is used for token/cost accounting when the API returns it; prices come
109
+ from a built-in table (override with `MAQ_PRICES`).
110
+
111
+ ## RouteLLM-style tiering
112
+
113
+ Cheap work (Scout triage, Verify review, non-complex Plan) routes to a **cheap**
114
+ model; genuinely complex Plans escalate to a **strong** model. Configure both:
115
+
116
+ ```bash
117
+ maq config set provider openai
118
+ maq config set cheapModel gpt-4o-mini
119
+ maq config set strongModel gpt-4o
120
+ maq config set defaultTarget codex
121
+ ```
122
+
123
+ ## Harness features (lift any model toward frontier-level results)
124
+
125
+ The point of the harness: a cheap model inside a good harness beats a strong
126
+ model with no harness. These supply, externally, the mechanisms a long-horizon
127
+ model does internally (plan → delegate → verify → self-improve):
128
+
129
+ - **Skills / rules layer** (`maq skills`) — standing instructions injected into
130
+ planning, loaded from built-in defaults + `~/.maqcli/skills` + project
131
+ `.maq/skills/*.md` + `AGENTS.md`/`CLAUDE.md`. **Tier-aware**: `scaffolding`
132
+ rules are dropped for the strong tier (rules written for a weak model hold a
133
+ strong one back). `maq skills init` scaffolds starter files.
134
+ - **Sub-agent isolation** (`maq subagent`) — run a scoped sub-task in a fresh,
135
+ minimal context that returns only a concise summary (containment over
136
+ delegation), so you get the token savings of isolation.
137
+ - **Safe tool registry** (`maq tools`) — `read_file` / `list_dir` / `grep_text`
138
+ / `headroom_retrieve`, sandboxed to the working dir; opt-in `http_get`
139
+ (`MAQ_ALLOW_NET=1`). Advertisable as tool-use schemas.
140
+ - **Session control** (`maq sessions <id> pause|resume|cancel`) — pause at phase
141
+ boundaries, resume, or cancel (kills in-flight worker processes via abort).
142
+ - **Self-learning** — verify failures append a lesson to `AGENTS.md`.
143
+
144
+ Set `skillsDir` and `thinkingEffort` via `maq config`.
145
+
146
+ ## Daemon + app seam
147
+
148
+ `maq serve` exposes the orchestrator over one normalized contract so the app
149
+ never speaks any worker CLI's dialect:
150
+
151
+ ```
152
+ GET /health liveness (no auth)
153
+ GET /v1/agents detected worker CLIs
154
+ GET /v1/connectivity connectivity tier probe
155
+ GET /v1/sessions list session summaries
156
+ POST /v1/sessions start a session {task,target?,cwd?,dryRun?}
157
+ GET /v1/sessions/:id one session (summary + events)
158
+ GET /v1/sessions/:id/events SSE stream (replay history, then live)
159
+ POST /v1/sessions/:id/message deliver a message to a session {text}
160
+ ```
161
+
162
+ Security: every route except `/health` requires `Authorization: Bearer <token>`
163
+ (constant-time compared). Binds to `127.0.0.1` by default; binding to all
164
+ interfaces is allowed but logged loudly. Put a tunnel/tailnet in front for
165
+ remote access rather than opening inbound ports.
166
+
167
+ Multi-agent coordination uses CAO-style vocabulary in the session registry:
168
+ `assign` (async, fire-and-forget), `handoff` (sync, await completion), and
169
+ `sendMessage` (inbox delivery) — the vocabulary, not the tmux/Bedrock runtime.
170
+
171
+ ## How it works
172
+
173
+ ```
174
+ task ─▶ SCOUT ─▶ PLAN ─▶ EXECUTE ─▶ VERIFY ─▶ result
175
+ (read- (branch/ (worker (tests as │
176
+ only, filter/ CLI, live ground └▶ on failure: append a
177
+ 0-tok) commit) streamed) truth) lesson to AGENTS.md
178
+ ```
179
+
180
+ - **Complexity gating** — trivial tasks skip Scout/Plan; standard/complex tasks
181
+ get the full pipeline, keeping multi-agent overhead off work that doesn't need it.
182
+ - **Verifier-gated planning** — short candidate approaches scored against
183
+ *checkable* structure; the first clear winner short-circuits the rest.
184
+ - **Streamed execution** — worker stdout/stderr is parsed line-by-line into
185
+ normalized events (`agent.stdout`, `agent.stderr`, `tool.call`, `agent.event`)
186
+ in real time; JSON-line streams (e.g. Claude Code) become structured events.
187
+ - **Headroom-style compression** — worker output is compressed before it would
188
+ reach a model, with the original retrievable by reference.
189
+ - **Verify-by-default + self-learning** — tests are the ground truth; on failure
190
+ a structured lesson is appended to `AGENTS.md` so the worker sees corrective
191
+ guidance next time.
192
+
193
+ ## Configuration
194
+
195
+ `~/.maqcli/config.json` (override the directory with `MAQ_CONFIG_DIR`):
196
+
197
+ ```json
198
+ {
199
+ "masterModel": "heuristic-local",
200
+ "defaultTarget": "auto",
201
+ "provider": "heuristic",
202
+ "cheapModel": "heuristic-local",
203
+ "strongModel": "heuristic-local",
204
+ "compactionThreshold": 0.6,
205
+ "projectTargets": {}
206
+ }
207
+ ```
208
+
209
+ ## Extending
210
+
211
+ - **New worker CLIs** — add an entry to `KNOWN_AGENTS` in `src/core/registry.ts`.
212
+ - **New providers** — implement `ModelProvider` (see `src/core/providers.ts`) and
213
+ register it in `getProvider` (`src/core/model.ts`).
214
+ - **Upstream Headroom** — swap the local `Headroom` class for the
215
+ `headroomlabs-ai/headroom` engine without changing callers.
216
+
217
+ ## Status
218
+
219
+ The `cli/` master tier is functional and tested (**72 tests**): offline pipeline,
220
+ real model providers, RouteLLM-style tiering, streamed execution, a secure
221
+ daemon with SSE, session/multi-agent management with pause/resume/cancel, a real
222
+ connectivity probe, a tier-aware skills/rules layer, sub-agent isolation, and a
223
+ safe tool registry.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Run artifacts + HMAC-chained audit log (Bernstein-inspired).
3
+ *
4
+ * When enabled, each run writes structured artifacts and an append-only,
5
+ * hash-linked audit trail under `<cwd>/.maq/runs/<runId>/`:
6
+ * scout.json plan.json execute.json verify.json result.json
7
+ * audit.log — one JSON line per action, each carrying an HMAC over
8
+ * (prevHash + payload), so tampering with any earlier line
9
+ * breaks the chain. This is the replayable trust artifact a
10
+ * "control my machine from my phone" product eventually needs.
11
+ *
12
+ * The HMAC key comes from MAQ_AUDIT_KEY (falls back to a fixed dev key with a
13
+ * marker so verification still works locally; set a real key for real use).
14
+ */
15
+ export interface AuditEntry {
16
+ seq: number;
17
+ ts: string;
18
+ action: string;
19
+ data: Record<string, unknown>;
20
+ prevHash: string;
21
+ hash: string;
22
+ }
23
+ export declare class AuditLog {
24
+ readonly dir: string;
25
+ private seq;
26
+ private prevHash;
27
+ private path;
28
+ constructor(cwd: string, runId: string);
29
+ /** Append a hash-linked action to the audit log. */
30
+ record(action: string, data?: Record<string, unknown>): AuditEntry;
31
+ /** Write a named JSON artifact next to the log. */
32
+ artifact(name: string, obj: unknown): void;
33
+ }
34
+ /**
35
+ * Verify an audit.log's hash chain. Returns { ok, entries, brokenAt? }.
36
+ * A mismatch means a line was altered/removed/reordered after it was written.
37
+ */
38
+ export declare function verifyAuditLog(dir: string): {
39
+ ok: boolean;
40
+ entries: number;
41
+ brokenAt?: number;
42
+ reason?: string;
43
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Run artifacts + HMAC-chained audit log (Bernstein-inspired).
3
+ *
4
+ * When enabled, each run writes structured artifacts and an append-only,
5
+ * hash-linked audit trail under `<cwd>/.maq/runs/<runId>/`:
6
+ * scout.json plan.json execute.json verify.json result.json
7
+ * audit.log — one JSON line per action, each carrying an HMAC over
8
+ * (prevHash + payload), so tampering with any earlier line
9
+ * breaks the chain. This is the replayable trust artifact a
10
+ * "control my machine from my phone" product eventually needs.
11
+ *
12
+ * The HMAC key comes from MAQ_AUDIT_KEY (falls back to a fixed dev key with a
13
+ * marker so verification still works locally; set a real key for real use).
14
+ */
15
+ import { createHmac } from "node:crypto";
16
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ const DEV_KEY = "maq-dev-audit-key-set-MAQ_AUDIT_KEY";
19
+ function auditKey() {
20
+ return process.env.MAQ_AUDIT_KEY ?? DEV_KEY;
21
+ }
22
+ export class AuditLog {
23
+ dir;
24
+ seq = 0;
25
+ prevHash = "genesis";
26
+ path;
27
+ constructor(cwd, runId) {
28
+ this.dir = join(cwd, ".maq", "runs", runId);
29
+ this.path = join(this.dir, "audit.log");
30
+ mkdirSync(this.dir, { recursive: true });
31
+ }
32
+ /** Append a hash-linked action to the audit log. */
33
+ record(action, data = {}) {
34
+ const seq = this.seq++;
35
+ const ts = new Date().toISOString();
36
+ const payload = JSON.stringify({ seq, ts, action, data });
37
+ const hash = createHmac("sha256", auditKey()).update(this.prevHash + payload).digest("hex");
38
+ const entry = { seq, ts, action, data, prevHash: this.prevHash, hash };
39
+ this.prevHash = hash;
40
+ appendFileSync(this.path, JSON.stringify(entry) + "\n", "utf8");
41
+ return entry;
42
+ }
43
+ /** Write a named JSON artifact next to the log. */
44
+ artifact(name, obj) {
45
+ writeFileSync(join(this.dir, name), JSON.stringify(obj, null, 2), "utf8");
46
+ }
47
+ }
48
+ /**
49
+ * Verify an audit.log's hash chain. Returns { ok, entries, brokenAt? }.
50
+ * A mismatch means a line was altered/removed/reordered after it was written.
51
+ */
52
+ export function verifyAuditLog(dir) {
53
+ const path = join(dir, "audit.log");
54
+ if (!existsSync(path))
55
+ return { ok: false, entries: 0, reason: "no audit.log" };
56
+ const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
57
+ let prevHash = "genesis";
58
+ for (let i = 0; i < lines.length; i++) {
59
+ let e;
60
+ try {
61
+ e = JSON.parse(lines[i]);
62
+ }
63
+ catch {
64
+ return { ok: false, entries: lines.length, brokenAt: i, reason: "invalid JSON" };
65
+ }
66
+ if (e.prevHash !== prevHash) {
67
+ return { ok: false, entries: lines.length, brokenAt: i, reason: "prevHash mismatch (reordered/removed)" };
68
+ }
69
+ const payload = JSON.stringify({ seq: e.seq, ts: e.ts, action: e.action, data: e.data });
70
+ const expect = createHmac("sha256", auditKey()).update(prevHash + payload).digest("hex");
71
+ if (expect !== e.hash) {
72
+ return { ok: false, entries: lines.length, brokenAt: i, reason: "hash mismatch (tampered)" };
73
+ }
74
+ prevHash = e.hash;
75
+ }
76
+ return { ok: true, entries: lines.length };
77
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * board.ts — Stigmergy shared-state layer for maqcli.
3
+ *
4
+ * Replaces passing large context blobs between pipeline phases with an
5
+ * append-only JSONL board stored at `<cwd>/.maq/board.jsonl`.
6
+ *
7
+ * Design properties:
8
+ * - **Crash-safe**: Every mutation is a single `appendFileSync` call.
9
+ * The worst-case failure is a partial last JSON line, which readers
10
+ * skip gracefully.
11
+ * - **Zero npm runtime deps**: Uses only `node:fs`, `node:path`,
12
+ * and `node:crypto`.
13
+ * - **Append-only**: Entries are never mutated or deleted; state is
14
+ * derived by replaying the log.
15
+ */
16
+ export type BoardPhase = "scout" | "plan" | "execute" | "verify";
17
+ export type BoardEntryType = "task.created" | "phase.start" | "phase.done" | "phase.data" | "task.done" | "task.error" | "task.aborted";
18
+ export interface BoardEntry {
19
+ id: string;
20
+ taskId: string;
21
+ ts: string;
22
+ type: BoardEntryType;
23
+ phase?: BoardPhase;
24
+ data: Record<string, unknown>;
25
+ }
26
+ export interface TaskSummary {
27
+ taskId: string;
28
+ task: string;
29
+ status: "running" | "done" | "failed" | "aborted";
30
+ startedAt: string;
31
+ finishedAt?: string;
32
+ phases: {
33
+ phase: BoardPhase;
34
+ status: string;
35
+ }[];
36
+ verified?: boolean;
37
+ }
38
+ /** Canonical path to the board file for a given working directory. */
39
+ export declare function boardPath(cwd: string): string;
40
+ export declare class Board {
41
+ private readonly filePath;
42
+ private dirEnsured;
43
+ constructor(cwd: string);
44
+ createTask(task: string): string;
45
+ phaseStart(taskId: string, phase: BoardPhase, data?: Record<string, unknown>): void;
46
+ phaseData(taskId: string, phase: BoardPhase, data: Record<string, unknown>): void;
47
+ phaseDone(taskId: string, phase: BoardPhase, data?: Record<string, unknown>): void;
48
+ taskDone(taskId: string, data?: Record<string, unknown>): void;
49
+ taskError(taskId: string, error: string): void;
50
+ taskAbort(taskId: string, reason?: string): void;
51
+ /** Return every entry for a given task, in append order. */
52
+ getEntries(taskId: string): BoardEntry[];
53
+ /**
54
+ * Merge all `phase.data` payloads for a task+phase into a single object.
55
+ * Returns `null` if no data entries exist for that combination.
56
+ */
57
+ getPhaseData(taskId: string, phase: BoardPhase): Record<string, unknown> | null;
58
+ /** Derive a summary for one task. Returns `null` if the task doesn't exist. */
59
+ getTask(taskId: string): TaskSummary | null;
60
+ /** List all tasks, newest first. */
61
+ listTasks(limit?: number): TaskSummary[];
62
+ /** Full detail view: summary + raw entries. */
63
+ showTask(taskId: string): {
64
+ summary: TaskSummary;
65
+ entries: BoardEntry[];
66
+ } | null;
67
+ /** Ensure `.maq/` directory exists (idempotent, cached). */
68
+ private ensureDir;
69
+ /** Append one entry as a JSON line. */
70
+ private append;
71
+ /**
72
+ * Read and parse the entire JSONL file.
73
+ * Gracefully skips blank or corrupt lines.
74
+ */
75
+ private readAll;
76
+ /** Derive a TaskSummary from a pre-filtered list of entries for one task. */
77
+ private static buildSummary;
78
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * board.ts — Stigmergy shared-state layer for maqcli.
3
+ *
4
+ * Replaces passing large context blobs between pipeline phases with an
5
+ * append-only JSONL board stored at `<cwd>/.maq/board.jsonl`.
6
+ *
7
+ * Design properties:
8
+ * - **Crash-safe**: Every mutation is a single `appendFileSync` call.
9
+ * The worst-case failure is a partial last JSON line, which readers
10
+ * skip gracefully.
11
+ * - **Zero npm runtime deps**: Uses only `node:fs`, `node:path`,
12
+ * and `node:crypto`.
13
+ * - **Append-only**: Entries are never mutated or deleted; state is
14
+ * derived by replaying the log.
15
+ */
16
+ import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { randomUUID } from "node:crypto";
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+ /** Canonical path to the board file for a given working directory. */
23
+ export function boardPath(cwd) {
24
+ return join(cwd, ".maq", "board.jsonl");
25
+ }
26
+ function entryId() {
27
+ return randomUUID().replace(/-/g, "").slice(0, 12);
28
+ }
29
+ function taskIdFromUUID() {
30
+ return randomUUID().slice(0, 8);
31
+ }
32
+ function nowISO() {
33
+ return new Date().toISOString();
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Board
37
+ // ---------------------------------------------------------------------------
38
+ export class Board {
39
+ filePath;
40
+ dirEnsured = false;
41
+ constructor(cwd) {
42
+ this.filePath = boardPath(cwd);
43
+ }
44
+ // ---- Writes -------------------------------------------------------------
45
+ createTask(task) {
46
+ const taskId = taskIdFromUUID();
47
+ this.append({
48
+ id: entryId(),
49
+ taskId,
50
+ ts: nowISO(),
51
+ type: "task.created",
52
+ data: { task },
53
+ });
54
+ return taskId;
55
+ }
56
+ phaseStart(taskId, phase, data = {}) {
57
+ this.append({
58
+ id: entryId(),
59
+ taskId,
60
+ ts: nowISO(),
61
+ type: "phase.start",
62
+ phase,
63
+ data,
64
+ });
65
+ }
66
+ phaseData(taskId, phase, data) {
67
+ this.append({
68
+ id: entryId(),
69
+ taskId,
70
+ ts: nowISO(),
71
+ type: "phase.data",
72
+ phase,
73
+ data,
74
+ });
75
+ }
76
+ phaseDone(taskId, phase, data = {}) {
77
+ this.append({
78
+ id: entryId(),
79
+ taskId,
80
+ ts: nowISO(),
81
+ type: "phase.done",
82
+ phase,
83
+ data,
84
+ });
85
+ }
86
+ taskDone(taskId, data = {}) {
87
+ this.append({
88
+ id: entryId(),
89
+ taskId,
90
+ ts: nowISO(),
91
+ type: "task.done",
92
+ data,
93
+ });
94
+ }
95
+ taskError(taskId, error) {
96
+ this.append({
97
+ id: entryId(),
98
+ taskId,
99
+ ts: nowISO(),
100
+ type: "task.error",
101
+ data: { error },
102
+ });
103
+ }
104
+ taskAbort(taskId, reason) {
105
+ this.append({
106
+ id: entryId(),
107
+ taskId,
108
+ ts: nowISO(),
109
+ type: "task.aborted",
110
+ data: { reason: reason ?? "aborted by user" },
111
+ });
112
+ }
113
+ // ---- Reads --------------------------------------------------------------
114
+ /** Return every entry for a given task, in append order. */
115
+ getEntries(taskId) {
116
+ return this.readAll().filter((e) => e.taskId === taskId);
117
+ }
118
+ /**
119
+ * Merge all `phase.data` payloads for a task+phase into a single object.
120
+ * Returns `null` if no data entries exist for that combination.
121
+ */
122
+ getPhaseData(taskId, phase) {
123
+ const entries = this.readAll().filter((e) => e.taskId === taskId &&
124
+ e.phase === phase &&
125
+ e.type === "phase.data");
126
+ if (entries.length === 0)
127
+ return null;
128
+ const merged = {};
129
+ for (const e of entries) {
130
+ Object.assign(merged, e.data);
131
+ }
132
+ return merged;
133
+ }
134
+ /** Derive a summary for one task. Returns `null` if the task doesn't exist. */
135
+ getTask(taskId) {
136
+ const entries = this.getEntries(taskId);
137
+ if (entries.length === 0)
138
+ return null;
139
+ return Board.buildSummary(taskId, entries);
140
+ }
141
+ /** List all tasks, newest first. */
142
+ listTasks(limit) {
143
+ const all = this.readAll();
144
+ const grouped = new Map();
145
+ for (const e of all) {
146
+ let arr = grouped.get(e.taskId);
147
+ if (!arr) {
148
+ arr = [];
149
+ grouped.set(e.taskId, arr);
150
+ }
151
+ arr.push(e);
152
+ }
153
+ const summaries = [];
154
+ grouped.forEach((entries, taskId) => {
155
+ summaries.push(Board.buildSummary(taskId, entries));
156
+ });
157
+ // Newest first (by startedAt descending)
158
+ summaries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
159
+ if (limit !== undefined && limit > 0) {
160
+ return summaries.slice(0, limit);
161
+ }
162
+ return summaries;
163
+ }
164
+ /** Full detail view: summary + raw entries. */
165
+ showTask(taskId) {
166
+ const entries = this.getEntries(taskId);
167
+ if (entries.length === 0)
168
+ return null;
169
+ return { summary: Board.buildSummary(taskId, entries), entries };
170
+ }
171
+ // ---- Internals ----------------------------------------------------------
172
+ /** Ensure `.maq/` directory exists (idempotent, cached). */
173
+ ensureDir() {
174
+ if (this.dirEnsured)
175
+ return;
176
+ mkdirSync(dirname(this.filePath), { recursive: true });
177
+ this.dirEnsured = true;
178
+ }
179
+ /** Append one entry as a JSON line. */
180
+ append(entry) {
181
+ this.ensureDir();
182
+ appendFileSync(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
183
+ }
184
+ /**
185
+ * Read and parse the entire JSONL file.
186
+ * Gracefully skips blank or corrupt lines.
187
+ */
188
+ readAll() {
189
+ if (!existsSync(this.filePath))
190
+ return [];
191
+ let raw;
192
+ try {
193
+ raw = readFileSync(this.filePath, "utf-8");
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ const entries = [];
199
+ for (const line of raw.split("\n")) {
200
+ const trimmed = line.trim();
201
+ if (trimmed.length === 0)
202
+ continue;
203
+ try {
204
+ entries.push(JSON.parse(trimmed));
205
+ }
206
+ catch {
207
+ // Partial / corrupt line — skip gracefully
208
+ }
209
+ }
210
+ return entries;
211
+ }
212
+ /** Derive a TaskSummary from a pre-filtered list of entries for one task. */
213
+ static buildSummary(taskId, entries) {
214
+ const created = entries.find((e) => e.type === "task.created");
215
+ const task = created?.data?.task ?? "";
216
+ const startedAt = created?.ts ?? entries[0].ts;
217
+ // Determine status from the last entry's type
218
+ const last = entries[entries.length - 1];
219
+ let status = "running";
220
+ if (last.type === "task.done")
221
+ status = "done";
222
+ else if (last.type === "task.error")
223
+ status = "failed";
224
+ else if (last.type === "task.aborted")
225
+ status = "aborted";
226
+ const finishedAt = status !== "running" ? last.ts : undefined;
227
+ // Build phase list
228
+ const phaseOrder = ["scout", "plan", "execute", "verify"];
229
+ const phaseMap = new Map();
230
+ for (const e of entries) {
231
+ if (!e.phase)
232
+ continue;
233
+ if (e.type === "phase.start")
234
+ phaseMap.set(e.phase, "running");
235
+ if (e.type === "phase.done")
236
+ phaseMap.set(e.phase, "done");
237
+ }
238
+ const phases = [];
239
+ for (const p of phaseOrder) {
240
+ const s = phaseMap.get(p);
241
+ if (s)
242
+ phases.push({ phase: p, status: s });
243
+ }
244
+ // Verified if the verify phase completed
245
+ const verified = phaseMap.get("verify") === "done";
246
+ return {
247
+ taskId,
248
+ task,
249
+ status,
250
+ startedAt,
251
+ finishedAt,
252
+ phases,
253
+ verified: verified || undefined,
254
+ };
255
+ }
256
+ }