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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline orchestrator: Scout -> Plan -> Execute -> Verify.
|
|
3
|
+
*
|
|
4
|
+
* The cost/quality dial lives here: trivial tasks skip Scout+Plan and go
|
|
5
|
+
* straight to a minimal Execute; standard/complex tasks run the full pipeline.
|
|
6
|
+
* Every phase transition emits a normalized event so a UI can render progress
|
|
7
|
+
* uniformly regardless of which worker CLI runs underneath.
|
|
8
|
+
*/
|
|
9
|
+
import { makeEvent } from "./types.js";
|
|
10
|
+
import { shouldRunPreflight } from "./complexity.js";
|
|
11
|
+
import { getProvider } from "./model.js";
|
|
12
|
+
import { Router } from "./router.js";
|
|
13
|
+
import { SkillsManager } from "./skills.js";
|
|
14
|
+
import { RecallMemory } from "./recall.js";
|
|
15
|
+
import { recordLesson } from "./memory.js";
|
|
16
|
+
import { CostMeter } from "./cost.js";
|
|
17
|
+
import { meteredProvider } from "./metered.js";
|
|
18
|
+
import { AuditLog } from "./audit.js";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { Headroom } from "./headroom.js";
|
|
21
|
+
import { detectAgents, resolveTarget, agentSpec } from "./registry.js";
|
|
22
|
+
import { loadConfig } from "./config-store.js";
|
|
23
|
+
import { runScout } from "../phases/scout.js";
|
|
24
|
+
import { runPlan } from "../phases/plan.js";
|
|
25
|
+
import { runExecute } from "../phases/execute.js";
|
|
26
|
+
import { runVerify } from "../phases/verify.js";
|
|
27
|
+
import { Board } from "./board.js";
|
|
28
|
+
import { CostTracker } from "./cost-tracker.js";
|
|
29
|
+
export async function runPipeline(task, opts = {}) {
|
|
30
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
31
|
+
const baseCfg = loadConfig();
|
|
32
|
+
// Ad-hoc overrides (do not persist).
|
|
33
|
+
const cfg = {
|
|
34
|
+
...baseCfg,
|
|
35
|
+
...(opts.provider ? { provider: opts.provider } : {}),
|
|
36
|
+
...(opts.model ? { cheapModel: opts.model, strongModel: opts.model } : {}),
|
|
37
|
+
};
|
|
38
|
+
const provider = getProvider(cfg.provider);
|
|
39
|
+
const router = new Router(cfg);
|
|
40
|
+
const skillsMgr = new SkillsManager(cwd, { skillsDir: cfg.skillsDir });
|
|
41
|
+
const headroom = new Headroom();
|
|
42
|
+
const meter = new CostMeter();
|
|
43
|
+
const runId = randomUUID();
|
|
44
|
+
const audit = opts.audit || baseCfg.auditRuns ? new AuditLog(cwd, runId) : undefined;
|
|
45
|
+
const board = new Board(cwd);
|
|
46
|
+
const costTracker = new CostTracker(cwd);
|
|
47
|
+
const boardTaskId = board.createTask(task);
|
|
48
|
+
const events = [];
|
|
49
|
+
const emit = (e) => {
|
|
50
|
+
events.push(e);
|
|
51
|
+
opts.onEvent?.(e);
|
|
52
|
+
};
|
|
53
|
+
emit(makeEvent("task.started", { task, cwd, provider: provider.name, cheapModel: cfg.cheapModel, strongModel: cfg.strongModel, boardTaskId }));
|
|
54
|
+
audit?.record("task.started", { task, provider: provider.name });
|
|
55
|
+
// --- SCOUT ---
|
|
56
|
+
board.phaseStart(boardTaskId, "scout");
|
|
57
|
+
emit(makeEvent("phase.started", { phase: "scout" }));
|
|
58
|
+
const scout = await runScout(task, cwd);
|
|
59
|
+
board.phaseData(boardTaskId, "scout", { complexity: scout.complexity, files: scout.files.length, notes: scout.notes });
|
|
60
|
+
board.phaseDone(boardTaskId, "scout");
|
|
61
|
+
emit(makeEvent("phase.done", { phase: "scout", complexity: scout.complexity, files: scout.files.length }));
|
|
62
|
+
audit?.record("scout.done", { complexity: scout.complexity, files: scout.files.length });
|
|
63
|
+
audit?.artifact("scout.json", scout);
|
|
64
|
+
// Resolve worker target.
|
|
65
|
+
const agents = detectAgents();
|
|
66
|
+
const requested = opts.target ?? cfg.projectTargets[cwd] ?? cfg.defaultTarget;
|
|
67
|
+
const { target, ambiguous } = resolveTarget(requested, agents);
|
|
68
|
+
const binPath = target === "none" ? null : (agents.find((a) => a.name === target)?.binPath ?? null);
|
|
69
|
+
if (ambiguous.length > 1) {
|
|
70
|
+
emit(makeEvent("agent.event", { note: "multiple ready agents; picked first", ambiguous, target }));
|
|
71
|
+
}
|
|
72
|
+
// --- PLAN (gated by complexity, routed to a model tier) ---
|
|
73
|
+
await opts.checkpoint?.();
|
|
74
|
+
let plan = null;
|
|
75
|
+
if (shouldRunPreflight(scout.complexity)) {
|
|
76
|
+
const route = router.routeByComplexity(scout.complexity);
|
|
77
|
+
const skillsBlock = skillsMgr.contextBlock(route.tier);
|
|
78
|
+
const recallBlock = new RecallMemory(cwd).contextBlock(task);
|
|
79
|
+
const context = [recallBlock, skillsBlock].filter(Boolean).join("\n\n");
|
|
80
|
+
board.phaseStart(boardTaskId, "plan", { tier: route.tier, model: route.model });
|
|
81
|
+
emit(makeEvent("phase.started", { phase: "plan", tier: route.tier, model: route.model, skills: skillsMgr.forTier(route.tier).length, recalled: recallBlock ? true : false }));
|
|
82
|
+
plan = await runPlan(scout, meteredProvider(route.provider, meter), route.model, context);
|
|
83
|
+
board.phaseData(boardTaskId, "plan", { winner: plan.winner.summary, candidates: plan.candidatesGenerated, earlyExit: plan.earlyExit });
|
|
84
|
+
board.phaseDone(boardTaskId, "plan");
|
|
85
|
+
emit(makeEvent("phase.done", {
|
|
86
|
+
phase: "plan",
|
|
87
|
+
winner: plan.winner.summary,
|
|
88
|
+
candidates: plan.candidatesGenerated,
|
|
89
|
+
earlyExit: plan.earlyExit,
|
|
90
|
+
}));
|
|
91
|
+
audit?.record("plan.done", { winner: plan.winner.summary, candidates: plan.candidatesGenerated });
|
|
92
|
+
audit?.artifact("plan.json", plan);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
board.phaseStart(boardTaskId, "plan", { skipped: true });
|
|
96
|
+
board.phaseDone(boardTaskId, "plan", { skipped: true, reason: "trivial task" });
|
|
97
|
+
emit(makeEvent("phase.done", { phase: "plan", skipped: true, reason: "trivial task" }));
|
|
98
|
+
plan = trivialPlan(task);
|
|
99
|
+
}
|
|
100
|
+
// --- EXECUTE ---
|
|
101
|
+
await opts.checkpoint?.();
|
|
102
|
+
board.phaseStart(boardTaskId, "execute", { target });
|
|
103
|
+
emit(makeEvent("phase.started", { phase: "execute", target }));
|
|
104
|
+
const execResult = await runExecute(plan, {
|
|
105
|
+
cwd,
|
|
106
|
+
target,
|
|
107
|
+
binPath,
|
|
108
|
+
dryRun: opts.dryRun,
|
|
109
|
+
timeoutMs: opts.timeoutMs,
|
|
110
|
+
headroom,
|
|
111
|
+
onEvent: emit,
|
|
112
|
+
signal: opts.signal,
|
|
113
|
+
});
|
|
114
|
+
board.phaseData(boardTaskId, "execute", { status: execResult.status, exitCode: execResult.exitCode, filesChanged: execResult.filesChanged });
|
|
115
|
+
board.phaseDone(boardTaskId, "execute");
|
|
116
|
+
emit(makeEvent("phase.done", { phase: "execute", status: execResult.status, exitCode: execResult.exitCode }));
|
|
117
|
+
audit?.record("execute.done", { status: execResult.status, exitCode: execResult.exitCode });
|
|
118
|
+
audit?.artifact("execute.json", execResult);
|
|
119
|
+
// --- VERIFY (cheap tier) ---
|
|
120
|
+
await opts.checkpoint?.();
|
|
121
|
+
board.phaseStart(boardTaskId, "verify");
|
|
122
|
+
emit(makeEvent("phase.started", { phase: "verify" }));
|
|
123
|
+
const verifyRoute = router.route("cheap");
|
|
124
|
+
const verifyResult = await runVerify(execResult, {
|
|
125
|
+
cwd,
|
|
126
|
+
provider: meteredProvider(verifyRoute.provider, meter),
|
|
127
|
+
model: verifyRoute.model,
|
|
128
|
+
skipTests: opts.dryRun,
|
|
129
|
+
timeoutMs: opts.timeoutMs,
|
|
130
|
+
});
|
|
131
|
+
board.phaseData(boardTaskId, "verify", { verified: verifyResult.verified, method: verifyResult.method });
|
|
132
|
+
board.phaseDone(boardTaskId, "verify");
|
|
133
|
+
emit(makeEvent("phase.done", { phase: "verify", verified: verifyResult.verified, method: verifyResult.method }));
|
|
134
|
+
audit?.record("verify.done", { verified: verifyResult.verified, method: verifyResult.method });
|
|
135
|
+
audit?.artifact("verify.json", verifyResult);
|
|
136
|
+
// Self-learning loop: on verify failure, append a lesson to AGENTS.md so the
|
|
137
|
+
// worker sees corrective guidance next time. Skipped for dry runs.
|
|
138
|
+
if (!opts.dryRun && !verifyResult.verified) {
|
|
139
|
+
const lessonPath = recordLesson(cwd, {
|
|
140
|
+
task,
|
|
141
|
+
method: verifyResult.method,
|
|
142
|
+
details: verifyResult.details,
|
|
143
|
+
errors: execResult.errors,
|
|
144
|
+
});
|
|
145
|
+
if (lessonPath)
|
|
146
|
+
emit(makeEvent("agent.event", { note: "recorded lesson", file: "AGENTS.md" }));
|
|
147
|
+
}
|
|
148
|
+
emit(makeEvent("task.done", {
|
|
149
|
+
verified: verifyResult.verified,
|
|
150
|
+
status: execResult.status,
|
|
151
|
+
compressionStoreSize: headroom.size(),
|
|
152
|
+
cost: meter.summary,
|
|
153
|
+
}));
|
|
154
|
+
audit?.record("task.done", { verified: verifyResult.verified, status: execResult.status, cost: meter.summary });
|
|
155
|
+
// Finalize the board task.
|
|
156
|
+
if (verifyResult.verified && execResult.status !== "failed") {
|
|
157
|
+
board.taskDone(boardTaskId, { verified: true, cost: meter.summary });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
board.taskError(boardTaskId, verifyResult.details || execResult.errors.join("; "));
|
|
161
|
+
}
|
|
162
|
+
// Record aggregated cost for this run.
|
|
163
|
+
if (meter.summary.calls > 0) {
|
|
164
|
+
costTracker.record({
|
|
165
|
+
ts: new Date().toISOString(),
|
|
166
|
+
taskId: boardTaskId,
|
|
167
|
+
task: task.slice(0, 100),
|
|
168
|
+
provider: cfg.provider,
|
|
169
|
+
model: cfg.cheapModel,
|
|
170
|
+
...meter.summary,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// Auto-write a skill file when verify succeeds on a non-trivial task.
|
|
174
|
+
if (!opts.dryRun && verifyResult.verified && scout.complexity !== "trivial") {
|
|
175
|
+
try {
|
|
176
|
+
const skillName = task.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase().slice(0, 40);
|
|
177
|
+
const skillContent = [
|
|
178
|
+
"maq-tier: all",
|
|
179
|
+
"maq-kind: context",
|
|
180
|
+
"",
|
|
181
|
+
`# Learned: ${task.slice(0, 80)}`,
|
|
182
|
+
"",
|
|
183
|
+
`Verified approach: ${plan?.winner.summary ?? task}`,
|
|
184
|
+
plan?.winner.steps?.length ? `Steps: ${plan.winner.steps.join("; ")}` : "",
|
|
185
|
+
`Complexity: ${scout.complexity}, target: ${execResult.target}`,
|
|
186
|
+
].filter(Boolean).join("\n");
|
|
187
|
+
const { writeFileSync, existsSync, mkdirSync } = await import("node:fs");
|
|
188
|
+
const { join } = await import("node:path");
|
|
189
|
+
const dir = join(cwd, cfg.skillsDir);
|
|
190
|
+
if (!existsSync(dir))
|
|
191
|
+
mkdirSync(dir, { recursive: true });
|
|
192
|
+
const skillPath = join(dir, `learned-${skillName}.md`);
|
|
193
|
+
if (!existsSync(skillPath)) {
|
|
194
|
+
writeFileSync(skillPath, skillContent, "utf8");
|
|
195
|
+
emit(makeEvent("agent.event", { note: "auto-learned skill", file: skillPath }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch { /* skill write is best-effort */ }
|
|
199
|
+
}
|
|
200
|
+
const result = {
|
|
201
|
+
task,
|
|
202
|
+
scout,
|
|
203
|
+
plan,
|
|
204
|
+
execute: execResult,
|
|
205
|
+
verify: verifyResult,
|
|
206
|
+
events,
|
|
207
|
+
cost: meter.summary,
|
|
208
|
+
runId: audit ? runId : undefined,
|
|
209
|
+
};
|
|
210
|
+
audit?.artifact("result.json", { ...result, events: undefined });
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
function trivialPlan(task) {
|
|
214
|
+
const winner = {
|
|
215
|
+
summary: task,
|
|
216
|
+
steps: [],
|
|
217
|
+
score: 1,
|
|
218
|
+
pass: true,
|
|
219
|
+
reason: "trivial task; no planning needed",
|
|
220
|
+
};
|
|
221
|
+
return { winner, allEvaluated: [winner], candidatesGenerated: 0, earlyExit: true };
|
|
222
|
+
}
|
|
223
|
+
export { agentSpec };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugins (outbound) — forward MAQ events to external targets (Slack / Discord /
|
|
3
|
+
* any webhook), CAO-style but dependency-free (global fetch). Observer-only:
|
|
4
|
+
* plugins never drive MAQ, they only stream events out. Attach one to the
|
|
5
|
+
* SessionRegistry's event bus and it POSTs each event to a URL.
|
|
6
|
+
*/
|
|
7
|
+
import type { SessionRegistry } from "./session.js";
|
|
8
|
+
export interface WebhookOptions {
|
|
9
|
+
url: string;
|
|
10
|
+
/** Only forward these event types (default: task.* lifecycle). */
|
|
11
|
+
types?: string[];
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
log?: (msg: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/** POST a single event to a webhook. Never throws. */
|
|
16
|
+
export declare function postWebhook(url: string, payload: unknown, timeoutMs?: number): Promise<boolean>;
|
|
17
|
+
/**
|
|
18
|
+
* Attach a webhook forwarder to a registry. Returns an unsubscribe function.
|
|
19
|
+
* By default forwards lifecycle events (task.started/done/error/cancelled).
|
|
20
|
+
*/
|
|
21
|
+
export declare function attachWebhook(registry: SessionRegistry, opts: WebhookOptions): () => void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugins (outbound) — forward MAQ events to external targets (Slack / Discord /
|
|
3
|
+
* any webhook), CAO-style but dependency-free (global fetch). Observer-only:
|
|
4
|
+
* plugins never drive MAQ, they only stream events out. Attach one to the
|
|
5
|
+
* SessionRegistry's event bus and it POSTs each event to a URL.
|
|
6
|
+
*/
|
|
7
|
+
/** POST a single event to a webhook. Never throws. */
|
|
8
|
+
export async function postWebhook(url, payload, timeoutMs = 5000) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "content-type": "application/json" },
|
|
13
|
+
body: JSON.stringify(payload),
|
|
14
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
15
|
+
});
|
|
16
|
+
return res.ok;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Attach a webhook forwarder to a registry. Returns an unsubscribe function.
|
|
24
|
+
* By default forwards lifecycle events (task.started/done/error/cancelled).
|
|
25
|
+
*/
|
|
26
|
+
export function attachWebhook(registry, opts) {
|
|
27
|
+
const types = new Set(opts.types ?? ["task.started", "task.done", "task.error", "task.cancelled"]);
|
|
28
|
+
const listener = (payload) => {
|
|
29
|
+
if (!types.has(payload.event.type))
|
|
30
|
+
return;
|
|
31
|
+
void postWebhook(opts.url, { sessionId: payload.sessionId, event: payload.event }, opts.timeoutMs).then((ok) => {
|
|
32
|
+
if (!ok)
|
|
33
|
+
opts.log?.(`webhook POST failed: ${opts.url}`);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
registry.onAny(listener);
|
|
37
|
+
return () => registry.offAny(listener);
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connectivity probe — turns the pure decision logic in connectivity.ts into a
|
|
3
|
+
* real measurement, dependency-free (node:dgram, node:net, node:os).
|
|
4
|
+
*
|
|
5
|
+
* - LAN presence: is there a private-range IPv4 interface (a LAN to discover
|
|
6
|
+
* peers on)? Full mDNS discovery lives in the native track; this is the
|
|
7
|
+
* cheap signal the daemon can compute alone.
|
|
8
|
+
* - P2P reachability: a real STUN Binding Request over UDP to a public STUN
|
|
9
|
+
* server. A Binding Success Response means UDP hole-punching is viable
|
|
10
|
+
* (direct P2P likely works). Timeout/refusal suggests UDP is blocked.
|
|
11
|
+
* - Relay reachability: an outbound TCP connect to a relay host:443 — the
|
|
12
|
+
* outbound-only path the product relies on when P2P fails.
|
|
13
|
+
* - Symmetric-NAT suspicion: outbound TCP works but STUN/UDP does not.
|
|
14
|
+
*/
|
|
15
|
+
import { type NetworkObservation, type TierDecision } from "./connectivity.js";
|
|
16
|
+
export interface ProbeResult {
|
|
17
|
+
observation: NetworkObservation;
|
|
18
|
+
decision: TierDecision;
|
|
19
|
+
details: {
|
|
20
|
+
lanInterfaces: string[];
|
|
21
|
+
stun: {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
rttMs?: number;
|
|
24
|
+
mapped?: string;
|
|
25
|
+
server: string;
|
|
26
|
+
};
|
|
27
|
+
relay: {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
rttMs?: number;
|
|
30
|
+
host: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** List non-internal private-range IPv4 addresses (a sign of a usable LAN). */
|
|
35
|
+
export declare function lanIpv4Interfaces(): string[];
|
|
36
|
+
/** Send a STUN Binding Request; resolve with the mapped address if we get one. */
|
|
37
|
+
export declare function stunProbe(host?: string, port?: number, timeoutMs?: number): Promise<{
|
|
38
|
+
ok: boolean;
|
|
39
|
+
rttMs?: number;
|
|
40
|
+
mapped?: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** Attempt an outbound TCP connection; resolve with reachability + RTT. */
|
|
43
|
+
export declare function tcpReachable(host?: string, port?: number, timeoutMs?: number): Promise<{
|
|
44
|
+
ok: boolean;
|
|
45
|
+
rttMs?: number;
|
|
46
|
+
}>;
|
|
47
|
+
/** Run all probes and produce a tier decision. */
|
|
48
|
+
export declare function probeConnectivity(premium?: boolean): Promise<ProbeResult>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connectivity probe — turns the pure decision logic in connectivity.ts into a
|
|
3
|
+
* real measurement, dependency-free (node:dgram, node:net, node:os).
|
|
4
|
+
*
|
|
5
|
+
* - LAN presence: is there a private-range IPv4 interface (a LAN to discover
|
|
6
|
+
* peers on)? Full mDNS discovery lives in the native track; this is the
|
|
7
|
+
* cheap signal the daemon can compute alone.
|
|
8
|
+
* - P2P reachability: a real STUN Binding Request over UDP to a public STUN
|
|
9
|
+
* server. A Binding Success Response means UDP hole-punching is viable
|
|
10
|
+
* (direct P2P likely works). Timeout/refusal suggests UDP is blocked.
|
|
11
|
+
* - Relay reachability: an outbound TCP connect to a relay host:443 — the
|
|
12
|
+
* outbound-only path the product relies on when P2P fails.
|
|
13
|
+
* - Symmetric-NAT suspicion: outbound TCP works but STUN/UDP does not.
|
|
14
|
+
*/
|
|
15
|
+
import { createSocket } from "node:dgram";
|
|
16
|
+
import { connect } from "node:net";
|
|
17
|
+
import { networkInterfaces } from "node:os";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { selectTier } from "./connectivity.js";
|
|
20
|
+
const STUN_SERVER = { host: process.env.MAQ_STUN_HOST ?? "stun.l.google.com", port: Number(process.env.MAQ_STUN_PORT ?? 19302) };
|
|
21
|
+
const RELAY_HOST = process.env.MAQ_RELAY_HOST ?? "1.1.1.1";
|
|
22
|
+
const RELAY_PORT = Number(process.env.MAQ_RELAY_PORT ?? 443);
|
|
23
|
+
/** List non-internal private-range IPv4 addresses (a sign of a usable LAN). */
|
|
24
|
+
export function lanIpv4Interfaces() {
|
|
25
|
+
const out = [];
|
|
26
|
+
const ifaces = networkInterfaces();
|
|
27
|
+
for (const addrs of Object.values(ifaces)) {
|
|
28
|
+
for (const a of addrs ?? []) {
|
|
29
|
+
if (a.family === "IPv4" && !a.internal && isPrivate(a.address))
|
|
30
|
+
out.push(a.address);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function isPrivate(ip) {
|
|
36
|
+
return (ip.startsWith("10.") ||
|
|
37
|
+
ip.startsWith("192.168.") ||
|
|
38
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
|
|
39
|
+
ip.startsWith("169.254."));
|
|
40
|
+
}
|
|
41
|
+
/** Send a STUN Binding Request; resolve with the mapped address if we get one. */
|
|
42
|
+
export function stunProbe(host = STUN_SERVER.host, port = STUN_SERVER.port, timeoutMs = 3000) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const sock = createSocket("udp4");
|
|
45
|
+
const txId = randomBytes(12);
|
|
46
|
+
// 20-byte header: type=0x0001 (Binding Request), length=0, magic cookie, txid.
|
|
47
|
+
const msg = Buffer.alloc(20);
|
|
48
|
+
msg.writeUInt16BE(0x0001, 0);
|
|
49
|
+
msg.writeUInt16BE(0x0000, 2);
|
|
50
|
+
msg.writeUInt32BE(0x2112a442, 4);
|
|
51
|
+
txId.copy(msg, 8);
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
let done = false;
|
|
54
|
+
const finish = (r) => {
|
|
55
|
+
if (done)
|
|
56
|
+
return;
|
|
57
|
+
done = true;
|
|
58
|
+
try {
|
|
59
|
+
sock.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
resolve(r);
|
|
65
|
+
};
|
|
66
|
+
const timer = setTimeout(() => finish({ ok: false }), timeoutMs);
|
|
67
|
+
sock.on("error", () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
finish({ ok: false });
|
|
70
|
+
});
|
|
71
|
+
sock.on("message", (resp) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
const rttMs = Date.now() - start;
|
|
74
|
+
const mapped = parseMappedAddress(resp);
|
|
75
|
+
finish({ ok: resp.readUInt16BE(0) === 0x0101, rttMs, mapped });
|
|
76
|
+
});
|
|
77
|
+
sock.send(msg, port, host, (err) => {
|
|
78
|
+
if (err) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
finish({ ok: false });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Parse XOR-MAPPED-ADDRESS (0x0020) or MAPPED-ADDRESS (0x0001) from a response. */
|
|
86
|
+
function parseMappedAddress(resp) {
|
|
87
|
+
try {
|
|
88
|
+
let offset = 20;
|
|
89
|
+
const magic = 0x2112a442;
|
|
90
|
+
while (offset + 4 <= resp.length) {
|
|
91
|
+
const type = resp.readUInt16BE(offset);
|
|
92
|
+
const len = resp.readUInt16BE(offset + 2);
|
|
93
|
+
const valStart = offset + 4;
|
|
94
|
+
if (type === 0x0020 || type === 0x0001) {
|
|
95
|
+
const family = resp.readUInt8(valStart + 1);
|
|
96
|
+
if (family === 0x01) {
|
|
97
|
+
let port = resp.readUInt16BE(valStart + 2);
|
|
98
|
+
let ip = [resp[valStart + 4], resp[valStart + 5], resp[valStart + 6], resp[valStart + 7]];
|
|
99
|
+
if (type === 0x0020) {
|
|
100
|
+
port ^= magic >>> 16;
|
|
101
|
+
ip = ip.map((b, i) => b ^ ((magic >>> (24 - i * 8)) & 0xff));
|
|
102
|
+
}
|
|
103
|
+
return `${ip.join(".")}:${port & 0xffff}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
offset = valStart + len + ((4 - (len % 4)) % 4);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ignore malformed */
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** Attempt an outbound TCP connection; resolve with reachability + RTT. */
|
|
115
|
+
export function tcpReachable(host = RELAY_HOST, port = RELAY_PORT, timeoutMs = 3000) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
const socket = connect({ host, port });
|
|
119
|
+
let done = false;
|
|
120
|
+
const finish = (ok) => {
|
|
121
|
+
if (done)
|
|
122
|
+
return;
|
|
123
|
+
done = true;
|
|
124
|
+
socket.destroy();
|
|
125
|
+
resolve(ok ? { ok: true, rttMs: Date.now() - start } : { ok: false });
|
|
126
|
+
};
|
|
127
|
+
socket.setTimeout(timeoutMs);
|
|
128
|
+
socket.once("connect", () => finish(true));
|
|
129
|
+
socket.once("timeout", () => finish(false));
|
|
130
|
+
socket.once("error", () => finish(false));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Run all probes and produce a tier decision. */
|
|
134
|
+
export async function probeConnectivity(premium = false) {
|
|
135
|
+
const lan = lanIpv4Interfaces();
|
|
136
|
+
const [stun, relay] = await Promise.all([stunProbe(), tcpReachable()]);
|
|
137
|
+
const observation = {
|
|
138
|
+
lanPeerFound: false, // real peer discovery is the native track's job; interfaces alone don't imply a peer
|
|
139
|
+
lanLatencyMs: undefined,
|
|
140
|
+
p2pConnected: stun.ok,
|
|
141
|
+
relayAvailable: relay.ok,
|
|
142
|
+
relayLatencyMs: relay.rttMs,
|
|
143
|
+
symmetricNatSuspected: !stun.ok && relay.ok,
|
|
144
|
+
premium,
|
|
145
|
+
};
|
|
146
|
+
const decision = selectTier(observation);
|
|
147
|
+
return {
|
|
148
|
+
observation,
|
|
149
|
+
decision,
|
|
150
|
+
details: {
|
|
151
|
+
lanInterfaces: lan,
|
|
152
|
+
stun: { ok: stun.ok, rttMs: stun.rttMs, mapped: stun.mapped, server: `${STUN_SERVER.host}:${STUN_SERVER.port}` },
|
|
153
|
+
relay: { ok: relay.ok, rttMs: relay.rttMs, host: `${RELAY_HOST}:${RELAY_PORT}` },
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent profiles — named, reusable specialists (CAO-style), mapped onto MAQ's
|
|
3
|
+
* existing target/provider/skills/tools machinery. A profile pins how a session
|
|
4
|
+
* runs: which worker target + provider/model, a role, an optional tool
|
|
5
|
+
* allow-list (enforced by the tool registry), and which skills apply.
|
|
6
|
+
*
|
|
7
|
+
* Sources (later wins): built-ins below, then `<cwd>/.maq/agents/*.md`.
|
|
8
|
+
* Profile files use light front-matter, e.g.:
|
|
9
|
+
* name: reviewer
|
|
10
|
+
* target: none
|
|
11
|
+
* role: reviewer
|
|
12
|
+
* allowedTools: read_file, list_dir, grep_text
|
|
13
|
+
* skills: verify-before-done, minimal-diff
|
|
14
|
+
* ---
|
|
15
|
+
* <freeform description / system guidance>
|
|
16
|
+
*/
|
|
17
|
+
export interface AgentProfile {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** Worker target: auto | claude-code | codex | gemini | none | … */
|
|
21
|
+
target?: string;
|
|
22
|
+
/** Provider override (e.g. openai, anthropic, cli:gemini). */
|
|
23
|
+
provider?: string;
|
|
24
|
+
/** Model override. */
|
|
25
|
+
model?: string;
|
|
26
|
+
role?: string;
|
|
27
|
+
/** Allow-list of tool names this agent may use (undefined = all). */
|
|
28
|
+
allowedTools?: string[];
|
|
29
|
+
/** Skill names relevant to this agent (advisory). */
|
|
30
|
+
skills?: string[];
|
|
31
|
+
path: string | null;
|
|
32
|
+
}
|
|
33
|
+
export declare class ProfileManager {
|
|
34
|
+
private cwd;
|
|
35
|
+
private dir;
|
|
36
|
+
constructor(cwd: string);
|
|
37
|
+
load(): AgentProfile[];
|
|
38
|
+
get(name: string): AgentProfile | undefined;
|
|
39
|
+
private parse;
|
|
40
|
+
/** Scaffold a starter profile dir. Returns files written. */
|
|
41
|
+
init(): string[];
|
|
42
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent profiles — named, reusable specialists (CAO-style), mapped onto MAQ's
|
|
3
|
+
* existing target/provider/skills/tools machinery. A profile pins how a session
|
|
4
|
+
* runs: which worker target + provider/model, a role, an optional tool
|
|
5
|
+
* allow-list (enforced by the tool registry), and which skills apply.
|
|
6
|
+
*
|
|
7
|
+
* Sources (later wins): built-ins below, then `<cwd>/.maq/agents/*.md`.
|
|
8
|
+
* Profile files use light front-matter, e.g.:
|
|
9
|
+
* name: reviewer
|
|
10
|
+
* target: none
|
|
11
|
+
* role: reviewer
|
|
12
|
+
* allowedTools: read_file, list_dir, grep_text
|
|
13
|
+
* skills: verify-before-done, minimal-diff
|
|
14
|
+
* ---
|
|
15
|
+
* <freeform description / system guidance>
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
const BUILT_INS = [
|
|
20
|
+
{
|
|
21
|
+
name: "code_supervisor",
|
|
22
|
+
description: "Plans and delegates: scopes the task, splits it, assigns workers, verifies and merges results.",
|
|
23
|
+
role: "supervisor",
|
|
24
|
+
target: "auto",
|
|
25
|
+
skills: ["verify-before-done", "plan-then-act", "structured-handoff"],
|
|
26
|
+
path: null,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "developer",
|
|
30
|
+
description: "Implements a scoped change with the smallest diff and keeps tests green.",
|
|
31
|
+
role: "developer",
|
|
32
|
+
target: "auto",
|
|
33
|
+
skills: ["minimal-diff", "read-before-write", "tests-are-truth"],
|
|
34
|
+
path: null,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "reviewer",
|
|
38
|
+
description: "Read-only reviewer: inspects code against acceptance criteria; never edits.",
|
|
39
|
+
role: "reviewer",
|
|
40
|
+
target: "none",
|
|
41
|
+
allowedTools: ["read_file", "list_dir", "grep_text"],
|
|
42
|
+
skills: ["verify-before-done", "cite-what-you-checked"],
|
|
43
|
+
path: null,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
const FIELDS = new Set(["name", "target", "provider", "model", "role", "allowedtools", "skills"]);
|
|
47
|
+
export class ProfileManager {
|
|
48
|
+
cwd;
|
|
49
|
+
dir;
|
|
50
|
+
constructor(cwd) {
|
|
51
|
+
this.cwd = cwd;
|
|
52
|
+
this.dir = join(cwd, ".maq", "agents");
|
|
53
|
+
}
|
|
54
|
+
load() {
|
|
55
|
+
const byName = new Map();
|
|
56
|
+
for (const p of BUILT_INS)
|
|
57
|
+
byName.set(p.name, p);
|
|
58
|
+
if (existsSync(this.dir)) {
|
|
59
|
+
let entries = [];
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(this.dir);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
entries = [];
|
|
65
|
+
}
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
if (!e.toLowerCase().endsWith(".md"))
|
|
68
|
+
continue;
|
|
69
|
+
const p = this.parse(join(this.dir, e), e.replace(/\.md$/i, ""));
|
|
70
|
+
if (p)
|
|
71
|
+
byName.set(p.name, p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...byName.values()];
|
|
75
|
+
}
|
|
76
|
+
get(name) {
|
|
77
|
+
return this.load().find((p) => p.name === name);
|
|
78
|
+
}
|
|
79
|
+
parse(path, fallbackName) {
|
|
80
|
+
try {
|
|
81
|
+
if (!statSync(path).isFile())
|
|
82
|
+
return null;
|
|
83
|
+
const raw = readFileSync(path, "utf8");
|
|
84
|
+
const lines = raw.split(/\r?\n/);
|
|
85
|
+
const p = { name: fallbackName, description: "", path };
|
|
86
|
+
let i = 0;
|
|
87
|
+
const desc = [];
|
|
88
|
+
let inFront = true;
|
|
89
|
+
for (; i < lines.length; i++) {
|
|
90
|
+
const line = lines[i];
|
|
91
|
+
if (inFront) {
|
|
92
|
+
if (line.trim() === "---") {
|
|
93
|
+
inFront = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const m = /^\s*([a-zA-Z]+)\s*:\s*(.+)$/.exec(line);
|
|
97
|
+
if (m && FIELDS.has(m[1].toLowerCase())) {
|
|
98
|
+
const key = m[1].toLowerCase();
|
|
99
|
+
const val = m[2].trim();
|
|
100
|
+
if (key === "name")
|
|
101
|
+
p.name = val;
|
|
102
|
+
else if (key === "target")
|
|
103
|
+
p.target = val;
|
|
104
|
+
else if (key === "provider")
|
|
105
|
+
p.provider = val;
|
|
106
|
+
else if (key === "model")
|
|
107
|
+
p.model = val;
|
|
108
|
+
else if (key === "role")
|
|
109
|
+
p.role = val;
|
|
110
|
+
else if (key === "allowedtools")
|
|
111
|
+
p.allowedTools = val.split(/[,\s]+/).filter(Boolean);
|
|
112
|
+
else if (key === "skills")
|
|
113
|
+
p.skills = val.split(/[,\s]+/).filter(Boolean);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// No recognized front-matter -> treat rest as description.
|
|
117
|
+
inFront = false;
|
|
118
|
+
}
|
|
119
|
+
desc.push(line);
|
|
120
|
+
}
|
|
121
|
+
p.description = desc.join("\n").trim();
|
|
122
|
+
return p;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Scaffold a starter profile dir. Returns files written. */
|
|
129
|
+
init() {
|
|
130
|
+
const written = [];
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(this.dir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
const example = join(this.dir, "reviewer.md");
|
|
138
|
+
if (!existsSync(example)) {
|
|
139
|
+
writeFileSync(example, [
|
|
140
|
+
"name: reviewer",
|
|
141
|
+
"target: none",
|
|
142
|
+
"role: reviewer",
|
|
143
|
+
"allowedTools: read_file, list_dir, grep_text",
|
|
144
|
+
"skills: verify-before-done, cite-what-you-checked",
|
|
145
|
+
"---",
|
|
146
|
+
"Read-only reviewer. Inspect the change against acceptance criteria and report",
|
|
147
|
+
"findings; never edit files.",
|
|
148
|
+
].join("\n"), "utf8");
|
|
149
|
+
written.push(example);
|
|
150
|
+
}
|
|
151
|
+
return written;
|
|
152
|
+
}
|
|
153
|
+
}
|