@descent-vtt/spec-harness 0.0.0 → 0.1.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 (161) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/.claude-plugin/plugin.json +17 -0
  3. package/.mcp.json +12 -0
  4. package/CHANGELOG.md +46 -0
  5. package/README.md +127 -9
  6. package/bin/spec-harness.js +19 -0
  7. package/dist/audit.d.ts +69 -0
  8. package/dist/audit.js +93 -0
  9. package/dist/audit.js.map +1 -0
  10. package/dist/branch.d.ts +26 -0
  11. package/dist/branch.js +100 -0
  12. package/dist/branch.js.map +1 -0
  13. package/dist/briefs.d.ts +49 -0
  14. package/dist/briefs.js +111 -0
  15. package/dist/briefs.js.map +1 -0
  16. package/dist/cli.d.ts +14 -0
  17. package/dist/cli.js +245 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/commands.d.ts +18 -0
  20. package/dist/commands.js +224 -0
  21. package/dist/commands.js.map +1 -0
  22. package/dist/config.d.ts +57 -0
  23. package/dist/config.js +167 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/context.d.ts +56 -0
  26. package/dist/context.js +125 -0
  27. package/dist/context.js.map +1 -0
  28. package/dist/document.d.ts +33 -0
  29. package/dist/document.js +9 -0
  30. package/dist/document.js.map +1 -0
  31. package/dist/fs.d.ts +29 -0
  32. package/dist/fs.js +85 -0
  33. package/dist/fs.js.map +1 -0
  34. package/dist/git.d.ts +57 -0
  35. package/dist/git.js +129 -0
  36. package/dist/git.js.map +1 -0
  37. package/dist/guard.d.ts +43 -0
  38. package/dist/guard.js +120 -0
  39. package/dist/guard.js.map +1 -0
  40. package/dist/hooks.d.ts +50 -0
  41. package/dist/hooks.js +109 -0
  42. package/dist/hooks.js.map +1 -0
  43. package/dist/index.d.ts +31 -0
  44. package/dist/index.js +20 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/junit.d.ts +30 -0
  47. package/dist/junit.js +166 -0
  48. package/dist/junit.js.map +1 -0
  49. package/dist/manifests.d.ts +52 -0
  50. package/dist/manifests.js +346 -0
  51. package/dist/manifests.js.map +1 -0
  52. package/dist/plugin.d.ts +45 -0
  53. package/dist/plugin.js +79 -0
  54. package/dist/plugin.js.map +1 -0
  55. package/dist/premises.d.ts +16 -0
  56. package/dist/premises.js +71 -0
  57. package/dist/premises.js.map +1 -0
  58. package/dist/probe.d.ts +108 -0
  59. package/dist/probe.js +203 -0
  60. package/dist/probe.js.map +1 -0
  61. package/dist/reader.d.ts +8 -0
  62. package/dist/reader.js +105 -0
  63. package/dist/reader.js.map +1 -0
  64. package/dist/round.d.ts +68 -0
  65. package/dist/round.js +339 -0
  66. package/dist/round.js.map +1 -0
  67. package/dist/rulings.d.ts +92 -0
  68. package/dist/rulings.js +160 -0
  69. package/dist/rulings.js.map +1 -0
  70. package/dist/sandbox.d.ts +28 -0
  71. package/dist/sandbox.js +110 -0
  72. package/dist/sandbox.js.map +1 -0
  73. package/dist/server.d.ts +13 -0
  74. package/dist/server.js +212 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/setup.d.ts +32 -0
  77. package/dist/setup.js +218 -0
  78. package/dist/setup.js.map +1 -0
  79. package/dist/siblings.d.ts +66 -0
  80. package/dist/siblings.js +134 -0
  81. package/dist/siblings.js.map +1 -0
  82. package/dist/types.d.ts +38 -0
  83. package/dist/types.js +3 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/vendor/spec-core/jsonrpc/index.d.ts +2 -0
  86. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  87. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  88. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +157 -0
  89. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  90. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  91. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +103 -0
  92. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  93. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  94. package/dist/vendor/spec-core/markdown/headings.d.ts +45 -0
  95. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  96. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  97. package/dist/vendor/spec-core/markdown/index.d.ts +5 -0
  98. package/dist/vendor/spec-core/markdown/index.js +4 -0
  99. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  100. package/dist/vendor/spec-core/markdown/layout.d.ts +39 -0
  101. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  102. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  103. package/dist/vendor/spec-core/markdown/links.d.ts +22 -0
  104. package/dist/vendor/spec-core/markdown/links.js +364 -0
  105. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  106. package/dist/vendor/spec-core/markdown/lists.d.ts +22 -0
  107. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  108. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  109. package/dist/vendor/spec-core/markdown/scan.d.ts +36 -0
  110. package/dist/vendor/spec-core/markdown/scan.js +439 -0
  111. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  112. package/dist/vendor/spec-core/markdown/syntax.d.ts +75 -0
  113. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  114. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  115. package/dist/vendor/spec-core/markdown/tables.d.ts +18 -0
  116. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  117. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  118. package/dist/vendor/spec-core/markdown/types.d.ts +287 -0
  119. package/dist/vendor/spec-core/markdown/types.js +9 -0
  120. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  121. package/dist/vendor/spec-core/path/index.d.ts +1 -0
  122. package/dist/vendor/spec-core/path/index.js +2 -0
  123. package/dist/vendor/spec-core/path/index.js.map +1 -0
  124. package/dist/vendor/spec-core/path/posix.d.ts +83 -0
  125. package/dist/vendor/spec-core/path/posix.js +150 -0
  126. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  127. package/dist/vendor/spec-core/pattern/automaton.d.ts +129 -0
  128. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  129. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  130. package/dist/vendor/spec-core/pattern/charset.d.ts +58 -0
  131. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  132. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  133. package/dist/vendor/spec-core/pattern/glob.d.ts +121 -0
  134. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  135. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  136. package/dist/vendor/spec-core/pattern/index.d.ts +6 -0
  137. package/dist/vendor/spec-core/pattern/index.js +4 -0
  138. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  139. package/dist/vendor/spec-core/pattern/regex.d.ts +67 -0
  140. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  141. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  142. package/dist/vendor/spec-core/text/index.d.ts +2 -0
  143. package/dist/vendor/spec-core/text/index.js +2 -0
  144. package/dist/vendor/spec-core/text/index.js.map +1 -0
  145. package/dist/vendor/spec-core/text/lines.d.ts +72 -0
  146. package/dist/vendor/spec-core/text/lines.js +156 -0
  147. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  148. package/dist/versions.d.ts +42 -0
  149. package/dist/versions.js +57 -0
  150. package/dist/versions.js.map +1 -0
  151. package/dist/workspace.d.ts +77 -0
  152. package/dist/workspace.js +138 -0
  153. package/dist/workspace.js.map +1 -0
  154. package/hooks/hooks.json +28 -0
  155. package/package.json +86 -6
  156. package/skills/close-round/SKILL.md +38 -0
  157. package/skills/draft-brief/SKILL.md +64 -0
  158. package/skills/run-round/SKILL.md +46 -0
  159. package/skills/split-goal/SKILL.md +42 -0
  160. package/src/vendor/spec-core/LICENSE +21 -0
  161. package/src/vendor/spec-core/README.md +8 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "spec-tools",
3
+ "owner": {
4
+ "name": "DescentVTT",
5
+ "url": "https://github.com/DescentVTT"
6
+ },
7
+ "description": "The spec-* tools for agents: briefs, guards, audits and rulings.",
8
+ "plugins": [
9
+ {
10
+ "name": "spec-harness",
11
+ "source": "./",
12
+ "description": "Rounds of agent work under a brief.",
13
+ "version": "0.1.0"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "spec-harness",
3
+ "version": "0.1.0",
4
+ "description": "Rounds of agent work under a brief: context, a guard on every write, escalations a person signs, an audit at the end. Needs @descent-vtt/spec-harness and @descent-vtt/spec-brief 0.2.0 or later installed in the project.",
5
+ "author": {
6
+ "name": "DescentVTT",
7
+ "url": "https://github.com/DescentVTT"
8
+ },
9
+ "homepage": "https://github.com/DescentVTT/spec-harness",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "brief",
13
+ "guardrails",
14
+ "spec",
15
+ "agents"
16
+ ]
17
+ }
package/.mcp.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "spec-harness": {
4
+ "command": "npx",
5
+ "args": [
6
+ "--no-install",
7
+ "spec-harness",
8
+ "mcp"
9
+ ]
10
+ }
11
+ }
12
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first release of spec-harness, the agent-facing member of the spec-*
6
+ tools: what an agent needs to start a round of work under a brief a person
7
+ approved, a guard on every write, escalations a person rules on and signs, an
8
+ audit when the round ends, and probes that prove a defect before it is filed.
9
+ It calls no model and has no runtime dependencies. It needs spec-brief 0.2.0
10
+ or later, and uses spec-guard 0.12.0 and spec-graph 0.9.0 or later when they
11
+ are installed; an older one is reported with its minimum and never run.
12
+
13
+ This version was staged on npm by CI from its tag, with provenance, and
14
+ released by a maintainer with a second factor. 0.0.0 on npm is a placeholder
15
+ without code, published by hand to claim the name, and is deprecated
16
+ ([ADR-0011](docs/adr/0011-releases-are-staged-by-ci.md)).
17
+
18
+ ### Added
19
+
20
+ - `context`: the brief in full, its scope as the guard reads it, signed
21
+ rulings, dependencies, the rules spec-guard holds the scope to, and the
22
+ documents the brief cites, within a budget.
23
+ - `guard` and `hook claude` / `hook git`: protected files refused unless a
24
+ signed ruling allows them, writes outside the scope warned, asked about or
25
+ refused; paths compared as the filesystem spells them.
26
+ - `escalate`, `rule`, `rulings`: requests kept outside the work tree; a ruling
27
+ is a row in the brief that counts when the commit that last changed it is
28
+ signed by a key the base branch's allowed signers list.
29
+ - `audit`: what spec-brief's archive would say, the brief's goals and
30
+ premises through spec-guard, unverified rulings, and dependencies added.
31
+ - `probe`: a brief's declared probes run in temporary worktrees, red at the
32
+ base for the declared reason and green at the head.
33
+ - `premises`: every live brief's premises run through spec-guard, and one
34
+ that no longer holds reported as `stale-premise`, exit 1, for CI.
35
+ - `init`: the family configured to agree; a plan until `--write`.
36
+ - `doctor`: which siblings are installed, at which versions, and which brief
37
+ is named. The minimums are `peerDependencies` too: spec-brief required,
38
+ spec-graph and spec-guard optional.
39
+ - `mcp`: start_round, check_path, request_escalation, audit_round and
40
+ list_rounds, with the workflow prompts, over both MCP protocol eras.
41
+ - Skills for Claude Code and other Agent Skills readers: draft-brief,
42
+ split-goal, run-round, close-round; the repository is also a Claude Code
43
+ plugin marketplace.
44
+ - spec-core at cbe2223, vendored with its licence, which the package ships.
45
+ A scope that writes `**` inside a name (`docs/**.md`) is refused with the
46
+ two ways to say what was meant, as spec-brief and spec-guard refuse it.
package/README.md CHANGED
@@ -1,15 +1,133 @@
1
- # @descent-vtt/spec-harness 0.0.0
1
+ # spec-harness
2
2
 
3
- A placeholder. It contains no code and does nothing.
3
+ **Rounds of agent work under a brief a person approved: what the agent needs to start, a guard on every write, an escalation a person signs when the brief is not enough, and an audit when it ends.**
4
4
 
5
- npm attaches a trusted publisher only to a package that already exists, so
6
- this version was published by hand to hold the name. Every version with code
7
- in it is built and published by CI from a tag of
8
- [DescentVTT/spec-harness](https://github.com/DescentVTT/spec-harness), with
9
- provenance.
10
-
11
- Install 0.1.0 or later:
5
+ spec-harness is the agent-facing member of the [spec-\* tools](https://github.com/DescentVTT/spec-core#the-family). It calls no model and writes no content - the agent is the model. It gives the agent the contract and the facts, and answers, the same way every time, whether the agent stayed inside the lines. No runtime dependencies, no account, no network.
12
6
 
13
7
  ```bash
14
8
  npm install --save-dev @descent-vtt/spec-harness @descent-vtt/spec-brief
9
+ npx spec-harness init # the plan: what it would configure, and why
10
+ npx spec-harness init --write # apply it
11
+ ```
12
+
13
+ spec-brief 0.2.0 or later is required: the brief is the contract, spec-brief is its reader, and 0.2.0 is the first whose archive asks this package's plugin about signed rulings. spec-guard 0.12.0 and spec-graph 0.9.0 or later are used when they are installed, and their absence is reported, never assumed clean. A sibling installed below its minimum is never run: `doctor` and every command that needed it name the minimum ([ADR-0011](docs/adr/0011-releases-are-staged-by-ci.md)).
14
+
15
+ ## A round
16
+
17
+ ```text
18
+ person approves brief 012 ─▶ agent on branch brief/012-rotate-tokens
19
+ │
20
+ spec-harness context ◀─┘ the brief, its scope, signed rulings,
21
+ │ the rules in force, the documents it cites
22
+ ▼
23
+ agent edits ── hook: protected? refused, with the next step
24
+ │ outside the scope? a warning after the write
25
+ │
26
+ ├── needs a protected file ─▶ escalate ─▶ person rules, signs
27
+ ▼
28
+ spec-harness audit ─▶ spec-brief archive --dry-run ─▶ person archives
29
+ ```
30
+
31
+ ### `context [brief]`
32
+
33
+ The brief in full - it is the contract - then what it implies: the files the round may write and must not change as the guard reads them, the rulings already signed, the briefs it depends on and whether they are done, the architecture rules spec-guard holds that code to, and the documents the brief links to, whole, until the packet reaches its budget (`context.budget`, 60,000 characters). Documents left out are named by path, not dropped.
34
+
35
+ ### `guard <path...>` and the hooks
36
+
37
+ May the round write this? A path the brief protects is **refused** unless a signed ruling allows it; a path in `affectedFiles` is allowed; anything else is **outside the scope** - a warning by default, or a question to the person, or a refusal (`outOfScope`: `warn`, `ask`, `deny`). Paths are compared as the filesystem spells them, links resolved and case corrected, so `SRC/db/schema.ts` does not walk past a protection on `src/db/schema.ts` on Windows.
38
+
39
+ `spec-harness hook claude` answers Claude Code's PreToolUse and PostToolUse hooks: a refusal before the write, with the reason and the next step; a warning after a write outside the scope. It never answers `allow`, which would skip the person's own permission prompt. `spec-harness hook git` is a pre-commit hook for any agent or none. A guard is a guardrail - an agent that writes through a shell passes it - so the audit and spec-brief's archive are the gates ([ADR-0005](docs/adr/0005-a-guard-is-a-guardrail.md)).
40
+
41
+ **Which brief?** `--brief <id>`, then `SPEC_BRIEF`, then the branch name (`brief/{id}`, `brief-{id}`, `*/brief/{id}`, `*/brief-{id}`). Never guessed ([ADR-0004](docs/adr/0004-the-active-brief-is-named-not-guessed.md)).
42
+
43
+ ### `escalate`, `rule`, `rulings`
44
+
45
+ ```bash
46
+ # the agent, when the round cannot be done without a protected file
47
+ npx spec-harness escalate --path src/db/schema.ts \
48
+ --reason "Rotation needs a rotated_at column." \
49
+ --option "Allow: one additive column" --option "Refuse: rotation keeps no timestamp" \
50
+ --recommend "Allow; the column is additive."
51
+
52
+ # the person
53
+ npx spec-harness escalate --show E-012-1
54
+ npx spec-harness rule E-012-1 --allow --note "Add rotated_at only."
55
+ git commit -S -m "ruling R-012-1: allow" -- briefs/012_rotate-tokens.md
56
+ ```
57
+
58
+ A ruling is a row in the brief's `## Rulings` table. It **counts** when the commit that last changed the row is signed by a key the **base branch's** `.github/allowed_signers` lists. An agent can write a row and compute any hash; it cannot produce the person's signature, and an edit to the row moves it to a commit that must be signed again ([ADR-0006](docs/adr/0006-a-ruling-is-a-signed-commit.md)). Use a FIDO2 key (`ed25519-sk`) where the agent runs as you: its signature needs a touch no process can supply.
59
+
60
+ ### `audit [brief]`
61
+
62
+ One report: what spec-brief's archive would refuse or warn about, run with `--dry-run`; the brief's own assertions through spec-guard - a goal that fails, or a premise (under a heading such as *The Defect, Measured*) that still holds after the round meant to change it; rulings whose signatures do not verify; and every dependency the round added to `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `requirements*.txt`, NuGet project files or a `Gemfile`. A part that could not be measured is a finding, never a silence.
63
+
64
+ ### `probe [brief]`
65
+
66
+ A defect is measured before it is filed. The brief carries its probe:
67
+
68
+ ````markdown
69
+ ```probe
70
+ id: old-token-still-accepted
71
+ setup: npm ci
72
+ run: npx vitest run tests/probes/rotate.test.ts
73
+ signature: expected 401, got 200
15
74
  ```
75
+
76
+ ```probe-file tests/probes/rotate.test.ts
77
+ ...
78
+ ```
79
+ ````
80
+
81
+ `probe --at base` runs it in a temporary worktree at the base commit: every run must fail **for the declared reason** - the `signature` in the output, or a JUnit `test` failing - or the verdict is `vacuous` (no defect), `flaky` or `invalid`. `probe --at head` must be green: `fixed`. The evidence table it prints names the hash of the probe it measured with ([ADR-0007](docs/adr/0007-probes-declare-their-failure.md)).
82
+
83
+ ### `premises`
84
+
85
+ Is every live brief still about something true? It runs spec-guard over the live briefs' assertions and reports each premise - an assertion under a section in `assertions.premises` - that no longer holds as `stale-premise`: the defect was fixed another way, or the code the brief describes is gone, and an agent sent to fix it would fix nothing. Goals are left to `audit`, since a goal fails until its round is done. Run it in CI: exit 1 when a premise is stale, exit 2 when spec-guard is not there to ask.
86
+
87
+ ### `mcp`
88
+
89
+ `start_round`, `check_path`, `request_escalation`, `audit_round` and `list_rounds` over MCP on stdio, with the four workflow prompts. Both protocol eras are served. The architecture rules themselves stay with spec-guard's server.
90
+
91
+ ### `init`
92
+
93
+ Configures the family to agree: spec-brief's directories, spec-graph reading the archive as history (so a brief depending on an archived one is not a stale premise), `.spec-harness.json` with the base branch, the Claude Code hooks, the MCP server, and with `--git-hook` a pre-commit hook. It prints the plan, merges into files that exist, and changes nothing without `--write`.
94
+
95
+ ### `doctor`
96
+
97
+ Which sibling tools are installed, at which versions, and how each is run, the repository root, the branch, and the brief the flag, `SPEC_BRIEF` or the branch names. A sibling older than this release needs is `outdated`, with the minimum and the command that installs a newer one, and exit 1. A command named under `tools` is run as named, and its version is not checked. The first thing to run when a hook refuses something unexpectedly.
98
+
99
+ ## As a Claude Code plugin
100
+
101
+ The repository is a plugin and a one-plugin marketplace: the four skills (`draft-brief`, `split-goal`, `run-round`, `close-round`), the hooks and the MCP server. It calls the `spec-harness` installed in your project.
102
+
103
+ ```text
104
+ /plugin marketplace add DescentVTT/spec-harness
105
+ /plugin install spec-harness@spec-tools
106
+ ```
107
+
108
+ ## Configuration
109
+
110
+ `.spec-harness.json` at the repository root. Every key is optional; an unknown key stops the run with exit 2.
111
+
112
+ | Key | Default | What it says |
113
+ | --- | --- | --- |
114
+ | `branches` | `brief/{id}`, `brief-{id}`, `*/brief/{id}`, `*/brief-{id}` | Branch names that carry the active brief's id. |
115
+ | `outOfScope` | `"warn"` | A write outside `affectedFiles`: `warn`, `ask` or `deny`. |
116
+ | `base` | the remote's default branch | What rounds are measured from, and where allowed signers are read. |
117
+ | `rulings.section` | `"Rulings"` | The brief section holding the rulings table. |
118
+ | `rulings.allowedSigners` | `".github/allowed_signers"` | The allowed-signers file, read from the base branch. |
119
+ | `dependencies.manifests` | the list above | Manifest names whose added dependencies the audit reports. |
120
+ | `context.budget` | `60000` | Characters a context packet may hold; cited documents fill what the rest leaves, and those that do not fit are named by path. |
121
+ | `assertions.premises` | `The Defect, Measured`, `Premises`, `Preconditions` | Sections whose assertions state what was true before the round. |
122
+ | `probes.runs`, `probes.timeout` | `2`, `600` | Runs per probe, and seconds per run. |
123
+ | `tools` | found in `node_modules` | A sibling's command by name: `{ "spec-brief": ["node", "path/to/spec-brief.js"] }`. |
124
+
125
+ **Exit codes:** `0` clean, `1` refused, found something, or waiting on a person, `2` the answer cannot be trusted.
126
+
127
+ ## Design
128
+
129
+ The decisions and what they cost are in [`docs/adr/`](docs/adr/README.md); what the whole family shares is [spec-core's ADR-0005](https://github.com/DescentVTT/spec-core/blob/main/docs/adr/0005-the-family-contract.md).
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin launcher. All logic lives in dist/cli.js so the published binary stays a
4
+ * short shim that is trivially auditable.
5
+ */
6
+ const entry = new URL('../dist/cli.js', import.meta.url);
7
+
8
+ let cli;
9
+ try {
10
+ cli = await import(entry.href);
11
+ } catch (error) {
12
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' && String(error.message).includes('dist')) {
13
+ process.stderr.write('spec-harness: build output is missing; run "npm run build" first, or install the published package.\n');
14
+ process.exit(2);
15
+ }
16
+ throw error;
17
+ }
18
+
19
+ process.exitCode = await cli.main();
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Did the round stay inside the lines?
3
+ *
4
+ * One report, from three sources and one question of its own:
5
+ *
6
+ * - spec-brief's archive, asked with `--dry-run`: open boxes, protected files
7
+ * changed, files outside the scope, a tree that was not committed. The
8
+ * archive is spec-brief's decision, so its reasons are carried as they are
9
+ * - one defect, one finding - and the harness does not measure the scope a
10
+ * second time.
11
+ * - spec-guard, over the brief's own assertions: the goals must hold now. A
12
+ * premise - an assertion in a section that states what was true before the
13
+ * round, such as "The Defect, Measured" - is expected to stop holding, and
14
+ * one that still holds says the defect may still be there.
15
+ * - The rulings: each one whose signature does not verify is reported, since
16
+ * an unverified ruling allows nothing.
17
+ * - And the one question nobody else asks: which dependencies the round
18
+ * added. A dependency is code no reviewer read.
19
+ *
20
+ * A part that could not be measured is a finding, never a silence.
21
+ */
22
+ import type { DependencyChange } from './manifests.js';
23
+ import type { BriefRow, Finding, Severity } from './types.js';
24
+ export interface AssertionOutcome {
25
+ readonly ok: boolean;
26
+ readonly description: string;
27
+ readonly message: string;
28
+ readonly line: number;
29
+ /** The brief section the directive sits in, or `null`. */
30
+ readonly section: string | null;
31
+ /** Every section it sits in, outermost first; `[section]` when not given. */
32
+ readonly enclosing?: readonly string[] | undefined;
33
+ }
34
+ export interface AuditInput {
35
+ readonly brief: BriefRow;
36
+ /** Why the round's changes could not be measured, when they could not. */
37
+ readonly unmeasured: string | null;
38
+ readonly dependencies: {
39
+ readonly changes: readonly DependencyChange[];
40
+ readonly unread: readonly string[];
41
+ };
42
+ readonly archive: {
43
+ readonly blocking: readonly Finding[];
44
+ readonly warnings: readonly Finding[];
45
+ } | {
46
+ readonly unavailable: string;
47
+ };
48
+ readonly assertions: readonly AssertionOutcome[] | {
49
+ readonly unavailable: string;
50
+ };
51
+ /** Section names whose assertions are premises. Compared without case, emphasis, a leading number or a trailing colon. */
52
+ readonly premiseSections: readonly string[];
53
+ readonly unverifiedRulings: readonly {
54
+ readonly id: string;
55
+ readonly reason: string;
56
+ }[];
57
+ }
58
+ export interface AuditReport {
59
+ readonly findings: readonly Finding[];
60
+ readonly counts: Readonly<Record<Severity, number>>;
61
+ }
62
+ /**
63
+ * Whether an assertion's section is a premise section. Compared as the
64
+ * rulings section is, and as spec-brief compares section names: a heading
65
+ * written `## 3. **The Defect, Measured:**` is still the premise section, and
66
+ * an assertion under it read as a goal would fail the round for retiring it.
67
+ */
68
+ export declare function isPremise(sections: string | null | readonly string[], premises: readonly string[]): boolean;
69
+ export declare function audit(input: AuditInput): AuditReport;
package/dist/audit.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Did the round stay inside the lines?
3
+ *
4
+ * One report, from three sources and one question of its own:
5
+ *
6
+ * - spec-brief's archive, asked with `--dry-run`: open boxes, protected files
7
+ * changed, files outside the scope, a tree that was not committed. The
8
+ * archive is spec-brief's decision, so its reasons are carried as they are
9
+ * - one defect, one finding - and the harness does not measure the scope a
10
+ * second time.
11
+ * - spec-guard, over the brief's own assertions: the goals must hold now. A
12
+ * premise - an assertion in a section that states what was true before the
13
+ * round, such as "The Defect, Measured" - is expected to stop holding, and
14
+ * one that still holds says the defect may still be there.
15
+ * - The rulings: each one whose signature does not verify is reported, since
16
+ * an unverified ruling allows nothing.
17
+ * - And the one question nobody else asks: which dependencies the round
18
+ * added. A dependency is code no reviewer read.
19
+ *
20
+ * A part that could not be measured is a finding, never a silence.
21
+ */
22
+ import { sameSection } from './reader.js';
23
+ function finding(rule, severity, message, hint, file, line) {
24
+ return { rule, severity, message, hint, ...(file === undefined ? {} : { file }), ...(line === undefined ? {} : { line }) };
25
+ }
26
+ /**
27
+ * Whether an assertion's section is a premise section. Compared as the
28
+ * rulings section is, and as spec-brief compares section names: a heading
29
+ * written `## 3. **The Defect, Measured:**` is still the premise section, and
30
+ * an assertion under it read as a goal would fail the round for retiring it.
31
+ */
32
+ export function isPremise(sections, premises) {
33
+ const list = sections === null ? [] : typeof sections === 'string' ? [sections] : sections;
34
+ // A premise section's subsections are still premises: `### Before` under
35
+ // `## The Defect, Measured` states what was true before the round.
36
+ return list.some((section) => premises.some((premise) => sameSection(section, premise)));
37
+ }
38
+ export function audit(input) {
39
+ const { brief } = input;
40
+ const out = [];
41
+ if (input.unmeasured !== null) {
42
+ out.push(finding('unmeasured', 'warning', `the round's changes were not measured: ${input.unmeasured}`, 'pass --base <ref>, set "base" in .spec-harness.json, or audit on the round\'s branch before it merges', brief.file));
43
+ }
44
+ if ('unavailable' in input.archive) {
45
+ out.push(finding('archive-unchecked', 'warning', `what the archive would say is unknown: ${input.archive.unavailable}`, 'install spec-brief, or fix what stopped it', brief.file));
46
+ }
47
+ else {
48
+ for (const reason of [...input.archive.blocking, ...input.archive.warnings]) {
49
+ out.push(finding(`archive/${reason.rule}`, reason.severity, reason.message, reason.hint, reason.file, reason.line));
50
+ }
51
+ }
52
+ if (!Array.isArray(input.assertions)) {
53
+ const unavailable = input.assertions.unavailable;
54
+ out.push(finding('assertions-unchecked', 'warning', `the brief's assertions were not run: ${unavailable}`, 'install spec-guard, or fix what stopped it', brief.file));
55
+ }
56
+ else {
57
+ for (const outcome of input.assertions) {
58
+ const premise = isPremise(outcome.enclosing ?? outcome.section, input.premiseSections);
59
+ if (premise && outcome.ok) {
60
+ out.push(finding('premise-holds', 'warning', `a premise still holds after the round: ${outcome.description}`, 'the round set out to change what this premise states; check that it did, or move the assertion out of the premises', brief.file, outcome.line));
61
+ }
62
+ else if (premise) {
63
+ out.push(finding('premise-retired', 'note', `a premise no longer holds, as the round intended: ${outcome.description}`, 'nothing to do', brief.file, outcome.line));
64
+ }
65
+ else if (!outcome.ok) {
66
+ out.push(finding('goal-failed', 'error', `${outcome.description}: ${outcome.message}`, 'the round is not done until this holds', brief.file, outcome.line));
67
+ }
68
+ }
69
+ }
70
+ for (const ruling of input.unverifiedRulings) {
71
+ out.push(finding('ruling-unverified', 'warning', `ruling ${ruling.id} allows nothing: ${ruling.reason}`, 'a ruling counts when the commit that last changed its row is signed by a key in the base branch\'s allowed signers', brief.file));
72
+ }
73
+ for (const file of input.dependencies.unread) {
74
+ out.push(finding('manifest-unread', 'warning', `${file} changed and could not be read for dependencies`, 'check its dependencies by hand; the audit cannot', file));
75
+ }
76
+ for (const change of input.dependencies.changes) {
77
+ const where = `${change.file} (${change.section})`;
78
+ if (change.before === null) {
79
+ out.push(finding('new-dependency', 'warning', `the round added ${change.ecosystem} dependency "${change.name}"${change.after === '' ? '' : ` ${change.after}`} in ${where}`, 'say in the brief why it is needed, or remove it; a new dependency is code no reviewer read', change.file));
80
+ }
81
+ else if (change.after === null) {
82
+ out.push(finding('dependency-removed', 'note', `the round removed "${change.name}" from ${where}`, 'nothing to do', change.file));
83
+ }
84
+ else {
85
+ out.push(finding('dependency-changed', 'note', `the round moved "${change.name}" from ${change.before} to ${change.after} in ${where}`, 'nothing to do, if the brief meant it', change.file));
86
+ }
87
+ }
88
+ const counts = { error: 0, warning: 0, note: 0 };
89
+ for (const item of out)
90
+ counts[item.severity] += 1;
91
+ return { findings: out, counts };
92
+ }
93
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Did the round stay inside the lines?\n *\n * One report, from three sources and one question of its own:\n *\n * - spec-brief's archive, asked with `--dry-run`: open boxes, protected files\n * changed, files outside the scope, a tree that was not committed. The\n * archive is spec-brief's decision, so its reasons are carried as they are\n * - one defect, one finding - and the harness does not measure the scope a\n * second time.\n * - spec-guard, over the brief's own assertions: the goals must hold now. A\n * premise - an assertion in a section that states what was true before the\n * round, such as \"The Defect, Measured\" - is expected to stop holding, and\n * one that still holds says the defect may still be there.\n * - The rulings: each one whose signature does not verify is reported, since\n * an unverified ruling allows nothing.\n * - And the one question nobody else asks: which dependencies the round\n * added. A dependency is code no reviewer read.\n *\n * A part that could not be measured is a finding, never a silence.\n */\n\nimport type { DependencyChange } from './manifests.js';\nimport { sameSection } from './reader.js';\nimport type { BriefRow, Finding, Severity } from './types.js';\n\nexport interface AssertionOutcome {\n readonly ok: boolean;\n readonly description: string;\n readonly message: string;\n readonly line: number;\n /** The brief section the directive sits in, or `null`. */\n readonly section: string | null;\n /** Every section it sits in, outermost first; `[section]` when not given. */\n readonly enclosing?: readonly string[] | undefined;\n}\n\nexport interface AuditInput {\n readonly brief: BriefRow;\n /** Why the round's changes could not be measured, when they could not. */\n readonly unmeasured: string | null;\n readonly dependencies: { readonly changes: readonly DependencyChange[]; readonly unread: readonly string[] };\n readonly archive: { readonly blocking: readonly Finding[]; readonly warnings: readonly Finding[] } | { readonly unavailable: string };\n readonly assertions: readonly AssertionOutcome[] | { readonly unavailable: string };\n /** Section names whose assertions are premises. Compared without case, emphasis, a leading number or a trailing colon. */\n readonly premiseSections: readonly string[];\n readonly unverifiedRulings: readonly { readonly id: string; readonly reason: string }[];\n}\n\nexport interface AuditReport {\n readonly findings: readonly Finding[];\n readonly counts: Readonly<Record<Severity, number>>;\n}\n\nfunction finding(rule: string, severity: Severity, message: string, hint: string, file?: string, line?: number): Finding {\n return { rule, severity, message, hint, ...(file === undefined ? {} : { file }), ...(line === undefined ? {} : { line }) };\n}\n\n/**\n * Whether an assertion's section is a premise section. Compared as the\n * rulings section is, and as spec-brief compares section names: a heading\n * written `## 3. **The Defect, Measured:**` is still the premise section, and\n * an assertion under it read as a goal would fail the round for retiring it.\n */\nexport function isPremise(sections: string | null | readonly string[], premises: readonly string[]): boolean {\n const list = sections === null ? [] : typeof sections === 'string' ? [sections] : sections;\n // A premise section's subsections are still premises: `### Before` under\n // `## The Defect, Measured` states what was true before the round.\n return list.some((section) => premises.some((premise) => sameSection(section, premise)));\n}\n\nexport function audit(input: AuditInput): AuditReport {\n const { brief } = input;\n const out: Finding[] = [];\n\n if (input.unmeasured !== null) {\n out.push(\n finding(\n 'unmeasured',\n 'warning',\n `the round's changes were not measured: ${input.unmeasured}`,\n 'pass --base <ref>, set \"base\" in .spec-harness.json, or audit on the round\\'s branch before it merges',\n brief.file,\n ),\n );\n }\n\n if ('unavailable' in input.archive) {\n out.push(finding('archive-unchecked', 'warning', `what the archive would say is unknown: ${input.archive.unavailable}`, 'install spec-brief, or fix what stopped it', brief.file));\n } else {\n for (const reason of [...input.archive.blocking, ...input.archive.warnings]) {\n out.push(finding(`archive/${reason.rule}`, reason.severity, reason.message, reason.hint, reason.file, reason.line));\n }\n }\n\n if (!Array.isArray(input.assertions)) {\n const unavailable = (input.assertions as { unavailable: string }).unavailable;\n out.push(finding('assertions-unchecked', 'warning', `the brief's assertions were not run: ${unavailable}`, 'install spec-guard, or fix what stopped it', brief.file));\n } else {\n for (const outcome of input.assertions) {\n const premise = isPremise(outcome.enclosing ?? outcome.section, input.premiseSections);\n if (premise && outcome.ok) {\n out.push(\n finding(\n 'premise-holds',\n 'warning',\n `a premise still holds after the round: ${outcome.description}`,\n 'the round set out to change what this premise states; check that it did, or move the assertion out of the premises',\n brief.file,\n outcome.line,\n ),\n );\n } else if (premise) {\n out.push(finding('premise-retired', 'note', `a premise no longer holds, as the round intended: ${outcome.description}`, 'nothing to do', brief.file, outcome.line));\n } else if (!outcome.ok) {\n out.push(finding('goal-failed', 'error', `${outcome.description}: ${outcome.message}`, 'the round is not done until this holds', brief.file, outcome.line));\n }\n }\n }\n\n for (const ruling of input.unverifiedRulings) {\n out.push(\n finding(\n 'ruling-unverified',\n 'warning',\n `ruling ${ruling.id} allows nothing: ${ruling.reason}`,\n 'a ruling counts when the commit that last changed its row is signed by a key in the base branch\\'s allowed signers',\n brief.file,\n ),\n );\n }\n\n for (const file of input.dependencies.unread) {\n out.push(finding('manifest-unread', 'warning', `${file} changed and could not be read for dependencies`, 'check its dependencies by hand; the audit cannot', file));\n }\n for (const change of input.dependencies.changes) {\n const where = `${change.file} (${change.section})`;\n if (change.before === null) {\n out.push(\n finding(\n 'new-dependency',\n 'warning',\n `the round added ${change.ecosystem} dependency \"${change.name}\"${change.after === '' ? '' : ` ${change.after}`} in ${where}`,\n 'say in the brief why it is needed, or remove it; a new dependency is code no reviewer read',\n change.file,\n ),\n );\n } else if (change.after === null) {\n out.push(finding('dependency-removed', 'note', `the round removed \"${change.name}\" from ${where}`, 'nothing to do', change.file));\n } else {\n out.push(finding('dependency-changed', 'note', `the round moved \"${change.name}\" from ${change.before} to ${change.after} in ${where}`, 'nothing to do, if the brief meant it', change.file));\n }\n }\n\n const counts: Record<Severity, number> = { error: 0, warning: 0, note: 0 };\n for (const item of out) counts[item.severity] += 1;\n return { findings: out, counts };\n}\n"]}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which brief a branch is working on, read from its name.
3
+ *
4
+ * A round runs on a branch, and naming the branch after the brief is the one
5
+ * convention every repository that runs agents on briefs already has. Reading
6
+ * it from there keeps the harness stateless: no file says which brief is
7
+ * active, so no file can say it wrongly (ADR-0004).
8
+ *
9
+ * A template is a branch name with `{id}` where the id goes and `*` for any
10
+ * run of characters other than `/`: `brief/{id}`, `*\/brief-{id}`. The id is
11
+ * the longest run of letters and digits at that point, and must be followed
12
+ * by the end of the name or a separator - so `brief/012-rotate-tokens` names
13
+ * 012, and `brief/012x` names 012x, never 012.
14
+ */
15
+ /** Why a template cannot be used, or `null`. */
16
+ export declare function templateError(template: string): string | null;
17
+ /** The brief id a branch name carries under one template, or `null`. */
18
+ export declare function idFromBranch(template: string, name: string): string | null;
19
+ /** The first template that yields an id, in order. */
20
+ export declare function briefIdFromBranch(templates: readonly string[], name: string): string | null;
21
+ /**
22
+ * Whether two ids name the same brief: equal as written, or equal as numbers
23
+ * when both are all digits, since a branch says `brief/12` of a brief whose
24
+ * file says `012`.
25
+ */
26
+ export declare function sameId(a: string, b: string): boolean;
package/dist/branch.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Which brief a branch is working on, read from its name.
3
+ *
4
+ * A round runs on a branch, and naming the branch after the brief is the one
5
+ * convention every repository that runs agents on briefs already has. Reading
6
+ * it from there keeps the harness stateless: no file says which brief is
7
+ * active, so no file can say it wrongly (ADR-0004).
8
+ *
9
+ * A template is a branch name with `{id}` where the id goes and `*` for any
10
+ * run of characters other than `/`: `brief/{id}`, `*\/brief-{id}`. The id is
11
+ * the longest run of letters and digits at that point, and must be followed
12
+ * by the end of the name or a separator - so `brief/012-rotate-tokens` names
13
+ * 012, and `brief/012x` names 012x, never 012.
14
+ */
15
+ const SEPARATORS = new Set(['-', '_', '/', '.']);
16
+ function isIdChar(ch) {
17
+ return /^[A-Za-z0-9]$/.test(ch);
18
+ }
19
+ /**
20
+ * The offsets in `name` at which a template prefix can end, by dynamic
21
+ * programming over prefix and name, so a template of many stars costs their
22
+ * product and never an exponent.
23
+ */
24
+ function prefixEnds(prefix, name) {
25
+ // reach[j]: the prefix read so far can end at name offset j.
26
+ let reach = new Array(name.length + 1).fill(false);
27
+ reach[0] = true;
28
+ for (const token of prefix) {
29
+ const next = new Array(name.length + 1).fill(false);
30
+ if (token === '*') {
31
+ let open = false;
32
+ for (let j = 0; j <= name.length; j += 1) {
33
+ if (reach[j])
34
+ open = true;
35
+ if (open)
36
+ next[j] = true;
37
+ if (name.charAt(j) === '/')
38
+ open = false;
39
+ }
40
+ }
41
+ else {
42
+ for (let j = 0; j < name.length; j += 1)
43
+ if (reach[j] && name.charAt(j) === token)
44
+ next[j + 1] = true;
45
+ }
46
+ reach = next;
47
+ }
48
+ const ends = [];
49
+ reach.forEach((ok, j) => {
50
+ if (ok)
51
+ ends.push(j);
52
+ });
53
+ return ends;
54
+ }
55
+ /** Why a template cannot be used, or `null`. */
56
+ export function templateError(template) {
57
+ const count = template.split('{id}').length - 1;
58
+ if (count !== 1)
59
+ return `"${template}" must contain {id} exactly once`;
60
+ if (!template.endsWith('{id}'))
61
+ return `"${template}" must end with {id}; whatever follows the id is the branch's own`;
62
+ return null;
63
+ }
64
+ /** The brief id a branch name carries under one template, or `null`. */
65
+ export function idFromBranch(template, name) {
66
+ if (templateError(template) !== null)
67
+ return null;
68
+ const prefix = template.slice(0, -'{id}'.length);
69
+ for (const start of prefixEnds(prefix, name)) {
70
+ let end = start;
71
+ while (end < name.length && isIdChar(name.charAt(end)))
72
+ end += 1;
73
+ if (end === start)
74
+ continue;
75
+ if (end < name.length && !SEPARATORS.has(name.charAt(end)))
76
+ continue;
77
+ return name.slice(start, end);
78
+ }
79
+ return null;
80
+ }
81
+ /** The first template that yields an id, in order. */
82
+ export function briefIdFromBranch(templates, name) {
83
+ for (const template of templates) {
84
+ const id = idFromBranch(template, name);
85
+ if (id !== null)
86
+ return id;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Whether two ids name the same brief: equal as written, or equal as numbers
92
+ * when both are all digits, since a branch says `brief/12` of a brief whose
93
+ * file says `012`.
94
+ */
95
+ export function sameId(a, b) {
96
+ if (a === b)
97
+ return true;
98
+ return /^\d+$/.test(a) && /^\d+$/.test(b) && BigInt(a) === BigInt(b);
99
+ }
100
+ //# sourceMappingURL=branch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch.js","sourceRoot":"","sources":["../src/branch.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which brief a branch is working on, read from its name.\n *\n * A round runs on a branch, and naming the branch after the brief is the one\n * convention every repository that runs agents on briefs already has. Reading\n * it from there keeps the harness stateless: no file says which brief is\n * active, so no file can say it wrongly (ADR-0004).\n *\n * A template is a branch name with `{id}` where the id goes and `*` for any\n * run of characters other than `/`: `brief/{id}`, `*\\/brief-{id}`. The id is\n * the longest run of letters and digits at that point, and must be followed\n * by the end of the name or a separator - so `brief/012-rotate-tokens` names\n * 012, and `brief/012x` names 012x, never 012.\n */\n\nconst SEPARATORS = new Set(['-', '_', '/', '.']);\n\nfunction isIdChar(ch: string): boolean {\n return /^[A-Za-z0-9]$/.test(ch);\n}\n\n/**\n * The offsets in `name` at which a template prefix can end, by dynamic\n * programming over prefix and name, so a template of many stars costs their\n * product and never an exponent.\n */\nfunction prefixEnds(prefix: string, name: string): number[] {\n // reach[j]: the prefix read so far can end at name offset j.\n let reach: boolean[] = new Array<boolean>(name.length + 1).fill(false);\n reach[0] = true;\n for (const token of prefix) {\n const next = new Array<boolean>(name.length + 1).fill(false);\n if (token === '*') {\n let open = false;\n for (let j = 0; j <= name.length; j += 1) {\n if (reach[j]) open = true;\n if (open) next[j] = true;\n if (name.charAt(j) === '/') open = false;\n }\n } else {\n for (let j = 0; j < name.length; j += 1) if (reach[j] && name.charAt(j) === token) next[j + 1] = true;\n }\n reach = next;\n }\n const ends: number[] = [];\n reach.forEach((ok, j) => {\n if (ok) ends.push(j);\n });\n return ends;\n}\n\n/** Why a template cannot be used, or `null`. */\nexport function templateError(template: string): string | null {\n const count = template.split('{id}').length - 1;\n if (count !== 1) return `\"${template}\" must contain {id} exactly once`;\n if (!template.endsWith('{id}')) return `\"${template}\" must end with {id}; whatever follows the id is the branch's own`;\n return null;\n}\n\n/** The brief id a branch name carries under one template, or `null`. */\nexport function idFromBranch(template: string, name: string): string | null {\n if (templateError(template) !== null) return null;\n const prefix = template.slice(0, -'{id}'.length);\n for (const start of prefixEnds(prefix, name)) {\n let end = start;\n while (end < name.length && isIdChar(name.charAt(end))) end += 1;\n if (end === start) continue;\n if (end < name.length && !SEPARATORS.has(name.charAt(end))) continue;\n return name.slice(start, end);\n }\n return null;\n}\n\n/** The first template that yields an id, in order. */\nexport function briefIdFromBranch(templates: readonly string[], name: string): string | null {\n for (const template of templates) {\n const id = idFromBranch(template, name);\n if (id !== null) return id;\n }\n return null;\n}\n\n/**\n * Whether two ids name the same brief: equal as written, or equal as numbers\n * when both are all digits, since a branch says `brief/12` of a brief whose\n * file says `012`.\n */\nexport function sameId(a: string, b: string): boolean {\n if (a === b) return true;\n return /^\\d+$/.test(a) && /^\\d+$/.test(b) && BigInt(a) === BigInt(b);\n}\n"]}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The briefs, as spec-brief reports them, and which one a round is working on.
3
+ *
4
+ * spec-brief owns the brief format and its configuration (its ADR-0003), so
5
+ * the harness reads briefs the way spec-brief does by asking it:
6
+ * `spec-brief list --archived --format json`. This module reads that
7
+ * document and nothing else; a shape it does not recognise is an error, never
8
+ * a best-effort reading of half the fields (ADR-0002).
9
+ */
10
+ import type { BriefRow } from './types.js';
11
+ /**
12
+ * The spec-brief JSON document versions this harness reads. Version 1 is the
13
+ * one spec-brief 0.1.0 prints; version 2 changed only `matrix`, which the
14
+ * harness never reads, so the `list` and `archive` documents are the same.
15
+ */
16
+ export declare const SUPPORTED_SCHEMA_VERSIONS: readonly number[];
17
+ export declare class SiblingOutputError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /** Reads the document `spec-brief list --format json` prints. */
21
+ export declare function parseBriefList(document: unknown): BriefRow[];
22
+ /** How the active brief was named, for messages. */
23
+ export type ActiveSource = 'flag' | 'environment' | 'branch';
24
+ export type ActiveBrief = {
25
+ readonly kind: 'found';
26
+ readonly brief: BriefRow;
27
+ readonly source: ActiveSource;
28
+ } | {
29
+ readonly kind: 'none';
30
+ readonly reason: string;
31
+ } | {
32
+ readonly kind: 'unknown';
33
+ readonly id: string;
34
+ readonly source: ActiveSource;
35
+ } | {
36
+ readonly kind: 'archived';
37
+ readonly brief: BriefRow;
38
+ readonly source: ActiveSource;
39
+ };
40
+ /**
41
+ * The brief a round is working on: named by a flag, by `SPEC_BRIEF`, or by
42
+ * the branch - never guessed from which brief happens to be the only active
43
+ * one (ADR-0004).
44
+ */
45
+ export declare function findActive(briefs: readonly BriefRow[], named: {
46
+ readonly flag?: string | undefined;
47
+ readonly environment?: string | undefined;
48
+ readonly branch?: string | null | undefined;
49
+ }): ActiveBrief;