@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.
- package/CHANGELOG.md +47 -0
- package/SCHEMA.md +22 -4
- package/dist/commands/instructions.js +11 -1
- package/dist/commands/instructions.js.map +1 -1
- package/dist/commands/list.js +4 -0
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/status.js +9 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/validate.js +191 -9
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/vouch.js +84 -6
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +119 -11
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agents-stamp.js +47 -10
- package/dist/core/agents-stamp.js.map +1 -1
- package/dist/core/asyncapi.js +253 -0
- package/dist/core/asyncapi.js.map +1 -0
- package/dist/core/brief.js +25 -3
- package/dist/core/brief.js.map +1 -1
- package/dist/core/docs.js +63 -2
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +38 -1
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/fleet-context.js +13 -0
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +13 -3
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/provenance.js +67 -0
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/repo.js +2 -0
- package/dist/core/repo.js.map +1 -1
- package/dist/core/spec.js +25 -1
- package/dist/core/spec.js.map +1 -1
- package/dist/core/status.js +30 -2
- package/dist/core/status.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/vouch.js
CHANGED
|
@@ -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
|
|
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
|
|
139
|
-
const archPlan = archVerified === null ? null : planStamp(archVerified, req
|
|
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,
|
|
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:
|
|
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"]}
|
package/dist/core/agent.js
CHANGED
|
@@ -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,
|
|
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
|
|
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\`,
|
|
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
|
|
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
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
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
|
|
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
|
/**
|