@intentset/cli 0.6.0 → 0.7.0

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 +19 -2
  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 +2 -2
  35. package/dist/main.d.ts.map +1 -1
  36. package/dist/main.js +154 -45
  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 +44 -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 +30 -3
  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 +158 -46
  60. package/src/reports.ts +27 -3
  61. package/src/session.ts +26 -1
  62. package/src/subject.ts +61 -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,26 +5,30 @@
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";
25
29
  import { serveCommand } from "./commands/serve.ts";
26
30
  import { validateCommand } from "./commands/validate.ts";
27
- import type { Io } from "./output.ts";
31
+ import { type Io, TOOL } from "./output.ts";
28
32
  import { type CarrierName, CARRIERS, findRoot } from "./repository.ts";
29
33
  import { EVIDENCE_DIR, evidenceFiles, openSession, type SessionOptions } from "./session.ts";
30
34
 
@@ -34,68 +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
123
+ --version print "intentset <version>", the checker version a report names
93
124
 
94
- exit codes: 0 no errors, 1 validation errors (or, with review --fail-on-drift, unacknowledged drift),
95
- 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`;
96
127
 
97
128
  const COMMANDS = [
98
129
  "init",
130
+ "guide",
99
131
  "validate",
100
132
  "graph",
101
133
  "architecture",
@@ -105,13 +137,16 @@ const COMMANDS = [
105
137
  "review",
106
138
  "publish",
107
139
  "serve",
140
+ "migrate",
108
141
  "mcp",
109
142
  ];
110
143
 
111
- const GLOBAL = ["root", "json", "carrier", "level", "help"];
112
- const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline"];
144
+ const GLOBAL = ["root", "json", "carrier", "level", "help", "version"];
145
+ const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline", "binding"];
113
146
  const COMMAND_OPTIONS: Record<string, string[]> = {
114
147
  init: ["repository", "example", "agents"],
148
+ guide: [],
149
+ migrate: ["to", "allow-dirty"],
115
150
  validate: LEVEL_OPTIONS,
116
151
  graph: [
117
152
  "format",
@@ -124,9 +159,13 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
124
159
  "evidence",
125
160
  "mode",
126
161
  "baseline",
162
+ "binding",
127
163
  ],
128
164
  architecture: ["mode", "baseline", "write-baseline"],
129
165
  evidence: [
166
+ "format",
167
+ "check",
168
+ "evidence",
130
169
  "from",
131
170
  "out",
132
171
  "product",
@@ -136,6 +175,7 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
136
175
  "finished-at",
137
176
  "uri-base",
138
177
  "tool-version",
178
+ "binding",
139
179
  ],
140
180
  impact: LEVEL_OPTIONS,
141
181
  context: ["include-restricted", ...LEVEL_OPTIONS],
@@ -172,9 +212,9 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
172
212
  };
173
213
 
174
214
  /** Positional arguments after the command: a fixed subcommand, then how many more. */
175
- const POSITIONALS: Record<string, { sub?: string; count: number; what?: string }> = {
176
- architecture: { sub: "check", count: 0 },
177
- 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" },
178
218
  impact: { count: 1, what: "an artifact ID" },
179
219
  context: { count: 1, what: "an artifact ID or a file path" },
180
220
  };
@@ -189,6 +229,10 @@ export async function main(argv: string[], io: Io): Promise<number> {
189
229
  }
190
230
  const { values, positionals, tokens } = parsed;
191
231
  const [command, ...rest] = positionals;
232
+ if (values.version && !values.help) {
233
+ io.stdout(`${TOOL.name} ${TOOL.version}\n`);
234
+ return 0;
235
+ }
192
236
  if (values.help || command === undefined) {
193
237
  io.stdout(`${USAGE}\n`);
194
238
  return values.help ? 0 : 2;
@@ -211,14 +255,48 @@ export async function main(argv: string[], io: Io): Promise<number> {
211
255
 
212
256
  const shape = POSITIONALS[command] ?? { count: 0 };
213
257
  let args = rest;
214
- if (shape.sub !== undefined) {
215
- 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];
216
271
  args = args.slice(1);
217
272
  }
218
- if (args.length < shape.count) return usage(`${shape.what} is required`);
219
- if (args.length > shape.count) {
220
- const hint = shape.count === 0 ? " (use --root <dir> to name the repository)" : "";
221
- 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
+ }
222
300
  }
223
301
 
224
302
  const carrier = values.carrier ?? "markset";
@@ -235,6 +313,15 @@ export async function main(argv: string[], io: Io): Promise<number> {
235
313
  if (command !== "mcp" && values.mode !== undefined && values.mode !== "migration" && values.mode !== "strict") {
236
314
  return usage(`--mode ${values.mode}: the modes are migration and strict`);
237
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
+ }
238
325
  if (command === "init") {
239
326
  if (values.agents && values.example)
240
327
  return usage("--agents writes only the guide; it does not combine with --example");
@@ -246,8 +333,8 @@ export async function main(argv: string[], io: Io): Promise<number> {
246
333
 
247
334
  // Evidence scope: --product with --release, or graph's --release <product>:<label>.
248
335
  let scope: RunScope | null = null;
249
- if (command !== "evidence" && command !== "publish" && command !== "mcp") {
250
- if (command === "graph") {
336
+ if ((command !== "evidence" || sub === "stale") && command !== "publish" && command !== "mcp") {
337
+ if (command === "graph" || command === "evidence") {
251
338
  const release = values.release === undefined ? null : parseRelease(values.release);
252
339
  scope = release === null ? null : { product: release.product, release: release.label };
253
340
  } else if ((values.product === undefined) !== (values.release === undefined)) {
@@ -257,6 +344,9 @@ export async function main(argv: string[], io: Io): Promise<number> {
257
344
  }
258
345
  }
259
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
+ }
260
350
  const sessionOptions = (level: Level): SessionOptions => ({
261
351
  root: values.root,
262
352
  carrier: carrier as CarrierName,
@@ -265,6 +355,7 @@ export async function main(argv: string[], io: Io): Promise<number> {
265
355
  baseline: values.baseline,
266
356
  evidence: values.evidence,
267
357
  scope,
358
+ binding: values.binding as EvidenceBinding | undefined,
268
359
  });
269
360
 
270
361
  switch (command) {
@@ -294,6 +385,21 @@ export async function main(argv: string[], io: Io): Promise<number> {
294
385
  return architectureCommand(session, { writeBaseline: values["write-baseline"] }, values.json, io);
295
386
  }
296
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
+ }
297
403
  const options = {
298
404
  from: values.from,
299
405
  report: args[0],
@@ -305,6 +411,7 @@ export async function main(argv: string[], io: Io): Promise<number> {
305
411
  finishedAt: values["finished-at"],
306
412
  uriBase: values["uri-base"],
307
413
  toolVersion: values["tool-version"],
414
+ binding: values.binding as EvidenceBinding | undefined,
308
415
  };
309
416
  const problem = evidenceUsageProblem(options);
310
417
  if (problem !== null) return usage(problem);
@@ -407,6 +514,7 @@ function parse(argv: string[]) {
407
514
  carrier: { type: "string" },
408
515
  level: { type: "string" },
409
516
  help: { type: "boolean", short: "h", default: false },
517
+ version: { type: "boolean", default: false },
410
518
  repository: { type: "string" },
411
519
  example: { type: "boolean", default: false },
412
520
  agents: { type: "boolean", default: false },
@@ -422,7 +530,9 @@ function parse(argv: string[]) {
422
530
  product: { type: "string" },
423
531
  mode: { type: "string" },
424
532
  baseline: { type: "string" },
533
+ binding: { type: "string" },
425
534
  "write-baseline": { type: "string" },
535
+ check: { type: "boolean", default: false },
426
536
  from: { type: "string" },
427
537
  environment: { type: "string" },
428
538
  "started-at": { type: "string" },
@@ -441,6 +551,8 @@ function parse(argv: string[]) {
441
551
  "published-at": { type: "string" },
442
552
  port: { type: "string" },
443
553
  host: { type: "string" },
554
+ to: { type: "string" },
555
+ "allow-dirty": { type: "boolean", default: false },
444
556
  },
445
557
  });
446
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;