@assuredloop/cli 0.0.0-stage → 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 +21 -0
- package/README.md +260 -2
- package/bin/al.js +111 -0
- package/package.json +32 -4
- package/skills/assuredloop/SKILL.md +48 -0
- package/src/adrs.js +110 -0
- package/src/audit.js +176 -0
- package/src/check.js +127 -0
- package/src/commands.js +456 -0
- package/src/conclude.js +293 -0
- package/src/consolidate.js +279 -0
- package/src/git.js +155 -0
- package/src/hints.js +433 -0
- package/src/links.js +181 -0
- package/src/record-section.js +126 -0
- package/src/sections.js +125 -0
- package/src/signoff.js +166 -0
- package/src/snapshot.js +63 -0
- package/src/spec.js +228 -0
- package/src/states.js +227 -0
- package/src/tests.js +267 -0
- package/src/tree.js +113 -0
- package/src/views.js +390 -0
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,3 +1,261 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AssuredLoop
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
10
|
+
|
|
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,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assuredloop/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|