@assuredloop/cli 0.0.1 → 0.2.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.
@@ -0,0 +1,207 @@
1
+ ---
2
+ name: assuredloop
3
+ description: >-
4
+ Keeping the records of a change with AssuredLoop's `al` (v4): the owner's
5
+ words, the signed requirement, the change spec, the hidden paragraph
6
+ markers in the spec, the decisions and the dispositions. Use in a project
7
+ whose AGENTS.md names AssuredLoop, before work that changes the spec, and
8
+ whenever you need to know where a request stands.
9
+ ---
10
+ # AssuredLoop v4
11
+
12
+ `al` keeps the records of what was asked, decided, changed and delivered,
13
+ and gives hints. It refuses only in `al conclude`. How and when you design,
14
+ test and review is your project's way of working; this skill does not set it.
15
+
16
+ ## Start here, on every path
17
+
18
+ 1. Run `al context`. For each spec paragraph that your work touches, run
19
+ `al context <ID>`. For an open request, run `al context <name>`. Each one
20
+ shows the requirement, the open changes, the ADRs and the checks of what
21
+ it names. Read those paragraphs and records, not the whole spec.
22
+ 2. To find a paragraph by its words, run `al search <words>`. Add
23
+ `--change <name>` for one request's open text, and `--history` for removed
24
+ and replaced text. In a central repo, a hit from an output repo shows
25
+ `<repo>:<path>` and the lines that name a central ID.
26
+ 3. Choose the path from the table. Then read "Markers" and the section of
27
+ your path only.
28
+
29
+ | Path | When | Records | Owner's sign-off |
30
+ |---|---|---|---|
31
+ | 0 | No promise changes: a fix that restores what the spec says, or a typo in any paragraph. | none | no |
32
+ | 1 | One or a few promise paragraphs change. | a short request; no change spec | yes |
33
+ | 1d | Only names or wording of design paragraphs change. | a short request; no change spec | no |
34
+ | 2 | A real design, in one or a few PRs. | a request and a change spec | if a promise changes |
35
+ | 3 | A big change, delivered in parts over time. | as path 2, part by part | as path 2 |
36
+ | S | It is not clear yet what to build. | a request with a question | yes, on the question |
37
+
38
+ The promise kinds are purpose, scope, rule, limit and definition. Work that
39
+ grows changes its path: a small change that needs a design becomes path 2.
40
+
41
+ 4. Before you ask for review, run `al index`, then `al check`. Fix each
42
+ `not ok`, or say in the PR why it stays. A hint is an observation, not a
43
+ verdict.
44
+ 5. Put the claim in a commit message and in the PR, for example
45
+ `Tier: 1 — amends INV-10`. `al check` reads the newest `Tier:` line of the
46
+ branch.
47
+
48
+ ## Markers
49
+
50
+ Each paragraph in `specs/` and in a `requests/<name>/spec.md` has a hidden
51
+ marker on its own line before it, with a blank line before and after it:
52
+
53
+ ```markdown
54
+ <!-- INV-41 rule serves:invoice-exports/R2 builds-on:INV-12 -->
55
+
56
+ The export link MUST expire 30 minutes after the email is sent.
57
+ ```
58
+
59
+ - When you edit a marked paragraph, keep its marker line and the blank lines
60
+ around it. Keep the ID when you move the paragraph. Never renumber or
61
+ delete an ID: a lost ID is `not ok`.
62
+ - For a new paragraph, write the text with no marker. Then run
63
+ `al spec --add-ids <file> --yes`: it gives each unmarked paragraph the next
64
+ free ID. Then write the kind and the links into each new marker.
65
+ - The kinds: promise (purpose, scope, rule, limit, definition); design
66
+ (component, interface, data, flow, choice); approach (how one change is
67
+ built; it stays with the change); informative (rationale, example, open);
68
+ note (headings and connecting text); change-only (plan, step, migration).
69
+ - The links: `serves:R<n>` in a change spec, `serves:<request>/R<n>` in
70
+ `specs/`; `builds-on:<ID>`, `changes:<ID>`, `removes:<ID>`;
71
+ `explains:<ID>`, `illustrates:<ID>`, `resolved-by:<ID>`,
72
+ `governed-by:<ADR>`; `for:<task>`. A promise, design or approach paragraph
73
+ needs at least one link. In `specs/`, the ID of a change spec paragraph
74
+ names its request: `invoice-exports/SP-12`.
75
+ - `al` writes every hash and every derived field. Do not write them by hand.
76
+
77
+ ## Path 0: a fix
78
+
79
+ 1. Change the code, or the spec text that has the error.
80
+ 2. Write no request. Claim one of:
81
+ - `Tier: 0 — restores INV-41` when the work makes the code do what the
82
+ spec says;
83
+ - `Tier: 0 — typo in INV-3` when you fix a typo in a spec paragraph. Name
84
+ each paragraph: `Tier: 0 — typo in INV-3, EXP-4`.
85
+ 3. `al check` shows each changed word. It marks a change to a normative
86
+ word, a negation, a number or a quantifier as "meaning-sensitive". The
87
+ review decides whether the meaning stays the same.
88
+
89
+ ## Path 1: a small promise change
90
+
91
+ 1. `al new <name> --tier 1 --from -`, with the owner's words on standard
92
+ input. The name is permanent.
93
+ 2. In `requests/<name>/request.md`, add the organized requirement, in plain
94
+ words that the owner reads:
95
+
96
+ ```markdown
97
+ ## Organized requirement
98
+
99
+ <!-- R1 from:<the snapshot file in origin/> -->
100
+
101
+ ### R1 <a short title>
102
+ <one requirement, with MUST, SHOULD or MAY>
103
+ ```
104
+
105
+ 3. Run `al index`. Show the requirement to the owner with
106
+ `al record <name> signoff --source <where> --words "<their words>"`. Only
107
+ on the owner's own yes, run the same command with `--yes`. Do not sign for
108
+ the owner.
109
+ 4. Edit the promise paragraphs in `specs/` directly. Each changed or new
110
+ promise paragraph says `serves:<name>/R<n>` in its marker.
111
+ 5. Run `al index`, then `al check`. Claim `Tier: 1 — amends <ID>`. When the
112
+ PR is ready to merge, run `al conclude <name> --yes`: it writes the
113
+ Outcome and moves the request to `requests/archive/<name>/`.
114
+
115
+ ## Path 1d: a small design correction
116
+
117
+ 1. `al new <name> --tier 1d --from -`, with a one-line note of where the
118
+ correction comes from.
119
+ 2. Change only names or wording of design paragraphs in `specs/`. Add or
120
+ remove no paragraph. Do not change an accepted ADR's text: a new ADR
121
+ supersedes it.
122
+ 3. Claim `Tier: 1d — <what changes>`. `al check` gives `not ok` when the PR
123
+ touches a promise paragraph, or adds or removes a design paragraph. No
124
+ sign-off is needed.
125
+ 4. Run `al conclude <name> --yes` with the PR.
126
+
127
+ ## Path 2: a big change
128
+
129
+ 1. `al new <name> --tier 2 --from -`. Write the organized requirement as in
130
+ path 1, with `Out:` and `Assumed:` lines where they help. Draft freely;
131
+ deliver with the owner's sign-off or after it. A change that touches no
132
+ promise kind needs no sign-off.
133
+ 2. Write the design in `requests/<name>/spec.md`, the change spec: the
134
+ promises this change adds or changes, its design, its plan. Run
135
+ `al spec --add-ids requests/<name>/spec.md --yes` for the IDs (SP-1,
136
+ SP-2…), then write the kinds and links. A paragraph that relies on a spec
137
+ paragraph says `builds-on:<ID>`. One that replaces it says `changes:<ID>`.
138
+ One that removes it says `removes:<ID>`. Never copy the old text.
139
+ 3. Snapshot each later word of the owner that you rely on:
140
+ `al record <name> origin --url <where> --from - --yes`. Record each later
141
+ decision: `al record <name> decision --source <who> --text "<it>" --yes`.
142
+ 4. Consolidate by hand, in the PR with the code or later: copy each paragraph
143
+ of a kind that goes into `specs/` (promise, the lasting design kinds,
144
+ rationale, example, open) into `specs/`, word for word. Give it a spec ID
145
+ (`al spec --add-ids`), its kind, and its links. In `specs/`, a link names
146
+ the request: `serves:<name>/R<n>`. Plan, step, migration, approach and note
147
+ paragraphs stay with the change.
148
+ 5. For each change paragraph that goes into `specs/`, and each `removes:`,
149
+ add a disposition to `.assuredloop/records/requests/<name>.yaml`, then run
150
+ `al index` to fill its hashes:
151
+
152
+ ```yaml
153
+ dispositions:
154
+ - {source: <name>/SP-4, disposition: incorporated, spec: EXP-9}
155
+ - {source: <name>/SP-6, disposition: removed, spec: INV-4}
156
+ - {source: <name>/SP-7, disposition: abandoned, decision: D2}
157
+ ```
158
+
159
+ - `incorporated`: the spec text equals the change paragraph exactly. If
160
+ you word it differently, record a decision and add
161
+ `wording: {source: <name>/SP-n, spec: <ID>}` to that decision in the
162
+ record; the disposition then names `decision: Dn`.
163
+ - `removed`: the paragraph declares `removes:<ID>`, and the ID is gone
164
+ from `specs/`. Removing a promise needs a signed requirement.
165
+ - `superseded`: `by: <later request>/SP-n`, when a later change replaced
166
+ the incorporated text.
167
+ - `abandoned`: the paragraph is not delivered. Name the owner's decision
168
+ when an applied effect is kept.
169
+ 6. When `al check` says a paragraph you build on changed, check your change
170
+ against the new text, then run `al index --align <name>/SP-n`.
171
+ 7. Close with `al conclude <name> --yes`. It refuses while a promise change
172
+ is not signed, or a paragraph with a baseline effect has no valid
173
+ disposition for its current version. `al context <name>` lists them.
174
+
175
+ ## Path 3: an epic
176
+
177
+ As path 2, in parts. Each part consolidates the paragraphs it delivers,
178
+ with their dispositions, in its own PR. Tasks are optional: where the
179
+ project uses them, list them in the record as
180
+ `tasks: [{id: T2, ref: "issue #34", delivers: [R3]}]`, and name the task in
181
+ the commit messages of its PRs. Run `al conclude <name> --yes` with the last
182
+ part.
183
+
184
+ ## Path S: a spike
185
+
186
+ 1. `al new <name> --tier S --from -`. In `request.md`, write
187
+ `## Organized question` with `<!-- Q1 -->` and `### Q1 <title>`.
188
+ 2. The owner signs the question, as in path 1, step 3.
189
+ 3. The change spec holds the candidates and the experiments. It is never
190
+ consolidated and needs no dispositions.
191
+ 4. Write the findings in the request folder. Open them with the answer, and
192
+ name the question version that it answers, for example "Answer to Q1
193
+ version 1: …". Change no spec text.
194
+ 5. Run `al conclude <name> --yes`. A spike may end with no follow-up. An
195
+ answer may also inform work that is already open. Only separately
196
+ authorized work needs a new request; say in its `request.md` that it
197
+ follows the spike: `Follows: <name>`.
198
+
199
+ ## The views
200
+
201
+ - `al context --diff main...HEAD --for review`: each changed paragraph
202
+ beside its kind, its requirement and the baseline it touches.
203
+ - `al context <name> --audit`: the whole trace of a request. Add
204
+ `--at <commit>` to any view for a past state.
205
+ - `al spec`: every doc's IDs, display numbers and kinds.
206
+ - Each output ends with `Read`, `Next` and `Not known`. Take `Not known` as
207
+ it is: the tool does not know it, and you do not either.
package/src/v4/base.js ADDED
@@ -0,0 +1,80 @@
1
+ // What every v4 command shares: the failure that refuses, the clock, hashes,
2
+ // request names and the record paths.
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, lstatSync } from 'node:fs';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+
7
+ // A refusal: exit 2, `al: <message>`, and nothing written.
8
+ export class Fail extends Error {
9
+ constructor(message, next) { super(message); this.next = next; }
10
+ }
11
+
12
+ export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
13
+
14
+ // The clock, read only to stamp what the tool writes. SOURCE_DATE_EPOCH
15
+ // (seconds) stands in for it when set.
16
+ export const now = () => (process.env.SOURCE_DATE_EPOCH ? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1000) : new Date());
17
+ export const STAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\dZ$/;
18
+ export const stamp = (d) => d.toISOString().slice(0, 16) + 'Z';
19
+ export const day = (d) => d.toISOString().slice(0, 10);
20
+
21
+ // A request name: lowercase letters, digits and hyphens; never a path.
22
+ export const isName = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(s);
23
+
24
+ export const SCHEMA = 'assuredloop/1';
25
+ export const recordPath = (name) => `.assuredloop/records/requests/${name}.yaml`;
26
+ export const docRecordPath = (file) => `.assuredloop/records/${file}.yaml`;
27
+
28
+ // A path that stays inside the repo: relative, with no `..` part.
29
+ export const inside = (path) => !isAbsolute(path) && !path.split(/[\\/]/).includes('..');
30
+
31
+ // The first part of `path` (a path under `top`) that is a symlink, or null.
32
+ function linkOn(top, path) {
33
+ let at = top;
34
+ for (const part of path.split('/')) {
35
+ at = join(at, part);
36
+ let st;
37
+ try { st = lstatSync(at); } catch { return null; }
38
+ if (st.isSymbolicLink()) return at.slice(top.length + 1);
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // The text of `path` under `top` (its bytes with `encoding` null), or null
44
+ // when it is absent. Nothing is read outside the repo or through a symlink,
45
+ // which can lead out of it: either is a refusal.
46
+ export function read(top, path, encoding = 'utf8') {
47
+ if (!inside(path)) throw new Fail(`${path} is outside the repository; nothing was read or written`);
48
+ const link = linkOn(top, path);
49
+ if (link) throw new Fail(`${path} is reached through the symlink ${link}; nothing was read or written`, `make ${link} a real file or folder in this repo`);
50
+ try { return readFileSync(join(top, path), encoding); } catch { return null; }
51
+ }
52
+ export const exists = (top, path) => existsSync(join(top, path));
53
+
54
+ // Refuses, before anything is written, when any of `paths` (paths under
55
+ // `top`) leaves the repo, or a part of one that exists is a symlink: records
56
+ // are written only through real folders, so nothing lands outside the repo.
57
+ export function guard(top, paths) {
58
+ for (const path of paths) {
59
+ if (!inside(path)) throw new Fail(`${path} is outside the repository; nothing was written`);
60
+ const link = linkOn(top, path);
61
+ if (link) throw new Fail(`${path} is reached through the symlink ${link}; records are written only through real folders, and nothing was written`, `make ${link} a real folder in this repo`);
62
+ }
63
+ }
64
+
65
+ // Writes `text` to `path` under `top`, making its folders; a file whose text
66
+ // is already that is left alone, so indexing twice changes no byte.
67
+ export function write(top, path, text) {
68
+ guard(top, [path]);
69
+ if (read(top, path) === text) return false;
70
+ mkdirSync(dirname(join(top, path)), { recursive: true });
71
+ writeFileSync(join(top, path), text);
72
+ return true;
73
+ }
74
+
75
+ // The first file name free in `dir`: `<base>.md`, else `<base>-2.md`...
76
+ export function freeName(top, dir, base) {
77
+ let file = `${base}.md`;
78
+ for (let n = 2; existsSync(join(top, dir, file)); n++) file = `${base}-${n}.md`;
79
+ return file;
80
+ }
@@ -0,0 +1,81 @@
1
+ // al check (design.md 3, What the script checks): the lints of every marker in
2
+ // scope, and per scope the ID lints of the branch against its base (lost, used
3
+ // again, one ID in two files), then each paragraph's change (T8); then the
4
+ // checks that read records (T10, checks.js). The base is the merge-base of
5
+ // HEAD with main; the head is the working tree. It exits 0; with --strict, 1
6
+ // when it prints a `not ok`. --strict raises every marker lint and an invalid
7
+ // disposition to `not ok`; the hints that need judgment stay hints.
8
+ import { loadConfig, loadSchema } from './config.js';
9
+ import { mergeBase } from './git.js';
10
+ import { diffParagraphs, idLints } from './ids.js';
11
+ import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootProblem, SPEC } from './scope.js';
12
+ import { Fail } from './spec.js';
13
+ import { centralLookup } from './repos.js';
14
+ import { checks } from './checks.js';
15
+
16
+ export function check({ top, opts }) {
17
+ const config = loadConfig(top);
18
+ const root = rootProblem(config);
19
+ if (root) throw new Fail(root, 'fix root: in .assuredloop/config.yaml');
20
+ const { kinds } = loadSchema(top);
21
+ const base = mergeBase(top);
22
+ const now = parseScopes(docsInScope(top, config), kinds);
23
+ const was = base ? parseScopes(docsInScope(top, config, base), kinds) : null;
24
+ const used = base ? idsEverUsed(top, config, base) : null;
25
+ // A change spec's removes: names spec paragraphs.
26
+ const removes = [...now.scopes].filter(([s]) => s !== SPEC).flatMap(([, ps]) => ps.flatMap((p) => p.links.removes));
27
+
28
+ const lints = [...now.lints];
29
+ const changes = [];
30
+ for (const scope of new Set([...now.scopes.keys(), ...(was?.scopes.keys() ?? [])])) {
31
+ const head = now.scopes.get(scope) ?? [];
32
+ const before = was?.scopes.get(scope) ?? [];
33
+ if (!base) {
34
+ lints.push(...idLints(head, head));
35
+ continue;
36
+ }
37
+ lints.push(...idLints(before, head, { everUsed: used.get(scope) ?? [], removes: scope === SPEC ? removes : [] }));
38
+ changes.push(...diffParagraphs(before, head).filter((c) => c.changes.length));
39
+ }
40
+
41
+ const t10 = checks(top, { base, strict: opts.strict });
42
+ // An adopted paragraph needs no link while its text is the captured text (D16).
43
+ const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/')));
44
+ lints.length = 0;
45
+ lints.push(...shown);
46
+ const severity = (l) => (opts.strict ? 'not ok' : l.severity);
47
+ const body = notRead(top, config);
48
+ body.push(...lints.map((l) => `${severity(l)} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
49
+ if (base) body.push(...changes.map((c) => `${c.file} ${c.id} ${c.changes.join('+')}`));
50
+ if (!lints.length) body.push('ok: no marker lint');
51
+ body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
52
+ // Under --strict every marker lint is a not ok.
53
+ const notOk = lints.length > 0 || t10.findings.some((f) => f.severity === 'not ok');
54
+ // In an output repo: the central IDs that the branch's changed files cite (T12).
55
+ const central = centralLookup(top);
56
+ body.push(...central.body);
57
+ const notKnown = ['IDs used on branches that were never fetched here', ...t10.notKnown, ...central.notKnown,
58
+ 'whether each kind is right, whether a requirement is fully covered, and whether a change is authorized in substance (the review judges these)'];
59
+ if (!base) notKnown.unshift('no base (no main branch, or no commit): lost and used-again IDs are not checked');
60
+ else if (isShallow(top)) notKnown.unshift('IDs used before the shallow history begins');
61
+ return {
62
+ body,
63
+ read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base',
64
+ next: nextStep([...lints.map(severity), ...t10.findings.map((f) => f.severity),
65
+ ...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id')),
66
+ notKnown,
67
+ exit: opts.strict && notOk ? 1 : 0,
68
+ };
69
+ }
70
+
71
+ // The Next line names what to do for what printed: a not ok, a hint, both, or
72
+ // neither (#198).
73
+ function nextStep(severities, noId) {
74
+ const notOk = severities.includes('not ok');
75
+ const hint = severities.includes('hint');
76
+ const addIds = noId ? '; al spec --add-ids <file> marks the paragraphs with no ID' : '';
77
+ if (notOk && hint) return `fix each not ok, and deal with each hint or say in the PR why it stays${addIds}`;
78
+ if (notOk) return `fix each not ok${addIds}`;
79
+ if (hint) return 'deal with each hint, or say in the PR why it stays';
80
+ return 'al context';
81
+ }