@outerlayer/cli 0.14.0 → 0.14.1
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/CHANGELOG.md +6 -0
- package/dist/build-info.json +1 -1
- package/package.json +1 -1
- package/skill-pack/shipped.json +5 -0
- package/skill-pack/template/build/SKILL.md +2 -1
- package/skill-pack/template/build/references/release.md +4 -1
- package/skill-pack/template/build/scripts/criterion-citations.mjs +184 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @outerlayer/cli
|
|
2
2
|
|
|
3
|
+
## 0.14.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f47136a: The starter `/build` skill now links each criterion to the tests that cite it, so the work item's Criteria tab lists them instead of "No tests found". A new script, `criterion-citations.mjs`, runs on the criteria judge's report before it is emitted. It finds tests that name a criterion's id in a title or a comment, whatever the id's form. `outerlayer init --update` adds the script to repositories set up earlier.
|
|
8
|
+
|
|
3
9
|
## 0.14.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"2026-10-
|
|
1
|
+
{"id":"2026-10-09T00:29:08.046Z+2d52691","root":"/home/runner/_work/outerlayer/outerlayer","pkg":"outerlayer"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outerlayer/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "OuterLayer CLI — commands: init, connect, doctor, login, logout, sync, daemon (alias: watch), emit, work, runner, hooks, mcp, import, context. Capture and sync coding-agent sessions, and record proof of factory work.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Magu Studios, Inc.",
|
package/skill-pack/shipped.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"5cf9bd75fb1f984f6e6b470d1f1581dc06168abf062448e6f43711fed8f6632a",
|
|
10
10
|
"6177ea457be0d238659640e6d29807708d60d68533ae3ff93af10fb513139ced",
|
|
11
11
|
"77cab8b446fb1aa865710f7cb8356c5334301a942f58f9243766bf1acf1377ad",
|
|
12
|
+
"93953397c31d47a6f1c06edb8ae2190e1d8a3fca20c616deb33991311cee39a8",
|
|
12
13
|
"a6743050c5c387e962ad4264526d9a3c10edf36823e4b6829f200bf79a4e45e7",
|
|
13
14
|
"b0aa9b6962675f772b67a4390c15b6b0100390983d958c656a826a066399f3d0",
|
|
14
15
|
"f4e1661c6f87d7ff6d23c6182530fa0a9cb87c4dc3f31e48f2be11f32ebb2c00"
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
".outerlayer/skills/build/references/release.md": [
|
|
28
29
|
"0b0af40c82473cf192f697537ed5a84801a14e79bb11790d72226d7f7f49cca0",
|
|
29
30
|
"1113259bdf7d8c28edcbdb9b4c83c2f2bd4c5eaf817cf30bf428478ff91694e9",
|
|
31
|
+
"1f4fe97ca8f11d6681db5734f3103af5ea175a9472ff1d38b10cb463f4956a6a",
|
|
30
32
|
"4687f56bcec766f1926d58a208418f800a1727c073b5e6047b40e3aaac2bd6c9",
|
|
31
33
|
"81a764eb317fcb4672bedd35e7b9aa68d1c0b73f82fc2dfa03e5a78758db8f14",
|
|
32
34
|
"985e02b98105b00c356014e003b2d60e9c458c3dcbeef7378274ccb84af031fc"
|
|
@@ -40,6 +42,9 @@
|
|
|
40
42
|
"6f93e2ae74d3c02a0d4c14a6b2c805f523d234a8c56789f40a7c7c59700ef277",
|
|
41
43
|
"9d4d2e5e0307ea8c38cf885cd67d06a939b7fc2bed65f5080ca9610178949d74"
|
|
42
44
|
],
|
|
45
|
+
".outerlayer/skills/build/scripts/criterion-citations.mjs": [
|
|
46
|
+
"0902118ec216182f9d0ba0f6c7966306ad6891d8e846022123d3f86aaf175fce"
|
|
47
|
+
],
|
|
43
48
|
".outerlayer/skills/build/scripts/record-criteria.mjs": [
|
|
44
49
|
"36f19e730bc220fa21622b3bb0a306999f038b8220a0a07a1efe21ec0f748729",
|
|
45
50
|
"596e9a78e62cbea040a3bf428cd54bf59687e9e89001fad719304f88d03ca2ea",
|
|
@@ -215,8 +215,9 @@ Run them in order.
|
|
|
215
215
|
which links it, and a local session with `gh pr create` and then
|
|
216
216
|
`outerlayer work pr <number>`. When the build recorded criteria, from the issue or drafted, a fresh agent judges them with
|
|
217
217
|
[references/criteria-judge.md](references/criteria-judge.md), and the
|
|
218
|
-
judge's HTML report is emitted:
|
|
218
|
+
judge's HTML report is linked to its tests and emitted:
|
|
219
219
|
|
|
220
|
+
node .outerlayer/skills/build/scripts/criterion-citations.mjs --report <judge-report>.html
|
|
220
221
|
outerlayer emit artifact <judge-report>.html --for acceptance-criteria --caption "Each criterion judged against its test"
|
|
221
222
|
|
|
222
223
|
Its verdict is recorded on the `acceptance-criteria` check, as release.md
|
|
@@ -68,8 +68,11 @@ and a report path under `build-reports/`. Above forty criteria, spawn one
|
|
|
68
68
|
judge per group of about forty in the same wave, each with its own report
|
|
69
69
|
path, and merge their results by concatenation into one report.
|
|
70
70
|
|
|
71
|
-
The judge writes an HTML report with one verdict per criterion.
|
|
71
|
+
The judge writes an HTML report with one verdict per criterion. Link each
|
|
72
|
+
criterion to the tests that cite it, which the Criteria tab lists. With several
|
|
73
|
+
judges, run it on the merged report. Then emit it:
|
|
72
74
|
|
|
75
|
+
node .outerlayer/skills/build/scripts/criterion-citations.mjs --report <judge-report>.html
|
|
73
76
|
outerlayer emit artifact <judge-report>.html --for acceptance-criteria --caption "Each criterion judged against the test that cites it"
|
|
74
77
|
|
|
75
78
|
Then run `outerlayer sync`. List every unsatisfied criterion in the final
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Links each recorded criterion to the tests that cite it, by writing a
|
|
3
|
+
// `criterion-citations` JSON block into the criteria judge's HTML report. The
|
|
4
|
+
// work item's Criteria tab reads that block to list a criterion's tests. Run
|
|
5
|
+
// it on the report just before emitting it. Needs only Node and git.
|
|
6
|
+
//
|
|
7
|
+
// node criterion-citations.mjs --report <path> [--criteria-file <path>]
|
|
8
|
+
//
|
|
9
|
+
// The criteria come from the file `record-criteria.mjs` wrote, by default
|
|
10
|
+
// `<git dir>/build-reports/criteria.json`. A test cites a criterion when the
|
|
11
|
+
// criterion's id appears as a whole token in a test's title or in a comment
|
|
12
|
+
// above it. Only git-tracked test files are scanned, so every link names code
|
|
13
|
+
// that a commit contains. The link names the HEAD commit and is written only
|
|
14
|
+
// when the origin remote is on GitHub; otherwise the test is listed unlinked.
|
|
15
|
+
// A criterion no test cites gets an empty list. An existing block in the
|
|
16
|
+
// report is replaced.
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
/** A tracked file that holds tests: JS and TS `.test`/`.spec`, Python `test_*.py`/`*_test.py`, Go `*_test.go`, Ruby `*_spec.rb`. */
|
|
23
|
+
export const TEST_FILE = /(?:\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|\/)test_[^/]*\.py|_test\.(?:py|go)|_spec\.rb)$/;
|
|
24
|
+
/** A line that is only a comment: `//`, `#`, `*`, `/*`, `--` or `<!--` first. */
|
|
25
|
+
const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|--|<!--)/;
|
|
26
|
+
const BLOCK_ID = /<script type="application\/json" id="criterion-citations">[\s\S]*?<\/script>/;
|
|
27
|
+
|
|
28
|
+
/** A criteria file the agent must fix before anything is written. */
|
|
29
|
+
export class CitationInputError extends Error {}
|
|
30
|
+
|
|
31
|
+
function escapeRegExp(text) {
|
|
32
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A pattern that finds `id` as a whole token: not inside a longer id such as `WC-10` for `WC-1`, and still found before a sentence's full stop. */
|
|
36
|
+
export function idPattern(id) {
|
|
37
|
+
return new RegExp(`(?<![A-Za-z0-9_:-])${escapeRegExp(id)}(?![A-Za-z0-9_:-]|\\.[A-Za-z0-9])`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The ids of a criteria file shaped `{ "criteria": [{ "id" }] }`. */
|
|
41
|
+
export function criterionIds(input) {
|
|
42
|
+
if (typeof input !== "object" || input === null || !Array.isArray(input.criteria)) {
|
|
43
|
+
throw new CitationInputError('the criteria file must be { "criteria": [ ... ] }');
|
|
44
|
+
}
|
|
45
|
+
return input.criteria.map((entry, n) => {
|
|
46
|
+
if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || entry.id === "") {
|
|
47
|
+
throw new CitationInputError(`criteria[${n}] has no id`);
|
|
48
|
+
}
|
|
49
|
+
return entry.id;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The 1-based lines of one file's tests that cite `id`. A citation in a
|
|
55
|
+
* comment belongs to the first line after the comment that is not blank or
|
|
56
|
+
* another comment line (the test call); a citation anywhere else, such as a
|
|
57
|
+
* title, belongs to its own line. A comment with nothing after it keeps its
|
|
58
|
+
* own line.
|
|
59
|
+
*/
|
|
60
|
+
export function citingLines(contents, id) {
|
|
61
|
+
const pattern = idPattern(id);
|
|
62
|
+
const lines = contents.split("\n");
|
|
63
|
+
const found = new Set();
|
|
64
|
+
lines.forEach((text, index) => {
|
|
65
|
+
if (!pattern.test(text)) return;
|
|
66
|
+
if (!COMMENT_LINE.test(text)) {
|
|
67
|
+
found.add(index + 1);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let next = index + 1;
|
|
71
|
+
while (next < lines.length && (lines[next].trim() === "" || COMMENT_LINE.test(lines[next]))) next += 1;
|
|
72
|
+
found.add(next < lines.length ? next + 1 : index + 1);
|
|
73
|
+
});
|
|
74
|
+
return [...found];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `owner/repo` for a github.com remote in ssh or https form, else `null`. */
|
|
78
|
+
export function parseGithubRemote(remoteUrl) {
|
|
79
|
+
const trimmed = (remoteUrl ?? "").trim();
|
|
80
|
+
const match =
|
|
81
|
+
/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed) ??
|
|
82
|
+
/^(?:https?|ssh):\/\/(?:[^@/]+@)?(?:www\.)?github\.com(?::\d+)?\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed);
|
|
83
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A link to `path` at line `line` of commit `sha`, or `null` when there is no GitHub repository to link to. */
|
|
87
|
+
export function testHref(repository, sha, path, line) {
|
|
88
|
+
if (!repository || !sha) return null;
|
|
89
|
+
const encoded = path.split("/").map(encodeURIComponent).join("/");
|
|
90
|
+
return `https://github.com/${repository}/blob/${sha}/${encoded}#L${line}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Each criterion's citing tests, as `{ [id]: [{ path, line, href }] }` in
|
|
95
|
+
* the order the ids are given. `files` is `[{ path, contents }]` for the
|
|
96
|
+
* tracked test files; tests sort by path then line.
|
|
97
|
+
*/
|
|
98
|
+
export function buildCitations({ ids, files, repository = null, sha = null }) {
|
|
99
|
+
const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
100
|
+
const result = {};
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
result[id] = sorted.flatMap(({ path, contents }) =>
|
|
103
|
+
citingLines(contents, id).map((line) => ({ path, line, href: testHref(repository, sha, path, line) })),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The block as HTML. `<` is escaped so no cited path or title can end the script element early. */
|
|
110
|
+
export function renderBlock(citations) {
|
|
111
|
+
const json = JSON.stringify(citations).replace(/</g, "\\u003c");
|
|
112
|
+
return `<script type="application/json" id="criterion-citations">${json}</script>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `html` with the block set: an existing one replaced, else added before `</body>`, else appended. */
|
|
116
|
+
export function withBlock(html, citations) {
|
|
117
|
+
const block = renderBlock(citations);
|
|
118
|
+
if (BLOCK_ID.test(html)) return html.replace(BLOCK_ID, () => block);
|
|
119
|
+
const at = html.lastIndexOf("</body>");
|
|
120
|
+
return at === -1 ? `${html}\n${block}\n` : `${html.slice(0, at)}${block}\n${html.slice(at)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function git(cwd, args) {
|
|
124
|
+
const run = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
125
|
+
return run.status === 0 ? run.stdout : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reads the repository's tracked test files and its GitHub identity. `null` outside a git checkout. */
|
|
129
|
+
function readRepository(cwd) {
|
|
130
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
131
|
+
const tracked = top ? git(top, ["ls-files", "-z"]) : null;
|
|
132
|
+
if (!top || tracked === null) return null;
|
|
133
|
+
const files = [];
|
|
134
|
+
for (const path of tracked.split("\0").filter((p) => p !== "" && TEST_FILE.test(p))) {
|
|
135
|
+
try {
|
|
136
|
+
files.push({ path, contents: readFileSync(join(top, path), "utf8") });
|
|
137
|
+
} catch {
|
|
138
|
+
// A tracked file deleted from the working tree has no tests to read.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const sha = git(top, ["rev-parse", "HEAD"])?.trim() || null;
|
|
142
|
+
const repository = parseGithubRemote(git(top, ["remote", "get-url", "origin"]) ?? "");
|
|
143
|
+
return { files, sha, repository };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function main(argv) {
|
|
147
|
+
const arg = (name) => {
|
|
148
|
+
const at = argv.indexOf(name);
|
|
149
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
150
|
+
};
|
|
151
|
+
const report = arg("--report");
|
|
152
|
+
if (!report) {
|
|
153
|
+
process.stderr.write("usage: criterion-citations.mjs --report <path> [--criteria-file <path>]\n");
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
const repo = readRepository(process.cwd());
|
|
157
|
+
if (!repo) {
|
|
158
|
+
process.stderr.write("not inside a git repository\n");
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
let criteriaFile = arg("--criteria-file");
|
|
162
|
+
if (!criteriaFile) {
|
|
163
|
+
const gitDir = git(process.cwd(), ["rev-parse", "--absolute-git-dir"])?.trim();
|
|
164
|
+
criteriaFile = join(gitDir ?? "", "build-reports", "criteria.json");
|
|
165
|
+
}
|
|
166
|
+
let ids;
|
|
167
|
+
try {
|
|
168
|
+
ids = criterionIds(JSON.parse(readFileSync(criteriaFile, "utf8")));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
process.stderr.write(`cannot read the criteria from "${criteriaFile}": ${err.message}\n`);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
173
|
+
if (!existsSync(report)) {
|
|
174
|
+
process.stderr.write(`the report "${report}" does not exist\n`);
|
|
175
|
+
return 2;
|
|
176
|
+
}
|
|
177
|
+
const citations = buildCitations({ ids, files: repo.files, repository: repo.repository, sha: repo.sha });
|
|
178
|
+
writeFileSync(report, withBlock(readFileSync(report, "utf8"), citations));
|
|
179
|
+
const cited = ids.filter((id) => citations[id].length > 0).length;
|
|
180
|
+
process.stdout.write(`linked ${cited} of ${ids.length} criteria to the tests that cite them in ${report}\n`);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = main(process.argv.slice(2));
|