@ybotok/loam 0.1.0-beta.2 → 0.1.0-beta.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.
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { loadConfig } from "../core/config.js";
3
4
  import { emitJson, fail, NO_SERVICE_MESSAGE, repoPath, reportNoConfig } from "../core/json.js";
4
5
  import { listField, parseFrontmatter, withFrontmatterFields } from "../core/frontmatter.js";
5
- import { contentDigest, emptySourcesMessage, encodeSourceIndex, missingSources, patternSources, sourcesDigest, today, unsafeSources, } from "../core/provenance.js";
6
+ import { contentDigest, emptySourcesMessage, encodeSourceIndex, gitIdentity, missingSources, patternSources, sourcesDigest, today, unsafeSources, } from "../core/provenance.js";
6
7
  import { SPEC_AXES, servicePaths } from "../core/repo.js";
7
8
  import { plural } from "./format.js";
8
9
  import { message, NotUtf8Error, readUtf8, rollbackStaged, stageWrites, swapStaged, } from "../core/staging.js";
@@ -11,6 +12,7 @@ export function registerVouch(program) {
11
12
  .command("vouch")
12
13
  .description("Vouch for a service's living specs: stamp spec.md, and arch.spec.md when present, verified against the code they describe")
13
14
  .option("--service <id>", "service to vouch for (defaults to the configured service)")
15
+ .option("--yes", "skip the confirmation — required when stdin is not a terminal, and still records `vouched_by`")
14
16
  .option("--json", "emit the machine contract instead of the human view")
15
17
  .action(async (opts) => {
16
18
  const json = opts.json === true;
@@ -29,9 +31,53 @@ export function registerVouch(program) {
29
31
  const here = config.service === undefined ? "this is not a service repo" : `this repo is '${config.service}'`;
30
32
  return fail(json, "invalid-option", `Cannot vouch for '${service}' from here: ${here}. \`sources\` resolve against the service's own repo — run it there.`);
31
33
  }
34
+ // The repo root, not the cwd — see the comment on `repoDir` below; the
35
+ // identity has to be asked of the same directory the sources resolve in,
36
+ // or a subdirectory with its own git config would answer for it.
37
+ const repoDir = config.root ?? process.cwd();
38
+ // Who. Before any reading, because a run that cannot name a person has
39
+ // nothing to offer at the end of it and should not spend a digest finding
40
+ // that out.
41
+ const vouchedBy = await gitIdentity(repoDir);
42
+ if (vouchedBy === null) {
43
+ return fail(json, "vouch-unattributable", `Cannot vouch for '${service}': git names no \`user.email\` in ${repoDir}, so the stamp would ` +
44
+ "record a claim with nobody behind it — which is the one thing `status: verified` must not mean. " +
45
+ "Set it (`git config user.email you@example.com`) and re-run.");
46
+ }
47
+ // Whether a person is actually here. `vouch` is the only command in loam
48
+ // whose output is a claim about a HUMAN act — every other check is
49
+ // internal consistency, which fluent prose satisfies — and it used to run
50
+ // unattended, unattributed, twice in a row, stamping `verified` both
51
+ // times. Meanwhile the generated skill files pre-approved `Bash(loam:*)`,
52
+ // so the same agent that wrote the draft was permitted to promote it.
53
+ // That inverts loam's own argument about test evidence: an agent must not
54
+ // be able to SAY a scenario is tested, and it must not be able to say a
55
+ // spec matches the code either. The allowlist no longer covers this
56
+ // command (core/agent.ts), and nothing but a terminal or an explicit
57
+ // `--yes` gets past here.
58
+ if (opts.yes !== true) {
59
+ if (process.stdin.isTTY !== true) {
60
+ return fail(json, "vouch-unattended", `Cannot vouch for '${service}' with nothing on the other end of stdin: this command records that ` +
61
+ "a PERSON read the code and says the document matches it, and nobody was asked. Run it from a " +
62
+ "terminal, or pass --yes to state that you are standing behind it anyway — `vouched_by` records " +
63
+ `${vouchedBy} either way.`);
64
+ }
65
+ // `--json` and a prompt do not compose: the envelope is one JSON
66
+ // document on stdout, and a question printed beside it is not parseable
67
+ // by the consumer that asked for it. A JSON caller is a program, so it
68
+ // gets the same answer a pipe does — say so with --yes.
69
+ if (json) {
70
+ return fail(json, "vouch-unattended", `Cannot vouch for '${service}' in --json mode without --yes: the confirmation is a question for a ` +
71
+ "person, and it cannot be asked on a stream whose whole contract is one JSON document.");
72
+ }
73
+ if (!(await confirmVouch(service, vouchedBy, config.docsDir))) {
74
+ return fail(json, "vouch-declined", `Nothing was stamped for '${service}'.`);
75
+ }
76
+ }
32
77
  const outcome = await vouch({
33
78
  docsDir: config.docsDir,
34
79
  service,
80
+ vouchedBy,
35
81
  // The repo root, not the cwd. `sources:` are spelled relative to the
36
82
  // repository — that is what they mean in the frontmatter and what
37
83
  // `loam validate` resolves them against — so vouching from a
@@ -39,7 +85,7 @@ export function registerVouch(program) {
39
85
  // (`sources-path-missing`) and refuse the stamp. `config.root` is the
40
86
  // directory loam.json was found in, which is the only definition of
41
87
  // "this repo" that does not move with the caller.
42
- repoDir: config.root ?? process.cwd(),
88
+ repoDir,
43
89
  today: today(new Date()),
44
90
  });
45
91
  if (!outcome.ok)
@@ -51,6 +97,7 @@ export function registerVouch(program) {
51
97
  path: repoPath(config.docsDir, spec.path),
52
98
  status: outcome.status,
53
99
  last_verified: outcome.lastVerified,
100
+ vouched_by: outcome.vouchedBy,
54
101
  sources: spec.sources,
55
102
  sources_digest: spec.digest,
56
103
  content_digest: spec.contentDigest,
@@ -79,6 +126,7 @@ export function registerVouch(program) {
79
126
  console.log(`${i > 0 ? "\n" : ""}${service} vouched — ${repoPath(config.docsDir, s.path)}\n`);
80
127
  console.log(` status ${outcome.status}`);
81
128
  console.log(` last_verified ${outcome.lastVerified}`);
129
+ console.log(` vouched_by ${outcome.vouchedBy}`);
82
130
  console.log(` sources_digest ${s.digest} (${plural(s.files, "file")} from ${plural(s.sources.length, "source")})`);
83
131
  console.log(` content_digest ${s.contentDigest}`);
84
132
  // Said at the moment of stamping, not only later by `loam validate`:
@@ -93,6 +141,30 @@ export function registerVouch(program) {
93
141
  console.log(`\n\`loam validate\` will now say when that code moves out from under the spec — or when the spec moves under its own stamp.`);
94
142
  });
95
143
  }
144
+ /**
145
+ * Ask, on a terminal, and answer only on an explicit yes.
146
+ *
147
+ * The question states what is about to be claimed rather than asking for
148
+ * assent to a verb: "vouch?" invites a reflex, and the whole value of this
149
+ * command is that the reflex is the thing being interrupted. Default is no —
150
+ * a bare Enter, a closed stdin and a Ctrl-C all mean the same thing, because
151
+ * the only answer that may stamp a document is one somebody typed.
152
+ */
153
+ async function confirmVouch(service, vouchedBy, docsDir) {
154
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
155
+ try {
156
+ console.log(`\nVouching for '${service}' records that YOU read the code and say ` +
157
+ `${docsDir}/services/${service}/ describes it.\n` +
158
+ `It will be stamped \`status: verified\`, \`vouched_by: ${vouchedBy}\`.\n` +
159
+ "loam has not checked this and cannot: every other check it runs is internal " +
160
+ "consistency, which well-written prose satisfies on its own.\n");
161
+ const answer = await rl.question("Have you read the code? [y/N] ");
162
+ return /^y(es)?$/i.test(answer.trim());
163
+ }
164
+ finally {
165
+ rl.close();
166
+ }
167
+ }
96
168
  /**
97
169
  * Stamp `status`, `last_verified`, `sources_digest` and `content_digest` into
98
170
  * a service's living specs — spec.md, and arch.spec.md beside it when the
@@ -135,8 +207,8 @@ export async function vouch(req) {
135
207
  archVerified = outcome;
136
208
  }
137
209
  const verified = [specVerified, ...(archVerified === null ? [] : [archVerified])];
138
- const specPlan = planStamp(specVerified, req.today);
139
- const archPlan = archVerified === null ? null : planStamp(archVerified, req.today);
210
+ const specPlan = planStamp(specVerified, req);
211
+ const archPlan = archVerified === null ? null : planStamp(archVerified, req);
140
212
  const writes = [specPlan.write, ...(archPlan === null ? [] : [archPlan.write])];
141
213
  // Commit through archive's stage-and-swap machinery (core/staging.ts) rather
142
214
  // than a writeFile per file: every stamp is computed above in memory, so a
@@ -206,6 +278,7 @@ export async function vouch(req) {
206
278
  ok: true,
207
279
  status: "verified",
208
280
  lastVerified: req.today,
281
+ vouchedBy: req.vouchedBy,
209
282
  stamped: { spec: specPlan.stamped, archSpec: archPlan === null ? null : archPlan.stamped },
210
283
  };
211
284
  }
@@ -215,7 +288,7 @@ export async function vouch(req) {
215
288
  * same computation — the report must describe the file that was actually
216
289
  * written, not a second guess at it.
217
290
  */
218
- function planStamp(v, lastVerified) {
291
+ function planStamp(v, req) {
219
292
  // Two passes on purpose: `content_digest` hashes the body BELOW the
220
293
  // frontmatter, and withFrontmatterFields promises that body byte-identical —
221
294
  // so hashing after the first stamp and writing the hash in a second one
@@ -223,7 +296,12 @@ function planStamp(v, lastVerified) {
223
296
  // takes the same road and refreshes every field, this one included.
224
297
  const restamped = withFrontmatterFields(v.raw, {
225
298
  status: "verified",
226
- last_verified: lastVerified,
299
+ last_verified: req.today,
300
+ // WHO, beside when. Without it `status: verified` recorded that the word
301
+ // had been written and nothing about who wrote it, so a vouch and an
302
+ // agent's own draft left the same trace in the document — the one
303
+ // distinction this command exists to make.
304
+ vouched_by: req.vouchedBy,
227
305
  sources_digest: v.digest,
228
306
  // Beside the digest, what it was taken over. `sources_digest` alone can
229
307
  // only ever say THAT the code moved; the next `loam validate` reads this
@@ -1 +1 @@
1
- {"version":3,"file":"vouch.js","sourceRoot":"","sources":["../../src/commands/vouch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam vouch` — the human act SCHEMA.md has always described as \"promote draft\n * -> verified\".\n *\n * Everything else loam checks is internal consistency, and an agent writing\n * fluent prose satisfies all of it. This is the one command that records a\n * person: they read the code, they say the document matches it, and loam stamps\n * a digest of what they read — and a digest of the document they read it\n * against, so neither side can move quietly. From then on `loam validate` can\n * tell the difference between a document nobody has checked, one that still\n * matches the code, one the code has moved out from under, and one whose own\n * prose changed after the stamp.\n *\n * The stamp is only worth what it claims, so vouch refuses everything it cannot\n * actually verify — a frontmatter block that will not parse (whose fields\n * nobody can read, and whose rewrite would lose the author's lines), a spec\n * with no sources, a glob pattern (no longer supported), a source that is\n * gone, a directory holding no files, or a repo that is not this service's —\n * and refuses without writing anything.\n *\n * \"The document\" is every spec-axis file the service has: spec.md always, and\n * arch.spec.md beside it when present — same frontmatter conventions, same\n * stamp, one vouch. The refusal discipline is per file but the vouch is\n * all-or-nothing per service: one unverifiable file refuses the whole run, so\n * a `verified` service never means \"half-stamped\".\n */\nimport type { Command } from \"commander\";\nimport { existsSync } from \"node:fs\";\nimport { loadConfig } from \"../core/config.js\";\nimport { emitJson, fail, NO_SERVICE_MESSAGE, repoPath, reportNoConfig, type ErrorCode } from \"../core/json.js\";\nimport { listField, parseFrontmatter, withFrontmatterFields } from \"../core/frontmatter.js\";\nimport {\n contentDigest,\n emptySourcesMessage,\n encodeSourceIndex,\n missingSources,\n patternSources,\n sourcesDigest,\n today,\n unsafeSources,\n type SkippedSource,\n type SourceIndexEntry,\n} from \"../core/provenance.js\";\nimport { SPEC_AXES, servicePaths } from \"../core/repo.js\";\nimport { plural } from \"./format.js\";\nimport {\n message,\n NotUtf8Error,\n readUtf8,\n rollbackStaged,\n stageWrites,\n swapStaged,\n type PlannedWrite,\n} from \"../core/staging.js\";\n\ninterface VouchOptions {\n service?: string;\n json?: boolean;\n}\n\nexport interface VouchRequest {\n docsDir: string;\n service: string;\n /** The service's own repo — what `sources` resolve against. */\n repoDir: string;\n /** The date to stamp. Injected rather than read off the clock, so it can be pinned. */\n today: string;\n}\n\n/** One spec-axis file's share of a successful vouch. */\nexport interface StampedSpec {\n /** Absolute path of the file that was stamped. */\n path: string;\n /** The axis's filename — \"spec.md\" or \"arch.spec.md\". */\n file: string;\n /** Digest of the sources, as stamped into `sources_digest`. */\n digest: string;\n /** Digest of the document's own body, as stamped into `content_digest`. */\n contentDigest: string;\n /** The `sources` entries, as written in the frontmatter. */\n sources: string[];\n /** How many files those entries expanded to. */\n files: number;\n /**\n * Paths under those entries the digest would not hash. A vouch over a spec\n * with a skipped path is still a vouch — the person read what they read — but\n * they are told, because the stamp cannot go stale over bytes it never saw.\n */\n skipped: SkippedSource[];\n}\n\nexport type VouchOutcome =\n | {\n ok: true;\n status: \"verified\";\n lastVerified: string;\n /**\n * Every spec-axis file stamped. Named, not ordered: spec.md is required\n * for a vouch to happen at all, and arch.spec.md is the only other file\n * one can touch, so the type is what says which is which. An array said it\n * in a comment instead, and every reader had to assert non-emptiness to\n * reach the file that comment promised was there.\n */\n stamped: { spec: StampedSpec; archSpec: StampedSpec | null };\n }\n | {\n ok: false;\n // The last three are the commit phase failing: `vouch-raced` says the\n // document moved under the run and nothing was written at all,\n // `merge-failed` says the rollback held (nothing was stamped, re-running\n // can work), `rollback-incomplete` says it did not and the message lists\n // the files.\n code: Extract<\n ErrorCode,\n | \"unknown-target\"\n | \"sources-absent\"\n | \"sources-path-missing\"\n // The spec is there but is not UTF-8 text: a diagnosable state, not the\n // unexpected throw `internal` is for.\n | \"repository-unavailable\"\n | \"vouch-raced\"\n | \"merge-failed\"\n | \"rollback-incomplete\"\n >;\n message: string;\n };\n\nexport function registerVouch(program: Command): void {\n program\n .command(\"vouch\")\n .description(\n \"Vouch for a service's living specs: stamp spec.md, and arch.spec.md when present, verified against the code they describe\",\n )\n .option(\"--service <id>\", \"service to vouch for (defaults to the configured service)\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .action(async (opts: VouchOptions) => {\n const json = opts.json === true;\n\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n\n const service = opts.service ?? config.service;\n if (service === undefined) {\n return fail(json, \"invalid-option\", NO_SERVICE_MESSAGE);\n }\n // Vouching hashes the code the doc was written from, so it can only run\n // where that code is. From anywhere else the paths are someone else's.\n if (config.service !== service) {\n const here =\n config.service === undefined ? \"this is not a service repo\" : `this repo is '${config.service}'`;\n return fail(\n json,\n \"invalid-option\",\n `Cannot vouch for '${service}' from here: ${here}. \\`sources\\` resolve against the service's own repo — run it there.`,\n );\n }\n\n const outcome = await vouch({\n docsDir: config.docsDir,\n service,\n // The repo root, not the cwd. `sources:` are spelled relative to the\n // repository — that is what they mean in the frontmatter and what\n // `loam validate` resolves them against — so vouching from a\n // subdirectory used to report every real source as missing\n // (`sources-path-missing`) and refuse the stamp. `config.root` is the\n // directory loam.json was found in, which is the only definition of\n // \"this repo\" that does not move with the caller.\n repoDir: config.root ?? process.cwd(),\n today: today(new Date()),\n });\n if (!outcome.ok) return fail(json, outcome.code, outcome.message);\n\n const { spec, archSpec: arch } = outcome.stamped;\n if (json) {\n emitJson({\n service,\n path: repoPath(config.docsDir, spec.path),\n status: outcome.status,\n last_verified: outcome.lastVerified,\n sources: spec.sources,\n sources_digest: spec.digest,\n content_digest: spec.contentDigest,\n files: spec.files,\n skipped: spec.skipped,\n // The architecture axis, same keys: null when the service has no\n // arch.spec.md, so a consumer can tell \"none present\" from an older\n // loam that never reported the axis. status/last_verified are not\n // repeated — the vouch is one act, and they hold for every file in it.\n archSpec:\n arch === null\n ? null\n : {\n path: repoPath(config.docsDir, arch.path),\n sources: arch.sources,\n sources_digest: arch.digest,\n content_digest: arch.contentDigest,\n files: arch.files,\n skipped: arch.skipped,\n },\n });\n return;\n }\n // spec.md first, arch.spec.md behind it when present — the order the\n // person who vouched reads them in, and the order the axes are declared.\n for (const [i, s] of [spec, ...(arch === null ? [] : [arch])].entries()) {\n console.log(`${i > 0 ? \"\\n\" : \"\"}${service} vouched — ${repoPath(config.docsDir, s.path)}\\n`);\n console.log(` status ${outcome.status}`);\n console.log(` last_verified ${outcome.lastVerified}`);\n console.log(\n ` sources_digest ${s.digest} (${plural(s.files, \"file\")} from ${plural(s.sources.length, \"source\")})`,\n );\n console.log(` content_digest ${s.contentDigest}`);\n // Said at the moment of stamping, not only later by `loam validate`:\n // this is the one screen the person who vouched is actually looking at,\n // and what it lists is the part of the tree their promise does not cover.\n if (s.skipped.length > 0) {\n console.log(`\\n ⚠ ${plural(s.skipped.length, \"path\")} under those sources went unhashed:`);\n for (const skip of s.skipped) console.log(` ${skip.path} — ${skip.reason}`);\n }\n }\n console.log(\n `\\n\\`loam validate\\` will now say when that code moves out from under the spec — or when the spec moves under its own stamp.`,\n );\n });\n}\n\n/**\n * Stamp `status`, `last_verified`, `sources_digest` and `content_digest` into\n * a service's living specs — spec.md, and arch.spec.md beside it when the\n * service has one — leaving every body byte-identical.\n *\n * Nothing is written unless all four can be stamped truthfully for EVERY file —\n * a half-stamp (verified, but with no digest behind it; or one file stamped and\n * its sibling not) is exactly the claim this command exists to stop being\n * possible, so every present file is verified before any is written — and the\n * writing itself is staged and swapped in like archive's merge, so a failure\n * between the pair's two writes rolls the first back instead of leaving it.\n * The two digests are the two halves of one promise: `sources_digest` pins the\n * code that was read, `content_digest` pins the words it was read against, so\n * `loam validate` can see either side move.\n */\nexport async function vouch(req: VouchRequest): Promise<VouchOutcome> {\n const paths = servicePaths(req.docsDir, req.service);\n if (!existsSync(paths.spec)) {\n return {\n ok: false,\n code: \"unknown-target\",\n message: `No living spec at ${paths.spec}. Run \\`loam adopt\\` for '${req.service}' first.`,\n };\n }\n\n // Verify first, stamp after: spec.md is required (checked above), arch.spec.md\n // rides when it exists, and one file that cannot be verified refuses the run\n // before anything is written. The two axes are walked by name rather than\n // accumulated in a loop, because everything downstream needs to know WHICH\n // file it is holding — the required one or the optional one — and a loop can\n // only say that positionally.\n const [specAxis, archAxis] = SPEC_AXES;\n const specVerified = await verifySpec(req, paths[specAxis.key], specAxis.file);\n if (!specVerified.ok) return specVerified;\n let archVerified: VerifiedSpec | null = null;\n if (existsSync(paths[archAxis.key])) {\n const outcome = await verifySpec(req, paths[archAxis.key], archAxis.file);\n if (!outcome.ok) return outcome;\n archVerified = outcome;\n }\n const verified: VerifiedSpec[] = [specVerified, ...(archVerified === null ? [] : [archVerified])];\n\n const specPlan = planStamp(specVerified, req.today);\n const archPlan = archVerified === null ? null : planStamp(archVerified, req.today);\n const writes: PlannedWrite[] = [specPlan.write, ...(archPlan === null ? [] : [archPlan.write])];\n\n // Commit through archive's stage-and-swap machinery (core/staging.ts) rather\n // than a writeFile per file: every stamp is computed above in memory, so a\n // plain sequential write could only die BETWEEN the pair's writes — spec.md\n // verified, arch.spec.md still carrying the old stamp — the exact half-stamped\n // state the all-or-nothing verification exists to rule out, lost at the last\n // step to a full disk. Staging parks each file's new bytes beside it, swaps by\n // rename(2), and on failure restores what already swapped from its pre-image.\n const staged = await stageWrites(writes);\n\n // One shared docs repo, ten service repos, and nothing stopping two of them\n // from vouching at once. Between reading a spec and swapping the stamp in\n // there is a window in which somebody else's vouch (or an editor, or a merge)\n // can land in the same file — and because the new bytes were computed from\n // the OLD ones, swapping them in would take that stamp back out without a\n // word. `stageWrites` has already read what is on disk right now, so the\n // check is a comparison, not another read: if the file is not what was\n // verified, this run is describing a document that no longer exists.\n // Bytes, not text: `stageWrites` reads the file as a Buffer (a string\n // comparison against it is never equal, which made every vouch report\n // `vouch-raced` and stamp nothing). A file that is GONE is still a race —\n // `null` is not \"empty\", it is \"the document this run described is not\n // there any more\".\n // Joined by path, not by index: pairing the two lists positionally assumed\n // `stageWrites` returns one entry per planned write in order — true today,\n // promised nowhere, and a compare-and-set that silently compares the wrong\n // pair would stamp over somebody else's vouch. A staged write no verified\n // file claims has nothing to be compared against, so it counts as raced;\n // fail-closed is the only safe direction here.\n const verifiedByPath = new Map(verified.map((v) => [v.path, v] as const));\n const raced = staged.filter((s) => {\n const v = verifiedByPath.get(s.write.path);\n return v === undefined || s.before === null || !s.before.equals(Buffer.from(v.raw, \"utf8\"));\n });\n if (raced.length > 0) {\n // Nothing has swapped yet, so the rollback is only the temp files going\n // away — the other writer's stamp is left exactly as it landed.\n await rollbackStaged(staged);\n return {\n ok: false,\n code: \"vouch-raced\",\n message:\n `${req.service}: ${raced.map((s) => s.write.path).join(\", \")} changed while this vouch was running — ` +\n `another vouch or an edit landed first. Nothing was stamped: re-read the document and re-run.`,\n };\n }\n\n try {\n await swapStaged(staged);\n } catch (err) {\n const failures = await rollbackStaged(staged);\n // Archive's two answers to \"can I trust the repo?\", reused rather than\n // minting vouch-only codes — a caller branches on the same fact either way:\n // rolled back → nothing changed, re-running can work; incomplete → the\n // files listed need a human. Only the prose is vouch's own.\n return failures.length > 0\n ? {\n ok: false,\n code: \"rollback-incomplete\",\n message: `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-stamped and need checking by hand: ${failures.join(\", \")}`,\n }\n : {\n ok: false,\n code: \"merge-failed\",\n message: `${message(err)} — the vouch was rolled back, no spec was stamped`,\n };\n }\n return {\n ok: true,\n status: \"verified\",\n lastVerified: req.today,\n stamped: { spec: specPlan.stamped, archSpec: archPlan === null ? null : archPlan.stamped },\n };\n}\n\n/**\n * One verified file's share of the commit: the bytes to write, and the record\n * of what was stamped into them. Kept beside each other because they are the\n * same computation — the report must describe the file that was actually\n * written, not a second guess at it.\n */\nfunction planStamp(v: VerifiedSpec, lastVerified: string): { write: PlannedWrite; stamped: StampedSpec } {\n // Two passes on purpose: `content_digest` hashes the body BELOW the\n // frontmatter, and withFrontmatterFields promises that body byte-identical —\n // so hashing after the first stamp and writing the hash in a second one\n // yields a digest that is true of the file exactly as written. A re-vouch\n // takes the same road and refreshes every field, this one included.\n const restamped = withFrontmatterFields(v.raw, {\n status: \"verified\",\n last_verified: lastVerified,\n sources_digest: v.digest,\n // Beside the digest, what it was taken over. `sources_digest` alone can\n // only ever say THAT the code moved; the next `loam validate` reads this\n // back to say which files did.\n sources_files: encodeSourceIndex(v.index),\n });\n const bodyDigest = contentDigest(restamped);\n return {\n write: { path: v.path, content: withFrontmatterFields(restamped, { content_digest: bodyDigest }) },\n stamped: {\n path: v.path,\n file: v.file,\n digest: v.digest,\n contentDigest: bodyDigest,\n sources: v.sources,\n files: v.index.length,\n skipped: v.skipped,\n },\n };\n}\n\n/** A spec-axis file whose sources all check out, carrying what the stamp needs. */\ninterface VerifiedSpec {\n ok: true;\n path: string;\n file: string;\n /** The file exactly as read — what the stamp is applied to. */\n raw: string;\n sources: string[];\n digest: string;\n /** Per-file shas, in digest order — what `sources_files` is stamped from. */\n index: SourceIndexEntry[];\n skipped: SkippedSource[];\n}\n\n/**\n * The per-file half of the refusal discipline: everything vouch cannot verify\n * about ONE spec file, checked the same way for spec.md and arch.spec.md.\n */\nasync function verifySpec(\n req: VouchRequest,\n path: string,\n file: string,\n): Promise<VerifiedSpec | Extract<VouchOutcome, { ok: false }>> {\n // Through readUtf8, so the round trip below is exact: the stamp is computed\n // from `raw` and the race check compares `Buffer.from(raw)` against the bytes\n // on disk, which only agree if the file decoded without substitutions. A\n // non-UTF-8 spec is refused by name rather than stamped as verified with\n // every undecodable byte replaced. Refused HERE rather than left to the CLI's\n // top-level handler, which would report a diagnosable state as `internal`.\n // What it replaces is a diagnosis that was worse than none: a UTF-16 spec\n // decoded to a document with no frontmatter loam could find, so vouch refused\n // it as `sources-absent` — \"names no sources\" — over a file whose `sources:`\n // line is right there.\n let raw: string;\n try {\n raw = await readUtf8(path);\n } catch (err) {\n if (!(err instanceof NotUtf8Error)) throw err;\n return {\n ok: false,\n code: \"repository-unavailable\",\n message: `${req.service}: ${file} cannot be vouched for — ${err.message}`,\n };\n }\n const fm = parseFrontmatter(raw);\n // Before any sources reasoning: a header that does not parse hides whatever\n // fields the author wrote, so \"names no sources\" would be a false diagnosis —\n // and if the file ever reached the stamp, withFrontmatterFields' rule for\n // unreadable headers is replace-don't-merge, which would silently discard the\n // author's owner/service/sources lines. Refuse before the write path can see\n // the file. The code stays `sources-absent` — for vouch's purposes the\n // sources ARE unreadable-hence-absent, and the message carries the real\n // diagnosis — rather than minting a new ErrorCode for one refusal.\n if (fm.malformed) {\n return {\n ok: false,\n code: \"sources-absent\",\n message:\n `${req.service}: ${file} has a frontmatter block that cannot be read as YAML — ` +\n `its fields (\\`sources\\` included) are unreadable, and stamping would rewrite the header wholesale, ` +\n `losing what the author wrote. Fix the YAML between the \\`---\\` fences ` +\n `(\\`loam validate\\` reports it as \\`frontmatter.malformed\\`), then re-run.`,\n };\n }\n const sources = listField(fm, \"sources\");\n if (sources.length === 0) {\n return {\n ok: false,\n code: \"sources-absent\",\n message: `${req.service}: ${file} names no sources — there is nothing to vouch it against. Add \\`sources:\\` naming the code it was written from.`,\n };\n }\n\n // spec.md's refusals predate the second axis and keep their exact wording;\n // where they did not name the file, only the newer axis adds it.\n const label = file === \"spec.md\" ? req.service : `${req.service}: ${file}`;\n\n // Before the existence check: a pattern is not a path that \"does not exist\",\n // it is an entry loam no longer reads at all, and the refusal has to say so.\n const patterns = patternSources(sources);\n if (patterns.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${patterns.length} source(s) are glob patterns — ${patterns.join(\", \")}. Patterns are no longer supported: name files or directories (a directory already covers everything beneath it). A stamp over a pattern would vouch for a file set nobody can be sure of.`,\n };\n }\n\n const unsafe = unsafeSources(req.repoDir, sources);\n if (unsafe.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${unsafe.length} source(s) escape the service repo — ${unsafe.join(\", \")}. Sources must be relative paths contained by this repository, including through symlinks; vouch will not hash files outside it.`,\n };\n }\n\n const missing = missingSources(req.repoDir, sources);\n if (missing.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${missing.length} source(s) do not exist — ${missing.join(\", \")}. Vouching now would stamp a claim about code that is not there.`,\n };\n }\n\n const { digest, index, skipped } = await sourcesDigest(req.repoDir, sources);\n if (index.length === 0) {\n // A directory that exists but holds no files — or only ones the walk leaves\n // out: dot-entries, `node_modules`, anything the repository itself ignores.\n // The paths \"resolve\", but a digest over nothing never changes, so the stamp\n // would read as current forever. The sentence comes from provenance.ts so\n // that `loam validate` can grade the same state in the same words instead of\n // going green on a document this command refuses.\n return { ok: false, code: \"sources-absent\", message: emptySourcesMessage(label, sources) };\n }\n return { ok: true, path, file, raw, sources, digest, index, skipped };\n}\n"]}
1
+ {"version":3,"file":"vouch.js","sourceRoot":"","sources":["../../src/commands/vouch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam vouch` — the human act SCHEMA.md has always described as \"promote draft\n * -> verified\".\n *\n * Everything else loam checks is internal consistency, and an agent writing\n * fluent prose satisfies all of it. This is the one command that records a\n * person: they read the code, they say the document matches it, and loam stamps\n * a digest of what they read — and a digest of the document they read it\n * against, so neither side can move quietly. From then on `loam validate` can\n * tell the difference between a document nobody has checked, one that still\n * matches the code, one the code has moved out from under, and one whose own\n * prose changed after the stamp.\n *\n * \"Records a person\" was, for two releases, a thing this file said and did not\n * do. There was no `vouched_by`, no identity of any kind and no interactive\n * gate, so an agent could run it twice unattended and stamp `verified` both\n * times — while the skill files loam generates pre-approved `Bash(loam:*)`,\n * which meant the agent that wrote the draft was permitted to promote it. Three\n * things close that, and none of them is a signature: the stamp carries git's\n * `user.email` (`vouched_by`), the run refuses without a terminal or an explicit\n * `--yes`, and the generated allowlist names loam's read-only and authoring\n * verbs one by one instead of all of them (core/agent.ts). What that buys is\n * attribution and a deliberate act, not proof — git config is a text file. A\n * reviewer can now ask a named person what they read, which is the question\n * `status: verified` was silently answering with nobody.\n *\n * The stamp is only worth what it claims, so vouch refuses everything it cannot\n * actually verify — a frontmatter block that will not parse (whose fields\n * nobody can read, and whose rewrite would lose the author's lines), a spec\n * with no sources, a glob pattern (no longer supported), a source that is\n * gone, a directory holding no files, or a repo that is not this service's —\n * and refuses without writing anything.\n *\n * \"The document\" is every spec-axis file the service has: spec.md always, and\n * arch.spec.md beside it when present — same frontmatter conventions, same\n * stamp, one vouch. The refusal discipline is per file but the vouch is\n * all-or-nothing per service: one unverifiable file refuses the whole run, so\n * a `verified` service never means \"half-stamped\".\n */\nimport type { Command } from \"commander\";\nimport { existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline/promises\";\nimport { loadConfig } from \"../core/config.js\";\nimport { emitJson, fail, NO_SERVICE_MESSAGE, repoPath, reportNoConfig, type ErrorCode } from \"../core/json.js\";\nimport { listField, parseFrontmatter, withFrontmatterFields } from \"../core/frontmatter.js\";\nimport {\n contentDigest,\n emptySourcesMessage,\n encodeSourceIndex,\n gitIdentity,\n missingSources,\n patternSources,\n sourcesDigest,\n today,\n unsafeSources,\n type SkippedSource,\n type SourceIndexEntry,\n} from \"../core/provenance.js\";\nimport { SPEC_AXES, servicePaths } from \"../core/repo.js\";\nimport { plural } from \"./format.js\";\nimport {\n message,\n NotUtf8Error,\n readUtf8,\n rollbackStaged,\n stageWrites,\n swapStaged,\n type PlannedWrite,\n} from \"../core/staging.js\";\n\ninterface VouchOptions {\n service?: string;\n yes?: boolean;\n json?: boolean;\n}\n\nexport interface VouchRequest {\n docsDir: string;\n service: string;\n /** The service's own repo — what `sources` resolve against. */\n repoDir: string;\n /** The date to stamp. Injected rather than read off the clock, so it can be pinned. */\n today: string;\n /**\n * Who is vouching, as `vouched_by` is stamped — git's identity for `repoDir`.\n * Injected for the same reason `today` is: this function must be reproducible\n * from its arguments, and the resolution (and its refusal) belongs to the\n * command that can talk to a person about it.\n */\n vouchedBy: string;\n}\n\n/** One spec-axis file's share of a successful vouch. */\nexport interface StampedSpec {\n /** Absolute path of the file that was stamped. */\n path: string;\n /** The axis's filename — \"spec.md\" or \"arch.spec.md\". */\n file: string;\n /** Digest of the sources, as stamped into `sources_digest`. */\n digest: string;\n /** Digest of the document's own body, as stamped into `content_digest`. */\n contentDigest: string;\n /** The `sources` entries, as written in the frontmatter. */\n sources: string[];\n /** How many files those entries expanded to. */\n files: number;\n /**\n * Paths under those entries the digest would not hash. A vouch over a spec\n * with a skipped path is still a vouch — the person read what they read — but\n * they are told, because the stamp cannot go stale over bytes it never saw.\n */\n skipped: SkippedSource[];\n}\n\nexport type VouchOutcome =\n | {\n ok: true;\n status: \"verified\";\n lastVerified: string;\n /** The identity stamped into every file this vouch touched. */\n vouchedBy: string;\n /**\n * Every spec-axis file stamped. Named, not ordered: spec.md is required\n * for a vouch to happen at all, and arch.spec.md is the only other file\n * one can touch, so the type is what says which is which. An array said it\n * in a comment instead, and every reader had to assert non-emptiness to\n * reach the file that comment promised was there.\n */\n stamped: { spec: StampedSpec; archSpec: StampedSpec | null };\n }\n | {\n ok: false;\n // The last three are the commit phase failing: `vouch-raced` says the\n // document moved under the run and nothing was written at all,\n // `merge-failed` says the rollback held (nothing was stamped, re-running\n // can work), `rollback-incomplete` says it did not and the message lists\n // the files.\n code: Extract<\n ErrorCode,\n | \"unknown-target\"\n | \"sources-absent\"\n | \"sources-path-missing\"\n // The spec is there but is not UTF-8 text: a diagnosable state, not the\n // unexpected throw `internal` is for.\n | \"repository-unavailable\"\n | \"vouch-raced\"\n | \"merge-failed\"\n | \"rollback-incomplete\"\n >;\n message: string;\n };\n\nexport function registerVouch(program: Command): void {\n program\n .command(\"vouch\")\n .description(\n \"Vouch for a service's living specs: stamp spec.md, and arch.spec.md when present, verified against the code they describe\",\n )\n .option(\"--service <id>\", \"service to vouch for (defaults to the configured service)\")\n .option(\n \"--yes\",\n \"skip the confirmation — required when stdin is not a terminal, and still records `vouched_by`\",\n )\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .action(async (opts: VouchOptions) => {\n const json = opts.json === true;\n\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n\n const service = opts.service ?? config.service;\n if (service === undefined) {\n return fail(json, \"invalid-option\", NO_SERVICE_MESSAGE);\n }\n // Vouching hashes the code the doc was written from, so it can only run\n // where that code is. From anywhere else the paths are someone else's.\n if (config.service !== service) {\n const here =\n config.service === undefined ? \"this is not a service repo\" : `this repo is '${config.service}'`;\n return fail(\n json,\n \"invalid-option\",\n `Cannot vouch for '${service}' from here: ${here}. \\`sources\\` resolve against the service's own repo — run it there.`,\n );\n }\n\n // The repo root, not the cwd — see the comment on `repoDir` below; the\n // identity has to be asked of the same directory the sources resolve in,\n // or a subdirectory with its own git config would answer for it.\n const repoDir = config.root ?? process.cwd();\n\n // Who. Before any reading, because a run that cannot name a person has\n // nothing to offer at the end of it and should not spend a digest finding\n // that out.\n const vouchedBy = await gitIdentity(repoDir);\n if (vouchedBy === null) {\n return fail(\n json,\n \"vouch-unattributable\",\n `Cannot vouch for '${service}': git names no \\`user.email\\` in ${repoDir}, so the stamp would ` +\n \"record a claim with nobody behind it — which is the one thing `status: verified` must not mean. \" +\n \"Set it (`git config user.email you@example.com`) and re-run.\",\n );\n }\n\n // Whether a person is actually here. `vouch` is the only command in loam\n // whose output is a claim about a HUMAN act — every other check is\n // internal consistency, which fluent prose satisfies — and it used to run\n // unattended, unattributed, twice in a row, stamping `verified` both\n // times. Meanwhile the generated skill files pre-approved `Bash(loam:*)`,\n // so the same agent that wrote the draft was permitted to promote it.\n // That inverts loam's own argument about test evidence: an agent must not\n // be able to SAY a scenario is tested, and it must not be able to say a\n // spec matches the code either. The allowlist no longer covers this\n // command (core/agent.ts), and nothing but a terminal or an explicit\n // `--yes` gets past here.\n if (opts.yes !== true) {\n if (process.stdin.isTTY !== true) {\n return fail(\n json,\n \"vouch-unattended\",\n `Cannot vouch for '${service}' with nothing on the other end of stdin: this command records that ` +\n \"a PERSON read the code and says the document matches it, and nobody was asked. Run it from a \" +\n \"terminal, or pass --yes to state that you are standing behind it anyway — `vouched_by` records \" +\n `${vouchedBy} either way.`,\n );\n }\n // `--json` and a prompt do not compose: the envelope is one JSON\n // document on stdout, and a question printed beside it is not parseable\n // by the consumer that asked for it. A JSON caller is a program, so it\n // gets the same answer a pipe does — say so with --yes.\n if (json) {\n return fail(\n json,\n \"vouch-unattended\",\n `Cannot vouch for '${service}' in --json mode without --yes: the confirmation is a question for a ` +\n \"person, and it cannot be asked on a stream whose whole contract is one JSON document.\",\n );\n }\n if (!(await confirmVouch(service, vouchedBy, config.docsDir))) {\n return fail(json, \"vouch-declined\", `Nothing was stamped for '${service}'.`);\n }\n }\n\n const outcome = await vouch({\n docsDir: config.docsDir,\n service,\n vouchedBy,\n // The repo root, not the cwd. `sources:` are spelled relative to the\n // repository — that is what they mean in the frontmatter and what\n // `loam validate` resolves them against — so vouching from a\n // subdirectory used to report every real source as missing\n // (`sources-path-missing`) and refuse the stamp. `config.root` is the\n // directory loam.json was found in, which is the only definition of\n // \"this repo\" that does not move with the caller.\n repoDir,\n today: today(new Date()),\n });\n if (!outcome.ok) return fail(json, outcome.code, outcome.message);\n\n const { spec, archSpec: arch } = outcome.stamped;\n if (json) {\n emitJson({\n service,\n path: repoPath(config.docsDir, spec.path),\n status: outcome.status,\n last_verified: outcome.lastVerified,\n vouched_by: outcome.vouchedBy,\n sources: spec.sources,\n sources_digest: spec.digest,\n content_digest: spec.contentDigest,\n files: spec.files,\n skipped: spec.skipped,\n // The architecture axis, same keys: null when the service has no\n // arch.spec.md, so a consumer can tell \"none present\" from an older\n // loam that never reported the axis. status/last_verified are not\n // repeated — the vouch is one act, and they hold for every file in it.\n archSpec:\n arch === null\n ? null\n : {\n path: repoPath(config.docsDir, arch.path),\n sources: arch.sources,\n sources_digest: arch.digest,\n content_digest: arch.contentDigest,\n files: arch.files,\n skipped: arch.skipped,\n },\n });\n return;\n }\n // spec.md first, arch.spec.md behind it when present — the order the\n // person who vouched reads them in, and the order the axes are declared.\n for (const [i, s] of [spec, ...(arch === null ? [] : [arch])].entries()) {\n console.log(`${i > 0 ? \"\\n\" : \"\"}${service} vouched — ${repoPath(config.docsDir, s.path)}\\n`);\n console.log(` status ${outcome.status}`);\n console.log(` last_verified ${outcome.lastVerified}`);\n console.log(` vouched_by ${outcome.vouchedBy}`);\n console.log(\n ` sources_digest ${s.digest} (${plural(s.files, \"file\")} from ${plural(s.sources.length, \"source\")})`,\n );\n console.log(` content_digest ${s.contentDigest}`);\n // Said at the moment of stamping, not only later by `loam validate`:\n // this is the one screen the person who vouched is actually looking at,\n // and what it lists is the part of the tree their promise does not cover.\n if (s.skipped.length > 0) {\n console.log(`\\n ⚠ ${plural(s.skipped.length, \"path\")} under those sources went unhashed:`);\n for (const skip of s.skipped) console.log(` ${skip.path} — ${skip.reason}`);\n }\n }\n console.log(\n `\\n\\`loam validate\\` will now say when that code moves out from under the spec — or when the spec moves under its own stamp.`,\n );\n });\n}\n\n/**\n * Ask, on a terminal, and answer only on an explicit yes.\n *\n * The question states what is about to be claimed rather than asking for\n * assent to a verb: \"vouch?\" invites a reflex, and the whole value of this\n * command is that the reflex is the thing being interrupted. Default is no —\n * a bare Enter, a closed stdin and a Ctrl-C all mean the same thing, because\n * the only answer that may stamp a document is one somebody typed.\n */\nasync function confirmVouch(service: string, vouchedBy: string, docsDir: string): Promise<boolean> {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n try {\n console.log(\n `\\nVouching for '${service}' records that YOU read the code and say ` +\n `${docsDir}/services/${service}/ describes it.\\n` +\n `It will be stamped \\`status: verified\\`, \\`vouched_by: ${vouchedBy}\\`.\\n` +\n \"loam has not checked this and cannot: every other check it runs is internal \" +\n \"consistency, which well-written prose satisfies on its own.\\n\",\n );\n const answer = await rl.question(\"Have you read the code? [y/N] \");\n return /^y(es)?$/i.test(answer.trim());\n } finally {\n rl.close();\n }\n}\n\n/**\n * Stamp `status`, `last_verified`, `sources_digest` and `content_digest` into\n * a service's living specs — spec.md, and arch.spec.md beside it when the\n * service has one — leaving every body byte-identical.\n *\n * Nothing is written unless all four can be stamped truthfully for EVERY file —\n * a half-stamp (verified, but with no digest behind it; or one file stamped and\n * its sibling not) is exactly the claim this command exists to stop being\n * possible, so every present file is verified before any is written — and the\n * writing itself is staged and swapped in like archive's merge, so a failure\n * between the pair's two writes rolls the first back instead of leaving it.\n * The two digests are the two halves of one promise: `sources_digest` pins the\n * code that was read, `content_digest` pins the words it was read against, so\n * `loam validate` can see either side move.\n */\nexport async function vouch(req: VouchRequest): Promise<VouchOutcome> {\n const paths = servicePaths(req.docsDir, req.service);\n if (!existsSync(paths.spec)) {\n return {\n ok: false,\n code: \"unknown-target\",\n message: `No living spec at ${paths.spec}. Run \\`loam adopt\\` for '${req.service}' first.`,\n };\n }\n\n // Verify first, stamp after: spec.md is required (checked above), arch.spec.md\n // rides when it exists, and one file that cannot be verified refuses the run\n // before anything is written. The two axes are walked by name rather than\n // accumulated in a loop, because everything downstream needs to know WHICH\n // file it is holding — the required one or the optional one — and a loop can\n // only say that positionally.\n const [specAxis, archAxis] = SPEC_AXES;\n const specVerified = await verifySpec(req, paths[specAxis.key], specAxis.file);\n if (!specVerified.ok) return specVerified;\n let archVerified: VerifiedSpec | null = null;\n if (existsSync(paths[archAxis.key])) {\n const outcome = await verifySpec(req, paths[archAxis.key], archAxis.file);\n if (!outcome.ok) return outcome;\n archVerified = outcome;\n }\n const verified: VerifiedSpec[] = [specVerified, ...(archVerified === null ? [] : [archVerified])];\n\n const specPlan = planStamp(specVerified, req);\n const archPlan = archVerified === null ? null : planStamp(archVerified, req);\n const writes: PlannedWrite[] = [specPlan.write, ...(archPlan === null ? [] : [archPlan.write])];\n\n // Commit through archive's stage-and-swap machinery (core/staging.ts) rather\n // than a writeFile per file: every stamp is computed above in memory, so a\n // plain sequential write could only die BETWEEN the pair's writes — spec.md\n // verified, arch.spec.md still carrying the old stamp — the exact half-stamped\n // state the all-or-nothing verification exists to rule out, lost at the last\n // step to a full disk. Staging parks each file's new bytes beside it, swaps by\n // rename(2), and on failure restores what already swapped from its pre-image.\n const staged = await stageWrites(writes);\n\n // One shared docs repo, ten service repos, and nothing stopping two of them\n // from vouching at once. Between reading a spec and swapping the stamp in\n // there is a window in which somebody else's vouch (or an editor, or a merge)\n // can land in the same file — and because the new bytes were computed from\n // the OLD ones, swapping them in would take that stamp back out without a\n // word. `stageWrites` has already read what is on disk right now, so the\n // check is a comparison, not another read: if the file is not what was\n // verified, this run is describing a document that no longer exists.\n // Bytes, not text: `stageWrites` reads the file as a Buffer (a string\n // comparison against it is never equal, which made every vouch report\n // `vouch-raced` and stamp nothing). A file that is GONE is still a race —\n // `null` is not \"empty\", it is \"the document this run described is not\n // there any more\".\n // Joined by path, not by index: pairing the two lists positionally assumed\n // `stageWrites` returns one entry per planned write in order — true today,\n // promised nowhere, and a compare-and-set that silently compares the wrong\n // pair would stamp over somebody else's vouch. A staged write no verified\n // file claims has nothing to be compared against, so it counts as raced;\n // fail-closed is the only safe direction here.\n const verifiedByPath = new Map(verified.map((v) => [v.path, v] as const));\n const raced = staged.filter((s) => {\n const v = verifiedByPath.get(s.write.path);\n return v === undefined || s.before === null || !s.before.equals(Buffer.from(v.raw, \"utf8\"));\n });\n if (raced.length > 0) {\n // Nothing has swapped yet, so the rollback is only the temp files going\n // away — the other writer's stamp is left exactly as it landed.\n await rollbackStaged(staged);\n return {\n ok: false,\n code: \"vouch-raced\",\n message:\n `${req.service}: ${raced.map((s) => s.write.path).join(\", \")} changed while this vouch was running — ` +\n `another vouch or an edit landed first. Nothing was stamped: re-read the document and re-run.`,\n };\n }\n\n try {\n await swapStaged(staged);\n } catch (err) {\n const failures = await rollbackStaged(staged);\n // Archive's two answers to \"can I trust the repo?\", reused rather than\n // minting vouch-only codes — a caller branches on the same fact either way:\n // rolled back → nothing changed, re-running can work; incomplete → the\n // files listed need a human. Only the prose is vouch's own.\n return failures.length > 0\n ? {\n ok: false,\n code: \"rollback-incomplete\",\n message: `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-stamped and need checking by hand: ${failures.join(\", \")}`,\n }\n : {\n ok: false,\n code: \"merge-failed\",\n message: `${message(err)} — the vouch was rolled back, no spec was stamped`,\n };\n }\n return {\n ok: true,\n status: \"verified\",\n lastVerified: req.today,\n vouchedBy: req.vouchedBy,\n stamped: { spec: specPlan.stamped, archSpec: archPlan === null ? null : archPlan.stamped },\n };\n}\n\n/**\n * One verified file's share of the commit: the bytes to write, and the record\n * of what was stamped into them. Kept beside each other because they are the\n * same computation — the report must describe the file that was actually\n * written, not a second guess at it.\n */\nfunction planStamp(v: VerifiedSpec, req: VouchRequest): { write: PlannedWrite; stamped: StampedSpec } {\n // Two passes on purpose: `content_digest` hashes the body BELOW the\n // frontmatter, and withFrontmatterFields promises that body byte-identical —\n // so hashing after the first stamp and writing the hash in a second one\n // yields a digest that is true of the file exactly as written. A re-vouch\n // takes the same road and refreshes every field, this one included.\n const restamped = withFrontmatterFields(v.raw, {\n status: \"verified\",\n last_verified: req.today,\n // WHO, beside when. Without it `status: verified` recorded that the word\n // had been written and nothing about who wrote it, so a vouch and an\n // agent's own draft left the same trace in the document — the one\n // distinction this command exists to make.\n vouched_by: req.vouchedBy,\n sources_digest: v.digest,\n // Beside the digest, what it was taken over. `sources_digest` alone can\n // only ever say THAT the code moved; the next `loam validate` reads this\n // back to say which files did.\n sources_files: encodeSourceIndex(v.index),\n });\n const bodyDigest = contentDigest(restamped);\n return {\n write: { path: v.path, content: withFrontmatterFields(restamped, { content_digest: bodyDigest }) },\n stamped: {\n path: v.path,\n file: v.file,\n digest: v.digest,\n contentDigest: bodyDigest,\n sources: v.sources,\n files: v.index.length,\n skipped: v.skipped,\n },\n };\n}\n\n/** A spec-axis file whose sources all check out, carrying what the stamp needs. */\ninterface VerifiedSpec {\n ok: true;\n path: string;\n file: string;\n /** The file exactly as read — what the stamp is applied to. */\n raw: string;\n sources: string[];\n digest: string;\n /** Per-file shas, in digest order — what `sources_files` is stamped from. */\n index: SourceIndexEntry[];\n skipped: SkippedSource[];\n}\n\n/**\n * The per-file half of the refusal discipline: everything vouch cannot verify\n * about ONE spec file, checked the same way for spec.md and arch.spec.md.\n */\nasync function verifySpec(\n req: VouchRequest,\n path: string,\n file: string,\n): Promise<VerifiedSpec | Extract<VouchOutcome, { ok: false }>> {\n // Through readUtf8, so the round trip below is exact: the stamp is computed\n // from `raw` and the race check compares `Buffer.from(raw)` against the bytes\n // on disk, which only agree if the file decoded without substitutions. A\n // non-UTF-8 spec is refused by name rather than stamped as verified with\n // every undecodable byte replaced. Refused HERE rather than left to the CLI's\n // top-level handler, which would report a diagnosable state as `internal`.\n // What it replaces is a diagnosis that was worse than none: a UTF-16 spec\n // decoded to a document with no frontmatter loam could find, so vouch refused\n // it as `sources-absent` — \"names no sources\" — over a file whose `sources:`\n // line is right there.\n let raw: string;\n try {\n raw = await readUtf8(path);\n } catch (err) {\n if (!(err instanceof NotUtf8Error)) throw err;\n return {\n ok: false,\n code: \"repository-unavailable\",\n message: `${req.service}: ${file} cannot be vouched for — ${err.message}`,\n };\n }\n const fm = parseFrontmatter(raw);\n // Before any sources reasoning: a header that does not parse hides whatever\n // fields the author wrote, so \"names no sources\" would be a false diagnosis —\n // and if the file ever reached the stamp, withFrontmatterFields' rule for\n // unreadable headers is replace-don't-merge, which would silently discard the\n // author's owner/service/sources lines. Refuse before the write path can see\n // the file. The code stays `sources-absent` — for vouch's purposes the\n // sources ARE unreadable-hence-absent, and the message carries the real\n // diagnosis — rather than minting a new ErrorCode for one refusal.\n if (fm.malformed) {\n return {\n ok: false,\n code: \"sources-absent\",\n message:\n `${req.service}: ${file} has a frontmatter block that cannot be read as YAML — ` +\n `its fields (\\`sources\\` included) are unreadable, and stamping would rewrite the header wholesale, ` +\n `losing what the author wrote. Fix the YAML between the \\`---\\` fences ` +\n `(\\`loam validate\\` reports it as \\`frontmatter.malformed\\`), then re-run.`,\n };\n }\n const sources = listField(fm, \"sources\");\n if (sources.length === 0) {\n return {\n ok: false,\n code: \"sources-absent\",\n message: `${req.service}: ${file} names no sources — there is nothing to vouch it against. Add \\`sources:\\` naming the code it was written from.`,\n };\n }\n\n // spec.md's refusals predate the second axis and keep their exact wording;\n // where they did not name the file, only the newer axis adds it.\n const label = file === \"spec.md\" ? req.service : `${req.service}: ${file}`;\n\n // Before the existence check: a pattern is not a path that \"does not exist\",\n // it is an entry loam no longer reads at all, and the refusal has to say so.\n const patterns = patternSources(sources);\n if (patterns.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${patterns.length} source(s) are glob patterns — ${patterns.join(\", \")}. Patterns are no longer supported: name files or directories (a directory already covers everything beneath it). A stamp over a pattern would vouch for a file set nobody can be sure of.`,\n };\n }\n\n const unsafe = unsafeSources(req.repoDir, sources);\n if (unsafe.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${unsafe.length} source(s) escape the service repo — ${unsafe.join(\", \")}. Sources must be relative paths contained by this repository, including through symlinks; vouch will not hash files outside it.`,\n };\n }\n\n const missing = missingSources(req.repoDir, sources);\n if (missing.length > 0) {\n return {\n ok: false,\n code: \"sources-path-missing\",\n message: `${label}: ${missing.length} source(s) do not exist — ${missing.join(\", \")}. Vouching now would stamp a claim about code that is not there.`,\n };\n }\n\n const { digest, index, skipped } = await sourcesDigest(req.repoDir, sources);\n if (index.length === 0) {\n // A directory that exists but holds no files — or only ones the walk leaves\n // out: dot-entries, `node_modules`, anything the repository itself ignores.\n // The paths \"resolve\", but a digest over nothing never changes, so the stamp\n // would read as current forever. The sentence comes from provenance.ts so\n // that `loam validate` can grade the same state in the same words instead of\n // going green on a document this command refuses.\n return { ok: false, code: \"sources-absent\", message: emptySourcesMessage(label, sources) };\n }\n return { ok: true, path, file, raw, sources, digest, index, skipped };\n}\n"]}
@@ -30,6 +30,7 @@ import { existsSync } from "node:fs";
30
30
  import { mkdir, writeFile } from "node:fs/promises";
31
31
  import { dirname, join } from "node:path";
32
32
  import { agentsStampLine } from "./agents-stamp.js";
33
+ import { featureIdProblem, serviceIdProblem } from "./ids.js";
33
34
  import { LOAM_VERSION } from "./version.js";
34
35
  export const AGENTS_MD = `${agentsStampLine(LOAM_VERSION)}
35
36
  # Working in this docs repo
@@ -227,15 +228,28 @@ holds of the new words.
227
228
  Write \`sources\` for anything you author from reading code, and leave \`status: draft\`
228
229
  until a human has read it. Promoting draft to verified is their call, not yours, and
229
230
  it has its own command — \`loam vouch --service <id>\`, run inside that service's repo,
230
- which stamps the status, the date, both digests and the per-file index
231
+ which stamps the status, the date, the identity (\`vouched_by\`, from git), both digests
232
+ and the per-file index
231
233
  (\`sources_files\`, which is what lets a later \`sources.stale\` name the paths that
232
234
  moved) together — into the living spec.md
233
235
  and, when the service has one, arch.spec.md, in one all-or-nothing run: a file whose
234
- sources cannot be verified refuses the whole vouch. Never write those five fields by
236
+ sources cannot be verified refuses the whole vouch. Never write those six fields by
235
237
  hand: a status with no digest behind it is a claim with nothing behind it. If the
236
238
  document changed under vouch between the read and the write, it refuses with
237
239
  \`vouch-raced\` and stamps nothing — re-read and re-run.
238
240
 
241
+ **\`loam vouch\` is not yours to run**, and it is built so that it cannot be. It is
242
+ the one command whose output is a claim about a HUMAN act — everything else loam
243
+ checks is internal consistency, which fluent prose satisfies on its own — so it
244
+ refuses \`vouch-unattended\` when stdin is not a terminal (and in \`--json\` mode,
245
+ where a question cannot be asked), answers \`vouch-declined\` when the person says
246
+ no, and refuses \`vouch-unattributable\` when git can name nobody, because a stamp
247
+ with no \`vouched_by\` behind it records only that the word was written. \`--yes\`
248
+ exists for a person's own scripted run; typing it on someone's behalf is
249
+ writing their name. The generated command and skill files pre-approve loam's
250
+ read-only and authoring verbs one by one and deliberately not this one: when
251
+ the work is done, hand back and say a vouch is owed.
252
+
239
253
  ## The ID spine
240
254
 
241
255
  Three artifacts describe the same call in three languages. They are joined by the
@@ -626,6 +640,10 @@ selected that tool. The map of which invocation surfaces what:
626
640
  archive/unarchive re-run, which repairs from the pre-image first, under the lock
627
641
  — except when the journal itself cannot be read, where it is \`loam doctor\` and
628
642
  the repair is a human's comparison against version control. Fleet-wide the rest are
643
+ \`next.adopt-bound\` (this repository's own loam.json names a service the docs repo
644
+ has no directory for at all — it outranks every other service's partial adoption,
645
+ because it is the only step that is about the repo you are standing in, and it is
646
+ the same state \`loam doctor\` reports as \`doctor.service-unknown\`),
629
647
  \`next.adopt\` (a service with no spec.md — nothing about it is written down, so
630
648
  no feature can be graded against it), \`next.complete-service\` (a living spec.md
631
649
  with no model.likec4 beside it), \`next.feature\` (something is in flight;
@@ -669,7 +687,11 @@ selected that tool. The map of which invocation surfaces what:
669
687
  \`api.ungoverned\`, \`api.ops-unlinked\`,
670
688
  \`api.requirement-deprecated\`, \`spec-api.op-undefined\` (the living spec's own
671
689
  \`Operations:\` lines, not only a delta's), \`spine.landscape-invalid\`, \`spine.op-undefined\`,
672
- \`spine.op-link-missing\`, \`spine.op-deprecated\`, and
690
+ \`spine.op-link-missing\`, \`spine.op-deprecated\`,
691
+ the async contract axis (AsyncAPI 3): \`service.no-asyncapi\`, \`asyncapi.invalid\`,
692
+ \`asyncapi.duplicate-message\`, \`spine.message-undefined\`,
693
+ \`spec-event.message-undefined\`, \`spine.message-unproduced\`,
694
+ \`asyncapi.message-contested\`, \`event.messages-unlinked\`, \`event.covered\`, and
673
695
  the architecture spec axis: \`covers.unknown\`, \`health.invalid\`, \`health.uncovered\`. Run inside
674
696
  the service's own repo, once a generated suite exists under
675
697
  \`<gherkinDir>/loam/\`, it also grades that suite against the living specs:
@@ -743,7 +765,15 @@ selected that tool. The map of which invocation surfaces what:
743
765
  \`doctor.docs-unreadable\`, \`doctor.docs-readonly\`, \`doctor.docs-absolute\`
744
766
  (\`docsDir\` stored as an absolute path in a committed loam.json — it resolves
745
767
  only on the machine that ran \`loam init\`), \`doctor.inventory-unreadable\`,
746
- \`doctor.landscape-missing\`, \`doctor.service-unbound\`, \`doctor.service-unknown\`,
768
+ \`doctor.landscape-missing\`, \`doctor.service-unbound\` (no \`service\` in loam.json —
769
+ never raised inside the docs repo itself, where having none is the correct state
770
+ and binding one would be meaningless), \`doctor.service-unknown\` (loam.json names
771
+ a service the docs repo has no directory for — the same state \`loam status\`
772
+ reports as \`next.adopt-bound\`), \`doctor.likec4-config-missing\` (the docs repo
773
+ has no \`likec4.config.json\`, so pointing LikeC4's own renderer at it fails:
774
+ loam parses each \`.likec4\` file alone and each declares its own
775
+ \`specification\` block, so a workspace load merges them and every declaration
776
+ reads as a duplicate — the \`fix\` field carries the exact file to write),
747
777
  and the two about this repo's own generated command and skill files:
748
778
  \`doctor.agent-files-missing\` — some of them are absent, because the repo was
749
779
  initialized by an older binary or they were deleted — and
@@ -911,7 +941,7 @@ delta did not parse, neither of them "this feature changes nothing there". Branc
911
941
  on the exit code before consuming either slice as a task brief.
912
942
 
913
943
  Findings with severity \`ok\` are confirmations, not work: \`c4.valid\`, \`delta.valid\`,
914
- \`requirements.covered\`, \`api.covered\`, \`spine.resolved\`, \`coherence.ok\`,
944
+ \`requirements.covered\`, \`api.covered\`, \`spine.resolved\`, \`event.covered\`, \`coherence.ok\`,
915
945
  \`landscape.matched\`, \`archedge.covered\`, \`sources.resolved\`, \`sources.current\`,
916
946
  \`gherkin.current\`.
917
947
 
@@ -1038,6 +1068,7 @@ const COMMANDS = [
1038
1068
  argumentHint: "<service-id>",
1039
1069
  purpose: "Write one service's baseline documentation into the docs repo from its code, as `draft`. You read the code; loam states the work and checks the result — it never reads the service, so anything you cannot show, do not write.",
1040
1070
  invocation: "loam instructions loam-adopt $1",
1071
+ placeholders: ["service"],
1041
1072
  spine: [
1042
1073
  "wire this repo (`loam init`, then `loam doctor`) if it has no ./loam.json yet",
1043
1074
  "`loam adopt` — the brief: every file to write, the grammar of each, and what the fleet map already says",
@@ -1108,6 +1139,8 @@ behaviour nobody can find is the one failure mode none of loam's checks can catc
1108
1139
  argumentHint: `<FEAT-id> "<title>"`,
1109
1140
  purpose: "Start a feature in the docs repo: decide which services it touches, scaffold it, then author the C4 delta and one requirement delta per service. The `--touches` list is the hardest call in the cycle and nothing downstream catches one that is short by a service.",
1110
1141
  invocation: 'loam instructions loam-feature $1 "$2"',
1142
+ // $2 is the feature's human title: any string is a legal one.
1143
+ placeholders: ["feature", "free"],
1111
1144
  spine: [
1112
1145
  "read `AGENTS.md` at the docs repo root — it defines the ID spine everything else depends on",
1113
1146
  "`loam explore` around the services you think are involved: the ring one hop out, and what is already in flight over the same ground",
@@ -1195,6 +1228,7 @@ line, and the OpenAPI \`operationId\` — must match exactly.
1195
1228
  argumentHint: "<FEAT-id> [service]",
1196
1229
  purpose: "Build one service's part of a feature, in that service's own repository. The generated Gherkin is the acceptance criterion — it comes before the step definitions, and those come before the code.",
1197
1230
  invocation: "loam instructions loam-implement $1 $2",
1231
+ placeholders: ["feature", "service"],
1198
1232
  spine: [
1199
1233
  "`loam status` — where this feature actually is, and what is owed before you start",
1200
1234
  "`loam delta` — the task: the intent, every requirement verbatim, the endpoints, and the calls in and out",
@@ -1286,6 +1320,10 @@ leave the spec disagreeing with the code.
1286
1320
  // the half of the fix that stops loam printing the broken line in the first
1287
1321
  // place.
1288
1322
  invocation: "loam instructions loam-check",
1323
+ // The one protocol whose body names no placeholder: its target (`--all`, a
1324
+ // feature id, a service id) is spelled inside the `loam validate` line the
1325
+ // agent chooses, not substituted into the page.
1326
+ placeholders: [],
1289
1327
  spine: [
1290
1328
  "`loam validate --all` in the docs repo for the fleet, or `loam validate <target>` for one feature or service",
1291
1329
  "fix every error, and every finding that gates the archive",
@@ -1345,6 +1383,14 @@ and say it.
1345
1383
  | \`spine.op-undefined\` | a landscape edge calls an operation this service's OpenAPI does not define | a broken contract between services — fix the edge or add the endpoint |
1346
1384
  | \`spine.op-link-missing\` (warn) | a landscape "Calls" edge into this service with no \`metadata { op }\` | link it to the operationId |
1347
1385
  | \`spine.op-deprecated\` (warn) | a landscape edge calls an operation this service's OpenAPI marks \`deprecated: true\` — the consumer is standing on a contract being retired | migrate the consumer to the replacement operation; deprecation is the first step of retiring an op, and the op stays defined until a human removes it |
1386
+ | \`service.no-asyncapi\` | no asyncapi.yaml, and message link(s) already point into it — a landscape edge's \`metadata { publishes }\` / \`metadata { consumes }\`, or a LIVING requirement's \`Publishes:\` / \`Consumes:\` line. The \`details\` list the stranded message names. Silent when nothing joins into it: an async contract is optional, and most services touch no topic | put the file back — every link the \`details\` name resolves to nothing until it is there |
1387
+ | \`asyncapi.invalid\` | asyncapi.yaml exists but does not parse — an unreadable contract proves nothing, so no \`event.*\` or message-spine finding is graded against it | fix the YAML first — the event axis is unchecked until it reads |
1388
+ | \`asyncapi.duplicate-message\` (warn) | one message name is declared in two slots — every join on the name (an edge's \`metadata { publishes }\`, a requirement's \`Publishes:\` line) then picks one of them arbitrarily | give each declaration its own name, or factor the second into a \`$ref\` at the first |
1389
+ | \`spine.message-undefined\` | a landscape edge \`publishes\`/\`consumes\` a message this service's asyncapi.yaml declares no \`action: send\`/\`receive\` operation for | declare the operation, or correct the edge — the fleet map and the contract disagree about what this service puts on the wire |
1390
+ | \`spec-event.message-undefined\` | a LIVING requirement's \`Publishes:\` / \`Consumes:\` line names a message this service's asyncapi.yaml does not declare in that direction | declare it, or correct the line |
1391
+ | \`spine.message-unproduced\` | this service consumes a message NO service in the fleet declares an \`action: send\` for. The inverse of \`spine.op-undefined\`, and it has no HTTP analog: on the API axis the provider owns the contract and the check is local, while an event's schema lives in the producer's repo | find the producer and have it declare the message, or correct the name — a consumer joined to nothing reads a payload nothing defines |
1392
+ | \`asyncapi.message-contested\` (warn) | two or more services declare they send one message name — every consumer's join picks one of them arbitrarily | namespace the message by its owning domain; one message has one producer, and which contract a consumer reads must not be a coin flip |
1393
+ | \`event.messages-unlinked\` (warn) | messages AND requirements exist but zero \`Publishes:\`/\`Consumes:\` lines join them — the event axis is vacuously green | link each requirement to the messages it governs |
1348
1394
  | \`covers.unknown\` (warn) | a \`Covers:\` entry in arch.spec.md resolves to no element, edge, alert or SLI — living and feature deltas alike | fix the id (the message offers close ones); a mistyped entry silently costs the coverage it was written for |
1349
1395
  | \`health.invalid\` (warn) | health.yaml exists but does not parse — alert/SLI ids are unreadable, so \`Covers: alert:/sli:\` entries and health coverage are unchecked (a missing health.yaml is legal and silent; an unreadable one used to masquerade as \`covers.unknown\` typos) | fix the YAML — the health axis resumes once it reads |
1350
1396
  | \`health.uncovered\` (warn) | health.yaml declares an alert or SLI no arch.spec.md requirement covers | write the arch requirement with \`Covers: alert:<id>\` / \`Covers: sli:<id>\` — a signal nothing tests is dashboard decoration |
@@ -1458,6 +1504,7 @@ have read it.
1458
1504
  argumentHint: "<FEAT-id>",
1459
1505
  purpose: "Check that the code somebody built is the feature that was designed. loam derives the questions; a test runner and you answer them. loam never reads the service, so a verdict is worth exactly what its evidence is worth.",
1460
1506
  invocation: "loam instructions loam-verify $1",
1507
+ placeholders: ["feature"],
1461
1508
  spine: [
1462
1509
  "`loam verify` — the checklist, derived from the feature's own artifacts",
1463
1510
  "run the generated suite with a JSON report: the scenario claims are the runner's to answer, not yours",
@@ -1564,6 +1611,7 @@ everyone later.
1564
1611
  argumentHint: "<FEAT-id>",
1565
1612
  purpose: "Merge a shipped feature into the living specs, API and landscape. This is the one command that rewrites the source of truth, so every step before it exists to make the merge reviewable before it runs.",
1566
1613
  invocation: "loam instructions loam-ship $1",
1614
+ placeholders: ["feature"],
1567
1615
  spine: [
1568
1616
  "confirm the code is actually built and merged — archiving early makes the docs claim something that does not exist",
1569
1617
  "`loam validate --feature $1` must come back valid; if it says nothing pinned the delta, rebase and re-validate first",
@@ -1644,13 +1692,45 @@ current snapshot format. Both mean version control is the way back.
1644
1692
  },
1645
1693
  ];
1646
1694
  /**
1647
- * Pre-approved tools in a generated file's frontmatter, so an agent that honors
1648
- * the field stops asking permission for each `loam` call. It only pre-approves:
1649
- * everything else a command needs (Read, Write, the service's own test runner)
1650
- * stays under the user's normal permission settings, and a tool that does not
1651
- * know the field ignores it.
1695
+ * The loam verbs a generated file pre-approves, so an agent that honors the
1696
+ * field stops asking permission for each call. Everything else a command needs
1697
+ * (Read, Write, the service's own test runner) stays under the user's normal
1698
+ * permission settings, and a tool that does not know the field ignores it.
1699
+ *
1700
+ * Enumerated rather than spelled `Bash(loam:*)`, for one verb's sake. `vouch`
1701
+ * is the only command in loam whose output is a claim about a HUMAN act — a
1702
+ * person read the code and says the document matches it — and a blanket
1703
+ * allowlist pre-approved it, which handed the agent that wrote a draft the
1704
+ * power to promote its own draft to `verified` without anybody being asked.
1705
+ * That inverts the argument loam makes everywhere else: it holds test evidence
1706
+ * to "an agent must not be able to SAY a scenario is tested", and then let one
1707
+ * say a spec matches the code. `vouch` now refuses without a terminal or
1708
+ * `--yes` (commands/vouch.ts) — this list is the other half, so the refusal is
1709
+ * something a person sees rather than something an agent routes around.
1710
+ *
1711
+ * A verb missing from this list is not forbidden, only unapproved: the agent
1712
+ * asks, and the user says yes. That is the correct cost for this one.
1652
1713
  */
1653
- const LOAM_ALLOWED_TOOLS = "Bash(loam:*)";
1714
+ const LOAM_ALLOWED_VERBS = [
1715
+ "adopt",
1716
+ "archive",
1717
+ "delta",
1718
+ "dependencies",
1719
+ "doctor",
1720
+ "explore",
1721
+ "gherkin",
1722
+ "init",
1723
+ "instructions",
1724
+ "list",
1725
+ "new",
1726
+ "rebase",
1727
+ "show",
1728
+ "status",
1729
+ "unarchive",
1730
+ "validate",
1731
+ "verify",
1732
+ ];
1733
+ const LOAM_ALLOWED_TOOLS = LOAM_ALLOWED_VERBS.map((v) => `Bash(loam ${v}:*)`).join(", ");
1654
1734
  /**
1655
1735
  * The Agent Skills convention, which every tool in the registry that reads
1656
1736
  * skills at all spells the same way: `<tool-dir>/skills/<name>/SKILL.md`, YAML
@@ -1970,6 +2050,34 @@ export const SLASH_COMMANDS = Object.fromEntries(COMMANDS.map((c) => [c.name, CL
1970
2050
  * and they did not move just because the delivery did.
1971
2051
  */
1972
2052
  export const PROTOCOLS = Object.fromEntries(COMMANDS.map((c) => [c.name, c.body]));
2053
+ /** What each workflow's `$1`, `$2`, … denote — see {@link CommandContent.placeholders}. */
2054
+ export const PLACEHOLDERS = Object.fromEntries(COMMANDS.map((c) => [c.name, c.placeholders]));
2055
+ /**
2056
+ * Why the arguments given to `name` cannot be substituted into its protocol, or
2057
+ * null when they can. One sentence per bad argument, already naming the
2058
+ * placeholder it was meant to fill.
2059
+ *
2060
+ * The grammars are `core/ids.ts`'s, never a second copy: a value this accepts
2061
+ * and `loam adopt` refuses would be worse than no check, because the protocol
2062
+ * would still be wrong and now something had approved it. An argument nobody
2063
+ * supplied is not an error — `protocolFor` leaves that placeholder standing on
2064
+ * purpose, so the page reads as "the service id goes here".
2065
+ */
2066
+ export function placeholderProblems(name, args) {
2067
+ const kinds = PLACEHOLDERS[name];
2068
+ if (kinds === undefined)
2069
+ return null;
2070
+ const problems = kinds.flatMap((kind, i) => {
2071
+ const arg = args[i];
2072
+ if (arg === undefined || arg === "" || kind === "free")
2073
+ return [];
2074
+ const problem = kind === "service"
2075
+ ? serviceIdProblem(arg, `$${i + 1}`)
2076
+ : featureIdProblem(arg, `feature id ($${i + 1})`);
2077
+ return problem === null ? [] : [problem];
2078
+ });
2079
+ return problems.length === 0 ? null : problems;
2080
+ }
1973
2081
  /** The workflow names, in cycle order — `loam instructions` with no argument lists these. */
1974
2082
  export const WORKFLOWS = COMMANDS.map(({ name, description, argumentHint }) => ({ name, description, argumentHint }));
1975
2083
  /**