loopctl-mcp-server 2.116.0 → 3.1.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.
@@ -84,7 +84,7 @@ function refuse(body) {
84
84
  * That 404 is the problem, not a 500. It is BYTE-IDENTICAL to the 404 for a perfectly
85
85
  * well-formed id that names no story — `FallbackController` answers `{:error, :not_found}` with
86
86
  * the same `%{error: %{status: 404, message: "Not found"}}`
87
- * (`lib/loopctl_web/fallback_controller.ex:74-78`) — so it cannot tell an operator which
87
+ * (`lib/loopctl_web/fallback_controller.ex:84-88`) — so it cannot tell an operator which
88
88
  * of the two happened, and the two have opposite remedies: re-read the argument you passed, or
89
89
  * go find the right story. The check here can tell them apart, and does it without a round trip.
90
90
  *
@@ -2,8 +2,8 @@
2
2
  * `github_credential`, `set_github_credential`, `clear_github_credential` — the tenant's own
3
3
  * GitHub token (#936), `GET|PUT|DELETE /api/v1/tenants/me/github-credential`.
4
4
  *
5
- * loopctl calls GitHub for a tenant in the merge gate, story and post-deploy verification,
6
- * thread checkpoint reads and issue closing. Each call authenticates with the tenant's own
5
+ * loopctl calls GitHub for a tenant in the merge gate, story and post-deploy verification
6
+ * and issue closing. Each call authenticates with the tenant's own
7
7
  * token when one is set, else with the operator's token for the (tenant, repository) pairs
8
8
  * the operator named in VERIFICATION_OPERATOR_TOKEN_TENANTS, else not at all
9
9
  * (`credential_unavailable`). The tenant's token is what lets loopctl serve a repository
@@ -74,10 +74,28 @@
74
74
  * is. The controller mounts `RequireRole, role: :user` — the HIERARCHY form, so a
75
75
  * `:superadmin` key would pass too and this package holds no such variable — plus
76
76
  * `RequireHumanAnchor` on create/update/delete and `RequireUnlineagedCaller` on create
77
- * (`lib/loopctl_web/controllers/intake_source_controller.ex:27-29`). That last one is why a
77
+ * (`lib/loopctl_web/controllers/intake_source_controller.ex:34-36`). That last one is why a
78
78
  * global `LOOPCTL_API_KEY` must not stand in: creating a source MINTS A CREDENTIAL that
79
79
  * belongs to no dispatch lineage, so a caller whose own key a dispatch minted is refused
80
- * `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`.
80
+ * `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`. An update that
81
+ * names a `chg` credential or sets `mode` to `chg` is held to the same ceiling under its own
82
+ * code, `403 chg_registration_forbidden`, whose remedy is the operator key itself.
83
+ *
84
+ * ## MODE `chg` AND ITS TWO CREDENTIALS (US-47.2, AC-47.7.2)
85
+ *
86
+ * A source in `mode: "chg"` has its changes landed by the change protocol (`chg submit`), and
87
+ * loopctl's merge precondition refuses every story on it with `409 change_protocol_enrolled`.
88
+ * It needs BOTH `change_events_secret` — the HMAC key chg-server signs its event webhook with,
89
+ * which the OPERATOR supplies because chg-server must hold the same value — and
90
+ * `chg_root_dispatch_id`, the parentless orchestrator-role dispatch chg-server's key was minted
91
+ * from (`422 chg_root_invalid` for anything else, another tenant's included).
92
+ *
93
+ * The secret is WRITE-ONLY on both sides: the server never serialises it, and `publicSource`
94
+ * below never names it, so no tool result carries it. A tool INPUT is recorded in the
95
+ * transcript too, so `change_events_secret_file` is offered beside it: the tool reads the
96
+ * value from that file and it never passes through a transcript at all. Exactly one trailing
97
+ * newline is stripped (what `openssl rand -hex 32 > file` writes); chg-server must hold the
98
+ * value without it.
81
99
  *
82
100
  * ## `repo_full_name` IS NOT SHAPE-CHECKED HERE, AND THAT IS A DECISION
83
101
  *
@@ -144,28 +162,142 @@ function epicRefusal(value, { clearable }) {
144
162
  }
145
163
 
146
164
  /**
147
- * The two merge routes a source may take (US-45.4). `pr` is the default and what every source
148
- * did before the field existed; `thread` makes the merge gate read the story's latest recorded
149
- * checkpoint instead of a pull request. NOT nullable — the server answers 422 — so a null is
150
- * refused here where the reason can be stated.
165
+ * The merge routes a source may take: `pr`, the default, and `chg` (US-47.2), where the change
166
+ * protocol lands the repository's changes and the PR route is closed. NOT nullable — the
167
+ * server answers 422 — so a null is refused here where the reason can be stated.
151
168
  */
152
- export const SOURCE_MODES = ["pr", "thread"];
169
+ export const SOURCE_MODES = ["pr", "chg"];
153
170
 
154
171
  export function modeRefusal(mode) {
155
172
  if (SOURCE_MODES.includes(mode)) return null;
156
173
 
157
174
  return refuse(
158
- "`mode` must be `pr` or `thread`, or must be left out entirely (enrolment then takes " +
159
- "`pr`, and an update keeps the current value). It cannot be null: every source reaches " +
160
- "its base branch by one route or the other.",
175
+ "`mode` must be `pr` or `chg`, or must be left out entirely (enrolment then takes `pr`, " +
176
+ "and an update keeps the current value). It cannot be null.",
177
+ );
178
+ }
179
+
180
+ /**
181
+ * The `chg` credentials a call NAMED, resolved into the request body fields, or a refusal.
182
+ *
183
+ * Resolves to `{ fields }` or `{ refusal }`. `clearable` is the update path, where an explicit
184
+ * null clears a credential on a `pr` source (the server refuses it on a `chg` one). No branch
185
+ * of this function puts the secret's value in a message.
186
+ */
187
+ export async function chgCredentials(args, { clearable, fs = defaultFs, homedir = os.homedir() }) {
188
+ const fields = {};
189
+ const { change_events_secret: secret, change_events_secret_file: secretFile } = args;
190
+
191
+ if (secret !== undefined && secretFile !== undefined) {
192
+ return {
193
+ refusal: refuse(
194
+ "Send `change_events_secret` OR `change_events_secret_file`, not both: they are two " +
195
+ "ways of naming the same value.",
196
+ ),
197
+ };
198
+ }
199
+
200
+ if (secret !== undefined) {
201
+ if (secret === null && clearable) {
202
+ fields.change_events_secret = null;
203
+ } else if (typeof secret !== "string" || secret === "") {
204
+ return {
205
+ refusal: refuse(
206
+ "`change_events_secret` must be a non-empty string (the server holds it to " +
207
+ "32-256 bytes)" +
208
+ (clearable ? ", or null to clear it on a `pr` source." : ".") +
209
+ " Prefer `change_events_secret_file`, which keeps the value out of the transcript.",
210
+ ),
211
+ };
212
+ } else {
213
+ fields.change_events_secret = secret;
214
+ }
215
+ }
216
+
217
+ if (secretFile !== undefined) {
218
+ const read = await readSecretFile(secretFile, { fs, homedir });
219
+ if (read.refusal) return read;
220
+ fields.change_events_secret = read.secret;
221
+ }
222
+
223
+ const root = args.chg_root_dispatch_id;
224
+
225
+ if (root !== undefined) {
226
+ if (root === null && clearable) {
227
+ fields.chg_root_dispatch_id = null;
228
+ } else {
229
+ const badRoot = uuidRefusal(root, "chg_root_dispatch_id");
230
+ if (badRoot) return { refusal: badRoot };
231
+ fields.chg_root_dispatch_id = root;
232
+ }
233
+ }
234
+
235
+ return { fields };
236
+ }
237
+
238
+ async function readSecretFile(secretFile, { fs, homedir }) {
239
+ if (typeof secretFile !== "string" || secretFile.trim() === "") {
240
+ return {
241
+ refusal: refuse("`change_events_secret_file` must be a path to the file holding the secret."),
242
+ };
243
+ }
244
+
245
+ const path = expandHome(secretFile, homedir);
246
+ if (!nodePath.isAbsolute(path)) {
247
+ return {
248
+ refusal: refuse(
249
+ `change_events_secret_file must be absolute or start with ~/ (got '${secretFile}').`,
250
+ ),
251
+ };
252
+ }
253
+
254
+ let raw;
255
+ try {
256
+ raw = await fs.readFile(path, "utf8");
257
+ } catch (err) {
258
+ return {
259
+ refusal: refuse(
260
+ `Could not read change_events_secret_file '${path}' (${err?.code || "error"}). ` +
261
+ "Nothing was sent.",
262
+ ),
263
+ };
264
+ }
265
+
266
+ const secret = raw.replace(/\r?\n$/, "");
267
+ if (secret === "") {
268
+ return { refusal: refuse(`change_events_secret_file '${path}' is empty. Nothing was sent.`) };
269
+ }
270
+
271
+ return { secret };
272
+ }
273
+
274
+ /**
275
+ * `mode: "chg"` at ENROLMENT needs both credentials in the same call (there is no earlier
276
+ * stint to keep them from), so their absence is refused here, before a secret file is
277
+ * reserved, with the 422 the server would answer named.
278
+ */
279
+ function chgEnrolmentRefusal(mode, fields) {
280
+ if (mode !== "chg") return null;
281
+
282
+ const missing = ["change_events_secret", "chg_root_dispatch_id"].filter(
283
+ (field) => fields[field] === undefined,
284
+ );
285
+
286
+ if (missing.length === 0) return null;
287
+
288
+ return refuse(
289
+ `\`mode: chg\` needs ${missing.map((f) => `\`${f}\``).join(" and ")} in the same call; ` +
290
+ "the server answers 422 without them. `chg_root_dispatch_id` is the parentless " +
291
+ "orchestrator-role dispatch chg-server's key was minted from (the operator mints it " +
292
+ "with `dispatch`, no parent), and the secret is the HMAC key chg-server signs its " +
293
+ "events with (`change_events_secret_file` keeps it out of the transcript).",
161
294
  );
162
295
  }
163
296
 
164
297
  /**
165
- * The CI checks a thread-mode checkpoint must pass on its exact commit (US-45.6). A list of
298
+ * The CI checks story verification judges a story's commit by (US-26.4.6). A list of
166
299
  * distinct, non-blank names; `local-gate` is refused because whoever pushed posts it. The
167
- * server holds the bounds and the "a thread source must name one" rule, which depends on the
168
- * stored mode this client cannot see, so only the shape is judged here.
300
+ * server holds the bounds, so only the shape is judged here.
169
301
  */
170
302
  export function requiredChecksRefusal(checks) {
171
303
  // The same shape the server holds (`Source.validate_required_checks/1`): non-blank, no
@@ -186,8 +318,8 @@ export function requiredChecksRefusal(checks) {
186
318
 
187
319
  if (checks.includes("local-gate")) {
188
320
  return refuse(
189
- "`local-gate` can never be a required check: whoever pushed posts it, so on a thread " +
190
- "it is the implementer attesting its own work. It is recorded on the checkpoint instead.",
321
+ "`local-gate` can never be a required check: whoever pushed posts it, so it is the " +
322
+ "implementer attesting its own work.",
191
323
  );
192
324
  }
193
325
 
@@ -224,6 +356,8 @@ export function publicSource(source) {
224
356
  base_branch: source.base_branch,
225
357
  mode: source.mode,
226
358
  required_checks: source.required_checks,
359
+ // `change_events_secret` is NEVER named here: write-only (US-47.2).
360
+ chg_root_dispatch_id: source.chg_root_dispatch_id,
227
361
  target_epic_id: source.target_epic_id,
228
362
  revoked_at: source.revoked_at,
229
363
  inserted_at: source.inserted_at,
@@ -258,6 +392,9 @@ export async function enrollIntakeSource(
258
392
  mode,
259
393
  required_checks,
260
394
  secret_file,
395
+ change_events_secret,
396
+ change_events_secret_file,
397
+ chg_root_dispatch_id,
261
398
  } = {},
262
399
  { userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
263
400
  ) {
@@ -298,21 +435,30 @@ export async function enrollIntakeSource(
298
435
  }
299
436
 
300
437
  // OPTIONAL, read by PRESENCE like the branch: omitted, the source takes `pr`. Named, it
301
- // must be one of the two modes; anything else (null included) is a 422 at the server and
438
+ // must be a known mode; anything else (null included) is a 422 at the server and
302
439
  // is refused here, before a secret file is reserved.
303
440
  if (mode !== undefined) {
304
441
  const badMode = modeRefusal(mode);
305
442
  if (badMode) return badMode;
306
443
  }
307
444
 
308
- // OPTIONAL too (US-45.6): the CI checks a thread checkpoint must pass. Its SHAPE is judged
309
- // here, before a secret file is reserved; whether a thread source names enough of them
310
- // depends on the stored mode, which only the server knows.
445
+ // OPTIONAL too: the CI checks story verification judges by. Its SHAPE is judged here,
446
+ // before a secret file is reserved.
311
447
  if (required_checks !== undefined) {
312
448
  const badChecks = requiredChecksRefusal(required_checks);
313
449
  if (badChecks) return badChecks;
314
450
  }
315
451
 
452
+ // The `chg` credentials (US-47.2), resolved and judged before a secret file is reserved.
453
+ const chg = await chgCredentials(
454
+ { change_events_secret, change_events_secret_file, chg_root_dispatch_id },
455
+ { clearable: false, fs, homedir },
456
+ );
457
+ if (chg.refusal) return chg.refusal;
458
+
459
+ const badChg = chgEnrolmentRefusal(mode, chg.fields);
460
+ if (badChg) return badChg;
461
+
316
462
  if (typeof secret_file !== "string" || secret_file.trim() === "") {
317
463
  return refuse(
318
464
  "`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
@@ -364,6 +510,7 @@ export async function enrollIntakeSource(
364
510
  if (base_branch !== undefined) body.base_branch = base_branch;
365
511
  if (mode !== undefined) body.mode = mode;
366
512
  if (required_checks !== undefined) body.required_checks = required_checks;
513
+ Object.assign(body, chg.fields);
367
514
 
368
515
  let result;
369
516
  try {
@@ -518,7 +665,10 @@ export async function listIntakeSources({ include_revoked } = {}, { userKey, api
518
665
  * uses `empty_values: []` precisely so that a caller who SENT a value gets an answer about
519
666
  * the value it sent, which is a 422.
520
667
  */
521
- export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
668
+ export async function updateIntakeSource(
669
+ args = {},
670
+ { userKey, apiCall, fs = defaultFs, homedir = os.homedir() } = {},
671
+ ) {
522
672
  if (!userKey) return refuse(MISSING_USER_KEY);
523
673
 
524
674
  const bad = uuidRefusal(args.source_id, "source_id");
@@ -557,11 +707,20 @@ export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
557
707
  body.required_checks = args.required_checks;
558
708
  }
559
709
 
710
+ // Switching to `chg` may rely on credentials an earlier `chg` stint left on the row, so their
711
+ // absence here is the SERVER's to judge (422 naming the missing one); only their shape is
712
+ // judged here.
713
+ const chg = await chgCredentials(args, { clearable: true, fs, homedir });
714
+ if (chg.refusal) return chg.refusal;
715
+ Object.assign(body, chg.fields);
716
+
560
717
  if (Object.keys(body).length === 0) {
561
718
  return refuse(
562
719
  "Nothing to update. Name at least one of `target_epic_id` (null clears it), " +
563
- "`base_branch`, `mode` or `required_checks`. A body carrying none of them is refused 422 " +
564
- "`nothing_to_update`, because a field you do not send is left exactly as it was.",
720
+ "`base_branch`, `mode`, `required_checks`, `change_events_secret` (or " +
721
+ "`change_events_secret_file`) or `chg_root_dispatch_id`. A body carrying none of them " +
722
+ "is refused 422 `nothing_to_update`, because a field you do not send is left exactly " +
723
+ "as it was.",
565
724
  );
566
725
  }
567
726
 
@@ -72,7 +72,7 @@
72
72
  * (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35`), and the body is
73
73
  * `LoopctlWeb.ErrorJSON`'s generic `{"error": {"status": 404, "message": "Not found"}}` — byte
74
74
  * for byte what `FallbackController` answers for a well-formed id naming no story
75
- * (`lib/loopctl_web/fallback_controller.ex:74-78`). Two opposite remedies, one answer.
75
+ * (`lib/loopctl_web/fallback_controller.ex:84-88`). Two opposite remedies, one answer.
76
76
  *
77
77
  * The check is `uuid()`, IMPORTED from `lib/delivery-loop.js` rather than reimplemented, so the
78
78
  * refusal a caller reads for a bad `story_id` does not depend on which tool they reached for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.116.0",
3
+ "version": "3.1.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/lib/threads.js DELETED
@@ -1,295 +0,0 @@
1
- /**
2
- * Change threads (loopctl US-45.1, Epic 45): read a story's thread, record a checkpoint,
3
- * record an entry; and review on it (US-45.3): request a review, read its payload, record a
4
- * fix. A review's findings and verdict are NOT here: they travel over the runner socket from
5
- * the runner loopctl placed the review on.
6
- *
7
- * SINGLE SOURCE OF TRUTH. index.js injects `apiCall`; the unit suite runs this code with a
8
- * recording fake. Keys are selected here, from the injected env, so which key a call travels
9
- * on is testable behaviour:
10
- *
11
- * - thread_checkpoint travels on the key claim_story claims with: LOOPCTL_API_KEY when it is
12
- * set, else LOOPCTL_AGENT_KEY (the same resolveKey order). The endpoint compares the key's
13
- * agent with the story's claimant, so the checkpoint must go out on the key that claimed.
14
- * - thread_entry writes a `message` on the key named by `principal` (agent by default; a
15
- * person writes on LOOPCTL_USER_KEY).
16
- * - thread_request_review travels on LOOPCTL_ORCH_KEY (LOOPCTL_USER_KEY for principal user).
17
- * - thread_fix travels on the key claim_story claims with, as thread_checkpoint does.
18
- * - thread_get reads on the first key configured, agent first, then LOOPCTL_API_KEY, the
19
- * orchestrator key and the user key: reads are open to every role.
20
- *
21
- * Every call passes the env var it is pinned to as `keyHint`, so a missing key is reported
22
- * by name rather than as some other feature's configuration error.
23
- *
24
- * Only presence of the required fields is checked here. Everything else is the server's
25
- * judgement and passes through unchanged.
26
- */
27
-
28
- const PRINCIPAL_KEYS = {
29
- agent: "LOOPCTL_AGENT_KEY",
30
- orchestrator: "LOOPCTL_ORCH_KEY",
31
- user: "LOOPCTL_USER_KEY",
32
- };
33
-
34
- // One literal template per route, so `test/tool-surface.js` resolves each call site to the
35
- // route it sends (a path built from a suffix argument reads as no route at all).
36
- export function threadPath(storyId) {
37
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread`;
38
- }
39
-
40
- export function checkpointsPath(storyId) {
41
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/checkpoints`;
42
- }
43
-
44
- export function entriesPath(storyId) {
45
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/entries`;
46
- }
47
-
48
- function missing(field) {
49
- return { error: true, status: 0, body: `\`${field}\` is required.` };
50
- }
51
-
52
- function present(value) {
53
- return typeof value === "string" && value.trim() !== "";
54
- }
55
-
56
- // The key claim_story claimed with (index.js resolveKey: LOOPCTL_API_KEY first), so a
57
- // checkpoint or an agent-principal entry is sent as the same agent that holds the claim.
58
- function claimKeyVar(env) {
59
- return env.LOOPCTL_API_KEY ? "LOOPCTL_API_KEY" : "LOOPCTL_AGENT_KEY";
60
- }
61
-
62
- // `kb_refs` (loopctl #978) is optional; when given it must be an array of strings. The server
63
- // owns every other rule (UUIDs, the bound, which articles this key can read), so this only
64
- // stops a call whose shape could never be right, such as one id passed as a bare string.
65
- function kbRefsShape(kbRefs) {
66
- return (
67
- kbRefs === undefined ||
68
- kbRefs === null ||
69
- (Array.isArray(kbRefs) && kbRefs.every((id) => typeof id === "string"))
70
- );
71
- }
72
-
73
- function badKbRefs() {
74
- return {
75
- error: true,
76
- status: 0,
77
- body: "`kb_refs` must be an array of knowledge article ids (strings).",
78
- };
79
- }
80
-
81
- // Drops keys whose value is undefined or null, so an absent optional is not sent as null.
82
- function compact(obj) {
83
- return Object.fromEntries(
84
- Object.entries(obj).filter(([, v]) => v !== undefined && v !== null),
85
- );
86
- }
87
-
88
- /** `GET /api/v1/stories/:id/thread`. */
89
- export async function getThread({ story_id, after_seq, limit } = {}, { apiCall, env = process.env } = {}) {
90
- if (!present(story_id)) return missing("story_id");
91
- const keyVar =
92
- ["LOOPCTL_AGENT_KEY", "LOOPCTL_API_KEY", "LOOPCTL_ORCH_KEY", "LOOPCTL_USER_KEY"].find(
93
- (name) => env[name],
94
- ) || "LOOPCTL_AGENT_KEY";
95
- const query = compact({ after_seq, limit });
96
- const qs = new URLSearchParams(query).toString();
97
- return apiCall(
98
- "GET",
99
- qs ? `${threadPath(story_id)}?${qs}` : threadPath(story_id),
100
- null,
101
- env[keyVar],
102
- keyVar,
103
- );
104
- }
105
-
106
- /** `POST /api/v1/stories/:id/thread/checkpoints` on the AGENT key. */
107
- export async function recordCheckpoint(
108
- { story_id, claim_epoch, commit_sha, tree_sha, note, kb_refs } = {},
109
- { apiCall, env = process.env } = {},
110
- ) {
111
- if (!present(story_id)) return missing("story_id");
112
- if (!present(commit_sha)) return missing("commit_sha");
113
- if (!kbRefsShape(kb_refs)) return badKbRefs();
114
- if (!present(tree_sha)) return missing("tree_sha");
115
- if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
116
- return {
117
- error: true,
118
- status: 0,
119
- body: "`claim_epoch` is required: the non-negative integer your claim returned.",
120
- };
121
- }
122
-
123
- return apiCall(
124
- "POST",
125
- checkpointsPath(story_id),
126
- compact({ claim_epoch, commit_sha, tree_sha, note, kb_refs }),
127
- env[claimKeyVar(env)],
128
- claimKeyVar(env),
129
- );
130
- }
131
-
132
- export function stageTransitionsPath(storyId) {
133
- return `/api/v1/stories/${encodeURIComponent(storyId)}/stage/transitions`;
134
- }
135
-
136
- /**
137
- * `POST /api/v1/stories/:id/stage/transitions` on the key claim_story claimed with (US-45.9):
138
- * the claimant of an INTERACTIVE thread claim reports its own story's stage transitions, as a
139
- * runner reports a placed claim's.
140
- */
141
- export async function reportStage(
142
- { story_id, claim_epoch, from, to, edge, reason, head_sha } = {},
143
- { apiCall, env = process.env } = {},
144
- ) {
145
- if (!present(story_id)) return missing("story_id");
146
- if (!present(from)) return missing("from");
147
- if (!present(to)) return missing("to");
148
- if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
149
- return {
150
- error: true,
151
- status: 0,
152
- body: "`claim_epoch` is required: the non-negative integer your claim returned.",
153
- };
154
- }
155
-
156
- return apiCall(
157
- "POST",
158
- stageTransitionsPath(story_id),
159
- compact({
160
- claim_epoch,
161
- from,
162
- to,
163
- edge,
164
- reason,
165
- effects: present(head_sha) ? { head_sha } : undefined,
166
- }),
167
- env[claimKeyVar(env)],
168
- claimKeyVar(env),
169
- );
170
- }
171
-
172
- /** `POST /api/v1/stories/:id/thread/entries` on the key `principal` names. */
173
- export async function recordEntry(
174
- {
175
- story_id,
176
- kind,
177
- idempotency_key,
178
- body,
179
- checkpoint_id,
180
- kb_refs,
181
- principal = "agent",
182
- } = {},
183
- { apiCall, env = process.env } = {},
184
- ) {
185
- if (!present(story_id)) return missing("story_id");
186
- if (!present(kind)) return missing("kind");
187
- if (!present(idempotency_key)) return missing("idempotency_key");
188
- if (!present(body)) return missing("body");
189
- if (!kbRefsShape(kb_refs)) return badKbRefs();
190
-
191
- const keyVar = principal === "agent" ? claimKeyVar(env) : PRINCIPAL_KEYS[principal];
192
- if (!PRINCIPAL_KEYS[principal]) {
193
- return {
194
- error: true,
195
- status: 0,
196
- body: "`principal` must be one of agent, orchestrator, user.",
197
- };
198
- }
199
-
200
- return apiCall(
201
- "POST",
202
- entriesPath(story_id),
203
- compact({ kind, idempotency_key, body, checkpoint_id, kb_refs }),
204
- env[keyVar],
205
- keyVar,
206
- );
207
- }
208
-
209
- // --- US-45.3: review on the thread ------------------------------------------------------
210
-
211
- export function reviewsPath(storyId) {
212
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/reviews`;
213
- }
214
-
215
- export function reviewPath(storyId, reviewId) {
216
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/reviews/${encodeURIComponent(reviewId)}`;
217
- }
218
-
219
- export function fixesPath(storyId) {
220
- return `/api/v1/stories/${encodeURIComponent(storyId)}/thread/fixes`;
221
- }
222
-
223
- /**
224
- * `POST /api/v1/stories/:id/thread/reviews` on the orchestrator key (or the user key): ask
225
- * loopctl to place a review on a runner. No credential comes back; the review's findings and
226
- * verdict travel over that runner's socket, never through this server.
227
- */
228
- export async function requestReview(
229
- {
230
- story_id,
231
- runner_id,
232
- dispatch_id,
233
- wall_clock_seconds,
234
- max_turns,
235
- repo,
236
- base_branch,
237
- principal = "orchestrator",
238
- } = {},
239
- { apiCall, env = process.env } = {},
240
- ) {
241
- if (!present(story_id)) return missing("story_id");
242
- if (!present(runner_id)) return missing("runner_id");
243
- if (principal !== "orchestrator" && principal !== "user") {
244
- return { error: true, status: 0, body: "`principal` must be orchestrator or user." };
245
- }
246
-
247
- const keyVar = PRINCIPAL_KEYS[principal];
248
- return apiCall(
249
- "POST",
250
- reviewsPath(story_id),
251
- compact({ runner_id, dispatch_id, wall_clock_seconds, max_turns, repo, base_branch }),
252
- env[keyVar],
253
- keyVar,
254
- );
255
- }
256
-
257
- /** `GET /api/v1/stories/:id/thread/reviews/:review_id` on the first key configured. */
258
- export async function getReview({ story_id, review_id } = {}, { apiCall, env = process.env } = {}) {
259
- if (!present(story_id)) return missing("story_id");
260
- if (!present(review_id)) return missing("review_id");
261
- const keyVar =
262
- ["LOOPCTL_AGENT_KEY", "LOOPCTL_API_KEY", "LOOPCTL_ORCH_KEY", "LOOPCTL_USER_KEY"].find(
263
- (name) => env[name],
264
- ) || "LOOPCTL_AGENT_KEY";
265
- return apiCall("GET", reviewPath(story_id, review_id), null, env[keyVar], keyVar);
266
- }
267
-
268
- /** `POST /api/v1/stories/:id/thread/fixes` on the key claim_story claims with. */
269
- export async function recordFix(
270
- { story_id, claim_epoch, checkpoint_id, finding_ids, idempotency_key, body } = {},
271
- { apiCall, env = process.env } = {},
272
- ) {
273
- if (!present(story_id)) return missing("story_id");
274
- if (!present(checkpoint_id)) return missing("checkpoint_id");
275
- if (!present(idempotency_key)) return missing("idempotency_key");
276
- if (!present(body)) return missing("body");
277
- if (!Array.isArray(finding_ids) || finding_ids.length === 0) {
278
- return { error: true, status: 0, body: "`finding_ids` must name at least one finding." };
279
- }
280
- if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
281
- return {
282
- error: true,
283
- status: 0,
284
- body: "`claim_epoch` is required: the non-negative integer your claim returned.",
285
- };
286
- }
287
-
288
- return apiCall(
289
- "POST",
290
- fixesPath(story_id),
291
- { claim_epoch, checkpoint_id, finding_ids, idempotency_key, body },
292
- env[claimKeyVar(env)],
293
- claimKeyVar(env),
294
- );
295
- }