@getnoan/wizard 0.1.1 → 0.1.3
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 +17 -6
- package/package.json +1 -1
- package/src/agents.mjs +27 -0
- package/src/report.mjs +1 -1
- package/src/run.mjs +19 -2
- package/src/telemetry.mjs +33 -5
package/README.md
CHANGED
|
@@ -30,11 +30,12 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
30
30
|
4. **Your fact layer.** If the workspace holds fewer than ten facts, the wizard does not guess
|
|
31
31
|
facts for you. It hands the seeding to your assistant, which has the procedure from the skill:
|
|
32
32
|
read your website, repo and docs, propose a structure, and write only after you say yes.
|
|
33
|
-
5. **The agent pack** (optional, `--agents`).
|
|
33
|
+
5. **The agent pack** (optional, `--agents`). Asks what to call your agent — Verity by default, the
|
|
34
|
+
name she answers to in NOAN — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
|
|
34
35
|
to your account, verifies and stores your Anthropic and Resend keys (and optionally a Postgres URL
|
|
35
36
|
and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
|
|
36
|
-
instructions are in your workspace, records the block slugs as repository variables,
|
|
37
|
-
one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
|
|
37
|
+
instructions are in your workspace, records the name and the block slugs as repository variables,
|
|
38
|
+
and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
|
|
38
39
|
6. **The report.** What happened, and the one thing to do next.
|
|
39
40
|
|
|
40
41
|
## For a coding assistant
|
|
@@ -65,12 +66,22 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
|
65
66
|
| `--agents` / `--no-agents` | include or skip the agent pack step |
|
|
66
67
|
| `--no-mcp`, `--no-skill` | skip a step |
|
|
67
68
|
| `--dry-run` | show every write without making it |
|
|
68
|
-
| `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY
|
|
69
|
+
| `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY`, `DO_NOT_TRACK`, or any `CI`) |
|
|
69
70
|
|
|
70
71
|
## Telemetry
|
|
71
72
|
|
|
72
|
-
Three
|
|
73
|
-
platform
|
|
73
|
+
Three events — a run started, and then completed or cancelled — carrying the Node version, the
|
|
74
|
+
platform, the exit code, how long the run took, whether it was non-interactive (`--yes`), whether
|
|
75
|
+
the report was JSON, and whether the workspace was empty. That is the whole payload. Never a key,
|
|
76
|
+
never a path, never a workspace name, never anything read out of your fact layer.
|
|
77
|
+
|
|
78
|
+
A run is identified by an id made up for that process and thrown away with it, so the events of one
|
|
79
|
+
run line up and nothing links one run to the next. The events ask PostHog for no person profile and
|
|
80
|
+
no geolocation. Like any HTTP request they arrive from your IP address.
|
|
81
|
+
|
|
82
|
+
Nothing is sent when any of these is true: `--no-telemetry`, `NOAN_WIZARD_NO_TELEMETRY` set to
|
|
83
|
+
anything but `0` or `false`, `DO_NOT_TRACK` likewise, or `CI` set — a pipeline running the wizard
|
|
84
|
+
is not a person trying it.
|
|
74
85
|
|
|
75
86
|
## Why a wizard and not a prompt
|
|
76
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getnoan/wizard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "One command from nothing to a grounded agent: sign in to NOAN, wire your coding assistant, install the skill, and set up the agent pack.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/agents.mjs
CHANGED
|
@@ -34,6 +34,18 @@ export function parseSeedOutput(text) {
|
|
|
34
34
|
export const PACK_SECRETS = ["NOAN_PERSONAL_API_KEY", "RESEND_API_KEY", "ANTHROPIC_API_KEY", "DATABASE_URL", "FIRECRAWL_API_KEY", "NEWSLETTER_UNSUB_SECRET"];
|
|
35
35
|
export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG"];
|
|
36
36
|
|
|
37
|
+
/** What the agents call themselves, and how their own prose refers to them.
|
|
38
|
+
* Verity is the default because that is the agent people meet in NOAN; anyone who
|
|
39
|
+
* wants their own name says so and gets it. Unset, the pack signs as "Agent", which
|
|
40
|
+
* is nobody, so the wizard always sends a name.
|
|
41
|
+
* Pronouns default to she/her only for Verity: assuming them for a name someone
|
|
42
|
+
* else chose is worse than the pack's own neutral fallback (they/them). */
|
|
43
|
+
export function agentIdentity({ name, pronouns } = {}) {
|
|
44
|
+
const n = String(name ?? "").trim() || "Verity";
|
|
45
|
+
const p = String(pronouns ?? "").trim() || (n.toLowerCase() === "verity" ? "she/her" : "");
|
|
46
|
+
return { AGENT_NAME: n, ...(p && { AGENT_PRONOUNS: p }) };
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export async function verifyAnthropic(key) {
|
|
38
50
|
try { const r = await fetch("https://api.anthropic.com/v1/models", { headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } }); return r.status === 200; } catch { return false; }
|
|
39
51
|
}
|
|
@@ -86,6 +98,21 @@ export function runSeeds(dest, env, { dryRun = false } = {}) {
|
|
|
86
98
|
return { slugs, rows };
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/** The pack's grounding check: which blocks the agents read hold no fact. Runs in the clone; absent in an old pack. */
|
|
102
|
+
export function runGroundingCheck(dest, env, { dryRun = false } = {}) {
|
|
103
|
+
const p = path.join(dest, "agents", "grounding-check.mjs");
|
|
104
|
+
if (!existsSync(p)) return { available: false };
|
|
105
|
+
const r = spawnSync(process.execPath, [p, "--json", ...(dryRun ? ["--dry-run"] : [])], { cwd: path.join(dest, "agents"), encoding: "utf8", env: { ...process.env, ...env } });
|
|
106
|
+
return { available: true, ...parseGroundingOutput(r.stdout), error: r.status === 0 ? undefined : (r.stderr || "").trim().split("\n").slice(-1)[0] };
|
|
107
|
+
}
|
|
108
|
+
export function parseGroundingOutput(text) {
|
|
109
|
+
for (const line of String(text || "").split("\n").reverse()) {
|
|
110
|
+
if (!line.trim().startsWith("{")) continue;
|
|
111
|
+
try { const j = JSON.parse(line); return { gaps: j.gaps || [], filed: j.filed || [] }; } catch {}
|
|
112
|
+
}
|
|
113
|
+
return { gaps: [], filed: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
89
116
|
export function dispatchDryRun(dest, workflow, dryRun) {
|
|
90
117
|
if (dryRun) return { workflow, action: "would dispatch" };
|
|
91
118
|
const r = gh(["workflow", "run", workflow, "-f", "dry_run=1"], { cwd: dest });
|
package/src/report.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export function renderReport(report, { json = false } = {}) {
|
|
|
14
14
|
}
|
|
15
15
|
if (s.skill?.rows) L.push(`skill: ${s.skill.rows.map(r => `${r.id} (${r.written} written)`).join(" · ")}; pointers: ${s.skill.pointers.map(p => p.action).join(", ")}`);
|
|
16
16
|
if (s.workspace) L.push(`facts: ${s.workspace.count ?? "?"} (${s.workspace.state})`);
|
|
17
|
-
if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
|
|
17
|
+
if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.agentName || "your agent"}, ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
|
|
18
18
|
if (report.next?.length) { L.push("", "──────── next ────────"); for (const n of report.next) L.push(`• ${n.say}${n.why ? ` (${n.why})` : ""}`); }
|
|
19
19
|
L.push("");
|
|
20
20
|
return L.join("\n");
|
package/src/run.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export async function run(args) {
|
|
|
86
86
|
ui.step(5, "The agent pack");
|
|
87
87
|
let wantAgents = args.agents;
|
|
88
88
|
if (wantAgents == null) wantAgents = ui.interactive ? await ui.confirm("Set up the six open-source agents on your GitHub account too?", false) : false;
|
|
89
|
-
if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me });
|
|
89
|
+
if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report });
|
|
90
90
|
else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
|
|
91
91
|
|
|
92
92
|
/* 6. report */
|
|
@@ -96,7 +96,7 @@ export async function run(args) {
|
|
|
96
96
|
|
|
97
97
|
async function ask(ui, q, o) { return ui.ask(q, o); }
|
|
98
98
|
|
|
99
|
-
async function setupAgents({ args, ui, dir, key, me }) {
|
|
99
|
+
async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
100
100
|
const ready = pack.ghReady();
|
|
101
101
|
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
102
102
|
const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
|
|
@@ -119,11 +119,17 @@ async function setupAgents({ args, ui, dir, key, me }) {
|
|
|
119
119
|
const mailFrom = process.env.MAIL_FROM || (ui.interactive ? await ui.ask("Sender for the agents' mail, e.g. Agent <agent@yourdomain.com> (a domain verified in Resend)") : "");
|
|
120
120
|
const replyTo = process.env.REPLY_TO || (ui.interactive ? await ui.ask("Reply-to address (a mailbox someone reads)", { dflt: mailFrom.replace(/^.*<|>.*$/g, "") }) : "");
|
|
121
121
|
const escalateTo = process.env.ESCALATE_TO || (ui.interactive ? await ui.ask("Where the support agent escalates when it cannot answer") : "");
|
|
122
|
+
const askedName = process.env.AGENT_NAME || (ui.interactive ? await ui.ask("What should your agent be called?", { dflt: "Verity" }) : "");
|
|
123
|
+
const identity = pack.agentIdentity({ name: askedName, pronouns: process.env.AGENT_PRONOUNS
|
|
124
|
+
|| (ui.interactive && askedName && askedName.toLowerCase() !== "verity"
|
|
125
|
+
? await ui.ask(`Pronouns for ${askedName}, in ${askedName}'s own prose (blank for they/them)`)
|
|
126
|
+
: "") });
|
|
122
127
|
|
|
123
128
|
const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
|
|
124
129
|
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
|
|
125
130
|
for (const [n, v] of Object.entries(secrets)) { const r = pack.setSecret(fork.dest, n, v, args.dryRun); out.secrets.push(r); ui.ok(`secret ${n}: ${r.action}${r.error ? ` — ${r.error}` : ""}`); }
|
|
126
131
|
const vars = { DRY_RUN: "1", STATE_BACKEND: db ? "postgres" : "local", ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
|
|
132
|
+
...identity,
|
|
127
133
|
COMPANY_NAME: me.project?.name || "", ...(me.identity?.id && { AGENT_IDENTITY_IDS: me.identity.id }), ...(me.identity?.email && { COMMANDERS: me.identity.email }) };
|
|
128
134
|
// The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
|
|
129
135
|
pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
|
|
@@ -131,6 +137,16 @@ async function setupAgents({ args, ui, dir, key, me }) {
|
|
|
131
137
|
out.seeds = seeds.rows;
|
|
132
138
|
for (const r of seeds.rows) ui.ok(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
|
|
133
139
|
Object.assign(vars, seeds.slugs);
|
|
140
|
+
// The blocks the agents read that hold no fact: one task each on the user's board, and a line in the report.
|
|
141
|
+
const grounding = args.dryRun ? { available: false } : pack.runGroundingCheck(fork.dest, { NOAN_PERSONAL_API_KEY: key });
|
|
142
|
+
out.grounding = grounding;
|
|
143
|
+
if (grounding.available) {
|
|
144
|
+
if (grounding.gaps.length) {
|
|
145
|
+
ui.warn(`${grounding.gaps.length} block(s) the agents read hold no fact — ${grounding.filed.filter(f => f.action === "filed").length} task(s) filed on your NOAN board`);
|
|
146
|
+
for (const g of grounding.gaps) ui.info(` ${g.title}: needed by ${g.agents.join(", ")}`);
|
|
147
|
+
report.next.push({ id: "grounding", say: `Fill the ${grounding.gaps.length} block(s) the agents read (tasks are on your NOAN board): ${grounding.gaps.map(g => g.title).join(", ")}.`, why: "an agent grounded in an empty block fails quietly" });
|
|
148
|
+
} else ui.ok("every block the agents read holds a fact");
|
|
149
|
+
}
|
|
134
150
|
for (const [n, v] of Object.entries(vars)) { if (!v) continue; const r = pack.setVariable(fork.dest, n, v, args.dryRun); out.variables.push(r); }
|
|
135
151
|
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
|
|
136
152
|
// No point dispatching a run that will only fail for a missing key: say what is missing instead.
|
|
@@ -145,5 +161,6 @@ async function setupAgents({ args, ui, dir, key, me }) {
|
|
|
145
161
|
ui.ok(`${wf}: ${d.action}${d.error ? ` — ${d.error}` : ""} — read it under Actions, then set DRY_RUN=0 for the agents you trust`);
|
|
146
162
|
}
|
|
147
163
|
out.repo = pack.repoSlug(fork.dest);
|
|
164
|
+
out.agentName = identity.AGENT_NAME;
|
|
148
165
|
return out;
|
|
149
166
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -1,17 +1,45 @@
|
|
|
1
1
|
/** Runs, completions and cancels — the three counts that say whether the wizard works for strangers.
|
|
2
|
-
* Off
|
|
3
|
-
|
|
2
|
+
* Off whenever the user says so, and off in CI. Never a key, never a path, never a workspace name. */
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// The public, write-only PostHog project key. Embedding one is what it is for; it can send
|
|
6
|
+
// events and read nothing. Empty = telemetry off.
|
|
7
|
+
export const POSTHOG_TOKEN = "phc_7O3g17L9CuhzTtpKkrklUZpFGJymLxrO3iMMH1krn6s";
|
|
4
8
|
const HOST = "https://us.i.posthog.com";
|
|
9
|
+
const TIMEOUT_MS = 2000; // a slow or blackholed endpoint must never hold up the wizard or its report
|
|
10
|
+
const RUN_ID = randomUUID(); // this process only, never stored: pairs started with completed/cancelled
|
|
11
|
+
|
|
12
|
+
/** Everything an event may carry, and the only thing it can. README and SECURITY.md list exactly
|
|
13
|
+
* this set; a property from any caller that is not here is dropped rather than sent. The promise
|
|
14
|
+
* is "never a key, never a path, never a workspace name", and a promise one call site can break
|
|
15
|
+
* by passing an extra key is not a promise — so the guard lives here, not in the callers. */
|
|
16
|
+
export const ALLOWED_PROPERTIES = new Set([
|
|
17
|
+
"$process_person_profile", "$geoip_disable",
|
|
18
|
+
"node", "platform", "yes", "json", "exit", "ms", "empty",
|
|
19
|
+
]);
|
|
20
|
+
const onlyAllowed = (props) => Object.fromEntries(Object.entries(props).filter(([k]) => ALLOWED_PROPERTIES.has(k)));
|
|
21
|
+
|
|
22
|
+
/** An opt-out env var is set unless it is absent, empty, "0" or "false" — because someone who
|
|
23
|
+
* writes NO_TELEMETRY=true means it, and the old exact match on "1" silently ignored them. */
|
|
24
|
+
const optedOut = (v) => {
|
|
25
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
26
|
+
return s !== "" && s !== "0" && s !== "false";
|
|
27
|
+
};
|
|
5
28
|
|
|
6
29
|
export function telemetryEnabled(args) {
|
|
7
|
-
|
|
30
|
+
if (!POSTHOG_TOKEN || args.telemetry === false) return false;
|
|
31
|
+
if (optedOut(process.env.NOAN_WIZARD_NO_TELEMETRY) || optedOut(process.env.DO_NOT_TRACK)) return false;
|
|
32
|
+
if (optedOut(process.env.CI)) return false; // a pipeline running the wizard is not a person trying it
|
|
33
|
+
return true;
|
|
8
34
|
}
|
|
9
35
|
export async function capture(args, event, props = {}) {
|
|
10
36
|
if (!telemetryEnabled(args)) return false;
|
|
11
37
|
try {
|
|
12
38
|
await fetch(`${HOST}/capture/`, { method: "POST", headers: { "Content-Type": "application/json" },
|
|
13
|
-
|
|
14
|
-
|
|
39
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
40
|
+
body: JSON.stringify({ api_key: POSTHOG_TOKEN, event: `wizard_${event}`, distinct_id: RUN_ID,
|
|
41
|
+
properties: onlyAllowed({ $process_person_profile: false, $geoip_disable: true,
|
|
42
|
+
node: process.version, platform: process.platform, yes: !!args.yes, json: !!args.json, ...props }) }) });
|
|
15
43
|
return true;
|
|
16
44
|
} catch { return false; }
|
|
17
45
|
}
|