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.
- package/README.md +223 -0
- package/dist/core/audit.d.ts +43 -0
- package/dist/core/audit.js +77 -0
- package/dist/core/board.d.ts +78 -0
- package/dist/core/board.js +256 -0
- package/dist/core/catalog.d.ts +50 -0
- package/dist/core/catalog.js +103 -0
- package/dist/core/command-catalog.d.ts +44 -0
- package/dist/core/command-catalog.js +86 -0
- package/dist/core/completion.d.ts +24 -0
- package/dist/core/completion.js +309 -0
- package/dist/core/complexity.d.ts +17 -0
- package/dist/core/complexity.js +87 -0
- package/dist/core/config-store.d.ts +33 -0
- package/dist/core/config-store.js +61 -0
- package/dist/core/connectivity.d.ts +34 -0
- package/dist/core/connectivity.js +49 -0
- package/dist/core/cost-tracker.d.ts +89 -0
- package/dist/core/cost-tracker.js +189 -0
- package/dist/core/cost.d.ts +35 -0
- package/dist/core/cost.js +89 -0
- package/dist/core/exec.d.ts +43 -0
- package/dist/core/exec.js +154 -0
- package/dist/core/flows.d.ts +36 -0
- package/dist/core/flows.js +96 -0
- package/dist/core/headroom.d.ts +36 -0
- package/dist/core/headroom.js +88 -0
- package/dist/core/help-topics.d.ts +26 -0
- package/dist/core/help-topics.js +294 -0
- package/dist/core/init-wizard.d.ts +26 -0
- package/dist/core/init-wizard.js +168 -0
- package/dist/core/interactive-registry.d.ts +50 -0
- package/dist/core/interactive-registry.js +86 -0
- package/dist/core/interactive.d.ts +48 -0
- package/dist/core/interactive.js +137 -0
- package/dist/core/logger.d.ts +16 -0
- package/dist/core/logger.js +46 -0
- package/dist/core/memory.d.ts +28 -0
- package/dist/core/memory.js +70 -0
- package/dist/core/metered.d.ts +9 -0
- package/dist/core/metered.js +16 -0
- package/dist/core/model.d.ts +74 -0
- package/dist/core/model.js +199 -0
- package/dist/core/pipeline.d.ts +33 -0
- package/dist/core/pipeline.js +223 -0
- package/dist/core/plugins.d.ts +21 -0
- package/dist/core/plugins.js +38 -0
- package/dist/core/probe.d.ts +48 -0
- package/dist/core/probe.js +156 -0
- package/dist/core/profiles.d.ts +42 -0
- package/dist/core/profiles.js +153 -0
- package/dist/core/providers.d.ts +84 -0
- package/dist/core/providers.js +275 -0
- package/dist/core/recall.d.ts +29 -0
- package/dist/core/recall.js +83 -0
- package/dist/core/registry.d.ts +41 -0
- package/dist/core/registry.js +162 -0
- package/dist/core/router.d.ts +33 -0
- package/dist/core/router.js +40 -0
- package/dist/core/sandbox.d.ts +78 -0
- package/dist/core/sandbox.js +268 -0
- package/dist/core/session.d.ts +105 -0
- package/dist/core/session.js +252 -0
- package/dist/core/skills.d.ts +56 -0
- package/dist/core/skills.js +289 -0
- package/dist/core/subagent.d.ts +40 -0
- package/dist/core/subagent.js +55 -0
- package/dist/core/supervisor.d.ts +37 -0
- package/dist/core/supervisor.js +40 -0
- package/dist/core/tools.d.ts +39 -0
- package/dist/core/tools.js +159 -0
- package/dist/core/types.d.ts +87 -0
- package/dist/core/types.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1032 -0
- package/dist/phases/execute.d.ts +39 -0
- package/dist/phases/execute.js +166 -0
- package/dist/phases/plan.d.ts +11 -0
- package/dist/phases/plan.js +118 -0
- package/dist/phases/scout.d.ts +10 -0
- package/dist/phases/scout.js +113 -0
- package/dist/phases/verify.d.ts +22 -0
- package/dist/phases/verify.js +81 -0
- package/dist/server/daemon.d.ts +50 -0
- package/dist/server/daemon.js +377 -0
- package/dist/server/relay-bridge.d.ts +44 -0
- package/dist/server/relay-bridge.js +175 -0
- 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
|
+
}
|