@meffecta/agent 1.1.0 → 1.1.2

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/engine.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "engineTag": "1.1.0",
3
- "builtFrom": "ce6db59f4b8be5d791d3924929484f03546b6822"
2
+ "engineTag": "1.1.2",
3
+ "builtFrom": "d7e2ae69bb32ec92bbc927d67cc0c96190d2789a"
4
4
  }
package/lib/commands.js CHANGED
@@ -4,9 +4,11 @@ import { fileURLToPath } from "node:url";
4
4
  import { configPath, decideAnalytics, readConfig, resolvePosthog, writeConfig } from "./analytics.js";
5
5
  import { requireDeployment, UserError } from "./config.js";
6
6
  import { checkJobs, createJob } from "./create-job.js";
7
+ import { checkSystems, createSystem } from "./create-system.js";
7
8
  import { runDoctor } from "./doctor.js";
8
9
  import { parseFlags } from "./flags.js";
9
10
  import { api, requireCommand, stream } from "./gcloud.js";
11
+ import { history } from "./history.js";
10
12
  import { connect } from "./integrations.js";
11
13
  import { resources } from "./resources.js";
12
14
  import { verifyCredentials } from "./verify-credentials.js";
@@ -310,7 +312,15 @@ async function jobs() {
310
312
  }
311
313
 
312
314
  async function runJob(args) {
313
- const { flags, positional } = parseFlags(args, { in: { type: "int", min: 1, max: 3600 } });
315
+ const { flags, positional, help } = parseFlags(
316
+ args,
317
+ { in: { type: "int", min: 1, max: 3600 } },
318
+ { usage: "meffecta-agent run <job> [--in <seconds>]" },
319
+ );
320
+ if (help) {
321
+ console.log(help);
322
+ return 0;
323
+ }
314
324
  const name = positional[0];
315
325
  if (!name) {
316
326
  throw new UserError("Which job? Try: meffecta-agent jobs");
@@ -331,11 +341,19 @@ async function ask(args) {
331
341
  // model and effort are deliberately not validated here. The engine owns that list, this
332
342
  // CLI may be deploying an engine older or newer than itself, and /test already answers
333
343
  // with the valid values — so the authority stays in one place and the error stays good.
334
- const { flags, positional } = parseFlags(args, {
335
- model: {},
336
- effort: {},
337
- timeoutSeconds: { type: "int", min: 1, max: 3600 },
338
- });
344
+ const { flags, positional, help } = parseFlags(
345
+ args,
346
+ {
347
+ model: {},
348
+ effort: {},
349
+ timeoutSeconds: { type: "int", min: 1, max: 3600 },
350
+ },
351
+ { usage: 'meffecta-agent ask "how did search do last week" [--model M] [--effort E]' },
352
+ );
353
+ if (help) {
354
+ console.log(help);
355
+ return 0;
356
+ }
339
357
  const prompt = positional.join(" ");
340
358
  if (!prompt) {
341
359
  throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
@@ -538,7 +556,15 @@ async function triggers() {
538
556
  /** Recent service logs, without making anyone remember the filter syntax. */
539
557
  async function logs(args) {
540
558
  requireCommand("gcloud", "logs come from Cloud Logging");
541
- const { flags } = parseFlags(args, { limit: { type: "int", min: 1, max: 1000 } });
559
+ const { flags, help } = parseFlags(
560
+ args,
561
+ { limit: { type: "int", min: 1, max: 1000 } },
562
+ { usage: "meffecta-agent logs [--limit N]" },
563
+ );
564
+ if (help) {
565
+ console.log(help);
566
+ return 0;
567
+ }
542
568
  const d = requireDeployment();
543
569
  const limit = String(flags.limit ?? 40);
544
570
  const { code } = await stream("gcloud", [
@@ -771,7 +797,9 @@ export const GROUPS = [
771
797
  ["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
772
798
  ["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
773
799
  ["logs", "Recent service logs (--limit N)", logs],
800
+ ["history", "What it has actually run: outcome, duration and cost per run (--job, --since, --failed)", history],
774
801
  ["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
802
+ ["check-systems", "Validate the register here, and every job's systems: against it", checkSystems],
775
803
  ["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
776
804
  ["resources", "Everything the set-up built in Google Cloud, and what each part is for", resources],
777
805
  ],
@@ -780,6 +808,11 @@ export const GROUPS = [
780
808
  title: "Operate it",
781
809
  commands: [
782
810
  ["create-job", 'Write a new job from a description: create-job "a report every Wednesday 2pm"', createJob],
811
+ [
812
+ "create-system",
813
+ 'Write a systems/ register entry: create-system "our HubSpot CRM" — it finds the variables',
814
+ createSystem,
815
+ ],
783
816
  ["run", "Trigger one job now, or --in <seconds>", runJob],
784
817
  ["ask", "Ask it something as a one-off run", ask],
785
818
  ["sweep", "Run the housekeeping sweep now", sweep],
package/lib/create-job.js CHANGED
@@ -215,7 +215,15 @@ function buildPrompt(description, { timezone, existing }) {
215
215
  }
216
216
 
217
217
  export async function createJob(args) {
218
- const { flags, positional } = parseFlags(args, { name: {}, model: {}, "dry-run": { type: "boolean" } });
218
+ const { flags, positional, help } = parseFlags(
219
+ args,
220
+ { name: {}, model: {}, "dry-run": { type: "boolean" } },
221
+ { usage: 'meffecta-agent create-job "a report every Wednesday at 2pm" [--name <file>] [--dry-run]' },
222
+ );
223
+ if (help) {
224
+ console.log(help);
225
+ return 0;
226
+ }
219
227
  const description = positional.join(" ").trim();
220
228
  if (!description) {
221
229
  throw new UserError(
@@ -0,0 +1,398 @@
1
+ // `create-system "<the system>"` — a register entry written by the agent itself.
2
+ //
3
+ // A register entry used to be a document: prose the model read to learn which of this
4
+ // deployment's variables reach which system. It is now load-bearing. A job's `systems:`
5
+ // names entries, and only those entries' `requires` and `selectors` are put in that run's
6
+ // environment — so an entry that lists too little leaves a job unable to work, one that
7
+ // lists too much hands out more than it should, and a `group:` sharing a name with a file
8
+ // in systems/ fails the engine's boot outright.
9
+ //
10
+ // WHERE THE MODEL RUNS: on the deployment, like create-job, and here the reason is sharper
11
+ // than context. Only a run inside the deployment can see which variables are actually SET,
12
+ // and the whole value of an entry is naming the real ones. A model on a laptop would guess
13
+ // them from the skill's documentation and be plausibly, silently wrong.
14
+ //
15
+ // WHAT COMES BACK IS NOT TRUSTED. The entry is parsed the way the engine parses it and
16
+ // checked before it is written — the name, the required fields, variables shaped like
17
+ // variables, and both directions of the group/filename collision that would stop the
18
+ // service booting. As with create-job, the value is the checking.
19
+ //
20
+ // It writes a file and stops. No commit, no push: the operator reads what their agent will
21
+ // be told it may reach.
22
+
23
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+ import { requireDeployment, UserError } from "./config.js";
26
+ import { readLocalJobs, readLocalSystems } from "./doctor.js";
27
+ import { parseFlags } from "./flags.js";
28
+ import { api, capture, gcloudArgs } from "./gcloud.js";
29
+
30
+ /** Sentinels rather than code fences: an entry's prose may itself contain fenced blocks. */
31
+ const BEGIN = "SYSTEM_FILE_BEGIN";
32
+ const END = "SYSTEM_FILE_END";
33
+
34
+ /** Every field the engine and the baseline prompt define for an entry. */
35
+ const REQUIRED_FIELDS = ["system", "skill", "requires", "access", "probe"];
36
+
37
+ /** The same shape src/systems.ts reads a variable name in. */
38
+ const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
39
+
40
+ /** A system name is a filename, and the key a job's `systems:` names it by. */
41
+ export function validName(name) {
42
+ return /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(name ?? "");
43
+ }
44
+
45
+ /** The engine's frontmatter rules (src/jobs.ts), which the register is parsed with too. */
46
+ export function parseFrontmatter(content) {
47
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
48
+ if (!match) {
49
+ return { meta: {}, body: content.trim() };
50
+ }
51
+ const meta = {};
52
+ for (const line of match[1].split("\n")) {
53
+ const [key, ...rest] = line.split(": ");
54
+ if (key && rest.length) {
55
+ meta[key.trim()] = rest.join(": ").replace(/^["']|["']$/g, "");
56
+ }
57
+ }
58
+ return { meta, body: match[2].trim() };
59
+ }
60
+
61
+ export function extractSystemFile(answer) {
62
+ const start = answer.indexOf(BEGIN);
63
+ const stop = answer.indexOf(END);
64
+ if (start < 0 || stop < 0 || stop < start) {
65
+ return null;
66
+ }
67
+ const named = answer.match(/SYSTEM_NAME:\s*([^\n]+)/);
68
+ const notes = answer.match(/SYSTEM_NOTES:\s*([^\n]+)/);
69
+ return {
70
+ name: named?.[1].trim().replace(/\.md$/, ""),
71
+ file: `${answer.slice(start + BEGIN.length, stop).trim()}\n`,
72
+ notes: notes?.[1].trim(),
73
+ };
74
+ }
75
+
76
+ const list = (value) =>
77
+ (value ?? "")
78
+ .split(",")
79
+ .map((part) => part.trim())
80
+ .filter(Boolean);
81
+
82
+ /**
83
+ * Everything that would make this entry grant the wrong things, or stop the service
84
+ * booting. `register` is the entries already on disk; `configured` is the variable names
85
+ * actually set on the deployment, when they could be read.
86
+ *
87
+ * Returns human problems. A `__note__` prefix marks one that is worth saying but not worth
88
+ * refusing over — writing the entry before minting the credential is a normal order to
89
+ * work in.
90
+ */
91
+ export function validateSystem({ name, file, register = [], configured }) {
92
+ const problems = [];
93
+ const names = new Set(register.map((entry) => entry.name));
94
+ const groups = new Set(register.flatMap((entry) => entry.groups));
95
+
96
+ if (!validName(name)) {
97
+ problems.push(
98
+ `"${name}" is not usable as a system name. It becomes systems/<name>.md and the key a job's systems: names it by: lower-case letters, digits and hyphens.`,
99
+ );
100
+ }
101
+ if (names.has(name)) {
102
+ problems.push(`systems/${name}.md already exists. Pass --name to call this one something else.`);
103
+ }
104
+ // Both directions of the collision the engine refuses to boot on.
105
+ if (groups.has(name)) {
106
+ problems.push(
107
+ `"${name}" is already a group: on another entry. A job naming it could mean either, so the engine refuses to boot — rename this entry, or the group.`,
108
+ );
109
+ }
110
+
111
+ const { meta, body } = parseFrontmatter(file);
112
+ if (!file.startsWith("---\n")) {
113
+ problems.push("No frontmatter block — the engine would find no variables here at all.");
114
+ }
115
+ for (const field of REQUIRED_FIELDS) {
116
+ if (!meta[field]) {
117
+ problems.push(`No ${field}: line. Every entry carries ${REQUIRED_FIELDS.join(", ")}.`);
118
+ }
119
+ }
120
+ for (const group of list(meta.group)) {
121
+ if (names.has(group)) {
122
+ problems.push(
123
+ `group: ${group} is also the name of systems/${group}.md. A job naming it could mean either, so the engine refuses to boot.`,
124
+ );
125
+ }
126
+ }
127
+
128
+ // `requires: none — <how it is reached instead>` is a real and common entry: a system the
129
+ // runtime service account is invited on carries no variable at all.
130
+ const declared = [...list(meta.requires), ...list(meta.selectors)];
131
+ const claimsNone = /^none\b/i.test(meta.requires ?? "");
132
+ const variables = declared.map((segment) => segment.match(/^([A-Z][A-Z0-9_]*)(?=$|[\s(])/)?.[1]).filter(Boolean);
133
+ const prose = (claimsNone ? list(meta.selectors) : declared).filter(
134
+ (segment) => !VARIABLE.test(segment.split(/[\s(]/)[0]),
135
+ );
136
+ if (!claimsNone && variables.length === 0) {
137
+ problems.push(
138
+ `requires: names no variable the engine can read. Use exact names (HUBSPOT_TOKEN), or "none — <how it is reached instead>" for a system that needs no credential.`,
139
+ );
140
+ }
141
+ if (claimsNone && variables.length === 0 && !/—|--|:/.test(meta.requires ?? "")) {
142
+ problems.push("__note__requires: none — say how it IS reached, or a run has nowhere to go when it needs to.");
143
+ }
144
+ // `requires: none — domain-wide delegation, keyless on Cloud Run` is one sentence that
145
+ // happens to contain a comma. Once the field opens with `none` the whole line is prose,
146
+ // and picking through its clauses for variable names finds only phrases.
147
+ if (!claimsNone) {
148
+ for (const segment of prose) {
149
+ problems.push(
150
+ `__note__"${segment}" in requires:/selectors: is not shaped like a variable name, so the engine will not pass it to a scoped run.`,
151
+ );
152
+ }
153
+ }
154
+ if (configured) {
155
+ const missing = variables.filter((variable) => !configured.has(variable));
156
+ if (missing.length) {
157
+ problems.push(
158
+ `__note__Not set on this deployment yet: ${missing.join(", ")}. A job scoped to this system gets nothing for them — meffecta-agent set-secret <NAME>.`,
159
+ );
160
+ }
161
+ }
162
+ if (body.length < 20) {
163
+ problems.push(
164
+ "__note__No prose under the frontmatter. That half is what tells a run which portal, which database, and what not to touch.",
165
+ );
166
+ }
167
+ return problems;
168
+ }
169
+
170
+ function buildPrompt(description, { existing, groups }) {
171
+ return [
172
+ "You are writing ONE systems/ register entry for the Meffecta Agent deployment you are running in.",
173
+ "",
174
+ "The register is not documentation. A job's `systems:` frontmatter names entries here, and",
175
+ "ONLY those entries' `requires` and `selectors` variables are put in that run's environment.",
176
+ "An entry naming too few leaves a job unable to work; one naming too many hands out more",
177
+ "than it should. Write the exact set.",
178
+ "",
179
+ "Read first:",
180
+ " - systems/*.md in your working directory — the house style, and what already exists",
181
+ " - the skill that reaches this system, for what KIND of credential it needs (the skills",
182
+ " are shared across deployments and deliberately name no deployment's variables)",
183
+ " - worlds/ if present, for the per-project selectors this system might need",
184
+ "",
185
+ "The operator asked for an entry for:",
186
+ ` ${JSON.stringify(description)}`,
187
+ "",
188
+ "Rules:",
189
+ " - Find the variable NAMES that are actually set in your environment for this system",
190
+ " (`printenv | cut -d= -f1 | grep -i <something>` lists names only). Never print, quote",
191
+ " or write a variable's VALUE anywhere — not in the file, not in your answer.",
192
+ " - requires: the exact names that must all be set. If the system authenticates as the",
193
+ ' runtime service account and needs no variable, write "none — <how it is reached>".',
194
+ " - selectors: optional per-project names (one per world), where an unset one means that",
195
+ " project is not wired up rather than that the system is broken.",
196
+ " - skill: the skill that reaches it, or `none` and say how it is reached instead.",
197
+ " - access: what a JOB may do with it, written honestly — read-only, may send mail, may",
198
+ " change DNS.",
199
+ " - probe: the cheapest read that proves the credential works, with NO side effect —",
200
+ " list domains rather than send, read quota rather than generate.",
201
+ " - group: optional labels a job can name instead of listing entries. Do not reuse a name",
202
+ " that is already an entry filename; the engine refuses to boot on that ambiguity.",
203
+ " - Do not invent variables. If this deployment has no credential for the system, say so",
204
+ " in SYSTEM_NOTES and write the entry with the names it WOULD need.",
205
+ " - The prose under the frontmatter is what only this deployment knows: which portal,",
206
+ " which account, what the naming means, what not to touch.",
207
+ existing.length ? ` - Entry names already taken: ${existing.join(", ")}` : "",
208
+ groups.length ? ` - Group names already in use: ${groups.join(", ")}` : "",
209
+ "",
210
+ "Answer in EXACTLY this shape and nothing else — no preamble, no code fences:",
211
+ "",
212
+ "SYSTEM_NAME: <short-kebab-case-name>",
213
+ `${BEGIN}`,
214
+ "---",
215
+ "<frontmatter lines>",
216
+ "---",
217
+ "<the prose>",
218
+ `${END}`,
219
+ "SYSTEM_NOTES: <one line: anything the operator must set up or decide, or 'none'>",
220
+ ]
221
+ .filter(Boolean)
222
+ .join("\n");
223
+ }
224
+
225
+ /**
226
+ * Variable NAMES set on the deployment — never a value, which this has no reason to read
227
+ * and no way to print. Undefined when gcloud cannot answer: that costs one unsaid note, not
228
+ * the check, so it is never worth failing the command over.
229
+ */
230
+ function configuredVariables(d) {
231
+ try {
232
+ const svc = JSON.parse(
233
+ capture(
234
+ "gcloud",
235
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
236
+ ),
237
+ );
238
+ return new Set((svc.spec.template.spec.containers[0].env ?? []).map((e) => e.name));
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ export async function createSystem(args) {
245
+ const { flags, positional, help } = parseFlags(
246
+ args,
247
+ { name: {}, model: {}, "dry-run": { type: "boolean" } },
248
+ { usage: 'meffecta-agent create-system "our HubSpot CRM" [--name <file>] [--dry-run]' },
249
+ );
250
+ if (help) {
251
+ console.log(help);
252
+ return 0;
253
+ }
254
+ const description = positional.join(" ").trim();
255
+ if (!description) {
256
+ throw new UserError(
257
+ 'Which system? e.g.\n\n meffecta-agent create-system "our HubSpot CRM"\n meffecta-agent create-system "the Postgres read replica for the product database"',
258
+ );
259
+ }
260
+ const d = requireDeployment();
261
+ const systemsDir = resolve(process.cwd(), "systems");
262
+ const register = readLocalSystems(systemsDir) ?? [];
263
+ const existing = register.map((entry) => entry.name);
264
+ const groups = [...new Set(register.flatMap((entry) => entry.groups))];
265
+
266
+ console.error(
267
+ `Asking ${d.SERVICE} to write it — only it can see which variables are actually set. This takes a minute.`,
268
+ );
269
+ const params = new URLSearchParams({ prompt: buildPrompt(description, { existing, groups }) });
270
+ if (flags.model) {
271
+ params.set("model", String(flags.model));
272
+ }
273
+ const answer = await api(d, `/test?${params}`, { accept: "text/markdown" });
274
+
275
+ const extracted = extractSystemFile(answer);
276
+ if (!extracted?.file) {
277
+ throw new UserError(
278
+ `The deployment did not answer in the expected shape, so nothing was written.\n\n${answer.slice(0, 600)}`,
279
+ );
280
+ }
281
+ const name = String(flags.name ?? extracted.name ?? "").trim();
282
+ const problems = validateSystem({ name, file: extracted.file, register, configured: configuredVariables(d) });
283
+ const notes = problems.filter((p) => p.startsWith("__note__")).map((p) => p.slice("__note__".length));
284
+ const errors = problems.filter((p) => !p.startsWith("__note__"));
285
+
286
+ console.log("");
287
+ console.log(`── systems/${name || "?"}.md ${"─".repeat(Math.max(0, 57 - name.length))}`);
288
+ console.log(extracted.file.trimEnd());
289
+ console.log("─".repeat(72));
290
+
291
+ if (extracted.notes && extracted.notes.toLowerCase() !== "none") {
292
+ console.log(`\nIt says: ${extracted.notes}`);
293
+ }
294
+ for (const note of notes) {
295
+ console.log(`\nℹ ${note}`);
296
+ }
297
+ if (errors.length) {
298
+ console.log("");
299
+ for (const problem of errors) {
300
+ console.log(`✖ ${problem}`);
301
+ }
302
+ throw new UserError("\nNot written. Re-run to try again, or fix it by hand from the text above.");
303
+ }
304
+
305
+ if (flags["dry-run"]) {
306
+ console.log("\n(dry run — nothing written)");
307
+ return 0;
308
+ }
309
+ mkdirSync(systemsDir, { recursive: true });
310
+ writeFileSync(resolve(systemsDir, `${name}.md`), extracted.file);
311
+ console.log(`\n✔ Written to systems/${name}.md — read it before you commit it. It decides what a job may reach.`);
312
+ console.log("");
313
+ console.log(" git add systems/ && git commit && git push live on the next run");
314
+ console.log(` meffecta-agent doctor checks the register against what is set`);
315
+ console.log(` systems: ${name}${" ".repeat(Math.max(1, 36 - name.length))}name it in a job to scope that job to it`);
316
+ return 0;
317
+ }
318
+
319
+ /**
320
+ * `check-systems` — the same validation, on the entries that already exist.
321
+ *
322
+ * `create-system` writes a file and stops so the operator reads it first, which means the
323
+ * next thing that happens is a hand-edit. And the register only became load-bearing
324
+ * recently: every entry written while it was documentation has never been checked at all,
325
+ * on a deployment where a job's `systems:` now decides what reaches the run.
326
+ *
327
+ * It also checks the other direction — every job's `systems:` against the register — because
328
+ * this is the command that has both loaded. Those are the two failures that do not announce
329
+ * themselves: a group named like a file stops the service booting, and a name matching
330
+ * nothing leaves a job running with no credentials and reporting every system unavailable.
331
+ *
332
+ * No gcloud, no deployment: this runs on the files in front of you.
333
+ */
334
+ export async function checkSystems() {
335
+ const systemsDir = resolve(process.cwd(), "systems");
336
+ const register = readLocalSystems(systemsDir);
337
+ if (!register) {
338
+ throw new UserError(`No systems/ directory in ${process.cwd()}. Run this from your content repo.`);
339
+ }
340
+ if (register.length === 0) {
341
+ console.log('No register entries yet. Write one: meffecta-agent create-system "..."');
342
+ return 0;
343
+ }
344
+
345
+ let failed = 0;
346
+ for (const entry of [...register].sort((a, b) => a.name.localeCompare(b.name))) {
347
+ const problems = validateSystem({
348
+ name: entry.name,
349
+ file: readFileSync(resolve(systemsDir, `${entry.name}.md`), "utf8"),
350
+ // Excluding itself, or every entry collides with its own filename.
351
+ register: register.filter((other) => other.name !== entry.name),
352
+ });
353
+ const errors = problems.filter((p) => !p.startsWith("__note__"));
354
+ const notes = problems.filter((p) => p.startsWith("__note__"));
355
+ if (errors.length === 0 && notes.length === 0) {
356
+ console.log(`✔ ${entry.name}`);
357
+ continue;
358
+ }
359
+ console.log(`${errors.length ? "✖" : "·"} ${entry.name}`);
360
+ for (const problem of [...errors, ...notes.map((n) => n.slice("__note__".length))]) {
361
+ console.log(` ${problem}`);
362
+ }
363
+ failed += errors.length ? 1 : 0;
364
+ }
365
+
366
+ // The other direction: what the jobs ask for.
367
+ const names = new Set(register.map((entry) => entry.name));
368
+ const groups = new Set(register.flatMap((entry) => entry.groups));
369
+ const jobs = readLocalJobs(resolve(process.cwd(), "jobs")) ?? [];
370
+ const scoped = jobs.filter((job) => job.systems && job.systems !== "*");
371
+ const unresolved = scoped
372
+ .map((job) => ({
373
+ job: job.name,
374
+ missing: list(job.systems).filter((n) => !names.has(n) && !groups.has(n)),
375
+ }))
376
+ .filter((entry) => entry.missing.length);
377
+ console.log("");
378
+ if (unresolved.length) {
379
+ for (const { job, missing } of unresolved) {
380
+ console.log(`✖ jobs/${job}.md is scoped to ${missing.join(", ")} — no entry and no group of that name`);
381
+ }
382
+ console.log(" Such a job runs with the credentials of the systems it DID name, and reports the rest");
383
+ console.log(" unavailable. It looks healthy while doing nothing.");
384
+ console.log("");
385
+ failed += unresolved.length;
386
+ } else if (scoped.length) {
387
+ console.log(`${scoped.length} of ${jobs.length} job(s) scoped, all naming systems that exist.`);
388
+ } else if (jobs.length) {
389
+ console.log(`No job declares systems:, so every run is given every credential this deployment holds.`);
390
+ }
391
+
392
+ console.log(
393
+ failed
394
+ ? `${failed} problem(s). A scoped run gets exactly what its entries declare, so these decide what works.`
395
+ : `${register.length} register ${register.length === 1 ? "entry" : "entries"} valid. \`meffecta-agent doctor\` also checks them against what is actually set.`,
396
+ );
397
+ return failed ? 1 : 0;
398
+ }
package/lib/doctor.js CHANGED
@@ -347,7 +347,7 @@ export async function doctor() {
347
347
  if (badAuth.length) {
348
348
  r.fail(
349
349
  "An inbox job has a requireAuth: it cannot honor",
350
- `${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — AgentMail authenticates mail itself and reports no verdict to check. Such a job refuses every message.`,
350
+ `${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — neither AgentMail nor Graph reports a per-message verdict this engine can check. Such a job refuses every message.`,
351
351
  "Fix the value, or remove the line",
352
352
  );
353
353
  } else if (unauthenticated.length) {
@@ -357,6 +357,15 @@ export async function doctor() {
357
357
  "Add requireAuth: dmarc to the job",
358
358
  );
359
359
  }
360
+ // Not a fix the operator can apply in the job file, so it is stated once rather than
361
+ // nagged about: on these, allowFrom: is the whole of the protection.
362
+ const unverifiable = inboxJobs.filter((j) => !j.inbox.startsWith("gmail:"));
363
+ if (unverifiable.length) {
364
+ r.ok(
365
+ `${unverifiable.length} inbox job(s) rely on allowFrom: alone`,
366
+ `${unverifiable.map((j) => `${j.name} (${j.inbox.split(":")[0]})`).join(", ")} — no per-message verdict to check on these systems, so the sender list is the whole of the protection`,
367
+ );
368
+ }
360
369
 
361
370
  // --- what each job may reach ------------------------------------------------
362
371
  const register = readLocalSystems(`${process.cwd()}/systems`);
package/lib/flags.js CHANGED
@@ -11,11 +11,19 @@ import { UserError } from "./config.js";
11
11
  *
12
12
  * Supports `--name value`, `--name=value`, boolean flags, and `--` to end flag parsing.
13
13
  */
14
- export function parseFlags(argv, spec = {}) {
14
+ export function parseFlags(argv, spec = {}, { usage } = {}) {
15
15
  const flags = {};
16
16
  const positional = [];
17
17
  let onlyPositional = false;
18
18
 
19
+ // `help` is answered here rather than by each command, because the spec is the only
20
+ // description of a command's options that exists — and the top-level help tells people
21
+ // this works. It comes back as a flag rather than printing and exiting, so the caller
22
+ // still owns its own exit code and this stays testable.
23
+ if (argv.includes("--help") || argv.includes("-h")) {
24
+ return { flags, positional, help: describeFlags(spec, usage) };
25
+ }
26
+
19
27
  for (let i = 0; i < argv.length; i += 1) {
20
28
  const token = argv[i];
21
29
 
@@ -78,5 +86,36 @@ export function parseFlags(argv, spec = {}) {
78
86
  flags[name] = raw;
79
87
  }
80
88
 
81
- return { flags, positional };
89
+ return { flags, positional, help: undefined };
90
+ }
91
+
92
+ /** What a command takes, from the only place that knows: its own spec. */
93
+ export function describeFlags(spec, usage) {
94
+ const lines = usage ? [usage, ""] : [];
95
+ const names = Object.keys(spec);
96
+ if (names.length === 0) {
97
+ return [...lines, "This command takes no options."].join("\n");
98
+ }
99
+ const shown = names.map((name) => {
100
+ const definition = spec[name];
101
+ if (definition.type === "boolean") {
102
+ return [`--${name}`, ""];
103
+ }
104
+ if (definition.type === "int") {
105
+ const bounds = [
106
+ definition.min !== undefined && `min ${definition.min}`,
107
+ definition.max !== undefined && `max ${definition.max}`,
108
+ ]
109
+ .filter(Boolean)
110
+ .join(", ");
111
+ return [`--${name} <n>`, bounds];
112
+ }
113
+ return [`--${name} <value>`, definition.choices ? `one of: ${definition.choices.join(", ")}` : ""];
114
+ });
115
+ const width = Math.max(...shown.map(([flag]) => flag.length));
116
+ return [
117
+ ...lines,
118
+ "Options:",
119
+ ...shown.map(([flag, note]) => ` ${flag.padEnd(width)}${note ? ` ${note}` : ""}`),
120
+ ].join("\n");
82
121
  }
package/lib/history.js ADDED
@@ -0,0 +1,360 @@
1
+ import { requireDeployment, UserError } from "./config.js";
2
+ import { parseFlags } from "./flags.js";
3
+ import { capture, gcloudArgs, probe, requireCommand } from "./gcloud.js";
4
+
5
+ /**
6
+ * What the deployment has actually been doing.
7
+ *
8
+ * Every other read this CLI offers is present-tense — `jobs` is the boot-time registration
9
+ * list, `queue` is what is pending, `status` is right now. The engine records the past
10
+ * faithfully, one `job-end` object per run in the audit bucket with the outcome, the
11
+ * duration and what the model cost, and until this there was no way to read it back short
12
+ * of composing a storage query by hand.
13
+ *
14
+ * **It reads the bucket, not the service.** That is deliberate on a scale-to-zero
15
+ * deployment: asking the engine would wake an instance and bill for it, and the records are
16
+ * not in the engine anyway. Looking at a year of history should not cost a cold start.
17
+ *
18
+ * The object names are what make this cheap without an index:
19
+ * `YYYY/MM/DD/HH-MM-SS.mmm_job-end_<job>.json` sorts lexicographically into chronological
20
+ * order and carries the job in the name, so the listing narrows by day and by job, and only
21
+ * the handful of records actually being shown are fetched.
22
+ */
23
+
24
+ /** One run, as `job-end` records it. */
25
+ const RECORD_KIND = "job-end";
26
+ const DAY_MS = 86_400_000;
27
+
28
+ /** A year, so a mistyped date cannot turn into a listing of the whole bucket. */
29
+ const MAX_DAYS = 366;
30
+
31
+ function utcMidnight(date) {
32
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
33
+ }
34
+
35
+ function parseDay(value, flag) {
36
+ const at = Date.parse(`${value.trim()}T00:00:00Z`);
37
+ if (Number.isNaN(at)) {
38
+ throw new UserError(`${flag} takes a date like 2026-08-01 (got "${value}").`);
39
+ }
40
+ return at;
41
+ }
42
+
43
+ /**
44
+ * The window to look in, as UTC midnights. `--since` is where to start — a date, or `7d` /
45
+ * `36h` counted back from the end — and `--until` is where to stop, so a closed range like
46
+ * `--since 2026-08-01 --until 2026-08-07` asks about that week and nothing outside it.
47
+ *
48
+ * Both ends are inclusive whole days, because the audit trail is partitioned by day and a
49
+ * half-day window would be a promise the listing cannot keep.
50
+ */
51
+ export function resolveWindow({ since, until } = {}, now = new Date()) {
52
+ const end = until ? parseDay(until, "--until") : utcMidnight(now);
53
+ let start;
54
+ if (!since) {
55
+ start = end - 6 * DAY_MS;
56
+ } else {
57
+ const relative = /^(\d+)([dh])$/.exec(since.trim());
58
+ if (relative) {
59
+ const count = Number(relative[1]);
60
+ const days = relative[2] === "d" ? Math.max(count, 1) : Math.max(Math.ceil(count / 24), 1);
61
+ start = end - (days - 1) * DAY_MS;
62
+ } else {
63
+ start = parseDay(since, "--since");
64
+ }
65
+ }
66
+ if (start > end) {
67
+ throw new UserError(
68
+ until
69
+ ? `--since ${since} is after --until ${until}.`
70
+ : `--since ${since} is in the future — there is no history there yet.`,
71
+ );
72
+ }
73
+ const days = (end - start) / DAY_MS + 1;
74
+ if (days > MAX_DAYS) {
75
+ throw new UserError(`That is ${days} days. Ask for at most ${MAX_DAYS} at a time.`);
76
+ }
77
+ return { start, end, days };
78
+ }
79
+
80
+ /** The UTC day prefixes a window covers, oldest first — the audit trail's own partitioning. */
81
+ export function dayPrefixes(window) {
82
+ const prefixes = [];
83
+ for (let at = window.start; at <= window.end; at += DAY_MS) {
84
+ const day = new Date(at);
85
+ prefixes.push(
86
+ `${day.getUTCFullYear()}/${String(day.getUTCMonth() + 1).padStart(2, "0")}/${String(day.getUTCDate()).padStart(2, "0")}`,
87
+ );
88
+ }
89
+ return prefixes;
90
+ }
91
+
92
+ /**
93
+ * A job name as the audit trail spells it: `recordAudit` replaces everything outside
94
+ * `[A-Za-z0-9._-]` with `-`, so a filter has to ask the same question the writer answered.
95
+ */
96
+ export function qualifier(job) {
97
+ return job.replace(/[^a-zA-Z0-9._-]/g, "-");
98
+ }
99
+
100
+ /** Where this deployment writes its audit trail, read off the service. */
101
+ function auditBucket(d) {
102
+ const svc = JSON.parse(
103
+ capture(
104
+ "gcloud",
105
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
106
+ ),
107
+ );
108
+ const bucket = (svc.spec?.template?.spec?.containers?.[0]?.env ?? []).find((e) => e.name === "AUDIT_BUCKET")?.value;
109
+ if (!bucket) {
110
+ throw new UserError(
111
+ "This deployment has no AUDIT_BUCKET set, so nothing records what its runs did.\n\n" +
112
+ " meffecta-agent setup-infra\n\nprovisions the bucket and sets it.",
113
+ );
114
+ }
115
+ return bucket;
116
+ }
117
+
118
+ /** When the run ended, in the reader's own timezone. The record carries its own UTC stamp. */
119
+ export function localTime(record) {
120
+ const at = record.at ? new Date(record.at) : undefined;
121
+ return at && !Number.isNaN(at.getTime())
122
+ ? at.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })
123
+ : "—";
124
+ }
125
+
126
+ export function formatDuration(ms) {
127
+ if (!Number.isFinite(ms)) {
128
+ return "—";
129
+ }
130
+ const seconds = Math.round(ms / 1000);
131
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
132
+ }
133
+
134
+ function formatCost(record) {
135
+ const usd = record.cli?.total_cost_usd;
136
+ return typeof usd === "number" ? `$${usd.toFixed(2)}` : "—";
137
+ }
138
+
139
+ export function describeTrigger(trigger) {
140
+ if (!trigger) {
141
+ return "—";
142
+ }
143
+ if (trigger.kind === "spawn") {
144
+ return `spawn:${trigger.label ?? "?"}`;
145
+ }
146
+ return String(trigger.kind ?? "—");
147
+ }
148
+
149
+ /** The first eight characters of a run id — what the table shows, and what `<id>` matches. */
150
+ export function shortId(runId) {
151
+ return typeof runId === "string" ? runId.slice(0, 8) : "—";
152
+ }
153
+
154
+ /** Newest-first object names, already narrowed to what will be fetched. */
155
+ export function selectRecords(names, { limit, job }) {
156
+ const wanted = job ? `_${RECORD_KIND}_${qualifier(job)}.json` : `_${RECORD_KIND}_`;
157
+ return names
158
+ .filter((name) => name.includes(wanted))
159
+ .sort()
160
+ .slice(-limit)
161
+ .reverse();
162
+ }
163
+
164
+ /** Indent a block so a multi-line result reads as one field rather than as more output. */
165
+ function indent(text, prefix = " ") {
166
+ return String(text)
167
+ .split("\n")
168
+ .map((line) => prefix + line)
169
+ .join("\n");
170
+ }
171
+
172
+ /**
173
+ * One run in full. The table answers "what has it been doing"; this answers "what happened
174
+ * in that one" — what it was told, what it could reach, what it produced, how it ended.
175
+ */
176
+ export function renderRun(record) {
177
+ const lines = [];
178
+ const status = record.status === "error" ? "FAILED" : (record.status ?? "—");
179
+ lines.push(`${record.jobName ?? "—"} · ${status} · ${localTime(record)}`, "");
180
+
181
+ const field = (label, value) => {
182
+ if (value !== undefined && value !== null && value !== "") {
183
+ lines.push(`${label.padEnd(11)}${value}`);
184
+ }
185
+ };
186
+ field("Run id", record.runId);
187
+ field("Trigger", describeTrigger(record.trigger));
188
+ field("Duration", formatDuration(record.durationMs));
189
+ field("Cost", formatCost(record));
190
+ const settings = record.jobSettings;
191
+ if (settings) {
192
+ field(
193
+ "Settings",
194
+ [
195
+ settings.model && `model ${settings.model}`,
196
+ settings.effort && `effort ${settings.effort}`,
197
+ settings.timeoutMs && `timeout ${Math.round(settings.timeoutMs / 1000)}s`,
198
+ settings.allowedTools && `tools ${settings.allowedTools}`,
199
+ ]
200
+ .filter(Boolean)
201
+ .join(", "),
202
+ );
203
+ }
204
+ if (record.scope) {
205
+ const { systems, variables, unknown } = record.scope;
206
+ field(
207
+ "Systems",
208
+ Array.isArray(systems)
209
+ ? `${systems.join(", ") || "none"}${typeof variables === "number" ? ` (${variables} variables)` : ""}${unknown?.length ? ` — unmatched: ${unknown.join(", ")}` : ""}`
210
+ : String(systems),
211
+ );
212
+ }
213
+ if (record.modelFallback) {
214
+ field("Fell back", `${record.modelFallback.from} → ${record.modelFallback.to} (${record.modelFallback.reason})`);
215
+ }
216
+
217
+ if (record.prompt) {
218
+ lines.push("", "Prompt", indent(record.prompt));
219
+ }
220
+ if (record.result) {
221
+ const truncated =
222
+ record.resultChars > record.result.length ? ` (first part of ${record.resultChars} characters)` : "";
223
+ lines.push("", `Result${truncated}`, indent(record.result));
224
+ }
225
+ if (record.error) {
226
+ lines.push("", "Error", indent(record.error));
227
+ }
228
+ if (record.stderr) {
229
+ lines.push("", "stderr", indent(record.stderr));
230
+ }
231
+ return lines.join("\n");
232
+ }
233
+
234
+ export async function history(args) {
235
+ requireCommand("gcloud", "run records are read from the audit bucket");
236
+ const { flags, positional, help } = parseFlags(
237
+ args,
238
+ {
239
+ job: { type: "string" },
240
+ since: { type: "string" },
241
+ until: { type: "string" },
242
+ limit: { type: "int", min: 1, max: 200 },
243
+ failed: { type: "boolean" },
244
+ json: { type: "boolean" },
245
+ },
246
+ {
247
+ usage:
248
+ "meffecta-agent history [<run id>] [options]\n\n" +
249
+ " history the last week of runs\n" +
250
+ " history --job morning-brief --since 30d one job, further back\n" +
251
+ " history --since 2026-08-01 --until 2026-08-07 a closed range\n" +
252
+ " history --failed only the ones that broke\n" +
253
+ " history 8b2811f7 one run in full, by the id in column one",
254
+ },
255
+ );
256
+ if (help) {
257
+ console.log(help);
258
+ return 0;
259
+ }
260
+ const wantedId = positional[0];
261
+ const d = requireDeployment();
262
+ const bucket = auditBucket(d);
263
+ const window = resolveWindow(flags);
264
+ const prefixes = dayPrefixes(window);
265
+ const span = prefixes.length === 1 ? prefixes[0] : `${prefixes[0]} to ${prefixes.at(-1)}`;
266
+ // Looking for one run means reading until it is found, so the cap is a search depth rather
267
+ // than a page size — and a run named by id is worth looking further back for.
268
+ const limit = flags.limit ?? (wantedId ? 200 : 20);
269
+
270
+ // One listing per day rather than one recursive listing of the bucket: a year of audit
271
+ // records is a large object count, and the window is almost always a few days of it. A
272
+ // day with no runs simply is not there, which `probe` reports as nothing, not a failure.
273
+ const listings = await Promise.all(
274
+ prefixes.map((prefix) => probe("gcloud", gcloudArgs(d, ["storage", "ls", `gs://${bucket}/${prefix}/**`]))),
275
+ );
276
+ const names = listings
277
+ .flatMap((out) => (out ?? "").split("\n"))
278
+ .map((line) => line.trim())
279
+ .filter((line) => line.startsWith("gs://"));
280
+
281
+ const chosen = selectRecords(names, { limit, job: flags.job });
282
+ if (chosen.length === 0) {
283
+ console.log(
284
+ `No runs recorded ${span}${flags.job ? ` for ${flags.job}` : ""}.\n\n` +
285
+ "The audit trail starts when a run ends, so a deployment that has not run yet is empty.\n" +
286
+ "Widen the window with --since 30d, or ask for a range: --since 2026-08-01 --until 2026-08-07.",
287
+ );
288
+ return 0;
289
+ }
290
+
291
+ // Fetched only for the records being shown — the listing above is names, the cheap half,
292
+ // and this is the one that costs a read per run.
293
+ const records = [];
294
+ for (const name of chosen) {
295
+ const body = await probe("gcloud", gcloudArgs(d, ["storage", "cat", name]));
296
+ if (!body) {
297
+ continue;
298
+ }
299
+ try {
300
+ const record = JSON.parse(body);
301
+ records.push(record);
302
+ // Newest first, so the wanted run is usually in the first few objects. Stopping there
303
+ // beats fetching the rest of the window to throw it away.
304
+ if (wantedId && String(record.runId ?? "").startsWith(wantedId)) {
305
+ break;
306
+ }
307
+ } catch {
308
+ // A truncated or half-written object is one run missing from a listing, not a reason
309
+ // to fail the whole read.
310
+ console.error(`(skipped an unreadable record: ${name.split("/").pop()})`);
311
+ }
312
+ }
313
+
314
+ if (wantedId) {
315
+ const match = records.find((r) => String(r.runId ?? "").startsWith(wantedId));
316
+ if (!match) {
317
+ throw new UserError(
318
+ `No run whose id starts with "${wantedId}" ${span}.\n\n` +
319
+ "Ids are the first column of `meffecta-agent history`. Look further back with --since.",
320
+ );
321
+ }
322
+ console.log(flags.json ? JSON.stringify(match, null, 2) : renderRun(match));
323
+ return 0;
324
+ }
325
+
326
+ const rows = flags.failed ? records.filter((r) => r.status === "error") : records;
327
+ if (flags.json) {
328
+ console.log(JSON.stringify(rows, null, 2));
329
+ return 0;
330
+ }
331
+ if (rows.length === 0) {
332
+ console.log(`No failed runs ${span}.`);
333
+ return 0;
334
+ }
335
+
336
+ const cells = rows.map((r) => [
337
+ shortId(r.runId),
338
+ localTime(r),
339
+ r.jobName ?? "—",
340
+ describeTrigger(r.trigger),
341
+ r.status ?? "—",
342
+ formatDuration(r.durationMs),
343
+ formatCost(r),
344
+ ]);
345
+ const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
346
+ for (const row of cells) {
347
+ console.log(row.map((cell, i) => (i >= 5 ? cell.padStart(widths[i]) : cell.padEnd(widths[i]))).join(" "));
348
+ }
349
+
350
+ const failed = records.filter((r) => r.status === "error").length;
351
+ const spend = records.reduce((sum, r) => sum + (r.cli?.total_cost_usd ?? 0), 0);
352
+ console.log(
353
+ `\n${rows.length} run(s), ${span}` +
354
+ `${failed ? `, ${failed} failed` : ""}` +
355
+ `${spend > 0 ? `, $${spend.toFixed(2)} of model usage` : ""}` +
356
+ `${records.length === limit ? ` — newest ${limit}, raise with --limit` : ""}`,
357
+ );
358
+ console.log("One run in full: meffecta-agent history <id from the first column>");
359
+ return 0;
360
+ }
@@ -219,8 +219,9 @@ const INTEGRATIONS = {
219
219
  "",
220
220
  "and `deploy` to register it — both fields are read when the service boots.",
221
221
  "",
222
- "The system is required: `gmail:<address>`, or `agentmail:<address>` for an inbox the",
223
- "agent owns. A bare address is refused rather than guessed at, because an address",
222
+ "The system is required: `gmail:<address>`, `outlook:<address>` for a Microsoft 365",
223
+ "mailbox, or `agentmail:<address>` for an inbox the agent owns. A bare address is",
224
+ "refused rather than guessed at, because an address",
224
225
  "polled against the wrong system finds nothing for ever and looks healthy doing it.",
225
226
  "",
226
227
  "allowFrom is required and enforced in code: mail from anyone not listed is dropped",
@@ -105,12 +105,20 @@ export function buildVerifyPrompt() {
105
105
  */
106
106
  export function verifyCredentials(ask) {
107
107
  return async (args) => {
108
- const { flags, positional } = parseFlags(args, {
109
- quick: { type: "boolean" },
110
- model: {},
111
- effort: {},
112
- timeoutSeconds: { type: "int", min: 1, max: 3600 },
113
- });
108
+ const { flags, positional, help } = parseFlags(
109
+ args,
110
+ {
111
+ quick: { type: "boolean" },
112
+ model: {},
113
+ effort: {},
114
+ timeoutSeconds: { type: "int", min: 1, max: 3600 },
115
+ },
116
+ { usage: "meffecta-agent verify-credentials [--quick]" },
117
+ );
118
+ if (help) {
119
+ console.log(help);
120
+ return 0;
121
+ }
114
122
  if (positional.length) {
115
123
  throw new UserError(`verify-credentials takes no arguments (got "${positional.join(" ")}").`);
116
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meffecta/agent",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Set up and operate a Meffecta Agent deployment — a self-hosted Claude Code job runner.",
5
5
  "type": "module",
6
6
  "bin": {