@outerlayer/cli 0.19.1 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @outerlayer/cli
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 89270e0: `outerlayer emit criteria` reads an acceptance file ending in `.md`, so a person can record or replace an item's criteria from the file a build committed.
8
+ - bf0bf30: `outerlayer work stop --item <n>` asks the host building an item to stop. The runner reports what it is doing, so an item can say why nothing is building it.
9
+
3
10
  ## 0.19.1
4
11
 
5
12
  ### Patch Changes
package/README.md CHANGED
@@ -91,8 +91,10 @@ content:
91
91
  work item's general thread or a criterion thread — the detected session id
92
92
  rides along the same way it does for `emit`, only ever narrowing what the
93
93
  comment may do; `outerlayer work threads` reads them back. `outerlayer work logs`
94
- reads the runner's log for a host attempt. A runner key
95
- needs `git.read` and `work.read` to
94
+ reads the runner's log for a host attempt. `outerlayer work stop` sends
95
+ `POST /v1/work-items/<n>/claim/stop` with the optional note, and the
96
+ gateway records the request against the item's live host build. A runner
97
+ key needs `git.read` and `work.read` to
96
98
  list and read items, and `work.claim` to claim, renew or release — `work.claim`
97
99
  is not granted to a dashboard role by default, since a claim is a host's
98
100
  lease, not a person's.
@@ -118,6 +120,12 @@ content:
118
120
  off for want of disk it adds the free and needed bytes. While a check of
119
121
  its own stops it claiming, it adds one sentence saying why, which can
120
122
  include the host's usable memory and Docker's own error text, unscrubbed.
123
+ Every heartbeat also says what the runner is doing about new work (taking
124
+ work, draining, restarting, holding for an update, blocked or low on
125
+ disk), the queues and repository patterns it takes, and, while draining,
126
+ the numbers of the items it waits on. The reply can name a build that
127
+ someone stopped with `outerlayer work stop`; the runner then ends that
128
+ build and releases it as stopped, with the name of whoever stopped it.
121
129
  When a build's command exits 0, it reads that work item once more, for its
122
130
  evidence verdict, before releasing it: it sends nothing but the request.
123
131
  Just before that read, the runner runs the repository's host checks. A host
@@ -408,6 +416,7 @@ before it gives up.
408
416
  | `outerlayer emit artifact <file> --caption <text> [--for <criterion-id>] [--pr <n>]` | Upload a proof artifact — screenshot, recording, report, or log — with its caption. Inside a recorded session it spools locally and ships on the next `sync`; otherwise it uploads immediately, anchored to a pull request or the current checkout. `--replaces` retires artifacts an earlier run uploaded. A JUnit XML file bound with `--for` proves a criterion that declares `test` proof; `--test <name[=path:line]>` binds only the named tests. |
409
417
  | `outerlayer emit <name> --result <pass\|fail> [--link <url>] [--body <text>\|--body-file <path>] --item <number>` | Record one named check's outcome on a work item. A check that ran carries the run URL as its proof (`--link`); a judgment you are making yourself carries one sentence (`--body`, or `--body-file` to read it from a file — `-` reads standard input). A `fail` needs at least one of the two; a `pass` needs neither. `--item` names the work item by the number printed when the item was created — always required, in or out of a recorded session; a session records checks on its own item like any other, stored as the session's, but cannot clear a person's fail. Prints the recorded check's id. Who recorded it comes from your API key, never from what you send. |
410
418
  | `outerlayer work comment --item <n> [--criterion <id>] --body <text> [--artifact <id>] [--pass\|--fail\|--attach\|--ready]` | Post one comment on a work item's general thread, or on one acceptance criterion's own thread with `--criterion`. `--pass`/`--fail` need a person's own credential and apply to the whole item, so they work on the general thread only and the gateway refuses them with `--criterion`; `--attach` makes the named artifact that criterion's current proof; `--ready` hands a thread back to a person. The detected session id rides along automatically, only ever narrowing what the comment may do. |
419
+ | `outerlayer work stop --item <n> [--note <text>]` | Ask the host that holds the item's build to end it. The host reads the request at its next heartbeat and ends the build with outcome `stopped`, so the build may run a little longer. The item is not built again until a new build request. Needs `work.update`. Exits 1 when no host build is running for the item. |
411
420
  | `outerlayer work logs --item <n> [--attempt <claim id>]` | Print the runner's log for a host attempt on a work item: the newest attempt a runner made, or the one `--attempt` names (an `id` from `claims` in `work status --json`). The log is what the runner did around the agent, with secrets removed on the host. Exits 1 for an attempt that is still running or that an older runner released without a log. |
412
421
  | `outerlayer work threads --item <n>` | Read a work item's threads back — the general thread first, then one per criterion with proof or a comment, each with its `waitingOn`. |
413
422
  | `outerlayer emit finding --id <id> --category <context\|flaky-test\|tooling\|environment\|defect\|other> --title <text> --file <path> --where <label> [--line <n>] [--item <number>] [--cause <text>] [--detail <text>] [--suggestion <text>] [--command <text>] [--error <text>] [--test <name>] [--symbol <name>] [--started-at <event> --ended-at <event>] [--problem <id> … --link-reason <text> \| --new] …` | Record one problem you hit in the factory. `--category` is one of `context` (an instruction that is wrong, broken or missing), `flaky-test`, `tooling`, `environment`, `defect` (a bug outside your own change) or `other`. A `context` finding also needs `--rule-path` and `--rule-relation` (`wrong`, `broken` or `missing`), and `--rule-quote` unless the relation is `missing`. `--cause`, `--detail`, `--suggestion`, `--command`, `--error` (the first error line), `--test` and `--symbol` carry what a fix needs. Every finding joins a factory-level problem: when a key of the finding matches a known problem it joins that problem; otherwise nothing is stored, the closest problems are printed, and the command exits with code 3. Run it again with `--problem <id> --link-reason <text>` (repeat `--problem` for each problem the finding reports) or with `--new`. Without `--started-at`, it prints the session's recent events with ids and times; name the event where you hit the problem and the one where you got past it with `--started-at` and `--ended-at`. Findings are informational; nothing reads one to pass or fail the work. `--item` names the work item; without it, inside a recorded session, the item that session was launched for is used automatically. Re-emitting the same `--id` on the item replaces that finding and keeps its problems. |
@@ -1 +1 @@
1
- {"id":"2026-10-13T20:24:21.442Z+3c40b5d","root":"/home/runner/_work/outerlayer/outerlayer","pkg":"outerlayer"}
1
+ {"id":"2026-10-14T00:32:09.011Z+25b1e13","root":"/home/runner/_work/outerlayer/outerlayer","pkg":"outerlayer"}
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { probeCredentials } from './chunk-F2KXHIFI.js';
3
+ import { probeCredentials } from './chunk-AD3EYI7P.js';
4
4
  import './chunk-B6SCN5NY.js';
5
+ import './chunk-SADUQSO4.js';
5
6
  import './chunk-MTP2QEVO.js';
6
7
  import './chunk-S5PCA3SN.js';
7
8
  import './chunk-3JVYMD4G.js';
8
9
  import './chunk-YETTWTAY.js';
9
- import './chunk-SADUQSO4.js';
10
10
  import './chunk-I2UOAV7X.js';
11
- import './chunk-BWSUPRDK.js';
11
+ import './chunk-ZX4I2MEK.js';
12
12
  import './chunk-JHGEBPLE.js';
13
13
  import './chunk-XNGMQ27R.js';
14
14
  import './chunk-R22NZSGA.js';
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
3
  import { LEASE_SECONDS, releaseLease, isUnfinished, setStage, jobEnv, runHook, describeHookEnd, provisionMicroVm, deliverCommitHooks, provisionProcess, provisionEnvironment, readProvisionOut, resolveHostBuild, workdirProblem, listenOnBroker, hostEnvironment, hostCommandEnvironment, execArgv, microVmCommandLine, runCommand, execCommandLine, startSender, startHostServices, finishJob, isGroupAlive, outcomeFromExitCode, isTimedOut, readCpuUsec, isIdle, stopOrphanedMicroVms, unfinishedJobs, listAdoptableJobs, retryFailedReleases, recoverLostJobs, hookLimitsFor, AttemptLogBook, needsRenew, renewLease, RENEW_INTERVAL_SECONDS, ALREADY_RELEASED } from './chunk-B6SCN5NY.js';
4
+ import { writeJobFile, readJobFile, readJobFileChecked } from './chunk-SADUQSO4.js';
4
5
  import { writePendingUpdate } from './chunk-MTP2QEVO.js';
5
6
  import { readHousekeepingRecord, writeHousekeepingRecord } from './chunk-S5PCA3SN.js';
6
7
  import { decideInstalledSwitch } from './chunk-3JVYMD4G.js';
7
8
  import { fetchVersionMeta, npmVersion, npmSupportsProvenance, fetchTarballDigest, fetchProvenance, installVersion, auditSignatures } from './chunk-YETTWTAY.js';
8
- import { writeJobFile, readJobFile, readJobFileChecked } from './chunk-SADUQSO4.js';
9
9
  import { compareVersions, satisfiesRange } from './chunk-I2UOAV7X.js';
10
- import { RUNNER_PROTOCOL } from './chunk-BWSUPRDK.js';
10
+ import { RUNNER_PROTOCOL } from './chunk-ZX4I2MEK.js';
11
11
  import { currentSkillCommand, formerSkillProblem, placeholderQueues, placeholderProblem } from './chunk-GEMQLBMW.js';
12
12
  import { freeBytes, formatBytes } from './chunk-YYYRBYWG.js';
13
13
  import { writeMarker, readMarker, currentVersion, removeMarker, entryIn, versionDir, startedThroughCurrent, switchCurrent, cleanVersions, isInLayout, stagingDir } from './chunk-AMFWYESY.js';
@@ -85,6 +85,9 @@ var HEARTBEAT_TIMEOUT_MS = 1e4;
85
85
  var ERROR_CODE_MAX = 100;
86
86
  var ERROR_MESSAGE_MAX = 500;
87
87
  var BLOCKED_REASON_MAX = 500;
88
+ var TAKES_PATTERNS_MAX = 50;
89
+ var TAKES_PATTERN_CHARS_MAX = 200;
90
+ var WAITING_ITEMS_MAX = 200;
88
91
  var UNREACHABLE_MESSAGE = "the request did not reach the platform";
89
92
  function noPlatformMessage(status) {
90
93
  return `the request got HTTP ${status} with no error message from the platform`;
@@ -112,6 +115,35 @@ function workRequestFromFailure(err) {
112
115
  function workRequestFromRetryFailure(outcome) {
113
116
  return shapeWorkRequestFailure(outcome.code, outcome.status, outcome.message);
114
117
  }
118
+ function stopsOf(data) {
119
+ const raw = data?.stops;
120
+ if (!Array.isArray(raw)) return [];
121
+ const stops = [];
122
+ for (const entry of raw) {
123
+ const stop = entry;
124
+ if (stop === null || typeof stop !== "object" || typeof stop.claimId !== "string") continue;
125
+ stops.push({
126
+ claimId: stop.claimId,
127
+ requestedByName: typeof stop.requestedByName === "string" ? stop.requestedByName : "",
128
+ note: typeof stop.note === "string" && stop.note !== "" ? stop.note : null
129
+ });
130
+ }
131
+ return stops;
132
+ }
133
+ function takesBody(takes) {
134
+ let truncated = false;
135
+ const list = (patterns) => {
136
+ const all = (patterns ?? []).filter((pattern) => typeof pattern === "string");
137
+ if (all.length > TAKES_PATTERNS_MAX) truncated = true;
138
+ return all.slice(0, TAKES_PATTERNS_MAX).map((pattern) => {
139
+ if (pattern.length > TAKES_PATTERN_CHARS_MAX) truncated = true;
140
+ return cutForGateway(pattern, TAKES_PATTERN_CHARS_MAX);
141
+ }).filter((pattern) => pattern !== "");
142
+ };
143
+ const include = list(takes.repos.include);
144
+ const exclude = list(takes.repos.exclude);
145
+ return { queues: takes.queues, repos: { include, exclude }, truncated };
146
+ }
115
147
  async function sendHeartbeat(args, fetchImpl) {
116
148
  const body = {
117
149
  host: args.host,
@@ -132,7 +164,10 @@ async function sendHeartbeat(args, fetchImpl) {
132
164
  // Free disk on the runner's file system, in bytes. Sent only while held, so a host with room sends nothing about its disk.
133
165
  lowDisk: args.lowDisk === void 0 ? void 0 : { free: args.lowDisk.free, needed: args.lowDisk.needed },
134
166
  // Why the runner claims nothing. Sent only while it holds, so a runner that takes work says nothing about it.
135
- blocked: args.blocked === void 0 ? void 0 : { reason: cutForGateway(args.blocked.reason, BLOCKED_REASON_MAX) }
167
+ blocked: args.blocked === void 0 ? void 0 : { reason: cutForGateway(args.blocked.reason, BLOCKED_REASON_MAX) },
168
+ state: args.state,
169
+ waitingItems: args.waitingItems === void 0 ? void 0 : args.waitingItems.slice(0, WAITING_ITEMS_MAX),
170
+ takes: args.takes === void 0 ? void 0 : takesBody(args.takes)
136
171
  };
137
172
  try {
138
173
  const response = await postJson(
@@ -146,11 +181,22 @@ async function sendHeartbeat(args, fetchImpl) {
146
181
  await signerFor(args.configPath)
147
182
  );
148
183
  const runnerVersion = response.data?.runnerVersion;
149
- return typeof runnerVersion === "string" ? { ok: true, runnerVersion } : { ok: true };
184
+ const stops = stopsOf(response.data);
185
+ return typeof runnerVersion === "string" ? { ok: true, runnerVersion, stops } : { ok: true, stops };
150
186
  } catch (err) {
151
187
  return { ok: false, reason: describeCallFailure(err) };
152
188
  }
153
189
  }
190
+
191
+ // src/runner/host-state.ts
192
+ function hostState(flags) {
193
+ if (flags.draining) return "draining";
194
+ if (flags.restarting) return "restarting";
195
+ if (flags.holdingForUpdate) return "holding-for-update";
196
+ if (flags.blocked) return "blocked";
197
+ if (flags.lowDisk) return "low-disk";
198
+ return "taking-work";
199
+ }
154
200
  var OUTCOMES = ["updated", "rolled_back", "verification_failed", "not_published", "retrying"];
155
201
  function lastUpdatePath(configPath) {
156
202
  return join(runnerDir(configPath), "last-update.json");
@@ -1042,6 +1088,7 @@ async function sendTickHeartbeat(state, workRequest) {
1042
1088
  let next = workRequest;
1043
1089
  for (; ; ) {
1044
1090
  reportRepeating(state, "the heartbeat's previous call", void 0, "line");
1091
+ const waitingItems = state.draining === true ? runningItems(state) : [];
1045
1092
  const result = await sendHeartbeat(
1046
1093
  {
1047
1094
  configPath: state.config.configPath,
@@ -1056,12 +1103,25 @@ async function sendTickHeartbeat(state, workRequest) {
1056
1103
  workRequest: next,
1057
1104
  lastUpdate: readLastUpdate(state.config.configPath),
1058
1105
  lowDisk: state.lowDisk,
1059
- blocked: state.blocked
1106
+ blocked: state.blocked,
1107
+ state: hostState({
1108
+ draining: state.draining === true,
1109
+ restarting: state.restarting === true,
1110
+ holdingForUpdate: state.holdingForUpdate === true,
1111
+ blocked: state.blocked !== void 0,
1112
+ lowDisk: state.lowDisk !== void 0
1113
+ }),
1114
+ // Left out unless a draining runner has builds to wait on, so the gateway stores null.
1115
+ waitingItems: waitingItems.length > 0 ? waitingItems : void 0,
1116
+ takes: { queues: state.config.queues, repos: { include: state.config.repos?.include, exclude: state.config.repos?.exclude } }
1060
1117
  },
1061
1118
  state.deps.fetchImpl
1062
1119
  );
1063
1120
  reportRepeating(state, "the heartbeat", result.ok ? void 0 : `heartbeat failed: ${result.reason}`);
1064
- if (result.ok) considerNamedVersion(state, result.runnerVersion);
1121
+ if (result.ok) {
1122
+ considerNamedVersion(state, result.runnerVersion);
1123
+ applyStops(state, result.stops);
1124
+ }
1065
1125
  next = state.queuedWorkRequest;
1066
1126
  state.queuedWorkRequest = void 0;
1067
1127
  if (next === void 0) break;
@@ -1070,6 +1130,27 @@ async function sendTickHeartbeat(state, workRequest) {
1070
1130
  state.heartbeating = false;
1071
1131
  }
1072
1132
  }
1133
+ function applyStops(state, stops) {
1134
+ for (const stop of stops) {
1135
+ const held = [...state.jobs.values()].find((entry) => entry.job.claimId === stop.claimId);
1136
+ const active = held !== void 0 && !held.ending ? held : void 0;
1137
+ if (active === void 0) {
1138
+ reportRepeating(
1139
+ state,
1140
+ `stop of claim ${stop.claimId}`,
1141
+ held === void 0 ? `ignoring a stop for claim ${stop.claimId}: this runner holds no build under that claim` : `#${held.job.item} a stop was asked for; the build is already ending`,
1142
+ "line"
1143
+ );
1144
+ continue;
1145
+ }
1146
+ const who = `stopped by ${stop.requestedByName === "" ? "someone" : stop.requestedByName} through OuterLayer`;
1147
+ const reason = stop.note === null ? who : `${who}: ${stop.note}`;
1148
+ logOf(state).line(`#${active.job.item} ${reason} \xB7 ending the build`);
1149
+ void endJob(state, active, "stopped", { reason }).catch((err) => {
1150
+ logOf(state).error(`#${active.job.item} could not be stopped: ${describeCallFailure(err)}`);
1151
+ });
1152
+ }
1153
+ }
1073
1154
  function ownVersionOf(deps) {
1074
1155
  return (deps.cliVersion ?? resolveVersion()).split(" ")[0] ?? "";
1075
1156
  }
@@ -1405,9 +1486,17 @@ async function housekeepingPass(state) {
1405
1486
  }
1406
1487
  }
1407
1488
  async function runTick(state) {
1489
+ state.restarting = false;
1490
+ state.holdingForUpdate = false;
1408
1491
  reloadConfig(state);
1409
- if (restartOntoInstalled(state)) return;
1410
- if (await updateWhenIdle(state)) return;
1492
+ if (restartOntoInstalled(state)) {
1493
+ state.restarting = true;
1494
+ return;
1495
+ }
1496
+ if (await updateWhenIdle(state)) {
1497
+ state.holdingForUpdate = true;
1498
+ return;
1499
+ }
1411
1500
  if (holdWhenBlocked(state)) return;
1412
1501
  if (holdOffForDisk(state)) return;
1413
1502
  const log = logOf(state);
@@ -2226,7 +2315,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2226
2315
  const outcome = await onStartUnderMarker({
2227
2316
  configPath,
2228
2317
  own: ownVersionOf(deps),
2229
- check: update.check ? () => update.check(configPath) : async () => (await import('./check-W2JGXN3B.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
2318
+ check: update.check ? () => update.check(configPath) : async () => (await import('./check-BR5D27CX.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
2230
2319
  now: () => (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime(),
2231
2320
  log
2232
2321
  });
@@ -2312,12 +2401,11 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2312
2401
  }
2313
2402
  let intakeClosed = false;
2314
2403
  let closed = false;
2315
- let draining = false;
2316
2404
  let stopping = false;
2317
2405
  let exited = false;
2318
2406
  let interrupts = 0;
2319
2407
  state.pollTimerSeconds = state.config.pollSeconds;
2320
- const pollTimer = setInterval(() => void tick(state), state.pollTimerSeconds * 1e3);
2408
+ let pollTimer = setInterval(() => void tick(state), state.pollTimerSeconds * 1e3);
2321
2409
  const hooksKind = builtinKind(config.hooks.provision);
2322
2410
  const housekeepingTimer = hooksKind === "container" || hooksKind === "vm" ? (() => {
2323
2411
  const everyMs = deps.housekeeping?.intervalMs ?? HOUSEKEEPING_INTERVAL_MS;
@@ -2357,6 +2445,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2357
2445
  if (intakeClosed) return;
2358
2446
  intakeClosed = true;
2359
2447
  clearInterval(pollTimer);
2448
+ pollTimer = setInterval(() => void sendTickHeartbeat(state, void 0), (state.pollTimerSeconds ?? state.config.pollSeconds) * 1e3);
2360
2449
  clearInterval(housekeepingTimer);
2361
2450
  };
2362
2451
  const close = () => {
@@ -2364,6 +2453,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2364
2453
  detachSignals();
2365
2454
  if (closed) return;
2366
2455
  closed = true;
2456
+ clearInterval(pollTimer);
2367
2457
  clearInterval(watchTimer);
2368
2458
  };
2369
2459
  const finish2 = () => {
@@ -2374,8 +2464,8 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2374
2464
  log.line("runner exited");
2375
2465
  };
2376
2466
  const drain = async (onWaiting) => {
2377
- if (draining) return;
2378
- draining = true;
2467
+ if (state.draining === true) return;
2468
+ state.draining = true;
2379
2469
  stopIntake();
2380
2470
  let passes = 0;
2381
2471
  while (state.jobs.size > 0 && !exited) {
@@ -2397,6 +2487,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
2397
2487
  if (stopping) return;
2398
2488
  stopping = true;
2399
2489
  stopIntake();
2490
+ clearInterval(pollTimer);
2400
2491
  clearInterval(watchTimer);
2401
2492
  log.line(`stopping: ending ${state.jobs.size} running job(s) now`);
2402
2493
  await Promise.all([...state.jobs.values()].map((active) => endJob(state, active, "stopped")));
@@ -4,6 +4,6 @@ import { createRequire } from 'node:module';
4
4
  createRequire(import.meta.url);
5
5
 
6
6
  // src/runner/protocol.ts
7
- var RUNNER_PROTOCOL = 20;
7
+ var RUNNER_PROTOCOL = 21;
8
8
 
9
9
  export { RUNNER_PROTOCOL };
@@ -4432,7 +4432,7 @@ the step, the command prints why and exits 1.
4432
4432
  throw err;
4433
4433
  }
4434
4434
  });
4435
- emitCmd.command("criteria <file>").description("Record a work item's acceptance criteria from a JSON file (- for stdin)").option("--item <number>", "work item to record on directly, by its number in this factory").option("--json", "machine-readable JSON output").option("--url <url>", "cloud base URL (or OUTERLAYER_URL / config)").addOption(apiKeyOption()).option("--app-id <id>", "factory id the key is bound to (or OUTERLAYER_APP_ID / config)").addHelpText(
4435
+ emitCmd.command("criteria <file>").description("Record a work item's acceptance criteria from a JSON or acceptance .md file").option("--item <number>", "work item to record on directly, by its number in this factory").option("--json", "machine-readable JSON output").option("--url <url>", "cloud base URL (or OUTERLAYER_URL / config)").addOption(apiKeyOption()).option("--app-id <id>", "factory id the key is bound to (or OUTERLAYER_APP_ID / config)").addHelpText(
4436
4436
  "after",
4437
4437
  `
4438
4438
  ${cliInvocation()} emit criteria criteria.json --item 412
@@ -4450,9 +4450,14 @@ Once an item has a list, only a person can replace it: run the command outside a
4450
4450
  agent session, with your own login. A key bound to no member cannot replace it either.
4451
4451
  --item names the work item; without it, inside a recorded session, the item that
4452
4452
  session was launched for is used automatically.
4453
+
4454
+ A file ending in .md is read as an acceptance file, such as acceptance/412-login.md:
4455
+ each list item that opens with an id in backticks is a criterion, its text is the rest
4456
+ of the item, and a (proof: <kind>) annotation names its proof.
4457
+ ${cliInvocation()} emit criteria acceptance/412-login.md --item 412
4453
4458
  `
4454
4459
  ).action(async (file, opts) => {
4455
- const { runEmitCriteria, EmitCriteriaError } = await import('./emit-criteria-cmd-CRTYEF7D.js');
4460
+ const { runEmitCriteria, EmitCriteriaError } = await import('./emit-criteria-cmd-UNMWHOUY.js');
4456
4461
  try {
4457
4462
  const result = await runEmitCriteria({
4458
4463
  file,
@@ -4625,6 +4630,30 @@ command exits 1.
4625
4630
  throw err;
4626
4631
  }
4627
4632
  });
4633
+ workCmd.command("stop").description("Stop the host build running on a work item").requiredOption("--item <number>", "the item's number inside this factory").option("--note <text>", "why the build is stopped (up to 500 characters); kept with the attempt").option("--json", "machine-readable JSON output").option("--url <url>", "cloud base URL (or OUTERLAYER_URL / config)").addOption(apiKeyOption()).option("--app-id <id>", "factory id the key is bound to (or OUTERLAYER_APP_ID / config)").addHelpText(
4634
+ "after",
4635
+ `
4636
+ ${cliInvocation()} work stop --item 7
4637
+ ${cliInvocation()} work stop --item 7 --note "wrong branch"
4638
+
4639
+ Asks the host that holds the item's build to end it. The host reads the
4640
+ request at its next heartbeat, so the build may run a little longer. The
4641
+ item is not built again until a new build request. Exits 1 when no host
4642
+ build is running for the item.
4643
+ `
4644
+ ).action(async (opts) => {
4645
+ const { runWorkStop, WorkStopCommandError } = await import('./work-stop-cmd-CRDHXQVW.js');
4646
+ const { WorkCommandError } = await import('./work-cmd-5I3J2XQQ.js');
4647
+ try {
4648
+ await runWorkStop(opts);
4649
+ } catch (err) {
4650
+ if (err instanceof WorkStopCommandError || err instanceof WorkCommandError) {
4651
+ fail(err.message);
4652
+ return;
4653
+ }
4654
+ throw err;
4655
+ }
4656
+ });
4628
4657
  workCmd.command("remove").description("Withdraw a source's addition \u2014 never deletes the item").option("--item <number>", "the item's number inside this factory").option("--issue <number>", "issue number or Linear/Jira key (default: named by --item instead)").option(TRACKER_OPTION_FLAG, "tracker for a keyed --issue, when both hold the key").option("--repo <owner/name>", "repository for an issue number (default: the checkout's remote)").requiredOption("--reason <text>", "why this source is no longer working on it").option("--json", "machine-readable JSON output").option("--url <url>", "cloud base URL (or OUTERLAYER_URL / config)").addOption(apiKeyOption()).option("--app-id <id>", "factory id the key is bound to (or OUTERLAYER_APP_ID / config)").addHelpText(
4629
4658
  "after",
4630
4659
  `
@@ -4680,7 +4709,7 @@ by a different key is refused; claiming again with the same key extends
4680
4709
  it. --host is a label only.
4681
4710
  `
4682
4711
  ).action(async (opts) => {
4683
- const { runWorkClaim, WorkCommandError } = await import('./work-claim-cmd-ZRPSHMPT.js');
4712
+ const { runWorkClaim, WorkCommandError } = await import('./work-claim-cmd-DRQCHCOK.js');
4684
4713
  try {
4685
4714
  await runWorkClaim(opts);
4686
4715
  } catch (err) {
@@ -4700,7 +4729,7 @@ Extends the live lease this API key holds. Refused with a 410 if the
4700
4729
  lease has expired, or a 403 if a different key holds it.
4701
4730
  `
4702
4731
  ).action(async (opts) => {
4703
- const { runWorkRenew, WorkCommandError } = await import('./work-claim-cmd-ZRPSHMPT.js');
4732
+ const { runWorkRenew, WorkCommandError } = await import('./work-claim-cmd-DRQCHCOK.js');
4704
4733
  try {
4705
4734
  await runWorkRenew(opts);
4706
4735
  } catch (err) {
@@ -4732,7 +4761,7 @@ applies to a host's lease only. A later tool call in the session
4732
4761
  takes the lease again.
4733
4762
  `
4734
4763
  ).action(async (opts) => {
4735
- const { runWorkRelease, WorkCommandError } = await import('./work-claim-cmd-ZRPSHMPT.js');
4764
+ const { runWorkRelease, WorkCommandError } = await import('./work-claim-cmd-DRQCHCOK.js');
4736
4765
  try {
4737
4766
  await runWorkRelease(opts);
4738
4767
  } catch (err) {
@@ -4802,7 +4831,7 @@ function registerRunnerCommands(program2) {
4802
4831
  const configOption = "the cloud config file to run against (default: ~/.outerlayer/config.json)";
4803
4832
  runnerCmd.command("start").description("Run the loop: claim work, run it, report, clean up, repeat").option("--config <path>", configOption).action(async (opts) => {
4804
4833
  const { defaultRunnerConfigPath, RunnerConfigError } = await import('./config-CLSGABD5.js');
4805
- const { start, RunnerStartError } = await import('./loop-TPUYUMSY.js');
4834
+ const { start, RunnerStartError } = await import('./loop-EAZQMAE2.js');
4806
4835
  const { RunnerAlreadyRunningError } = await import('./pidfile-3DM4OBKO.js');
4807
4836
  const configPath = opts.config ?? defaultRunnerConfigPath();
4808
4837
  try {
@@ -4818,7 +4847,7 @@ function registerRunnerCommands(program2) {
4818
4847
  });
4819
4848
  runnerCmd.command("check").description("Validate the config, the hooks and the key, and print the effective settings").option("--config <path>", configOption).action(async (opts) => {
4820
4849
  const { defaultRunnerConfigPath } = await import('./config-CLSGABD5.js');
4821
- const { runRunnerCheck } = await import('./check-W2JGXN3B.js');
4850
+ const { runRunnerCheck } = await import('./check-BR5D27CX.js');
4822
4851
  const configPath = opts.config ?? defaultRunnerConfigPath();
4823
4852
  const result = await runRunnerCheck(configPath);
4824
4853
  if (!result.ok) {
@@ -5209,14 +5238,14 @@ Next:
5209
5238
  });
5210
5239
  runnerCmd.command("stop").description("Take no more work; wait for running jobs to finish, then exit").option("--config <path>", "the cloud config file naming the runner to stop").option("--now", "end running jobs immediately, with outcome stopped").action(async (opts) => {
5211
5240
  const { defaultRunnerConfigPath } = await import('./config-CLSGABD5.js');
5212
- const { requestStop } = await import('./loop-TPUYUMSY.js');
5241
+ const { requestStop } = await import('./loop-EAZQMAE2.js');
5213
5242
  const configPath = opts.config ?? defaultRunnerConfigPath();
5214
5243
  await requestStop(configPath, Boolean(opts.now));
5215
5244
  });
5216
5245
  runnerCmd.command("status").description("List running jobs; with --recent, finished ones too").option("--config <path>", "the cloud config file naming the runner to inspect").option("--recent", "also list finished jobs, newest first, capped at 20").option("--json", "machine-readable JSON output").action(async (opts) => {
5217
5246
  const { defaultRunnerConfigPath, loadRunnerConfig } = await import('./config-CLSGABD5.js');
5218
5247
  const { jobsDir, runnerDir } = await import('./paths-7XP63OAX.js');
5219
- const { listJobFiles, runnerProcessState } = await import('./loop-TPUYUMSY.js');
5248
+ const { listJobFiles, runnerProcessState } = await import('./loop-EAZQMAE2.js');
5220
5249
  const { renderStatus } = await import('./status-BINH7MBG.js');
5221
5250
  const { readHousekeepingRecord } = await import('./record-3WP6V2K5.js');
5222
5251
  const path2 = await import('path');
@@ -5273,7 +5302,7 @@ Next:
5273
5302
  });
5274
5303
  runnerCmd.command("logs").argument("<item>", "the work item's number in the factory").description("Print the newest attempt's log for one item").option("--config <path>", "the cloud config file naming the runner to inspect").option("-f, --follow", "keep printing as the log grows").action(async (itemArg, opts) => {
5275
5304
  const { defaultRunnerConfigPath } = await import('./config-CLSGABD5.js');
5276
- const { listJobFiles } = await import('./loop-TPUYUMSY.js');
5305
+ const { listJobFiles } = await import('./loop-EAZQMAE2.js');
5277
5306
  const { newestAttempt, logPathFor, tail, RunnerLogsError } = await import('./logs-AMLKC3VP.js');
5278
5307
  const configPath = opts.config ?? defaultRunnerConfigPath();
5279
5308
  const item = parseTrackerNumber(itemArg);
@@ -24,6 +24,33 @@ import './chunk-Z5GEFMRW.js';
24
24
  import { readFileSync } from 'fs';
25
25
 
26
26
  createRequire(import.meta.url);
27
+
28
+ // src/acceptance-file.ts
29
+ var CRITERION_LINE = /^\s*\d+\.\s+`(AC-(?:\d{3,}|[A-Z][A-Z0-9]*-\d+)-\d{2})`\s*(.*)$/;
30
+ var PROOF_ANNOTATION = /\(proof:\s*([A-Za-z]+)\)/;
31
+ var LIST_ITEM = /^\s*(?:\d+\.|[-*])\s/;
32
+ var CONTINUATION = /^\s+\S/;
33
+ function criteriaFromAcceptanceFile(markdown) {
34
+ const criteria = [];
35
+ let current = null;
36
+ for (const line of markdown.split(/\r?\n/)) {
37
+ const match = CRITERION_LINE.exec(line);
38
+ if (match) {
39
+ current = { id: match[1], text: match[2].trim(), proof: null };
40
+ criteria.push(current);
41
+ } else if (current !== null && CONTINUATION.test(line) && !LIST_ITEM.test(line)) {
42
+ current.text = `${current.text} ${line.trim()}`;
43
+ } else {
44
+ current = null;
45
+ }
46
+ }
47
+ for (const criterion of criteria) {
48
+ criterion.proof = PROOF_ANNOTATION.exec(criterion.text.replace(/`[^`]*`/g, ""))?.[1] ?? null;
49
+ }
50
+ return criteria;
51
+ }
52
+
53
+ // src/emit-criteria-cmd.ts
27
54
  var EmitCriteriaError = class extends Error {
28
55
  };
29
56
  var REPLACE_REFUSED_MESSAGE = "this item already has a recorded list, and a person must replace it. Run the command yourself, outside the session, with your own login. A repository's .outerlayer/policy.yaml can let sessions replace it with `criteria: { replace: anyone }`.";
@@ -111,10 +138,18 @@ async function runEmitCriteria(opts) {
111
138
  }
112
139
  }
113
140
  let raw;
114
- try {
115
- raw = JSON.parse(text);
116
- } catch (err) {
117
- throw new EmitCriteriaError(`"${opts.file}" is not valid JSON: ${String(err)}`);
141
+ if (opts.file.endsWith(".md")) {
142
+ const criteria = criteriaFromAcceptanceFile(text);
143
+ if (criteria.length === 0) {
144
+ throw new EmitCriteriaError(`"${opts.file}" lists no criteria: none of its list items opens with an id such as \`AC-123-01\``);
145
+ }
146
+ raw = { criteria };
147
+ } else {
148
+ try {
149
+ raw = JSON.parse(text);
150
+ } catch (err) {
151
+ throw new EmitCriteriaError(`"${opts.file}" is not valid JSON: ${String(err)}`);
152
+ }
118
153
  }
119
154
  const list = parseFile(raw);
120
155
  const identity = resolveRepoIdentity(cwd);
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-RL5E5OXE.js')).recordLinkExitFailure(argv);
29
- var { runCli } = await import('./cli-EXX2R73G.js');
29
+ var { runCli } = await import('./cli-MKF7TU7X.js');
30
30
  await runCli(process.argv);
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- export { RunnerStartError, listJobFiles, probeCredentials, requestStop, runnerProcessState, start, tick, watchTick } from './chunk-F2KXHIFI.js';
3
+ export { RunnerStartError, listJobFiles, probeCredentials, requestStop, runnerProcessState, start, tick, watchTick } from './chunk-AD3EYI7P.js';
4
4
  import './chunk-B6SCN5NY.js';
5
+ import './chunk-SADUQSO4.js';
5
6
  import './chunk-MTP2QEVO.js';
6
7
  import './chunk-S5PCA3SN.js';
7
8
  import './chunk-3JVYMD4G.js';
8
9
  import './chunk-YETTWTAY.js';
9
- import './chunk-SADUQSO4.js';
10
10
  import './chunk-I2UOAV7X.js';
11
- import './chunk-BWSUPRDK.js';
11
+ import './chunk-ZX4I2MEK.js';
12
12
  import './chunk-JHGEBPLE.js';
13
13
  import './chunk-XNGMQ27R.js';
14
14
  import './chunk-R22NZSGA.js';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { RUNNER_PROTOCOL } from './chunk-BWSUPRDK.js';
3
+ import { RUNNER_PROTOCOL } from './chunk-ZX4I2MEK.js';
4
4
  import { resolveCredentials, WorkCommandError, parsePositiveInt, postJson, environmentFacts, findLiveItemForIssue } from './chunk-FBTGMSQF.js';
5
5
  export { WorkCommandError } from './chunk-FBTGMSQF.js';
6
6
  import './chunk-IF2OSI5T.js';
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { resolveCredentials, postJson, WorkCommandError } from './chunk-FBTGMSQF.js';
4
+ import './chunk-IF2OSI5T.js';
5
+ import './chunk-CG36IONK.js';
6
+ import './chunk-G3IJ3N6F.js';
7
+ import './chunk-BM63HB2M.js';
8
+ import './chunk-QNLX477Q.js';
9
+ import './chunk-GOSFARMS.js';
10
+ import './chunk-ANPNT6PM.js';
11
+ import { parseTrackerNumber, trackerNumberError } from './chunk-WZBHBH3J.js';
12
+ import { defaultFetch } from './chunk-FVMYKL6L.js';
13
+ import './chunk-NTRCK4NH.js';
14
+ import './chunk-BJ3KTMDH.js';
15
+ import './chunk-EABW6AJQ.js';
16
+ import './chunk-CRB3POP4.js';
17
+ import './chunk-7VUK5RFS.js';
18
+ import './chunk-VNVZDWO3.js';
19
+ import './chunk-M2ZWWTWZ.js';
20
+ import './chunk-N2PXGQRL.js';
21
+ import './chunk-BQXFBI7T.js';
22
+ import './chunk-Z5GEFMRW.js';
23
+
24
+ createRequire(import.meta.url);
25
+
26
+ // src/work-stop-cmd.ts
27
+ var WorkStopCommandError = class extends Error {
28
+ };
29
+ async function runWorkStop(opts) {
30
+ const itemNumber = parseTrackerNumber(opts.item);
31
+ if (itemNumber === void 0) {
32
+ throw new WorkStopCommandError(trackerNumberError("--item", opts.item ?? ""));
33
+ }
34
+ const creds = resolveCredentials(opts);
35
+ const fetchImpl = opts.fetchImpl ?? defaultFetch;
36
+ const body = opts.note === void 0 || opts.note === "" ? {} : { note: opts.note };
37
+ let data;
38
+ try {
39
+ ({ data } = await postJson(creds.url, `/v1/work-items/${itemNumber}/claim/stop`, creds.apiKey, creds.appId, body, fetchImpl));
40
+ } catch (err) {
41
+ if (err instanceof WorkCommandError && err.code === "no_build_running") {
42
+ throw new WorkStopCommandError(`no host build is running for #${itemNumber}, so there is nothing to stop`);
43
+ }
44
+ throw err;
45
+ }
46
+ const claim = data;
47
+ const output = opts.json ? JSON.stringify(data) : `Stop requested for #${itemNumber}: ${claim.host ?? "the host"} ends the build at its next heartbeat. The stop stays pending until the build ends.`;
48
+ if (!opts.quiet) process.stdout.write(output + "\n");
49
+ return { data, output };
50
+ }
51
+
52
+ export { WorkStopCommandError, runWorkStop };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outerlayer/cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "OuterLayer CLI — commands: init, claude, connect, doctor, login, logout, sync, daemon (alias: watch), emit, work, spec, 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.",