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

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:11:53.196Z+8818aa0","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.20261022160949",
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.",
@@ -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;