@assuredloop/cli 0.0.1 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wenqing Gu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,261 @@
1
- # @assuredloop/cli
1
+ # AssuredLoop
2
2
 
3
- Placeholder. This name is reserved for AssuredLoop's command-line tool, `al`.
3
+ AssuredLoop keeps an AI focused on one change. It holds together:
4
+ - the owner's words;
5
+ - the organized requirement the owner signs off;
6
+ - the current consolidated spec, the baseline: Markdown sections in `specs/`,
7
+ each headed by an ID such as `## [INV-3] Dates`; and the change to it;
8
+ - the decisions;
9
+ - the code and tests that came with it.
4
10
 
5
- The first real release will be published from this project's GitHub Actions
6
- release workflow. Until then, this package contains no code.
11
+ A change to a promise keeps these records in a request, the folder
12
+ `requests/<name>/`.
13
+
14
+ Its command, `al`, gathers and checks these records and says what to do next.
15
+ It gives hints; it blocks nothing but its own `consolidate` and `conclude`.
16
+
17
+ ## Install
18
+
19
+ It needs Node 24 or newer and git 2.31 or newer, and nothing else. Install
20
+ the npm package `@assuredloop/cli`, which puts `al` on your PATH:
21
+
22
+ npm install --global @assuredloop/cli
23
+
24
+ To pin a version, name it: `npm install --global @assuredloop/cli@0.1.0`.
25
+ `al --version` prints the version and the folder it runs from.
26
+
27
+ The package carries the skill. Its folder, `<dir>` below, is
28
+ `$(npm root -g)/@assuredloop/cli`, so the skill is at
29
+ `$(npm root -g)/@assuredloop/cli/skills/assuredloop/SKILL.md`. Then add one
30
+ line to the project's AGENTS.md:
31
+
32
+ ```
33
+ AssuredLoop keeps this project's requests and spec: before work that changes a promise, read <dir>/skills/assuredloop/SKILL.md and run `al context`.
34
+ ```
35
+
36
+ In a shared repo whose contributors install `al` in different places, copy
37
+ `skills/assuredloop/SKILL.md` from `<dir>` at the pinned version into the
38
+ project, for example as `.claude/skills/assuredloop/SKILL.md`. Name that path
39
+ in the AGENTS.md line, say in the commit which version it came from, and copy
40
+ it again when you re-pin.
41
+
42
+ To work on `al` itself, use a clone in place of the npm package:
43
+
44
+ git clone https://github.com/guwenqing/assuredloop <dir>
45
+ npm install --global <dir>
46
+
47
+ The global `al` links to `<dir>`, so it follows whatever `<dir>` checks out.
48
+ To pin a commit, check out its SHA in a clone of its own (`git -C <dir>
49
+ checkout <sha>`) and run `node <dir>/bin/al.js` in place of `al`; it needs no
50
+ install, having no dependencies. From a clone, `al --version` also prints the
51
+ commit.
52
+
53
+ To run `al check --strict` in CI, check the project out with its whole history
54
+ (`fetch-depth: 0`), install the pinned version
55
+ (`npm install --global @assuredloop/cli@0.1.0`), and run `al check --strict`. A
56
+ checkout with no main ref says
57
+ `no main to compare with, so no commits were checked`: it compares none of the
58
+ branch's commits with main, though its other checks, such as duplicate IDs in
59
+ the working tree, still run and can fail. A shallow clone that has
60
+ `origin/main` does not print that line, but reads no history before its
61
+ shallow boundary.
62
+
63
+ ## The commands
64
+
65
+ - `al new <name> --from <file|-> [--tier <1|2|3|S>] [--title <text>]`: start
66
+ a request from the owner's words (`-` reads standard input); `--title` sets
67
+ its heading, which is the name otherwise.
68
+ - `al record <name> origin|signoff|decision|part|section`: write one part of
69
+ a record. To change a section the request already holds, write the decision
70
+ first, then `al record <name> section <ID> --decision Dn` adds a new version.
71
+ - `al context [<name> | <ID> | --diff <range>]`: where the project, a request
72
+ or a section stands; `--audit` for the whole trace, `--at <commit>` for any
73
+ past state.
74
+ - `al spec [--list]`: the design as it stands. `--add-ids <file> --prefix <P>`
75
+ gives IDs to the headings of a baseline file that have none.
76
+ - `al check [--strict] [--all]`: the hints for this branch, five at most;
77
+ `--all` shows every one.
78
+ - `al consolidate <name>`: write a request's pending sections into the baseline.
79
+ - `al conclude <name>`: write the Outcome (what became of each requirement) and
80
+ archive the request.
81
+
82
+ Every command that changes files, except `new`, shows what it would write, and
83
+ writes only with `--yes`. The skill, `skills/assuredloop/SKILL.md`, says how to
84
+ choose a tier (0 to 3, or S for a spike) and when to use which command. The
85
+ promises are in `specs/`.
86
+
87
+ A request that follows an earlier one says so on its Status line, as in
88
+ `Status: open · Follows: <name>`. Every word after `Follows:` is read as a
89
+ request name, so write names only.
90
+
91
+ ## Settings
92
+
93
+ A project may add a file `.assuredloop` at its root, one `key: value` per line,
94
+ each path inside the repo:
95
+ - `root: <folder>`: the baseline's folder, `specs` when there is no such line;
96
+ - `tests: <path>`: a test file or folder beyond the usual names;
97
+ - `results: <path>`: a test result file or folder for `al check` to read;
98
+ - `adrs: <folder>`: a folder of decision records; `docs/adr` is always read too.
99
+
100
+ `tests:`, `results:` and `adrs:` may each be given on several lines.
101
+
102
+ ## Common situations
103
+
104
+ Each example gives the steps, and what `al check` says on the PR. The `Tier:`
105
+ line goes in a commit message and in the PR.
106
+
107
+ ### Adopting when the spec lives elsewhere
108
+
109
+ Say the design is `docs/prd.md`, and the root is to be `docs/prd/`. Use two
110
+ PRs.
111
+
112
+ PR 1 moves the file, fixes each relative link the move breaks (`(adr/)`
113
+ becomes `(../adr/)`), and sets the root. It comes before the project adopts
114
+ AssuredLoop, so it carries no Tier line:
115
+
116
+ mkdir docs/prd && git mv docs/prd.md docs/prd/prd.md
117
+ printf 'root: docs/prd\n' > .assuredloop
118
+
119
+ This is the step before the baseline exists. `al check`, if run on it, exits 0
120
+ (`--strict` too) with two notes:
121
+
122
+ note: <sha> changes docs/prd/prd.md with no request linked (fine for tier 0; say why); git show <sha>
123
+ note: no Tier line in origin/main..HEAD; add "Tier: <n> — <claim>" to a commit message, e.g. with git commit --amend
124
+
125
+ A tier-0 claim here would get
126
+ `not ok: the claim is tier 0, but origin/main..HEAD edits the baseline`: the
127
+ fork had no baseline, so the whole file reads as new.
128
+
129
+ PR 2, after PR 1 merges, adopts: the AGENTS.md line from Install, and IDs. It
130
+ claims `Tier: 0 — adds section IDs; no promise changes`:
131
+
132
+ al spec --add-ids docs/prd/prd.md --prefix PRD --yes
133
+
134
+ `al check --strict` passes, with one note: the commit changes
135
+ `docs/prd/prd.md` with no request linked (fine for tier 0; say why). In one PR,
136
+ the IDs read as edits too: `edits the baseline: [PRD-1], [PRD-2], [PRD-3]`.
137
+
138
+ ### Adding IDs to a spec file in the root
139
+
140
+ al spec --add-ids specs/api.md --prefix API --yes
141
+
142
+ It numbers the headings that have no ID, with the next free numbers. Claim
143
+ tier 0. `al check --strict` passes, with the same note.
144
+
145
+ ### Moving or splitting sections in the root
146
+
147
+ Move each section whole, heading and text unchanged, to another file in the
148
+ root. Claim tier 0. `al check --strict` passes, and its Evidence line says
149
+ `moves [R-3] specs/rules.md → specs/dates.md, text unchanged`. A new file holds
150
+ only the moved sections: a title or an intro line in it is new text, and gets
151
+ a `not ok`.
152
+
153
+ ### A move that forces a text change
154
+
155
+ Moving `[R-2]` from `specs/` into `specs/files/` breaks its link
156
+ `(limits.md)`. The fix, `(../limits.md)`, changes the section's text. So does a
157
+ heading reworded on the way. `al` follows neither. Claimed as tier 0:
158
+
159
+ not ok: the claim is tier 0, but origin/main..HEAD edits the baseline: [R-2]; al context --diff origin/main..HEAD
160
+
161
+ Either keep the change out of the move: move the section where its text still
162
+ works (a file in the same folder keeps its links), and reword in a tier-1 PR of
163
+ its own. Or claim tier 1 for the whole PR, as in the skill's quick path, with
164
+ `Amends: [R-2]` and the owner's sign-off. `al check --strict` then passes.
165
+
166
+ ### Renaming a section's ID
167
+
168
+ Code, tests and requests cite a section by its ID. Rename `[R-2]` to
169
+ `[SIZE-1]`, claim tier 0, and `al check` says
170
+ `not ok: the claim is tier 0, but origin/main..HEAD edits the baseline: [R-2], [SIZE-1]`.
171
+ `al context R-2` then says `[R-2] not in the baseline`, and still lists the
172
+ code and tests that name it.
173
+
174
+ Keep the ID: it names the promise, not the file it sits in. If it must change,
175
+ claim tier 1, with the owner's sign-off and `Amends: [R-2], [SIZE-1]` (name
176
+ both, or a note says the new one is not named). In the same PR, change the
177
+ citations in code and tests; `al context R-2` lists them. `[R-2]` is never used
178
+ again.
179
+
180
+ Do not replace `[R-2]` across the whole repo:
181
+ - Archived requests and the other append-only records ([REC-12]) stay as they
182
+ are, and keep `[R-2]` as history. Editing one gives
183
+ `not ok: <sha> requests/archive/<name>/request.md: changes a request archived on main ([REC-12])`.
184
+ - An open request that holds `[R-2]` then reads it as
185
+ `not found; candidates SIZE-1`, shown as that request's own information. It
186
+ is re-aligned in that request's own work. A replace there edits its signed
187
+ `origin/` snapshot, which gives a `not ok`.
188
+
189
+ ### Starting a new repo from an already-agreed spec
190
+
191
+ Adopt the agreed text as it is, with no request: the owner signs a request's
192
+ organized requirement, never the spec text ([REC-5]). Say in the commit
193
+ message where and when the owner agreed it. Use two steps, as in the first
194
+ example.
195
+
196
+ PR 1, before adoption, imports the agreed text under `specs/` (with
197
+ `.assuredloop` if the root is elsewhere). It has no AGENTS.md line and no Tier
198
+ line: AssuredLoop is not adopted yet, and its rules start at adoption.
199
+ `al check`, if run on it, exits 0 with the two notes PR 1 above gets. In an
200
+ empty repo, PR 1 can instead be the first commit, straight to main.
201
+
202
+ PR 2 adopts: the AGENTS.md line from Install, and the IDs. It claims
203
+ `Tier: 0 — adds section IDs and the AGENTS line; no promise changes`:
204
+
205
+ al spec --add-ids specs/<file>.md --prefix <P> --yes
206
+
207
+ `al check --strict` passes, with one note: the commit changes
208
+ `specs/<file>.md` with no request linked (fine for tier 0; say why). Requests,
209
+ with the owner's sign-off, start at the first promise change after that.
210
+
211
+ `specs/` holds promises, and each change to one needs the owner's sign-off, so
212
+ a build or UI design belongs in `docs/`, or as ADRs in `docs/adr/`, which `al`
213
+ reads, unless a passage is a promise the owner wants guarded.
214
+
215
+ ## What is enforced, and by whom
216
+
217
+ The way of working (tests first, review, the PR flow) belongs to the bots that
218
+ use AssuredLoop, and is not in this table.
219
+
220
+ | The tool checks | The skill asks | The owner decides |
221
+ |---|---|---|
222
+ | `consolidate` and `conclude` refuse while the requirement is not signed, or a section is not ready | write the owner's words and the organized requirement, and ask for the sign-off | what is wanted, by signing the organized requirement |
223
+ | section states: was and now against the baseline, by exact text | choose the tier, and state it in a commit message (where `al check` reads it) and the PR | whether a changed requirement is signed again |
224
+ | append-only records, snapshot hashes, duplicate IDs, broken links, as hints | read the hints, and fix each `not ok` or say why it stands | decisions whose source is the owner, such as dropping or keeping work |
225
+ | `check --strict` exits 1 on a `not ok` this branch owns, when a project opts in | record each later decision, with its source | |
226
+
227
+ Everything else the tool shows is a hint: an observation, not a guarantee.
228
+ Nothing is installed in git hooks.
229
+
230
+ ## Beyond code
231
+
232
+ The same records work on a repo of documents alone. The documents are the
233
+ deliverable, and `specs/` holds promises about them. "Tests" are named checks
234
+ (a deterministic check, a checklist or a review), listed with `tests:` in
235
+ `.assuredloop`; their results are the files they write, listed with
236
+ `results:`, in TAP or JUnit form. A result counts as evidence for a change
237
+ only when a `revision: <sha>` line names the commit being checked:
238
+ `# revision: <sha>` in TAP, `<!-- revision: <sha> -->` in JUnit XML. A result
239
+ with no such line, or at an older commit, is shown but is not evidence.
240
+ `test/non-code.test.js` walks one through.
241
+
242
+ ## This repository
243
+
244
+ - `requests/`: the requests, and `requests/archive/` for the concluded ones;
245
+ - `docs/adr/`: the decision records;
246
+ - `specs/`: the current spec.
247
+
248
+ The previous version is on the `legacy` branch. Its build is tagged
249
+ `assuredloop-base-0.1.0-canonical-build`, and its issues and pull requests stay
250
+ in this repository.
251
+
252
+ ## Releasing
253
+
254
+ 1. Bump `version` in `package.json` in a PR, and merge it.
255
+ 2. Publish a GitHub Release tagged `v<version>` on that commit. Mark it a
256
+ pre-release to publish under npm's `next` tag.
257
+
258
+ `.github/workflows/publish.yml` does the rest: it runs the tests and
259
+ `al check --strict`, checks that the tag matches `package.json`, and publishes
260
+ @assuredloop/cli to npm. It stores no npm token: npm trusts the workflow by
261
+ name, and adds a provenance statement saying which commit built the package.
package/bin/al.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // al: AssuredLoop's command line. Every output names what it read and ends
3
+ // with a Next line and a Not known line ([VW-9]).
4
+ import { parseArgs } from 'node:util';
5
+ import { readFileSync, realpathSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Fail, git, mainRef, remember, resolveCommit, topLevel } from '../src/git.js';
8
+ import { newRequest, recordOrigin, context, line } from '../src/commands.js';
9
+ import { spec } from '../src/spec.js';
10
+ import { consolidate } from '../src/consolidate.js';
11
+ import { conclude } from '../src/conclude.js';
12
+ import { check } from '../src/check.js';
13
+
14
+ const COMMANDS = {
15
+ new: { run: newRequest, options: { from: { type: 'string' }, title: { type: 'string' }, tier: { type: 'string' } } },
16
+ record: {
17
+ run: recordOrigin,
18
+ options: {
19
+ from: { type: 'string' }, url: { type: 'string' }, verify: { type: 'string' },
20
+ fetched: { type: 'string' }, updated: { type: 'string' }, yes: { type: 'boolean' },
21
+ source: { type: 'string' }, words: { type: 'string' },
22
+ 'builds-on': { type: 'string' }, accept: { type: 'boolean' }, decision: { type: 'string' }, text: { type: 'string' },
23
+ },
24
+ },
25
+ context: { run: context, options: { at: { type: 'string' }, diff: { type: 'string' }, for: { type: 'string' }, all: { type: 'boolean' }, audit: { type: 'boolean' } } },
26
+ spec: {
27
+ run: spec,
28
+ options: { at: { type: 'string' }, list: { type: 'boolean' }, 'add-ids': { type: 'string' }, prefix: { type: 'string' }, yes: { type: 'boolean' } },
29
+ },
30
+ consolidate: { run: consolidate, options: { section: { type: 'string' }, revert: { type: 'string' }, yes: { type: 'boolean' } } },
31
+ conclude: { run: conclude, options: { dropped: { type: 'string' }, yes: { type: 'boolean' } } },
32
+ check: { run: check, options: { at: { type: 'string' }, strict: { type: 'boolean' }, all: { type: 'boolean' } } },
33
+ };
34
+ const ONE_LINE = ['words', 'source', 'text', 'url', 'updated', 'title'];
35
+ const USAGE = 'al new | record | context | spec | consolidate | conclude | check | --version';
36
+
37
+ // `al --version`: the package version, and the commit of the install folder
38
+ // when that folder is the top of a git checkout, so a run says what it runs.
39
+ function version() {
40
+ const dir = realpathSync(fileURLToPath(new URL('..', import.meta.url)));
41
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
42
+ // The caller's GIT_DIR and the like point at the caller's repo, not this folder.
43
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
44
+ const top = git(dir, ['rev-parse', '--show-toplevel'], { allowFail: true, env });
45
+ const sha = top && realpathSync(top) === dir ? git(dir, ['rev-parse', 'HEAD'], { allowFail: true, env }) : null;
46
+ const where = sha ? `${sha.slice(0, 7)} · ${dir}` : `not a git checkout: ${dir}`;
47
+ print([`al ${pkg.version ?? 'unreleased'} · ${where}`], 'the install folder', { label: 'no project read' }, 'al context',
48
+ sha ? ['whether the install folder has uncommitted changes'] : ['which commit it was installed from']);
49
+ return 0;
50
+ }
51
+
52
+ function main(argv) {
53
+ const cwd = process.cwd();
54
+ let top = null;
55
+ let read = 'working tree';
56
+ remember();
57
+ try {
58
+ if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) return version();
59
+ top = topLevel(cwd);
60
+ const [name, ...rest] = argv;
61
+ const command = COMMANDS[name];
62
+ if (!command) throw new Fail(name ? `unknown command: ${name}` : 'no command given', USAGE);
63
+ let parsed;
64
+ try {
65
+ parsed = parseArgs({ args: rest, options: command.options, allowPositionals: true, strict: true });
66
+ } catch (e) {
67
+ throw new Fail(e.message, USAGE);
68
+ }
69
+ // A one-line value is written into a record line; a line break in it would forge another line.
70
+ const broken = ONE_LINE.find((k) => /[\r\n]/.test(parsed.values[k] ?? ''));
71
+ if (broken) throw new Fail(`--${broken} holds a line break; it takes one line`, `pass --${broken} on one line`);
72
+ // [VW-9]: under --at, every output names the commit, error exits included.
73
+ // [VW-8]: from here on --at is the commit's full id, however it was spelled.
74
+ if (parsed.values.at !== undefined) {
75
+ read = `--at ${parsed.values.at} (not found)`; // stays if the rev does not resolve
76
+ parsed.values.at = resolveCommit(top, parsed.values.at);
77
+ read = `commit ${parsed.values.at.slice(0, 7)}`;
78
+ }
79
+ const out = command.run({ top, cwd, args: parsed.positionals, opts: parsed.values });
80
+ if (out.tree) read = out.tree.label;
81
+ print(out.body, read, mainRef(top), out.next, out.notKnown);
82
+ return out.exit ?? (out.refused ? 1 : 0); // consolidate and conclude refusing, and check --strict ([HNT-3])
83
+ } catch (e) {
84
+ if (!(e instanceof Fail)) throw e;
85
+ let main = { label: 'no main read (not in a git repository)' };
86
+ if (top) try { main = mainRef(top); } catch { /* keep the fallback */ }
87
+ if (!top) read = 'nothing';
88
+ print([`al: ${e.message}`], read, main, e.next ?? USAGE, ['nothing was written']);
89
+ return 2;
90
+ }
91
+ }
92
+
93
+ // Every line of output is one line: text from files, file names and commit
94
+ // subjects are shown with each control character (C0, DEL, C1) but a tab as
95
+ // \xNN, so a line break in a file name or an escape code in a subject reaches
96
+ // no reader as such. A CR that ends a line (a CRLF file) stays: it hides nothing.
97
+ const shown = (l) => String(l).replace(/[\x00-\x08\x0a-\x0c\x0e-\x1f\x7f-\x9f]|\r(?!$)/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
98
+
99
+ function print(body, read, main, next, notKnown) {
100
+ const unknown = main.unknown ? [...notKnown, main.unknown] : notKnown;
101
+ const text = [...body, line('Read', `${read} · ${main.label}`), line('Next', next), line('Not known', unknown.join('; ') || 'nothing beyond what is shown')]
102
+ .map(shown).join('\n');
103
+ process.stdout.write(text + '\n');
104
+ }
105
+
106
+ try {
107
+ process.exitCode = main(process.argv.slice(2));
108
+ } catch (e) {
109
+ process.stderr.write(`al: internal error: ${e.stack || e}\n`);
110
+ process.exitCode = 2;
111
+ }
package/package.json CHANGED
@@ -1,9 +1,33 @@
1
1
  {
2
2
  "name": "@assuredloop/cli",
3
- "version": "0.0.1",
4
- "description": "Placeholder for AssuredLoop's CLI (al). The first real release will be published from GitHub Actions.",
5
- "license": "UNLICENSED",
6
- "files": ["README.md"],
3
+ "version": "0.1.0",
4
+ "description": "AssuredLoop keeps an AI focused on one change: the al command gathers and checks a change's requirement, spec, decisions, code and tests.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/guwenqing/assuredloop.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/guwenqing/assuredloop/issues"
12
+ },
13
+ "homepage": "https://github.com/guwenqing/assuredloop#readme",
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "bin": {
19
+ "al": "bin/al.js"
20
+ },
21
+ "files": [
22
+ "bin/",
23
+ "src/",
24
+ "skills/",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test"
30
+ },
7
31
  "publishConfig": {
8
32
  "access": "public"
9
33
  }
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: assuredloop
3
+ description: >-
4
+ Writing the records of a change with AssuredLoop's `al`: the owner's words,
5
+ the signed organized requirement, the baseline spec, the change spec and the
6
+ decisions. Use in a project whose AGENTS.md names AssuredLoop, before work
7
+ that changes a promise, and whenever you need to know where a request stands.
8
+ ---
9
+ ## Quick path (tiers 0 and 1)
10
+ 1. Run `al context`. Its hints (observations, not verdicts) and Next line say what to do.
11
+ 2. No promise in the baseline (`specs/`) changes: tier 0. Write no record. A commit
12
+ message (`al check` reads it there) and the PR say `Tier: 0 — <what it restores>`.
13
+ 3. A small change to a promise: tier 1, several small ones in one request for
14
+ one sign-off. `al new <name> --tier 1 --from -`, the owner's words on stdin.
15
+ 4. In its `request.md`, add `## Organized requirement` in plain words the owner
16
+ reads, no jargon: a line each, `R1: <MUST/SHOULD/MAY …>. Amends: [ID]` (or
17
+ `### R1` sub-sections); `Assumed:` for anything you added, `Out:` if needed.
18
+ 5. Show it to the owner. The owner signs, not you: only on their OK, run
19
+ `al record <name> signoff --source <where> --words "<their words>" --yes`.
20
+ 6. Edit the baseline section, then `al conclude <name>`. The commit message and
21
+ the PR say `Tier: 1 — <claim>`. Moves and IDs: al's README, "Common situations".
22
+ 7. Before review, run `al check`. Fix or explain each `not ok`; read each `note`.
23
+ `al new` writes at once. Other commands that write show first; add `--yes` to write.
24
+
25
+ ## Larger work: tier 2 needs a design, 3 lands in parts, S is a spike
26
+ - `al new <name> --tier <2|3|S> --from -`, then step 4's `## Organized requirement`, with
27
+ `### R1` sub-sections, `Out:` and `Assumed:` (one-line `R1:` is tier 1 only). S writes
28
+ `## Organized question` instead, then `findings.md` opening with its Answer, and no spec
29
+ change. The owner signs (step 5). Draft freely; deliver with the sign-off or after it.
30
+ - `change.md` holds the design, then `## Spec changes`: a block per section. End each
31
+ heading with `for R<n>`, so the Outcome links R<n> to it (an `Amends: [ID]` under R<n>
32
+ also does). `al record <name> section <ID>` copies a section into "was" and "now"; edit
33
+ "now". `--builds-on <request>` starts from another request's version. For a new section,
34
+ write `### [PAY-1]@1 add in specs/pay.md` (or `add after [ID]`), then `Now:` and the
35
+ section, indented. Its ID is one more than the highest ever used for that prefix.
36
+ - Each later decision (D1, D2…): `al record <name> decision --source <who> --text <it>`.
37
+ Snapshot every link or chat you rely on: `al record <name> origin --url <u> --from -`,
38
+ with `--updated <time>` when the source shows when it last changed.
39
+ - `al consolidate <name> --section <ID>` writes a part's "now" into the baseline.
40
+ `al conclude <name>` needs each section consolidated; carried (a later block built on
41
+ it is in the baseline; if dropped, keep it, never revert); `Dropped <date> (Dn)`, with
42
+ applied text also reverted by consolidate's `--revert <ID>`; or `Kept <date> (Dn)`, for
43
+ a consolidated or carried section with `for R<n>` or Dn from the owner.
44
+
45
+ ## Reading where things stand
46
+ - `al context <name>`: a request in twelve lines. `al context <ID>`: one section, with
47
+ its links. `al spec`: the design now. `--audit` gives the whole trace; `--at <commit>`,
48
+ any past state. `al context --diff main...HEAD --for review`: what a reviewer reads.
package/src/adrs.js ADDED
@@ -0,0 +1,110 @@
1
+ // Decision records ([LNK-4]): `NNNN-<decision>.md` in docs/adr and each
2
+ // `adrs:` folder of .assuredloop, in the kit's format. The tool shows the
3
+ // ADRs governing a section, lists those a request added or superseded, and
4
+ // flags what breaks the format's rules; it never writes one.
5
+ import { git } from './git.js';
6
+ import { openTree } from './tree.js';
7
+ import { configured, pastLines } from './spec.js';
8
+ import { cites, idsOn, paths, requestOf } from './links.js';
9
+
10
+ // A file name is bounded (255 bytes), so the name is too: unbounded, a long token takes quadratic time.
11
+ const numbers = (s) => [...new Set([...s.matchAll(/\bADR[ \t]+(\d{4})\b|(?<![\w-])(\d{4})-[\w.-]{0,250}\.md\b/g)].map((m) => m[1] ?? m[2]))];
12
+ const numberOf = (p) => p.match(/(?:^|\/)(\d{4})-[^/]*\.md$/)?.[1];
13
+ // `Status:` anywhere in the header, the lines before the first `##` heading,
14
+ // so `Date: … Status: superseded by …` on one line is read. The status ends
15
+ // with its sentence, so a field after it on the line is not part of it.
16
+ const STATUS = /(?<![\w-])Status:[ \t]*(.*?)(?:\.(?=[ \t]|$)|$)/m;
17
+ const statusIn = (text) => { const end = text.search(/^##[ \t]/m); return (end < 0 ? text : text.slice(0, end)).match(STATUS); };
18
+
19
+ // A `past` tree's `adrs:` lines are read leniently ([VW-8]).
20
+ export const adrFolders = (top, tree, at, past = false) => [...new Set(['docs/adr', ...(past ? pastLines : configured)(top, tree, at, 'adrs')])];
21
+
22
+ // Every ADR in `tree`: its path, number, text, status word, and its links.
23
+ export function adrsIn(top, tree, at, past = false) {
24
+ return adrFolders(top, tree, at, past).flatMap((d) => (tree.list(d) ?? []).filter((f) => numberOf(f)).map((f) => {
25
+ const text = tree.read(`${d}/${f}`)?.toString('utf8') ?? '';
26
+ const field = (k) => text.match(new RegExp(`^${k}:[ \\t]*(.*)$`, 'm'))?.[1] ?? '';
27
+ const m = statusIn(text);
28
+ const status = m?.[1] ?? '';
29
+ return { path: `${d}/${f}`, n: numberOf(f), text, status: status.match(/^[a-z]+/i)?.[0].toLowerCase() ?? '', hasStatus: Boolean(m),
30
+ by: /^superseded/i.test(status) ? numbers(status) : [], supersedes: numbers(field('Supersedes')), hasSupersedes: /^Supersedes:/m.test(text),
31
+ request: field('Request').trim(), governs: idsOn(field('Governs')) };
32
+ }));
33
+ }
34
+
35
+ const shown = (a) => `${a.n} ${a.status === 'superseded' ? `superseded by ${a.by.join(', ')}` : a.status || 'no status'}`;
36
+
37
+ // The ADRs that govern section `id` (a Governs: line) or name it, current first.
38
+ export function governing(top, tree, id, at) {
39
+ return adrsIn(top, tree, at).filter((a) => a.governs.includes(id) || cites(a.text, id))
40
+ .sort((x, y) => (y.status === 'accepted') - (x.status === 'accepted') || x.n.localeCompare(y.n))
41
+ .map((a) => `${shown(a)} (${a.governs.includes(id) ? 'governs' : 'names'} [${id}])`);
42
+ }
43
+
44
+ // The ADRs request `name` added (its Request: line, or the commit that added
45
+ // the file maps to it, [LNK-2]), and the ADRs those supersede.
46
+ export function adrsOf(top, tree, at, name, requests) {
47
+ const all = adrsIn(top, tree, at);
48
+ const addedIn = new Map();
49
+ let sha = null;
50
+ // -z keeps each path literal: `>sha`, then its paths, each ended by NUL.
51
+ for (const l of paths(git(top, ['log', '--diff-filter=A', '--name-only', '-z', '--format=>%H', at ?? 'HEAD', '--', ...adrFolders(top, tree, at)], { allowFail: true }))) {
52
+ const p = l.replace(/^\n/, '');
53
+ if (p.startsWith('>')) sha = p.slice(1);
54
+ else if (!addedIn.has(p)) addedIn.set(p, sha);
55
+ }
56
+ // A commit that touched several request folders maps to none of them here.
57
+ const seen = new Map();
58
+ const mapped = (sha) => requestOf(top, sha, requests, seen);
59
+ const added = all.filter((a) => a.request === name || (addedIn.has(a.path) && mapped(addedIn.get(a.path)).how !== 'folder, ambiguous' && mapped(addedIn.get(a.path)).names.includes(name)));
60
+ return { added, superseded: all.filter((o) => added.some((a) => a.supersedes.includes(o.n) || o.by.includes(a.n))) };
61
+ }
62
+
63
+ // The ADR hints of branch `b`: not ok for an accepted ADR edited beyond its
64
+ // status (base against the final state), a broken or one-way supersede link,
65
+ // a reused number; a note for a superseded ADR cited as current.
66
+ export function adrHints(top, b) {
67
+ const out = [];
68
+ const add = (kind, owners, text, command) => out.push({ kind, rank: kind === 'note' ? 14 : 5, owners, text, command });
69
+ const all = adrsIn(top, b.tree, b.at);
70
+ const byN = new Map();
71
+ for (const a of all) byN.set(a.n, [...(byN.get(a.n) ?? []), a]);
72
+ // A number's file names over history. A rename that keeps the number
73
+ // replaces its name; a renumbering leaves the old number used.
74
+ const names = new Map();
75
+ const log = paths(git(top, ['log', '--reverse', '-M', '--diff-filter=AR', '--name-status', '-z', '--format=', b.at ?? 'HEAD', '--', ...adrFolders(top, b.tree, b.at)], { allowFail: true }));
76
+ for (let i = 0; i < log.length; i += log[i][0] === 'R' ? 3 : 2) {
77
+ const [was, p] = log[i][0] === 'R' ? [log[i + 1], log[i + 2]] : [null, log[i + 1]];
78
+ if (was && numberOf(was) === numberOf(p)) names.get(numberOf(was))?.delete(was);
79
+ if (numberOf(p)) names.set(numberOf(p), (names.get(numberOf(p)) ?? new Set()).add(p));
80
+ }
81
+ for (const a of all) names.set(a.n, (names.get(a.n) ?? new Set()).add(a.path));
82
+ for (const [n, ps] of names) if (ps.size > 1) add('not ok', [], `ADR number ${n} is reused: ${[...ps].join(' and ')}; a number is never reused`, 'git mv the newer one to the next free number, then al check');
83
+ for (const a of all) {
84
+ // A field the record already has is changed, never added again.
85
+ const links = [...a.supersedes.map((n) => [n, 'supersedes', (t) => t.by.includes(a.n),
86
+ (t) => (t.hasStatus ? `set its Status: to "superseded by ADR ${a.n}" in` : `add "Status: superseded by ADR ${a.n}" to`)]),
87
+ ...a.by.map((n) => [n, 'is superseded by', (t) => t.supersedes.includes(a.n),
88
+ (t) => (t.hasSupersedes ? `add ADR ${a.n} to the Supersedes: line of` : `add "Supersedes: ADR ${a.n}" to`)])];
89
+ for (const [n, says, back, fix] of links) {
90
+ const t = byN.get(n);
91
+ if (!t) add('not ok', [], `a broken supersede link: ${a.path} ${says} ADR ${n}, which does not exist`, `correct the link in ${a.path}, then al check`);
92
+ else if (!t.some(back)) add('not ok', [], `a one-way supersede link: ${a.path} ${says} ADR ${n}, but ${t[0].path} does not say so back`, `${fix(t[0])} ${t[0].path}, then al check`);
93
+ }
94
+ }
95
+ if (b.base) {
96
+ const strip = (t) => { const m = statusIn(t); return m ? t.slice(0, m.index) + t.slice(m.index + m[0].length) : t; };
97
+ for (const a of adrsIn(top, openTree(top, b.base), b.base, true).filter((x) => x.status === 'accepted')) {
98
+ const now = b.tree.read(a.path)?.toString('utf8');
99
+ if (now === undefined || strip(now) !== strip(a.text)) {
100
+ add('not ok', [], `${a.path} was accepted at ${b.base.slice(0, 7)} and is ${now === undefined ? 'deleted' : 'edited beyond its Status line'} on this branch; a change is a new ADR that supersedes it`, `git diff ${b.base.slice(0, 7)} -- ${a.path}`);
101
+ }
102
+ }
103
+ }
104
+ const old = (text) => numbers(text).filter((n) => byN.get(n)?.some((a) => a.status === 'superseded'));
105
+ for (const f of b.files) {
106
+ for (const s of f.sections) for (const n of old(s.text)) add('note', [], `${s.id ? `[${s.id}]` : `${f.path}:${s.line}`} cites ADR ${n}, which is superseded`, `al context ${s.id ?? f.path}`);
107
+ }
108
+ for (const x of b.blocks.values()) if (x.open && x.now) for (const n of old(x.now)) add('note', [x.request], `${x.key} of ${x.request} cites ADR ${n}, which is superseded`, `al context ${x.request}`);
109
+ return out;
110
+ }