@outerlayer/cli 0.33.0-next.20261022124145 → 0.33.0-next.20261022161023

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.
@@ -1 +1 @@
1
- {"id":"2026-10-22T12:43:11.879Z+45d3a2f","root":"/home/runner/_work/outerlayer/outerlayer","pkg":"outerlayer"}
1
+ {"id":"2026-10-22T16:41:55.061Z+a212c93","root":"/home/runner/_work/outerlayer/outerlayer","pkg":"outerlayer"}
@@ -2,32 +2,13 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { launchRefusalForSession } from './chunk-ESVW4MJY.js';
4
4
  import { detectActiveSession, autoConnect } from './chunk-C3XLVHN6.js';
5
- import './chunk-B3EE7OKK.js';
6
- import './chunk-EWVYQLD3.js';
7
5
  import { resolveRepoIdentity } from './chunk-5DNCFOXA.js';
8
- import './chunk-HJYBWJAS.js';
9
- import './chunk-NSPRS7WK.js';
10
- import './chunk-G2JWZDFA.js';
11
6
  import { C } from './chunk-ANPNT6PM.js';
12
7
  import { resolveGatewayCredentials, defaultFetch, unauthorizedMessage } from './chunk-VQ2JGZSC.js';
13
- import './chunk-PJDQPF5N.js';
14
- import './chunk-JT3YO5MH.js';
15
- import './chunk-ESP5VZBD.js';
16
- import './chunk-HDSDUTX4.js';
17
- import './chunk-BJ3KTMDH.js';
18
- import './chunk-G63KT5I5.js';
19
- import './chunk-5BHIBJDA.js';
20
- import './chunk-CDDED64E.js';
21
- import './chunk-RA3O54FC.js';
22
8
  import { launchedItemForSession } from './chunk-T2Q7ISPE.js';
23
9
  import { parseTrackerNumber, trackerNumberError } from './chunk-WZBHBH3J.js';
24
- import './chunk-CRB3POP4.js';
25
- import './chunk-LJG6EMLJ.js';
26
10
  import { CriteriaListSchema } from './chunk-E6CO3MJE.js';
27
11
  import { homeFromEnv } from './chunk-VNVZDWO3.js';
28
- import './chunk-XD4ZCMA4.js';
29
- import './chunk-MESOOLF4.js';
30
- import './chunk-Z5GEFMRW.js';
31
12
  import { readFileSync } from 'fs';
32
13
 
33
14
  createRequire(import.meta.url);
@@ -129,11 +110,7 @@ async function errorCode(response) {
129
110
  }
130
111
  return { code, detail: text.replace(/\s+/g, " ").trim().slice(0, 160) };
131
112
  }
132
- async function runEmitCriteria(opts) {
133
- const cwd = opts.cwd ?? process.cwd();
134
- const home = opts.home ?? homeFromEnv();
135
- const env = opts.env ?? process.env;
136
- const now = opts.now ?? (() => /* @__PURE__ */ new Date());
113
+ async function readListFile(opts) {
137
114
  let text;
138
115
  if (opts.file === "-") {
139
116
  text = await (opts.readStdin ?? readAllStdin)();
@@ -144,21 +121,25 @@ async function runEmitCriteria(opts) {
144
121
  throw new EmitCriteriaError(`cannot read "${opts.file}": ${String(err)}`);
145
122
  }
146
123
  }
147
- let raw;
148
124
  if (opts.file.endsWith(".md")) {
149
125
  const criteria = criteriaFromAcceptanceFile(text);
150
126
  if (criteria.length === 0) {
151
127
  throw new EmitCriteriaError(`"${opts.file}" lists no criteria: none of its list items opens with an id such as \`AC-123-01\``);
152
128
  }
153
- raw = { criteria };
154
- } else {
155
- try {
156
- raw = JSON.parse(text);
157
- } catch (err) {
158
- throw new EmitCriteriaError(`"${opts.file}" is not valid JSON: ${String(err)}`);
159
- }
129
+ return { criteria };
160
130
  }
161
- const list = parseFile(raw);
131
+ try {
132
+ return JSON.parse(text);
133
+ } catch (err) {
134
+ throw new EmitCriteriaError(`"${opts.file}" is not valid JSON: ${String(err)}`);
135
+ }
136
+ }
137
+ async function runEmitCriteria(opts) {
138
+ const cwd = opts.cwd ?? process.cwd();
139
+ const home = opts.home ?? homeFromEnv();
140
+ const env = opts.env ?? process.env;
141
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
142
+ const list = parseFile(opts.list ?? await readListFile(opts));
162
143
  const identity = resolveRepoIdentity(cwd);
163
144
  const sessionId = detectActiveSession({
164
145
  env,
@@ -212,4 +193,4 @@ async function runEmitCriteria(opts) {
212
193
  return { data, output, exitCode: 0 };
213
194
  }
214
195
 
215
- export { EmitCriteriaError, runEmitCriteria };
196
+ export { EmitCriteriaError, criteriaFromAcceptanceFile, runEmitCriteria };
@@ -4601,7 +4601,7 @@ of the item, and a (proof: <kind>) annotation names its proof.
4601
4601
  ${cliInvocation()} emit criteria acceptance/412-login.md --item 412
4602
4602
  `
4603
4603
  ).action(async (file, opts) => {
4604
- const { runEmitCriteria, EmitCriteriaError } = await import('./emit-criteria-cmd-IC5OIZX2.js');
4604
+ const { runEmitCriteria, EmitCriteriaError } = await import('./emit-criteria-cmd-6D4D5ZBW.js');
4605
4605
  try {
4606
4606
  const result = await runEmitCriteria({
4607
4607
  file,
@@ -4627,7 +4627,7 @@ function registerSpecCommands(program2) {
4627
4627
  const run = async (action) => {
4628
4628
  const { WorkCommandError } = await import('./work-cmd-DYM67Z3F.js');
4629
4629
  try {
4630
- await action(await import('./spec-cmd-SLJT7MRX.js'));
4630
+ await action(await import('./spec-cmd-ZJAPP2XZ.js'));
4631
4631
  } catch (err) {
4632
4632
  if (err instanceof WorkCommandError) {
4633
4633
  fail(err.message);
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ export { EmitCriteriaError, runEmitCriteria } from './chunk-J42MMS4R.js';
4
+ import './chunk-ESVW4MJY.js';
5
+ import './chunk-C3XLVHN6.js';
6
+ import './chunk-B3EE7OKK.js';
7
+ import './chunk-EWVYQLD3.js';
8
+ import './chunk-5DNCFOXA.js';
9
+ import './chunk-HJYBWJAS.js';
10
+ import './chunk-NSPRS7WK.js';
11
+ import './chunk-G2JWZDFA.js';
12
+ import './chunk-ANPNT6PM.js';
13
+ import './chunk-VQ2JGZSC.js';
14
+ import './chunk-PJDQPF5N.js';
15
+ import './chunk-JT3YO5MH.js';
16
+ import './chunk-ESP5VZBD.js';
17
+ import './chunk-HDSDUTX4.js';
18
+ import './chunk-BJ3KTMDH.js';
19
+ import './chunk-G63KT5I5.js';
20
+ import './chunk-5BHIBJDA.js';
21
+ import './chunk-CDDED64E.js';
22
+ import './chunk-RA3O54FC.js';
23
+ import './chunk-T2Q7ISPE.js';
24
+ import './chunk-WZBHBH3J.js';
25
+ import './chunk-CRB3POP4.js';
26
+ import './chunk-LJG6EMLJ.js';
27
+ import './chunk-E6CO3MJE.js';
28
+ import './chunk-VNVZDWO3.js';
29
+ import './chunk-XD4ZCMA4.js';
30
+ import './chunk-MESOOLF4.js';
31
+ import './chunk-Z5GEFMRW.js';
32
+
33
+ createRequire(import.meta.url);
package/dist/index.js CHANGED
@@ -26,5 +26,5 @@ if (argv[0] === "claude") {
26
26
  process.exit(await runClaudeLauncher(argv.slice(1)));
27
27
  }
28
28
  (await import('./hook-fast-FJ2PMBML.js')).recordLinkExitFailure(argv);
29
- var { runCli } = await import('./cli-5JSVOAE5.js');
29
+ var { runCli } = await import('./cli-OHQKHGEU.js');
30
30
  await runCli(process.argv);
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
+ import { criteriaFromAcceptanceFile, runEmitCriteria } from './chunk-J42MMS4R.js';
4
+ import './chunk-ESVW4MJY.js';
3
5
  import { getJson, postJson, patchJson, WorkCommandError, resolveCredentials } from './chunk-6JNCNHSV.js';
4
6
  import { autoConnect } from './chunk-C3XLVHN6.js';
5
7
  import './chunk-B3EE7OKK.js';
@@ -19,6 +21,7 @@ import './chunk-G63KT5I5.js';
19
21
  import './chunk-5BHIBJDA.js';
20
22
  import './chunk-CDDED64E.js';
21
23
  import './chunk-RA3O54FC.js';
24
+ import './chunk-T2Q7ISPE.js';
22
25
  import './chunk-WZBHBH3J.js';
23
26
  import './chunk-CRB3POP4.js';
24
27
  import './chunk-LJG6EMLJ.js';
@@ -115,8 +118,59 @@ async function runSpecEdit(opts) {
115
118
  payload,
116
119
  opts.fetchImpl ?? defaultFetch
117
120
  );
118
- const text = [`\u2713 edited ${String(data.key)}`, ` ${String(data.url)}`, ...updatedAtLine(data)].join("\n");
119
- return finish(opts, data, text);
121
+ const criteria = await recordEditedCriteria(opts, creds, data, criteriaFromAcceptanceFile(body));
122
+ const text = [`\u2713 edited ${String(data.key)}`, ` ${String(data.url)}`, ...updatedAtLine(data), ...criteria.lines].join("\n");
123
+ return finish(opts, criteria.result === null ? data : { ...data, criteria: criteria.result }, text);
124
+ }
125
+ function sameList(read, held) {
126
+ return read.length === held.length && read.every((criterion, i) => criterion.id === held[i].id && criterion.text === held[i].text && criterion.proof === (held[i].proof ?? null));
127
+ }
128
+ async function recordEditedCriteria(opts, creds, edited, criteria) {
129
+ if (criteria.length === 0) return { result: null, lines: [] };
130
+ const fetchImpl = opts.fetchImpl ?? defaultFetch;
131
+ let item;
132
+ let held;
133
+ try {
134
+ const query = new URLSearchParams({ key: opts.key });
135
+ if (typeof edited.tracker === "string") query.set("tracker", edited.tracker);
136
+ const page = await getJson(creds.url, `/v1/work-items?${query}`, creds.apiKey, creds.appId, fetchImpl);
137
+ item = page.data[0];
138
+ if (item === void 0) return { result: null, lines: [] };
139
+ const read = await getJson(creds.url, `/v1/work-items/${item.id}`, creds.apiKey, creds.appId, fetchImpl);
140
+ held = read.data.criteria?.items;
141
+ } catch (err) {
142
+ return { result: null, lines: [`\u2717 the edit's criteria were not checked against the issue's work item: ${err.message}`] };
143
+ }
144
+ const count = criteria.length;
145
+ const counted = `${count} ${count === 1 ? "criterion" : "criteria"}`;
146
+ if (held !== void 0 && sameList(criteria, held)) {
147
+ return { result: { item: item.number, outcome: "unchanged", count }, lines: [` item #${item.number} already holds these ${counted}`] };
148
+ }
149
+ try {
150
+ await runEmitCriteria({
151
+ file: `the body of ${opts.key}`,
152
+ list: { criteria },
153
+ item: String(item.number),
154
+ quiet: true,
155
+ url: creds.url,
156
+ apiKey: creds.apiKey,
157
+ appId: creds.appId,
158
+ cwd: opts.cwd,
159
+ home: opts.home,
160
+ env: opts.env,
161
+ fetchImpl
162
+ });
163
+ } catch (err) {
164
+ const reason = err.message;
165
+ return {
166
+ result: { item: item.number, outcome: "not_recorded", count, reason },
167
+ lines: [
168
+ `\u2717 item #${item.number} is still held to its earlier criteria: ${reason}`,
169
+ ` To record the new ones, save the body as a .md file and run: outerlayer emit criteria <file> --item ${item.number}`
170
+ ]
171
+ };
172
+ }
173
+ return { result: { item: item.number, outcome: "recorded", count }, lines: [`\u2713 recorded ${counted} on item #${item.number}`] };
120
174
  }
121
175
 
122
176
  export { runSpecEdit, runSpecFile, runSpecSearch, runSpecTarget };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outerlayer/cli",
3
- "version": "0.33.0-next.20261022124145",
3
+ "version": "0.33.0-next.20261022161023",
4
4
  "description": "OuterLayer CLI — commands: init, claude, connect, doctor, login, logout, sync, daemon (alias: watch), emit, findings, work, spec, runner, hooks, mcp, 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.",
@@ -9,7 +9,7 @@ description: >
9
9
  the reviewer failed". Invoke with /outerlayer-amend.
10
10
  ---
11
11
 
12
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
12
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
13
13
 
14
14
  Answer every thread waiting on an agent on one work item's pull request, then
15
15
  stop. A work item has one general thread and one thread per acceptance
@@ -67,10 +67,10 @@ change, to its own branch only.
67
67
  `criterion` (null for the general thread), `waitingOn` (`agent`, `person` or
68
68
  null), `verdict`, `proof` and `comments`, each comment with its `author`,
69
69
  `body`, `action` and `artifact`. Keep the threads whose `waitingOn` is
70
- `agent`: one send of notes can leave several.
71
- If none is waiting and step 1 resolved no conflict, say so and stop: there
72
- is nothing to answer. Take the pull request (`repository`, `prNumber`) from
73
- the step 1 status JSON's `pullRequests`.
70
+ `agent`.
71
+ If none is waiting and step 1 resolved no conflict, say so and stop. Take
72
+ the pull request (`repository`, `prNumber`) from the step 1 status JSON's
73
+ `pullRequests`.
74
74
  3. **Re-orient.** The status JSON's `issue.body` is OuterLayer's copy of the
75
75
  issue. It is the spec: find each thread's criterion there. Read the pull
76
76
  request, with its evidence comment, through the git host. Read every person's comment since the last agent
@@ -80,8 +80,7 @@ change, to its own branch only.
80
80
  `reason`. When a person asks you to fix failing checks or criteria, fix
81
81
  each failing check item by its reason.
82
82
  4. **Decide each thread.** Reply on every criterion thread waiting on an
83
- agent, with `--criterion`, so each note gets its answer under it. Read each
84
- note and pick one:
83
+ agent, with `--criterion`. Read each note and pick one:
85
84
  - **Answer it.** A question gets an answer. Reply with the answer and
86
85
  change nothing. Say which file or test you looked at.
87
86
  - **Fix it.** A requested change gets a fix. Change the code, tests, docs or
@@ -139,13 +138,12 @@ change, to its own branch only.
139
138
  and why. Post the lines once on the general thread:
140
139
  `outerlayer work comment --item "$OUTERLAYER_WORK" --body "<the lines>"`.
141
140
  A plain comment from an agent leaves the general thread waiting on a person.
142
- A local session also posts them on the pull request; a build's token
143
- cannot. End with the same lines.
141
+ A local session also posts them on the pull request. End with the same lines.
144
142
 
145
143
  ## Rules
146
144
 
147
- - **Answer only threads waiting on an agent.** A thread waiting on a person, or
148
- on no one, is not yours to reply on.
145
+ - **Answer only threads waiting on an agent.** Never reply on one waiting on a
146
+ person or on no one.
149
147
  - **One thread, one proof.** Never attach an artifact that does not prove that
150
148
  thread's criterion.
151
149
  - **A person decides.** Never pass `--pass` or `--fail`, never mark a check as
@@ -7,7 +7,7 @@ description: >
7
7
  skill tells you to. Only the `build` skill starts it.
8
8
  ---
9
9
 
10
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
10
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
11
11
 
12
12
  Build one issue into an evidenced pull request. `/spec` writes and gets
13
13
  approval for the issue. This skill builds it. It never writes the spec.
@@ -258,14 +258,18 @@ Run them in order.
258
258
  `"$(git rev-parse --absolute-git-dir)/build-reports/criteria.json"` and runs
259
259
  `outerlayer emit criteria` on it. The gateway decides whether a list may be
260
260
  recorded. A 409 means the item already has a recorded list. That list
261
- stands, and nothing is recorded. No command returns the item's list, so the
262
- build cannot read it: it holds the work to its own reading of the criteria
263
- and says so in the final report. Any other refusal is written to the state
261
+ stands, and nothing is recorded. The script then reads the item's list with
262
+ `outerlayer work status --item <n> --json`. When it differs from the
263
+ issue's criteria, the script reports a step on the item that says so, and
264
+ the item's Activity tab shows it. The build holds the work to its own
265
+ reading of the criteria. The final report says which list the item holds
266
+ and how they differ. Any other refusal is written to the state
264
267
  file with its message, and the build continues. The script writes the
265
268
  outcome under `criteria` in the state file: `source` (`issue`, `drafted`,
266
269
  `item`, or `none` when even a draft holds no criterion), `recorded`,
267
270
  `count`, `list` and `numbered`. On a 409, `list` is empty, and the build's
268
- reading of the criteria goes under `issueReading`.
271
+ reading of the criteria goes under `issueReading`. The item's own list goes
272
+ under `recordedList`, and `differs` says whether the two lists differ.
269
273
 
270
274
  Drafted criteria are tiered, proven and judged like any others. A drafted
271
275
  list stands like any recorded list. A later build's record
@@ -27,10 +27,12 @@ Judge only that group. Other judges handle the rest.
27
27
  1. Read the criteria from the build's state file, under `criteria.list`. They
28
28
  are the item's recorded list. If the state file lacks `criteria`, read the
29
29
  `criteria` array in `criteria.json` in the build-reports directory. When
30
- `criteria.source` is `item`, the item already had a recorded list that no
31
- command returns, so `list` is empty. Judge `criteria.issueReading` instead,
32
- and say in the report that it is the issue's reading, whose ids may differ
33
- from the item's recorded list. Each has an id
30
+ `criteria.source` is `item`, the item already had a recorded list, so
31
+ `list` is empty. Judge `criteria.recordedList` instead: it is the list the
32
+ item is held to. When `criteria.differs` is true, say in the report that
33
+ the issue states other criteria. When there is no `recordedList`, judge
34
+ `criteria.issueReading`, and say in the report that it is the issue's
35
+ reading, whose ids may differ from the item's recorded list. Each has an id
34
36
  and a text, and the text is usually a Given, When and Then. Judge every criterion you were given,
35
37
  including one no line of the diff touched. A criterion with `proof: null`
36
38
  is proven by a citing test or any artifact bound to its id.
@@ -13,7 +13,9 @@
13
13
  // `AC-<item>-NN` in order. A drafted list is always numbered and is recorded
14
14
  // with `drafted: true`. An empty list records nothing. The script never edits
15
15
  // the issue, and never replaces a list the item already has (the gateway
16
- // answers 409 and the item's list stands).
16
+ // answers 409 and the item's list stands). When that list differs from the
17
+ // one read here, it reports a step on the item, so Work shows that the item
18
+ // is held to criteria the issue no longer states.
17
19
  import { spawnSync } from "node:child_process";
18
20
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
21
  import { dirname, join } from "node:path";
@@ -78,6 +80,17 @@ export function readCriteria(input, item, { drafted = false } = {}) {
78
80
  return numbered ? { criteria, numbered, numberedBecause: problem } : { criteria, numbered };
79
81
  }
80
82
 
83
+ /** The step reported on the item when its recorded list differs from the issue's. At most 80 characters. */
84
+ export const DIFFERS_STEP = "Criteria differ from the issue: a person must replace them";
85
+
86
+ /** Whether two lists hold the same criteria, in the same order, with the same text and proof. */
87
+ export function sameCriteria(a, b) {
88
+ return (
89
+ a.length === b.length &&
90
+ a.every((criterion, n) => criterion.id === b[n].id && criterion.text === b[n].text && (criterion.proof ?? null) === (b[n].proof ?? null))
91
+ );
92
+ }
93
+
81
94
  /** Sorts a failed `emit criteria` into "the item already has a list" and any other refusal. */
82
95
  export function classifyEmitFailure(message) {
83
96
  // Only the CLI's replace refusal reads "refused (409):". Other answers that
@@ -85,16 +98,20 @@ export function classifyEmitFailure(message) {
85
98
  return /\brefused \(409\):/.test(message) ? "item-list-stands" : "refused";
86
99
  }
87
100
 
101
+ /** Runs `outerlayer`, or `npx -y @outerlayer/cli` on a machine with no `outerlayer` on PATH. */
102
+ function runCli(spawn, args) {
103
+ const options = { encoding: "utf8", timeout: 120000 };
104
+ const run = spawn("outerlayer", args, options);
105
+ return run.error?.code === "ENOENT" ? spawn("npx", ["-y", "@outerlayer/cli", ...args], options) : run;
106
+ }
107
+
88
108
  /**
89
109
  * Builds the `emit` function that runs `outerlayer emit criteria`; `spawn` is `spawnSync`, or a stand-in in tests.
90
110
  * A machine that ran the CLI through `npx` has no `outerlayer` on PATH, so a missing command falls back to `npx -y @outerlayer/cli`.
91
111
  */
92
112
  export function makeEmit(spawn, item) {
93
113
  return (file) => {
94
- const args = ["emit", "criteria", file, "--item", item];
95
- const options = { encoding: "utf8", timeout: 120000 };
96
- let run = spawn("outerlayer", args, options);
97
- if (run.error?.code === "ENOENT") run = spawn("npx", ["-y", "@outerlayer/cli", ...args], options);
114
+ const run = runCli(spawn, ["emit", "criteria", file, "--item", item]);
98
115
  if (run.status === 0) return { ok: true };
99
116
  if (run.error) return { ok: false, message: `could not run outerlayer emit criteria: ${run.error.message}` };
100
117
  if (run.signal) return { ok: false, message: `outerlayer emit criteria was stopped by ${run.signal}` };
@@ -102,6 +119,25 @@ export function makeEmit(spawn, item) {
102
119
  };
103
120
  }
104
121
 
122
+ /** Builds the `readRecorded` function: the item's recorded list, or `null` when it cannot be read. */
123
+ export function makeReadRecorded(spawn, item) {
124
+ return () => {
125
+ const run = runCli(spawn, ["work", "status", "--item", item, "--json"]);
126
+ if (run.status !== 0) return null;
127
+ try {
128
+ const items = JSON.parse(run.stdout).criteria?.items;
129
+ return Array.isArray(items) ? items.map((c) => ({ id: c.id, text: c.text, proof: c.proof ?? null })) : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+ }
135
+
136
+ /** Builds the `reportStep` function, which reports a step on the item. Returns whether it was recorded. */
137
+ export function makeReportStep(spawn, item) {
138
+ return (name) => runCli(spawn, ["emit", "step", name, "--item", item]).status === 0;
139
+ }
140
+
105
141
  function readState(path) {
106
142
  try {
107
143
  return JSON.parse(readFileSync(path, "utf8"));
@@ -115,7 +151,7 @@ function readState(path) {
115
151
  * the parsed criteria file. `emit(file)` runs `outerlayer emit criteria
116
152
  * <file>` and returns `{ ok: true }` or `{ ok: false, message }`.
117
153
  */
118
- export async function recordCriteria({ input, item, drafted = false, reportsDir, statePath, emit }) {
154
+ export async function recordCriteria({ input, item, drafted = false, reportsDir, statePath, emit, readRecorded, reportStep }) {
119
155
  const read = readCriteria(input, item, { drafted });
120
156
  const numbering = read.numbered ? { numbered: true, numberedBecause: read.numberedBecause } : { numbered: false };
121
157
  let outcome;
@@ -132,13 +168,21 @@ export async function recordCriteria({ input, item, drafted = false, reportsDir,
132
168
  if (result.ok) {
133
169
  outcome = { source, recorded: true, ...base };
134
170
  } else if (classifyEmitFailure(result.message) === "item-list-stands") {
135
- // No command returns the item's own list, so `list` stays empty; the build's reading goes under `issueReading`.
171
+ // `list` stays empty and the build's reading goes under `issueReading`.
172
+ // The item's own list goes under `recordedList` when it could be read,
173
+ // and `differs` says whether it is another list (`null`: unknown).
174
+ const recorded = readRecorded === undefined ? null : await readRecorded();
175
+ const differs = recorded === null ? null : !sameCriteria(recorded, read.criteria);
176
+ const reported = differs === true && reportStep !== undefined ? await reportStep(DIFFERS_STEP) : false;
136
177
  outcome = {
137
178
  source: "item",
138
179
  recorded: false,
139
180
  count: read.criteria.length,
140
181
  list: [],
141
182
  issueReading: read.criteria,
183
+ ...(recorded === null ? {} : { recordedList: recorded }),
184
+ differs,
185
+ ...(differs === true ? { differsReported: reported } : {}),
142
186
  ...numbering,
143
187
  note: "the item already has a recorded list; it stands",
144
188
  };
@@ -156,7 +200,17 @@ export function summarize(outcome) {
156
200
  const noun = n === 1 ? "criterion" : "criteria";
157
201
  const ids = outcome.numbered ? `, numbered AC-<item>-NN because ${outcome.numberedBecause}` : ", with the issue's own ids";
158
202
  if (outcome.source === "none") return "no criteria in the file; none recorded";
159
- if (outcome.source === "item") return `the item already has a recorded list; it stands, and the ${n} ${noun} read here were not recorded`;
203
+ if (outcome.source === "item") {
204
+ if (outcome.differs === false) return `the item already has these ${n} ${noun} recorded`;
205
+ if (outcome.differs === true) {
206
+ const where = outcome.differsReported ? "; the item's Activity tab says so" : "; reporting it on the item failed, so say it in the final report";
207
+ return (
208
+ `the item's recorded list differs from the ${n} ${noun} read here. It stands, and the item is held to it${where}. ` +
209
+ "A person replaces it with `outerlayer emit criteria`"
210
+ );
211
+ }
212
+ return `the item already has a recorded list; it stands, and the ${n} ${noun} read here were not recorded`;
213
+ }
160
214
  const what = outcome.source === "drafted" ? `drafted ${n} ${noun} from the issue's description` : `read ${n} ${noun} from the issue`;
161
215
  return outcome.recorded
162
216
  ? `${what} and recorded them${outcome.source === "drafted" ? " as drafted" : ids}`
@@ -195,6 +249,8 @@ async function main(argv) {
195
249
  reportsDir: join(gitDir, "build-reports"),
196
250
  statePath: join(gitDir, "build-state.json"),
197
251
  emit: makeEmit(spawnSync, item),
252
+ readRecorded: makeReadRecorded(spawnSync, item),
253
+ reportStep: makeReportStep(spawnSync, item),
198
254
  });
199
255
  process.stdout.write(`${summarize(outcome)}\n`);
200
256
  return 0;
@@ -9,7 +9,7 @@ description: >
9
9
  attach evidence or answer a review.
10
10
  ---
11
11
 
12
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
12
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
13
13
 
14
14
  OuterLayer is a software factory. An issue goes in. A reviewed and verified
15
15
  pull request comes out, with the evidence that backs it. An agent builds it in
@@ -8,7 +8,7 @@ description: >
8
8
  works", and in CI steps that produce verifiable output.
9
9
  ---
10
10
 
11
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
11
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
12
12
 
13
13
  An artifact is an exhibit: evidence *of* a specific change, anchored to the
14
14
  pull request and rendered in its evidence comment. Emit the few exhibits the
@@ -12,7 +12,7 @@ description: >
12
12
  `outerlayer emit finding` command.
13
13
  ---
14
14
 
15
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
15
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
16
16
 
17
17
  ## What this is for
18
18
 
@@ -74,8 +74,7 @@ title.
74
74
 
75
75
  ## What to record in a finding
76
76
 
77
- Fill these in whenever you know them. They are what lets the next agent
78
- recognise the problem and fix its cause.
77
+ Fill these in whenever you know them.
79
78
 
80
79
  - `--cause`: what is actually wrong. Write `unknown` when you do not know.
81
80
  - `--detail`: what happened, a few paragraphs.
@@ -124,9 +123,8 @@ failure: read the candidates, then run the same command again:
124
123
  - `--new` when none of them is the same problem. Use it rather than linking
125
124
  the nearest one.
126
125
 
127
- `--problem` always needs `--link-reason`. Apply rule 1 to every candidate: the
128
- same error is not enough, one fix has to resolve both. If a candidate's
129
- reports carry a different cause, it is a different problem.
126
+ `--problem` always needs `--link-reason`. Apply rule 1 to every candidate. If
127
+ a candidate's reports carry a different cause, it is a different problem.
130
128
 
131
129
  If a key of your finding is owned by a problem, the finding joins that problem
132
130
  whatever `--problem` you named, and the output says so.
@@ -86,6 +86,7 @@
86
86
  "82f75a53c591f94615dc27c099e94fcf95e2a09b95aceb38b9022d5f7f054cb0",
87
87
  "8d3a5131d0c82302279d319c51bcde74f7677e8d2e3f6cd68aa7f3ea84fa1051",
88
88
  "9039ae30d4e836b5c5cfba4a14344e46fd13694ae9e4d88e750abce34dfcd9e6",
89
+ "ba26711f3ac400f38e2bb78d4e410cde940e666317cd9aeb3fa949d89bb39f15",
89
90
  "fc2bec8d43764c4e4b40b0ee761b7f9ee8646950dd4e52392f652e68679f79ea"
90
91
  ],
91
92
  ".outerlayer/skills/writing-specs/SKILL.md": [
@@ -9,7 +9,7 @@ description: >
9
9
  /spec <what you want built>.
10
10
  ---
11
11
 
12
- Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule.
12
+ Run the CLI as `outerlayer`. If that command is not on PATH, run `npx -y @outerlayer/cli` with the same arguments. Every `outerlayer ...` command below follows this rule. Write the full command each time. Do not put it in a shell variable: zsh runs `$VAR args` as one command name, not as a command with arguments.
13
13
 
14
14
  Take one idea from a sentence to an issue a person can build from. This file
15
15
  is the procedure. The content discipline is the `writing-specs` skill: summary