@intentset/cli 0.6.1 → 0.7.1

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.
Files changed (62) hide show
  1. package/README.md +35 -0
  2. package/dist/agents.d.ts.map +1 -1
  3. package/dist/agents.js +18 -10
  4. package/dist/agents.js.map +1 -1
  5. package/dist/commands/evidence-stale.d.ts +19 -0
  6. package/dist/commands/evidence-stale.d.ts.map +1 -0
  7. package/dist/commands/evidence-stale.js +118 -0
  8. package/dist/commands/evidence-stale.js.map +1 -0
  9. package/dist/commands/evidence.d.ts +7 -0
  10. package/dist/commands/evidence.d.ts.map +1 -1
  11. package/dist/commands/evidence.js +27 -9
  12. package/dist/commands/evidence.js.map +1 -1
  13. package/dist/commands/graph.js +1 -1
  14. package/dist/commands/guide.d.ts +5 -0
  15. package/dist/commands/guide.d.ts.map +1 -0
  16. package/dist/commands/guide.js +16 -0
  17. package/dist/commands/guide.js.map +1 -0
  18. package/dist/commands/init.d.ts +6 -0
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +11 -3
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/migrate.d.ts +23 -0
  23. package/dist/commands/migrate.d.ts.map +1 -0
  24. package/dist/commands/migrate.js +125 -0
  25. package/dist/commands/migrate.js.map +1 -0
  26. package/dist/git.d.ts +13 -0
  27. package/dist/git.d.ts.map +1 -1
  28. package/dist/git.js +26 -0
  29. package/dist/git.js.map +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/main.d.ts +1 -1
  35. package/dist/main.d.ts.map +1 -1
  36. package/dist/main.js +146 -44
  37. package/dist/main.js.map +1 -1
  38. package/dist/reports.d.ts.map +1 -1
  39. package/dist/reports.js +26 -3
  40. package/dist/reports.js.map +1 -1
  41. package/dist/session.d.ts +8 -2
  42. package/dist/session.d.ts.map +1 -1
  43. package/dist/session.js +18 -1
  44. package/dist/session.js.map +1 -1
  45. package/dist/subject.d.ts +16 -0
  46. package/dist/subject.d.ts.map +1 -0
  47. package/dist/subject.js +46 -0
  48. package/dist/subject.js.map +1 -0
  49. package/package.json +18 -10
  50. package/src/agents.ts +18 -10
  51. package/src/commands/evidence-stale.ts +151 -0
  52. package/src/commands/evidence.ts +39 -9
  53. package/src/commands/graph.ts +1 -1
  54. package/src/commands/guide.ts +17 -0
  55. package/src/commands/init.ts +12 -3
  56. package/src/commands/migrate.ts +136 -0
  57. package/src/git.ts +25 -0
  58. package/src/index.ts +3 -1
  59. package/src/main.ts +150 -44
  60. package/src/reports.ts +27 -3
  61. package/src/session.ts +26 -1
  62. package/src/subject.ts +63 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `intentset migrate --to 0.2` (Core §1): moves the records the model reads from the specifications' `0.1` to `0.2`
3
+ * by rewriting two values in each record's frontmatter, `intentset.spec` and `intentset.profile`, which Core §4
4
+ * requires to agree with it. It reads the same records every other command reads (the files the configured scope
5
+ * matches), changes those two lines and nothing else, byte for byte, and keeps the quoting each line used. A tree
6
+ * with uncommitted changes to tracked files is refused unless `--allow-dirty` says otherwise, so the rewrite is a
7
+ * diff of its own that git can undo. Other files (`.intentset/agents.md`, which carries an example record) are not
8
+ * records and are left to their author.
9
+ */
10
+ import { writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { sortDiagnostics } from "@intentset/core";
13
+ import { trackedChanges } from "../git.ts";
14
+ import { formatDiagnostic, type Io } from "../output.ts";
15
+ import { type CarrierName, CONFIG_PATH, findRoot, loadRepository } from "../repository.ts";
16
+
17
+ /** The specification versions this command moves between. */
18
+ export const MIGRATE_FROM = "0.1";
19
+ export const MIGRATE_TO = "0.2";
20
+
21
+ export interface MigrateOptions {
22
+ root?: string;
23
+ to?: string;
24
+ allowDirty?: boolean;
25
+ carrier: CarrierName;
26
+ }
27
+
28
+ /** A message when the options cannot be run, before anything is read; null when they can. */
29
+ export function migrateUsageProblem(options: Pick<MigrateOptions, "to">): string | null {
30
+ if (options.to === undefined) return `--to is required: the only target is ${MIGRATE_TO}, from ${MIGRATE_FROM}`;
31
+ if (options.to !== MIGRATE_TO) {
32
+ return `--to ${options.to}: the only target is ${MIGRATE_TO}, which moves records written for ${MIGRATE_FROM}`;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ const SPEC_LINE = /^(\s+spec:\s*)(['"]?)(\d+\.\d+)\2(\s*(?:#.*)?)$/;
38
+ const PROFILE_LINE = /^(\s+profile:\s*)(['"]?)intentset\/([a-z0-9-]+)\/(\d+\.\d+)\2(\s*(?:#.*)?)$/;
39
+
40
+ /**
41
+ * The record's text moved from `from` to `to`, or null when it declares another value. Only lines inside the
42
+ * `intentset:` mapping of the frontmatter, and only `spec` and `profile`, are rewritten; line endings are kept.
43
+ */
44
+ export function migrateText(
45
+ source: string,
46
+ frontmatter: { line: number; text: string },
47
+ from: string,
48
+ to: string,
49
+ ): string | null {
50
+ const lines = source.split("\n");
51
+ const first = frontmatter.line - 1;
52
+ const last = first + frontmatter.text.split("\n").length;
53
+ let inIntentset = false;
54
+ let childIndent: number | null = null;
55
+ let specSeen = false;
56
+ const edits = new Map<number, string>();
57
+ for (let i = first; i < last && i < lines.length; i++) {
58
+ const raw = lines[i];
59
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
60
+ const eol = raw.endsWith("\r") ? "\r" : "";
61
+ if (/^\S/.test(line)) {
62
+ inIntentset = /^intentset:\s*(#.*)?$/.test(line);
63
+ childIndent = null;
64
+ continue;
65
+ }
66
+ if (!inIntentset || line.trim() === "") continue;
67
+ const indent = line.length - line.trimStart().length;
68
+ childIndent ??= indent;
69
+ if (indent !== childIndent) continue;
70
+ const spec = SPEC_LINE.exec(line);
71
+ if (spec !== null) {
72
+ if (spec[3] !== from) return null;
73
+ specSeen = true;
74
+ edits.set(i, `${spec[1]}${spec[2]}${to}${spec[2]}${spec[4]}${eol}`);
75
+ continue;
76
+ }
77
+ const profile = PROFILE_LINE.exec(line);
78
+ if (profile !== null && profile[4] === from) {
79
+ edits.set(i, `${profile[1]}${profile[2]}intentset/${profile[3]}/${to}${profile[2]}${profile[5]}${eol}`);
80
+ }
81
+ }
82
+ if (!specSeen) return null;
83
+ for (const [i, text] of edits) lines[i] = text;
84
+ return lines.join("\n");
85
+ }
86
+
87
+ export function migrateCommand(options: MigrateOptions, io: Io): number {
88
+ const root = findRoot(io.cwd, options.root);
89
+ if (root === null) {
90
+ const where = options.root === undefined ? "this directory or any above it" : options.root;
91
+ io.stderr(`intentset migrate: no ${CONFIG_PATH} in ${where}.\n`);
92
+ return 2;
93
+ }
94
+ if (options.allowDirty !== true) {
95
+ const changes = trackedChanges(root);
96
+ if (changes === null) {
97
+ io.stderr(
98
+ "intentset migrate: this is not a git work tree, so the rewrite could not be undone; pass --allow-dirty to migrate anyway.\n",
99
+ );
100
+ return 2;
101
+ }
102
+ if (changes.length > 0) {
103
+ io.stderr(
104
+ `intentset migrate: ${changes.length} tracked file${changes.length === 1 ? " has" : "s have"} uncommitted changes. ` +
105
+ "Commit or stash them so the rewrite is a diff on its own, or pass --allow-dirty.\n",
106
+ );
107
+ return 2;
108
+ }
109
+ }
110
+ const repo = loadRepository(root, options.carrier);
111
+ if (!repo.configOk) {
112
+ io.stderr("intentset migrate: the configuration has errors, so the scope is unknown.\n");
113
+ for (const d of sortDiagnostics(repo.diagnostics)) io.stderr(formatDiagnostic(d));
114
+ return 2;
115
+ }
116
+ let moved = 0;
117
+ let current = 0;
118
+ let other = 0;
119
+ for (const input of repo.inputs) {
120
+ if (input.frontmatter === null) continue;
121
+ const text = migrateText(input.source, input.frontmatter, MIGRATE_FROM, MIGRATE_TO);
122
+ if (text === null) {
123
+ if (/^\s+spec:\s*['"]?0\.2['"]?\s*(#.*)?$/m.test(input.frontmatter.text)) current++;
124
+ else other++;
125
+ continue;
126
+ }
127
+ if (text === input.source) continue;
128
+ writeFileSync(join(root, input.path), text);
129
+ moved++;
130
+ }
131
+ io.stdout(
132
+ `intentset migrate: moved ${moved} record${moved === 1 ? "" : "s"} from ${MIGRATE_FROM} to ${MIGRATE_TO}` +
133
+ `${current > 0 ? `; ${current} already ${MIGRATE_TO}` : ""}${other > 0 ? `; ${other} declare neither (left as they are)` : ""}.\n`,
134
+ );
135
+ return 0;
136
+ }
package/src/git.ts CHANGED
@@ -94,3 +94,28 @@ export function acknowledgedSlices(root: string, base: Base | null): string[] {
94
94
  if (out === null) return [];
95
95
  return [...new Set(out.split(/[\s,]+/).filter((id) => id !== ""))].sort(compareStrings);
96
96
  }
97
+
98
+ /**
99
+ * The files that differ between `commit` and the working tree (tracked changes, staged or not, and untracked files
100
+ * .gitignore does not exclude), sorted, as paths relative to the CLI's root; or null when `commit` is not an ancestor of
101
+ * HEAD or git cannot say. One `git diff --name-only`, so the evidence report can list what changed outside every
102
+ * subject (Core §8) without computing anything per file.
103
+ */
104
+ export function changedSince(root: string, commit: string): string[] | null {
105
+ if (git(root, ["merge-base", "--is-ancestor", commit, "HEAD"]) === null) return null;
106
+ const diff = git(root, ["diff", "--relative", "--name-only", "-z", commit]);
107
+ if (diff === null) return null;
108
+ const untracked = paths(git(root, ["ls-files", "-z", "--others", "--exclude-standard"]));
109
+ return [...new Set([...paths(diff), ...untracked])].sort(compareStrings);
110
+ }
111
+
112
+ /**
113
+ * The tracked files that differ from HEAD, staged or not, as `git status` lists them; an empty list for a clean
114
+ * tree, and null when this is not a git work tree or git cannot say. Untracked files are not counted: a command that
115
+ * rewrites tracked files asks only that its rewrite be a diff on its own.
116
+ */
117
+ export function trackedChanges(root: string): string[] | null {
118
+ const out = git(root, ["status", "--porcelain", "--untracked-files=no", "-z"]);
119
+ if (out === null) return null;
120
+ return out.split("\0").filter((entry) => entry !== "");
121
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * @intentset/cli as a library: `main` for running the command in-process,
3
- * and the repository loader and pattern matcher it is built on.
3
+ * the agent guide `init` writes (intentset.org serves it from the same
4
+ * generator), and the repository loader and pattern matcher it is built on.
4
5
  */
5
6
  export { main, USAGE } from "./main.ts";
7
+ export { AGENT_GUIDE_PATH, AGENT_POINTERS, agentGuide, type PackageManager } from "./agents.ts";
6
8
  export type { Io } from "./output.ts";
7
9
  export { matchAny, matchGlob } from "./glob.ts";
8
10
  export { CONFIG_PATH, enumerate, findRoot, loadRepository, readCommit, type Repository } from "./repository.ts";
package/src/main.ts CHANGED
@@ -5,20 +5,24 @@
5
5
  *
6
6
  * Exit codes (Core §11, ADR 0004): 0 no errors, 1 validation errors, 2
7
7
  * invocation or tool failure. validate, graph, architecture check, impact,
8
- * context, review, serve and mcp never write; graph --out, architecture
8
+ * context, review, evidence stale, serve, mcp and guide never write; graph --out, architecture
9
9
  * check --write-baseline, evidence import --out, publish --out and serve
10
- * --out write only what they name; init only creates.
10
+ * --out write only what they name; init only creates; migrate rewrites the
11
+ * two version values of each record's frontmatter and nothing else.
11
12
  */
12
13
  import { parseArgs } from "node:util";
13
14
  import type { Mode } from "@intentset/architecture";
14
- import { LEVELS, type Level } from "@intentset/core";
15
+ import { EVIDENCE_BINDINGS, type EvidenceBinding, LEVELS, type Level } from "@intentset/core";
15
16
  import type { RunScope } from "@intentset/verification";
16
17
  import { architectureCommand } from "./commands/architecture.ts";
17
18
  import { contextCommand } from "./commands/context.ts";
18
19
  import { evidenceImportCommand, evidenceUsageProblem } from "./commands/evidence.ts";
20
+ import { evidenceStaleCommand, evidenceStaleUsageProblem } from "./commands/evidence-stale.ts";
19
21
  import { graphCommand, graphUsageProblem, parseRelease } from "./commands/graph.ts";
22
+ import { guideCommand } from "./commands/guide.ts";
20
23
  import { impactCommand } from "./commands/impact.ts";
21
24
  import { initCommand } from "./commands/init.ts";
25
+ import { migrateCommand, migrateUsageProblem } from "./commands/migrate.ts";
22
26
  import { mcpCommand, mcpUsageProblem } from "./commands/mcp.ts";
23
27
  import { publishCommand, publishUsageProblem } from "./commands/publish.ts";
24
28
  import { reviewCommand } from "./commands/review.ts";
@@ -34,69 +38,96 @@ export const USAGE = `usage: intentset <command> [options]
34
38
 
35
39
  commands
36
40
  init [--repository <name>] [--example] [--agents]
37
- create .intentset/config.yaml, an empty .intentset/registries.yaml and
38
- .intentset/agents.md, the guide that tells coding agents how to keep the
39
- model current; --example adds the scheduling example under
40
- product/scheduling/; --agents writes only the guide, for a repository
41
- already initialized. Never overwrites a file.
42
- validate every check the level asks for; exit 1 when any diagnostic is an error
41
+ create .intentset/config.yaml, an empty .intentset/registries.yaml
42
+ and .intentset/agents.md, the guide that tells coding agents how to
43
+ keep the model current; --example adds the scheduling example under
44
+ product/scheduling/; --agents writes only the guide, for a
45
+ repository already initialized. Never overwrites a file.
46
+ guide print that guide for this repository, for the scope its config
47
+ names, without writing anything
48
+ validate every check the level asks for; exit 1 when any diagnostic is an
49
+ error
43
50
  graph [--format json] [--include-bodies] [--release <product>:<label>] [--out <file>]
44
51
  [--generated-at <time>] [--report evidence|knowledge|impact|ownership|all]...
45
52
  [--include-restricted]
46
- print the intentset/export/0.3 envelope, or write it to --out;
53
+ print the intentset/export/0.4 envelope, or write it to --out;
47
54
  each --report adds a section (ownership needs L2, evidence L3; all
48
55
  is every one the level reads). Restricted artifacts are withheld
49
56
  and counted unless --include-restricted
50
57
  architecture check [--mode migration|strict] [--baseline <file>] [--write-baseline <file>]
51
58
  ownership, claims, imports, layers and regions (default level L2)
52
- evidence import --from vitest|node-tap <report> --out <file> --product <ID> --release <label>
59
+ evidence import --from vitest|playwright|node-tap <report> --out <file> --product <ID>
60
+ --release <label>
53
61
  [--environment <text>] [--started-at <time>] [--finished-at <time>] [--uri-base <uri>]
54
62
  [--tool-version <v>]
55
63
  turn a test report into run records bound to this commit and graph
56
- impact <ID> what depends on an artifact: direct, candidates, review context, ancestors
64
+ evidence stale [--release <product>:<label>] [--check] [--binding <subject|commit>]
65
+ [--format list|json|playwright|vitest|node-test]
66
+ what needs a run: the automated verifications whose evidence is
67
+ not a current pass, as a table, as JSON, or as the arguments that
68
+ make a test runner run only those tests; never writes; --check
69
+ exits 1 when any needs a run
70
+ impact <ID> what depends on an artifact: direct, candidates, review context,
71
+ ancestors
57
72
  context <ID|path> [--include-restricted]
58
- the bounded engineering context of an artifact, with paths and bodies;
59
- given a file a slice claims, the context of that slice
73
+ the bounded engineering context of an artifact, with paths and
74
+ bodies; given a file a slice claims, the context of that slice
60
75
  review [--base <ref>] [--fail-on-drift]
61
- the local review report: checks, coverage, the impact of what changed,
62
- and each slice whose code changed while its records did not;
63
- --fail-on-drift exits 1 for any such slice no commit acknowledged with
64
- an Intentset-Unchanged trailer
76
+ the local review report: checks, coverage, the impact of what
77
+ changed, and each slice whose code changed while its records did
78
+ not; --fail-on-drift exits 1 for any such slice no commit
79
+ acknowledged with an Intentset-Unchanged trailer
65
80
  publish --visibility <v> --audience <a> --product <ID> --release <r> --role <r> --edition <e>
66
81
  [--flag <f>]... [--authorized-internal] [--authorized-restricted] --out <dir> [--html]
67
82
  [--published-at <time>]
68
- reviewed knowledge for one audience and release, as Markset with provenance
83
+ reviewed knowledge for one audience and release, as Markset with
84
+ provenance
69
85
  serve [--port <n>] [--host <address>] [--out <dir>]
70
- the Atlas, the internal review pages, on http://127.0.0.1:3000/ (rebuilt
71
- when a file changes), or written to --out; runs at L2, or L3 with run records
86
+ the Atlas, the internal review pages, on http://127.0.0.1:3000/
87
+ (rebuilt when a file changes), or written to --out; runs at L2, or
88
+ L3 with run records
89
+ migrate --to 0.2 [--allow-dirty]
90
+ move the records the model reads from specifications 0.1 to 0.2:
91
+ rewrites intentset.spec and intentset.profile in each record's
92
+ frontmatter and nothing else, and says how many. Refuses a tree
93
+ with uncommitted changes to tracked files unless --allow-dirty
72
94
  mcp --mode engineering [--include-restricted]
73
- mcp --mode customer --visibility <public|customer> --audience <a> --product <ID> --release <r>
74
- --role <r> --edition <e> [--flag <f>]...
95
+ mcp --mode customer --visibility <public|customer> --audience <a> --product <ID>
96
+ --release <r> --role <r> --edition <e> [--flag <f>]...
75
97
  the read-only MCP server on stdio: the engineering graph, or one
76
98
  publication made in memory as publish makes it, and nothing else
77
99
 
78
100
  options
79
- --root <dir> repository root (default: the nearest directory at or above this one
80
- holding .intentset/config.yaml)
101
+ --root <dir> repository root (default: the nearest directory at or above this
102
+ one holding .intentset/config.yaml)
81
103
  --json machine-readable output, keys in canonical order
82
104
  --carrier <markset|plain> how documents are read (default markset)
83
105
  --level <L1..L4> conformance level; each includes those below it (default L1):
84
106
  L2 adds architecture, L3 evidence, L4 publication readiness
85
- --evidence <file> L3 and above: run records to read, repeatable (default: every *.json in
86
- ${EVIDENCE_DIR}/, which holds local output and is not committed)
107
+ --evidence <file> L3 and above: run records to read, repeatable (default: every
108
+ *.json in ${EVIDENCE_DIR}/, which holds local output and is not
109
+ committed)
87
110
  --product <ID> --release <label>
88
- L3 and above: the exact scope evidence is assessed for (default: every scope)
111
+ L3 and above: the exact scope evidence is assessed for (default:
112
+ every scope)
113
+ --binding <subject|commit>
114
+ L3 and above, and evidence import: how run evidence is judged
115
+ current, overriding evidence.binding in the configuration (default:
116
+ subject where slices claim files, else commit). Subject keeps a
117
+ record current while what it checked is unchanged; commit only at
118
+ the commit it ran at
89
119
  --mode <migration|strict> --baseline <file>
90
- L2 and above: whether a baseline of known violations applies, and which
91
- (for mcp, --mode names the server's mode instead)
120
+ L2 and above: whether a baseline of known violations applies, and
121
+ which (for mcp, --mode names the server's mode instead)
92
122
  -h, --help show this help
93
123
  --version print "intentset <version>", the checker version a report names
94
124
 
95
- exit codes: 0 no errors, 1 validation errors (or, with review --fail-on-drift, unacknowledged drift),
96
- 2 invocation or tool failure`;
125
+ exit codes: 0 no errors, 1 validation errors (or, with review --fail-on-drift,
126
+ unacknowledged drift), 2 invocation or tool failure`;
97
127
 
98
128
  const COMMANDS = [
99
129
  "init",
130
+ "guide",
100
131
  "validate",
101
132
  "graph",
102
133
  "architecture",
@@ -106,13 +137,16 @@ const COMMANDS = [
106
137
  "review",
107
138
  "publish",
108
139
  "serve",
140
+ "migrate",
109
141
  "mcp",
110
142
  ];
111
143
 
112
144
  const GLOBAL = ["root", "json", "carrier", "level", "help", "version"];
113
- const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline"];
145
+ const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline", "binding"];
114
146
  const COMMAND_OPTIONS: Record<string, string[]> = {
115
147
  init: ["repository", "example", "agents"],
148
+ guide: [],
149
+ migrate: ["to", "allow-dirty"],
116
150
  validate: LEVEL_OPTIONS,
117
151
  graph: [
118
152
  "format",
@@ -125,9 +159,13 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
125
159
  "evidence",
126
160
  "mode",
127
161
  "baseline",
162
+ "binding",
128
163
  ],
129
164
  architecture: ["mode", "baseline", "write-baseline"],
130
165
  evidence: [
166
+ "format",
167
+ "check",
168
+ "evidence",
131
169
  "from",
132
170
  "out",
133
171
  "product",
@@ -137,6 +175,7 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
137
175
  "finished-at",
138
176
  "uri-base",
139
177
  "tool-version",
178
+ "binding",
140
179
  ],
141
180
  impact: LEVEL_OPTIONS,
142
181
  context: ["include-restricted", ...LEVEL_OPTIONS],
@@ -173,9 +212,9 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
173
212
  };
174
213
 
175
214
  /** Positional arguments after the command: a fixed subcommand, then how many more. */
176
- const POSITIONALS: Record<string, { sub?: string; count: number; what?: string }> = {
177
- architecture: { sub: "check", count: 0 },
178
- evidence: { sub: "import", count: 1, what: "a report file" },
215
+ const POSITIONALS: Record<string, { subs?: Record<string, number>; count: number; what?: string }> = {
216
+ architecture: { subs: { check: 0 }, count: 0 },
217
+ evidence: { subs: { import: 1, stale: 0 }, count: 0, what: "a report file" },
179
218
  impact: { count: 1, what: "an artifact ID" },
180
219
  context: { count: 1, what: "an artifact ID or a file path" },
181
220
  };
@@ -216,14 +255,48 @@ export async function main(argv: string[], io: Io): Promise<number> {
216
255
 
217
256
  const shape = POSITIONALS[command] ?? { count: 0 };
218
257
  let args = rest;
219
- if (shape.sub !== undefined) {
220
- if (args[0] !== shape.sub) return usage(`the only subcommand is "${command} ${shape.sub}"`);
258
+ let wanted = shape.count;
259
+ let sub: string | undefined;
260
+ if (shape.subs !== undefined) {
261
+ const names = Object.keys(shape.subs);
262
+ sub = args[0];
263
+ if (sub === undefined || !Object.hasOwn(shape.subs, sub)) {
264
+ return usage(
265
+ names.length === 1
266
+ ? `the only subcommand is "${command} ${names[0]}"`
267
+ : `the subcommands are ${names.map((name) => `"${command} ${name}"`).join(" and ")}`,
268
+ );
269
+ }
270
+ wanted = shape.subs[sub];
221
271
  args = args.slice(1);
222
272
  }
223
- if (args.length < shape.count) return usage(`${shape.what} is required`);
224
- if (args.length > shape.count) {
225
- const hint = shape.count === 0 ? " (use --root <dir> to name the repository)" : "";
226
- return usage(`unexpected argument "${args[shape.count]}"${hint}`);
273
+ if (args.length < wanted) return usage(`${shape.what} is required`);
274
+ if (args.length > wanted) {
275
+ const hint = wanted === 0 ? " (use --root <dir> to name the repository)" : "";
276
+ return usage(`unexpected argument "${args[wanted]}"${hint}`);
277
+ }
278
+ if (command === "evidence") {
279
+ // Each subcommand takes its own options, though one list allows them all.
280
+ const own =
281
+ sub === "stale"
282
+ ? ["format", "check", "evidence", "release", "binding"]
283
+ : [
284
+ "from",
285
+ "out",
286
+ "product",
287
+ "release",
288
+ "environment",
289
+ "started-at",
290
+ "finished-at",
291
+ "uri-base",
292
+ "tool-version",
293
+ "binding",
294
+ ];
295
+ for (const token of tokens) {
296
+ if (token.kind === "option" && COMMAND_OPTIONS.evidence.includes(token.name) && !own.includes(token.name)) {
297
+ return usage(`--${token.name} is not an option of evidence ${sub}`);
298
+ }
299
+ }
227
300
  }
228
301
 
229
302
  const carrier = values.carrier ?? "markset";
@@ -240,6 +313,15 @@ export async function main(argv: string[], io: Io): Promise<number> {
240
313
  if (command !== "mcp" && values.mode !== undefined && values.mode !== "migration" && values.mode !== "strict") {
241
314
  return usage(`--mode ${values.mode}: the modes are migration and strict`);
242
315
  }
316
+ if (command === "guide") return guideCommand({ root: values.root }, io);
317
+ if (command === "migrate") {
318
+ const problem = migrateUsageProblem({ to: values.to });
319
+ if (problem !== null) return usage(problem);
320
+ return migrateCommand(
321
+ { root: values.root, to: values.to, allowDirty: values["allow-dirty"], carrier: carrier as CarrierName },
322
+ io,
323
+ );
324
+ }
243
325
  if (command === "init") {
244
326
  if (values.agents && values.example)
245
327
  return usage("--agents writes only the guide; it does not combine with --example");
@@ -251,8 +333,8 @@ export async function main(argv: string[], io: Io): Promise<number> {
251
333
 
252
334
  // Evidence scope: --product with --release, or graph's --release <product>:<label>.
253
335
  let scope: RunScope | null = null;
254
- if (command !== "evidence" && command !== "publish" && command !== "mcp") {
255
- if (command === "graph") {
336
+ if ((command !== "evidence" || sub === "stale") && command !== "publish" && command !== "mcp") {
337
+ if (command === "graph" || command === "evidence") {
256
338
  const release = values.release === undefined ? null : parseRelease(values.release);
257
339
  scope = release === null ? null : { product: release.product, release: release.label };
258
340
  } else if ((values.product === undefined) !== (values.release === undefined)) {
@@ -262,6 +344,9 @@ export async function main(argv: string[], io: Io): Promise<number> {
262
344
  }
263
345
  }
264
346
 
347
+ if (values.binding !== undefined && !(EVIDENCE_BINDINGS as readonly string[]).includes(values.binding)) {
348
+ return usage(`--binding ${values.binding}: the bindings are ${EVIDENCE_BINDINGS.join(" and ")}`);
349
+ }
265
350
  const sessionOptions = (level: Level): SessionOptions => ({
266
351
  root: values.root,
267
352
  carrier: carrier as CarrierName,
@@ -270,6 +355,7 @@ export async function main(argv: string[], io: Io): Promise<number> {
270
355
  baseline: values.baseline,
271
356
  evidence: values.evidence,
272
357
  scope,
358
+ binding: values.binding as EvidenceBinding | undefined,
273
359
  });
274
360
 
275
361
  switch (command) {
@@ -299,6 +385,21 @@ export async function main(argv: string[], io: Io): Promise<number> {
299
385
  return architectureCommand(session, { writeBaseline: values["write-baseline"] }, values.json, io);
300
386
  }
301
387
  case "evidence": {
388
+ if (sub === "stale") {
389
+ if (levelGiven === "L1" || levelGiven === "L2") {
390
+ return usage("evidence is judged at L3 and above, where run records are read");
391
+ }
392
+ const staleOptions = {
393
+ release: values.release,
394
+ format: values.format,
395
+ check: values.check,
396
+ json: values.json,
397
+ };
398
+ const checked = evidenceStaleUsageProblem(staleOptions);
399
+ if ("problem" in checked) return usage(checked.problem);
400
+ const session = openSession("evidence stale", sessionOptions(levelGiven ?? "L3"), io);
401
+ return typeof session === "number" ? session : evidenceStaleCommand(session, checked.format, staleOptions, io);
402
+ }
302
403
  const options = {
303
404
  from: values.from,
304
405
  report: args[0],
@@ -310,6 +411,7 @@ export async function main(argv: string[], io: Io): Promise<number> {
310
411
  finishedAt: values["finished-at"],
311
412
  uriBase: values["uri-base"],
312
413
  toolVersion: values["tool-version"],
414
+ binding: values.binding as EvidenceBinding | undefined,
313
415
  };
314
416
  const problem = evidenceUsageProblem(options);
315
417
  if (problem !== null) return usage(problem);
@@ -428,7 +530,9 @@ function parse(argv: string[]) {
428
530
  product: { type: "string" },
429
531
  mode: { type: "string" },
430
532
  baseline: { type: "string" },
533
+ binding: { type: "string" },
431
534
  "write-baseline": { type: "string" },
535
+ check: { type: "boolean", default: false },
432
536
  from: { type: "string" },
433
537
  environment: { type: "string" },
434
538
  "started-at": { type: "string" },
@@ -447,6 +551,8 @@ function parse(argv: string[]) {
447
551
  "published-at": { type: "string" },
448
552
  port: { type: "string" },
449
553
  host: { type: "string" },
554
+ to: { type: "string" },
555
+ "allow-dirty": { type: "boolean", default: false },
450
556
  },
451
557
  });
452
558
  }
package/src/reports.ts CHANGED
@@ -35,6 +35,17 @@ function ofCounts(part: ClaimCounts, whole: ClaimCounts): string {
35
35
  ].join(", ");
36
36
  }
37
37
 
38
+ /** Which binding judged the evidence, and why (Core §8). */
39
+ function bindingLine(run: EvidenceRun): string {
40
+ const { binding, reason } = run.binding;
41
+ if (binding === "subject") {
42
+ return `Binding: subject (${reason === "default" ? "the default, since slices claim files" : "as configured"}); a record is current while the subject it checked is unchanged`;
43
+ }
44
+ return reason === "no-slices"
45
+ ? "Binding: commit, because no slice claims files and there is no subject to bind to (EVID004); a record is current only at the assessed commit and graph hash"
46
+ : "Binding: commit, as configured; a record is current only at the assessed commit and graph hash";
47
+ }
48
+
38
49
  export function evidenceLines(run: EvidenceRun): string[] {
39
50
  const { coverage, classification } = run.check;
40
51
  const snap = run.snapshot;
@@ -46,6 +57,7 @@ export function evidenceLines(run: EvidenceRun): string[] {
46
57
  const d = coverage.denominator;
47
58
  const lines = [
48
59
  `Evidence: ${count(run.records, "run record")} from ${from}; assessed at ${snap.commit === null ? "no commit" : `commit ${snap.commit}`}, graph ${snap.graphHash}, for ${scope}`,
60
+ bindingLine(run),
49
61
  `Coverage over ${count(d.behaviors, "behavior")}, ${count(d.rules, "rule")} and ${count(d.scenarios, "scenario")} that are not retired:`,
50
62
  ` linked: ${ofCounts(coverage.linked, d)} (${coverage.measures.linked})`,
51
63
  ` verified: ${ofCounts(coverage.verified, d)} (${coverage.measures.verified})`,
@@ -57,7 +69,7 @@ export function evidenceLines(run: EvidenceRun): string[] {
57
69
  if (ids.length === 0) lines.push(" none");
58
70
  for (const id of ids) {
59
71
  const v = classification.verifications[id];
60
- lines.push(` ${id} ${v.method} ${v.status}${v.note === undefined ? "" : ` (${v.note})`}`);
72
+ lines.push(` ${id} ${v.method} ${v.status} [${v.binding}]${v.note === undefined ? "" : ` (${v.note})`}`);
61
73
  }
62
74
  lines.push(
63
75
  `Not checked, so not a pass (${coverage.unresolved.length}):${coverage.unresolved.length === 0 ? " none" : ""}`,
@@ -94,9 +106,21 @@ export function levelJson(session: Session): Record<string, unknown> {
94
106
  const { files, records, snapshot, check } = session.evidence;
95
107
  const verifications: Record<string, unknown> = {};
96
108
  for (const [id, v] of Object.entries(check.classification.verifications)) {
97
- verifications[id] = { method: v.method, status: v.status, ...(v.note === undefined ? {} : { note: v.note }) };
109
+ verifications[id] = {
110
+ method: v.method,
111
+ status: v.status,
112
+ binding: v.binding,
113
+ ...(v.note === undefined ? {} : { note: v.note }),
114
+ };
98
115
  }
99
- out.evidence = { files, records, snapshot, coverage: check.coverage, verifications };
116
+ out.evidence = {
117
+ files,
118
+ records,
119
+ binding: session.evidence.binding,
120
+ snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash, scope: snapshot.scope ?? null },
121
+ coverage: check.coverage,
122
+ verifications,
123
+ };
100
124
  }
101
125
  if (session.publication !== undefined) out.publication = { checked: session.publication.checked };
102
126
  return out;