@momentiq/dark-factory-cli 0.7.0-alpha.9 → 1.0.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.
Files changed (79) hide show
  1. package/dist/cli.js +383 -29
  2. package/dist/cli.js.map +1 -1
  3. package/dist/handoff/accept-verb.d.ts +31 -0
  4. package/dist/handoff/accept-verb.d.ts.map +1 -0
  5. package/dist/handoff/accept-verb.js +165 -0
  6. package/dist/handoff/accept-verb.js.map +1 -0
  7. package/dist/handoff/args.d.ts +19 -0
  8. package/dist/handoff/args.d.ts.map +1 -0
  9. package/dist/handoff/args.js +68 -0
  10. package/dist/handoff/args.js.map +1 -0
  11. package/dist/handoff/assignees.d.ts +37 -0
  12. package/dist/handoff/assignees.d.ts.map +1 -0
  13. package/dist/handoff/assignees.js +46 -0
  14. package/dist/handoff/assignees.js.map +1 -0
  15. package/dist/handoff/handoff-verb.d.ts +34 -0
  16. package/dist/handoff/handoff-verb.d.ts.map +1 -0
  17. package/dist/handoff/handoff-verb.js +451 -0
  18. package/dist/handoff/handoff-verb.js.map +1 -0
  19. package/dist/handoff/handoffs-verb.d.ts +29 -0
  20. package/dist/handoff/handoffs-verb.d.ts.map +1 -0
  21. package/dist/handoff/handoffs-verb.js +84 -0
  22. package/dist/handoff/handoffs-verb.js.map +1 -0
  23. package/dist/handoff/index.d.ts +14 -143
  24. package/dist/handoff/index.d.ts.map +1 -1
  25. package/dist/handoff/index.js +28 -647
  26. package/dist/handoff/index.js.map +1 -1
  27. package/dist/handoff/iso.d.ts +9 -0
  28. package/dist/handoff/iso.d.ts.map +1 -0
  29. package/dist/handoff/iso.js +47 -0
  30. package/dist/handoff/iso.js.map +1 -0
  31. package/dist/handoff/links.d.ts +48 -0
  32. package/dist/handoff/links.d.ts.map +1 -0
  33. package/dist/handoff/links.js +211 -0
  34. package/dist/handoff/links.js.map +1 -0
  35. package/dist/handoff/markers.d.ts +50 -0
  36. package/dist/handoff/markers.d.ts.map +1 -0
  37. package/dist/handoff/markers.js +121 -0
  38. package/dist/handoff/markers.js.map +1 -0
  39. package/dist/handoff/ports.d.ts +126 -0
  40. package/dist/handoff/ports.d.ts.map +1 -0
  41. package/dist/handoff/ports.js +34 -0
  42. package/dist/handoff/ports.js.map +1 -0
  43. package/dist/handoff/real-clients.d.ts +43 -0
  44. package/dist/handoff/real-clients.d.ts.map +1 -0
  45. package/dist/handoff/real-clients.js +291 -0
  46. package/dist/handoff/real-clients.js.map +1 -0
  47. package/dist/handoff/rehydrate-core.d.ts +55 -0
  48. package/dist/handoff/rehydrate-core.d.ts.map +1 -0
  49. package/dist/handoff/rehydrate-core.js +243 -0
  50. package/dist/handoff/rehydrate-core.js.map +1 -0
  51. package/dist/handoff/rehydrate-render.d.ts +3 -0
  52. package/dist/handoff/rehydrate-render.d.ts.map +1 -0
  53. package/dist/handoff/rehydrate-render.js +107 -0
  54. package/dist/handoff/rehydrate-render.js.map +1 -0
  55. package/dist/handoff/rehydrate-verb.d.ts +31 -0
  56. package/dist/handoff/rehydrate-verb.d.ts.map +1 -0
  57. package/dist/handoff/rehydrate-verb.js +111 -0
  58. package/dist/handoff/rehydrate-verb.js.map +1 -0
  59. package/dist/handoff/scrub.d.ts +99 -0
  60. package/dist/handoff/scrub.d.ts.map +1 -0
  61. package/dist/handoff/scrub.js +127 -0
  62. package/dist/handoff/scrub.js.map +1 -0
  63. package/dist/handoff/strip-control.d.ts +4 -0
  64. package/dist/handoff/strip-control.d.ts.map +1 -0
  65. package/dist/handoff/strip-control.js +16 -0
  66. package/dist/handoff/strip-control.js.map +1 -0
  67. package/dist/mcp/server.d.ts +0 -8
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +1 -5
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/tools/handoff.d.ts +16 -8
  72. package/dist/mcp/tools/handoff.d.ts.map +1 -1
  73. package/dist/mcp/tools/handoff.js +253 -303
  74. package/dist/mcp/tools/handoff.js.map +1 -1
  75. package/package.json +2 -2
  76. package/dist/handoff/cli.d.ts +0 -5
  77. package/dist/handoff/cli.d.ts.map +0 -1
  78. package/dist/handoff/cli.js +0 -227
  79. package/dist/handoff/cli.js.map +0 -1
@@ -1,653 +1,34 @@
1
- // Agent handoff protocol — shared core (Cycle 8 Phase 8.2).
1
+ // packages/cli/src/handoff/index.ts
2
2
  //
3
- // This module is the single source of mechanism for the four handoff
4
- // verbs, consumed by BOTH the `df handoff`/`df accept`/`df rehydrate`/
5
- // `df handoffs` CLI subcommands (src/cli.ts) AND the
6
- // `df_handoff`/`df_accept`/`df_rehydrate`/`df_handoffs` MCP tools
7
- // (src/mcp/tools/handoff.ts) — the same split `runDoctor` (src/doctor.ts)
8
- // uses to back `df doctor` and the `df_doctor` tool.
3
+ // Public surface for the Cycle 12 handoff protocol. Re-exports the verb
4
+ // orchestrators + types for CLI (src/cli.ts) and MCP (src/mcp/tools/handoff.ts)
5
+ // consumption.
9
6
  //
10
- // It is a faithful TypeScript port of the dogfooded Phase 8.1 bash
11
- // scripts (.claude/skills/handoff/scripts/*.sh in dark-factory-platform).
12
- // The judgment layer (when to hand off, what to write, the security rule)
13
- // lives in the skill / the two MCP prompts; this is the deterministic
14
- // mechanism: marker-bounded PR-comment upsert, the secret-scrub, native
15
- // baton (handoff label + assignee + PR timeline), and live-state-first
16
- // rehydration.
7
+ // Replaces the Cycle 8 v1 monolith (894 LOC) which carried the runner-shaped
8
+ // `GhRunner`/`GitRunner` function seams, the PR-arg verbs, and the bash-port
9
+ // throw sites with a thin shim over the modular pure-helpers + verbs in this
10
+ // directory. v2 uses the object-shaped `GhClient`/`GitClient` ports from
11
+ // ports.ts; the v1 function-runner types are intentionally NOT exported (any
12
+ // caller that still references them is a v1 holdover that needs porting).
17
13
  //
18
- // GitHub access shells out to `gh` exactly as the bash did (the repo's
19
- // other GitHub touch points git.ts, the Python services — talk to
20
- // git/gh the same way). The `gh` runner is INJECTABLE (default = real
21
- // `gh`) so the MCP tools, which run in-process over an in-memory
22
- // transport, can be tested hermetically without a PATH stub or the
23
- // network — mirroring how review-bypass takes `_internalRunReview`.
14
+ // Single-class identity for `HandoffError` is preserved: it's defined in
15
+ // ports.ts (the lowest leaf) and re-exported here, so the verb modules that
16
+ // import from `./ports.js` and the consumers that import from `./index.js`
17
+ // both see the exact same class for `instanceof` checks.
24
18
  //
25
- // Design source of truth:
26
- // docs/superpowers/specs/2026-05-29-agent-handoff-protocol-design.md
27
- // (in dark-factory-platform Phase 8.1's spec).
28
- import { spawn } from "node:child_process";
29
- const MAX_BUFFER = 64 * 1024 * 1024;
30
- export const MARKER_OPEN = "<!-- agent-context:v1 -->";
31
- export const MARKER_CLOSE = "<!-- /agent-context:v1 -->";
19
+ // See docs/superpowers/specs/2026-05-30-agent-handoff-v2-issue-anchor-design.md
20
+ // for the design rationale.
21
+ export { HandoffError } from "./ports.js";
22
+ export { MARKER_OPEN, MARKER_CLOSE } from "./markers.js";
23
+ // HANDOFF_LABEL is currently a private const in links.ts (per Task 5's
24
+ // deliberate deviation it sits next to the link-resolution helpers that
25
+ // reference it). Re-declared here as the permanent public home so CLI/MCP
26
+ // consumers can import the label without coupling to links.ts's internals.
32
27
  export const HANDOFF_LABEL = "handoff";
33
- // ---------------------------------------------------------------------------
34
- // Errors + IO shims.
35
- // ---------------------------------------------------------------------------
36
- /**
37
- * Raised by the core for every operator-facing refusal/abort (the bash
38
- * `die`). Carries an optional `savedNotePath` so the CLI can echo where a
39
- * composed note was preserved when a push/gate blocked it (Decision D5 —
40
- * the reasoning is the precious artifact and is never discarded).
41
- */
42
- export class HandoffError extends Error {
43
- savedNotePath;
44
- constructor(message, savedNotePath) {
45
- super(message);
46
- this.name = "HandoffError";
47
- this.savedNotePath = savedNotePath;
48
- }
49
- }
50
- // We use `spawn` (not `execFile`/`promisify`) specifically because the
51
- // upsert path pipes a JSON body to `gh api … --input -` on the child's
52
- // STDIN. `promisify(execFile)`'s `input` option is silently dropped (it's a
53
- // `*Sync` / `child_process.exec`-only option), which would send an EMPTY
54
- // body to `--input -` (and hang the child waiting on EOF). `spawn` lets us
55
- // write + end stdin deterministically. Resolves a non-zero `code` instead
56
- // of rejecting, so callers branch on `code` uniformly (no try/catch noise).
57
- function defaultExec(bin, args, options = {}) {
58
- return new Promise((resolvePromise) => {
59
- const child = spawn(bin, [...args], {
60
- stdio: [options.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
61
- });
62
- let stdout = "";
63
- let stderr = "";
64
- let overflow = false;
65
- child.stdout?.on("data", (chunk) => {
66
- if (stdout.length < MAX_BUFFER)
67
- stdout += chunk.toString("utf8");
68
- else
69
- overflow = true;
70
- });
71
- child.stderr?.on("data", (chunk) => {
72
- if (stderr.length < MAX_BUFFER)
73
- stderr += chunk.toString("utf8");
74
- });
75
- // ENOENT (bin not on PATH) → surface code 127 (command-not-found) so
76
- // requireTools can tell "not installed" from "ran but failed".
77
- child.on("error", (err) => {
78
- resolvePromise({
79
- code: 127,
80
- stdout: "",
81
- stderr: err.message,
82
- });
83
- });
84
- child.on("close", (code) => {
85
- resolvePromise({
86
- code: overflow ? 1 : (code ?? 1),
87
- stdout,
88
- stderr,
89
- });
90
- });
91
- if (options.input !== undefined && child.stdin) {
92
- child.stdin.on("error", () => {
93
- // EPIPE if the child exits before reading stdin — non-fatal; the
94
- // close handler reports the real exit code.
95
- });
96
- child.stdin.end(options.input);
97
- }
98
- });
99
- }
100
- export const defaultGh = (args, options) => defaultExec("gh", args, options ?? {});
101
- export const defaultGit = (args) => defaultExec("git", args);
102
- /**
103
- * Test-only: the real spawn-backed runner, exposed so a test can drive a
104
- * REAL subprocess (e.g. `cat`) and pin that `options.input` is actually
105
- * delivered to the child's stdin. The hermetic gh/git fakes can't catch a
106
- * regression where async `input` is silently dropped (the bug this spawn
107
- * impl fixes); a real-subprocess round-trip can. Not part of the public API.
108
- */
109
- export const _execForTest = defaultExec;
110
- export function defaultDeps(log = () => { }) {
111
- return { gh: defaultGh, git: defaultGit, log };
112
- }
113
- // ---------------------------------------------------------------------------
114
- // Tool preflight (bash require_tools).
115
- // ---------------------------------------------------------------------------
116
- export async function requireTools(deps) {
117
- const ghVersion = await deps.gh(["--version"]);
118
- if (ghVersion.code === 127) {
119
- throw new HandoffError("gh not found — install GitHub CLI and run 'gh auth login'.");
120
- }
121
- const auth = await deps.gh(["auth", "status"]);
122
- if (auth.code !== 0) {
123
- throw new HandoffError("gh not authenticated — run 'gh auth login'.");
124
- }
125
- }
126
- // ---------------------------------------------------------------------------
127
- // PR-number validation (bash require_pr_number).
128
- //
129
- // Die if a PR identifier is set but not a positive integer — so the value
130
- // is always safe to interpolate anywhere (incl. the printed
131
- // `gh pr checkout <pr>` footer) and a malicious `/rehydrate '42; rm -rf'`
132
- // can't produce a copy-pastable injectable command. Empty/undefined is
133
- // allowed (the no-PR /handoff path resolves/creates the PR later).
134
- // ---------------------------------------------------------------------------
135
- export function requirePrNumber(pr) {
136
- if (pr === undefined || pr === "")
137
- return; // empty allowed
138
- // Reject 0, leading zero, and anything non-digit.
139
- if (!/^[1-9][0-9]*$/.test(pr)) {
140
- throw new HandoffError(`PR must be a positive integer (got: '${pr}').`);
141
- }
142
- }
143
- // ---------------------------------------------------------------------------
144
- // Secret-scrub (bash scrub_secrets + SECRET_PATTERNS).
145
- //
146
- // Conservative-but-real — a false refusal the author rephrases beats a
147
- // leaked credential. Covers: key/secret assignment var-names; GitHub/Slack/
148
- // AWS token shapes; LLM-provider keys (OpenAI sk-/sk-proj-, Anthropic
149
- // sk-ant-, Google AIza); credentialed connection strings (scheme://u:p@h);
150
- // well-known credential FILE PATHS; and PEM blocks (`-----BEGIN`, avoiding
151
- // the full literal so this file stays clean against the repo's own
152
- // secrets-scan). The scrub is a BACKSTOP for these known shapes; the
153
- // author (guided by the prompts/skill) is the primary control.
154
- // ---------------------------------------------------------------------------
155
- // Mirrors lib.sh's SECRET_PATTERNS, translated to a JS regex with the `i`
156
- // flag. `[[:space:]]` → `\s`. The leading alternative matches a
157
- // secret-bearing variable NAME anywhere before an assignment, so embedded
158
- // forms (`AWS_SECRET_ACCESS_KEY=` / `GITHUB_TOKEN=` / `DB_PASSWORD=`) are
159
- // caught, not only a bare keyword-then-equals.
160
- const SECRET_PATTERN = /[A-Za-z0-9_]*(?:api[_-]?key|secret|token|passwd|password|access[_-]?key|private[_-]?key)[A-Za-z0-9_]*\s*[:=]\s*[^\s]|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]+|AKIA[0-9A-Z]{16}|sk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}|AIza[A-Za-z0-9_-]{16,}|[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@|\.(?:aws\/credentials|kube\/config|ssh\/id_[a-z0-9]+|codex\/auth\.json|config\/gcloud|docker\/config\.json|netrc|npmrc|pgpass|dockercfg)|-----BEGIN/i;
161
- /**
162
- * Scan a note body for secret-shaped content. Returns the matching LINE
163
- * NUMBERS only — never the matched content (echoing it would re-surface
164
- * the secret in terminal/scrollback/logs). Detection is decoupled from
165
- * line formatting so a formatting hiccup can never cause a MISSED secret.
166
- */
167
- export function scrubSecrets(body) {
168
- const lines = body.split("\n");
169
- const matched = [];
170
- for (let i = 0; i < lines.length; i++) {
171
- if (SECRET_PATTERN.test(lines[i] ?? "")) {
172
- matched.push(i + 1);
173
- }
174
- }
175
- return { clean: matched.length === 0, lines: matched };
176
- }
177
- // ---------------------------------------------------------------------------
178
- // Marker validation (bash validate_note_markers).
179
- // ---------------------------------------------------------------------------
180
- /**
181
- * A well-formed agent-context block has an open marker that precedes a
182
- * close marker. Guards against posting a malformed/partial note.
183
- */
184
- export function validateNoteMarkers(body) {
185
- const open = body.indexOf(MARKER_OPEN);
186
- const close = body.indexOf(MARKER_CLOSE);
187
- return open >= 0 && close >= 0 && open < close;
188
- }
189
- // ---------------------------------------------------------------------------
190
- // Control-char strip for display (bash `tr -d '\000-\010\013-\037\177'`).
191
- //
192
- // The note body is untrusted PR-comment text. Strip control/ESC bytes
193
- // (keep TAB \t = \x09 and LF \n = \x0a) so a hostile note can't drive the
194
- // terminal via ANSI escapes when displayed.
195
- // ---------------------------------------------------------------------------
196
- // eslint-disable-next-line no-control-regex
197
- const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
198
- export function stripControlChars(text) {
199
- return text.replace(CONTROL_CHARS, "");
200
- }
201
- /** `gh api repos/{owner}/{repo}/issues/<pr>/comments --paginate` as JSON. */
202
- async function listIssueComments(deps, pr) {
203
- // --paginate concatenates pages; with `--slurp` gh returns a single
204
- // array-of-arrays, so we flatten. Without --slurp, multiple pages emit
205
- // multiple JSON arrays which aren't parseable as one document. We use
206
- // --slurp for deterministic parsing.
207
- const res = await deps.gh([
208
- "api",
209
- `repos/{owner}/{repo}/issues/${pr}/comments`,
210
- "--paginate",
211
- "--slurp",
212
- ]);
213
- if (res.code !== 0) {
214
- throw new HandoffError(`gh api (list comments on #${pr}) failed: ${res.stderr.trim() || `exit ${res.code}`}`);
215
- }
216
- const raw = res.stdout.trim();
217
- if (!raw)
218
- return [];
219
- let parsed;
220
- try {
221
- parsed = JSON.parse(raw);
222
- }
223
- catch (err) {
224
- throw new HandoffError(`could not parse gh comments JSON for #${pr}: ${err.message}`);
225
- }
226
- // --slurp wraps the per-page arrays in an outer array → flatten one level.
227
- const flat = [];
228
- const pages = Array.isArray(parsed) ? parsed : [];
229
- for (const page of pages) {
230
- if (Array.isArray(page)) {
231
- for (const c of page)
232
- flat.push(c);
233
- }
234
- else if (page && typeof page === "object") {
235
- // A non-paginated single page (rare) — gh returned a flat array.
236
- flat.push(page);
237
- }
238
- }
239
- return flat;
240
- }
241
- /**
242
- * Echo the MOST RECENT marked comment id on a PR, or undefined. There
243
- * should only ever be one (upsert maintains a single note); if more
244
- * exist, use the newest and warn. The issues/comments API returns
245
- * ascending by creation, so the last marked one is newest.
246
- */
247
- export async function markerCommentId(deps, pr) {
248
- const comments = await listIssueComments(deps, pr);
249
- const marked = comments.filter((c) => c.body.includes(MARKER_OPEN));
250
- if (marked.length === 0)
251
- return undefined;
252
- if (marked.length > 1) {
253
- deps.log("found multiple agent-context comments on this PR (expected 1) — using the most recent.");
254
- }
255
- return marked[marked.length - 1]?.id;
256
- }
257
- /**
258
- * Body of the MOST RECENT marked comment on a PR (undefined if none).
259
- * Fetched by id so it stays consistent with upsert's most-recent target.
260
- */
261
- export async function markerCommentBody(deps, pr) {
262
- const id = await markerCommentId(deps, pr);
263
- if (id === undefined)
264
- return undefined;
265
- const res = await deps.gh([
266
- "api",
267
- `repos/{owner}/{repo}/issues/comments/${id}`,
268
- "--jq",
269
- ".body",
270
- ]);
271
- if (res.code !== 0)
272
- return undefined; // non-fatal — caller decides
273
- return res.stdout.replace(/\n$/, "");
274
- }
275
- /**
276
- * Upsert the marked comment from a body. Returns the comment html_url.
277
- * If PATCH fails (e.g. HTTP 403 — the existing note was authored by
278
- * another identity), fall back to POSTing a fresh note + warn (spec §9).
279
- */
280
- export async function upsertNote(deps, pr, body) {
281
- const id = await markerCommentId(deps, pr);
282
- const payload = JSON.stringify({ body });
283
- if (id !== undefined) {
284
- const patch = await deps.gh([
285
- "api",
286
- "--method",
287
- "PATCH",
288
- `repos/{owner}/{repo}/issues/comments/${id}`,
289
- "--input",
290
- "-",
291
- "--jq",
292
- ".html_url",
293
- ], { input: payload });
294
- if (patch.code === 0)
295
- return patch.stdout.trim();
296
- deps.log(`couldn't edit the existing note (id ${id}) — PATCH failed (commonly HTTP 403: the note was authored by another identity). Posting a fresh note instead.`);
297
- }
298
- const post = await deps.gh([
299
- "api",
300
- "--method",
301
- "POST",
302
- `repos/{owner}/{repo}/issues/${pr}/comments`,
303
- "--input",
304
- "-",
305
- "--jq",
306
- ".html_url",
307
- ], { input: payload });
308
- if (post.code !== 0) {
309
- throw new HandoffError(`gh api (post note on #${pr}) failed: ${post.stderr.trim() || `exit ${post.code}`}`);
310
- }
311
- return post.stdout.trim();
312
- }
313
- /** Ensure the handoff label exists (idempotent; never fatal). */
314
- async function ensureLabel(deps) {
315
- await deps.gh([
316
- "label",
317
- "create",
318
- HANDOFF_LABEL,
319
- "--description",
320
- "Work-stream handed off, awaiting pickup (agent handoff protocol)",
321
- "--color",
322
- "FBCA04",
323
- ]);
324
- // Ignore failure (already exists) — bash used `|| true`.
325
- }
326
- /** Open PR number for a branch, or undefined. `--state open` only. */
327
- async function prForBranch(deps, branch) {
328
- const res = await deps.gh([
329
- "pr",
330
- "list",
331
- "--head",
332
- branch,
333
- "--state",
334
- "open",
335
- "--json",
336
- "number",
337
- "--jq",
338
- ".[0].number // empty",
339
- ]);
340
- if (res.code !== 0)
341
- return undefined;
342
- const n = res.stdout.trim();
343
- return n === "" ? undefined : n;
344
- }
345
- async function currentBranch(deps) {
346
- const res = await deps.git(["rev-parse", "--abbrev-ref", "HEAD"]);
347
- if (res.code !== 0)
348
- return "HEAD";
349
- return res.stdout.trim() || "HEAD";
350
- }
351
- export async function runHandoff(input, deps) {
352
- await requireTools(deps);
353
- const warnings = [];
354
- const explicitPr = input.pr !== undefined && input.pr !== "";
355
- const branch = await currentBranch(deps);
356
- if (branch === "HEAD" && !explicitPr) {
357
- throw new HandoffError("detached HEAD — pass a PR number: df handoff <pr>");
358
- }
359
- let pr = explicitPr ? input.pr : "";
360
- if (!pr) {
361
- pr = (await prForBranch(deps, branch)) ?? "";
362
- }
363
- // Validate BEFORE any gh use of the (possibly user-supplied) PR arg.
364
- requirePrNumber(pr || undefined);
365
- // Branch-mismatch guard: if a PR was supplied EXPLICITLY and we're not
366
- // detached, the note would land on that PR while `git push origin HEAD`
367
- // pushes the CURRENT branch. A failed lookup must NOT silently pass.
368
- if (explicitPr && branch !== "HEAD") {
369
- const view = await deps.gh([
370
- "pr",
371
- "view",
372
- pr,
373
- "--json",
374
- "headRefName",
375
- "--jq",
376
- ".headRefName",
377
- ]);
378
- if (view.code !== 0) {
379
- throw new HandoffError(`can't verify PR #${pr} (gh pr view failed — does it exist?); not posting blind.`);
380
- }
381
- const prBranch = view.stdout.trim();
382
- if (prBranch !== branch) {
383
- throw new HandoffError(`PR #${pr} is for branch '${prBranch}' but you're on '${branch}' — refusing: the note would land on #${pr} while 'git push' pushes '${branch}'. Switch to '${prBranch}' or pass the matching PR.`);
384
- }
385
- }
386
- const body = input.note;
387
- if (!body.trim()) {
388
- throw new HandoffError("empty note body — nothing to post.");
389
- }
390
- // Validate markers BEFORE any network call.
391
- if (!validateNoteMarkers(body)) {
392
- throw new HandoffError(`note is missing/malformed agent-context markers (need ${MARKER_OPEN} … ${MARKER_CLOSE}) — compose it per the df.handoff prompt / handoff skill.`);
393
- }
394
- // Secret-scrub BEFORE any network call (the backstop control).
395
- const scrub = scrubSecrets(body);
396
- if (!scrub.clean) {
397
- deps.log(`the note appears to contain secret-shaped content at line(s): ${scrub.lines.join(",")} — refusing to post.`);
398
- deps.log("rephrase as a SETUP STEP (e.g. 'switch off the prod kube context'), never a secret value/path. See the df.handoff prompt § Security rule.");
399
- throw new HandoffError("aborted: secret-shaped content in the note (see above).");
400
- }
401
- // Dirty-worktree preflight — uncommitted TRACKED changes are NOT pushed
402
- // by `git push origin HEAD`, so a handoff would label the PR "available"
403
- // while the next session rehydrates a branch MISSING that work. Refuse.
404
- // (Untracked files are warned about, not fatal — often scratch.)
405
- const unstaged = await deps.git(["diff", "--quiet"]);
406
- const staged = await deps.git(["diff", "--cached", "--quiet"]);
407
- if (unstaged.code !== 0 || staged.code !== 0) {
408
- throw new HandoffError("uncommitted changes in the worktree — commit or stash them first, else this handoff drops them (the note points at a branch that won't have your work).");
409
- }
410
- const porcelain = await deps.git([
411
- "status",
412
- "--porcelain",
413
- "--untracked-files=normal",
414
- ]);
415
- if (porcelain.code === 0 &&
416
- porcelain.stdout.split("\n").some((l) => l.startsWith("??"))) {
417
- warnings.push("untracked files present — they won't be pushed; commit them too if they're part of this work.");
418
- deps.log(warnings[warnings.length - 1]);
419
- }
420
- let noteUrl;
421
- let pushed = false;
422
- let createdDraftPr = false;
423
- if (pr) {
424
- // HAS-PR: post first (the note attaches regardless of the branch tip),
425
- // then best-effort push.
426
- noteUrl = await upsertNote(deps, pr, body);
427
- deps.log(`note posted: ${noteUrl}`);
428
- if (branch === "HEAD") {
429
- // Detached HEAD (the explicit-PR escape hatch): can't push a branch.
430
- const w = `detached HEAD — not pushing. Ensure PR #${pr}'s branch already has your commits; the note is posted on #${pr}.`;
431
- warnings.push(w);
432
- deps.log(w);
433
- }
434
- else {
435
- const push = await deps.git(["push", "origin", "HEAD"]);
436
- if (push.code === 0) {
437
- pushed = true;
438
- }
439
- else {
440
- const w = "branch tip is NOT on origin (push failed/blocked). Resolve the gate (make df-show COMMIT=HEAD) and push; the note is already saved on the PR.";
441
- warnings.push(w);
442
- deps.log(w);
443
- }
444
- }
445
- }
446
- else {
447
- // NO-PR: a PR is required to comment on, so the push is a hard prereq.
448
- const push = await deps.git(["push", "origin", "HEAD"]);
449
- if (push.code !== 0) {
450
- throw new HandoffError("can't open a PR without pushing, and the push failed/was gate-blocked. Resolve the gate (make df-show COMMIT=HEAD) or open a PR manually, then re-run.");
451
- }
452
- pushed = true;
453
- const create = await deps.gh([
454
- "pr",
455
- "create",
456
- "--draft",
457
- "--fill",
458
- "--head",
459
- branch,
460
- ]);
461
- if (create.code !== 0) {
462
- throw new HandoffError(`gh pr create failed: ${create.stderr.trim() || `exit ${create.code}`}`);
463
- }
464
- const createUrl = create.stdout.trim();
465
- // Re-query the PR number ROBUSTLY — never parse the bare create output.
466
- const num = await deps.gh([
467
- "pr",
468
- "view",
469
- createUrl,
470
- "--json",
471
- "number",
472
- "--jq",
473
- ".number",
474
- ]);
475
- if (num.code !== 0) {
476
- throw new HandoffError(`opened a draft PR but couldn't read its number (${num.stderr.trim() || `exit ${num.code}`}).`);
477
- }
478
- pr = num.stdout.trim();
479
- createdDraftPr = true;
480
- deps.log(`opened draft PR #${pr}`);
481
- noteUrl = await upsertNote(deps, pr, body);
482
- deps.log(`note posted: ${noteUrl}`);
483
- }
484
- // Put it on the stack: label + leave open (remove self as assignee).
485
- await ensureLabel(deps);
486
- const addLabel = await deps.gh([
487
- "pr",
488
- "edit",
489
- pr,
490
- "--add-label",
491
- HANDOFF_LABEL,
492
- ]);
493
- if (addLabel.code !== 0) {
494
- const w = `couldn't add the '${HANDOFF_LABEL}' label to #${pr} — the note is posted; add it manually so /handoffs lists it.`;
495
- warnings.push(w);
496
- deps.log(w);
497
- }
498
- // Putting it DOWN → open on the stack (best-effort; bash used `|| true`).
499
- await deps.gh(["pr", "edit", pr, "--remove-assignee", "@me"]);
500
- deps.log(`#${pr} is on the handoff stack (open).`);
501
- return { pr, noteUrl, pushed, createdDraftPr, warnings };
502
- }
503
- export async function runHandoffs(deps) {
504
- await requireTools(deps);
505
- const res = await deps.gh([
506
- "pr",
507
- "list",
508
- "--label",
509
- HANDOFF_LABEL,
510
- "--state",
511
- "open",
512
- "--json",
513
- "number,title,headRefName,assignees,updatedAt",
514
- ]);
515
- if (res.code !== 0) {
516
- throw new HandoffError(`gh pr list (handoff stack) failed: ${res.stderr.trim() || `exit ${res.code}`}`);
517
- }
518
- const raw = res.stdout.trim();
519
- if (!raw)
520
- return { entries: [] };
521
- let parsed;
522
- try {
523
- parsed = JSON.parse(raw);
524
- }
525
- catch (err) {
526
- throw new HandoffError(`could not parse gh pr list JSON: ${err.message}`);
527
- }
528
- const entries = parsed
529
- .slice()
530
- .sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
531
- .map((p) => ({
532
- number: p.number,
533
- title: p.title,
534
- branch: p.headRefName,
535
- owner: p.assignees.length > 0 ? p.assignees[0]?.login : undefined,
536
- updatedAt: p.updatedAt,
537
- }));
538
- return { entries };
539
- }
540
- export async function runRehydrate(input, deps) {
541
- await requireTools(deps);
542
- let pr = input.pr ?? "";
543
- if (!pr) {
544
- pr = (await prForBranch(deps, await currentBranch(deps))) ?? "";
545
- }
546
- if (!pr) {
547
- throw new HandoffError("no PR for this branch — pass one: df rehydrate <pr>.");
548
- }
549
- requirePrNumber(pr);
550
- // STEP ZERO — derive LIVE state FIRST (Commitment 5), with
551
- // SCRIPT-CONTROLLED, fixed commands keyed off the resolved PR. This runs
552
- // BEFORE the note fetch so a transient comments-API failure can never
553
- // stop the operator from seeing the authoritative PR state. We never
554
- // execute commands transcribed from a PR comment (injection vector).
555
- const view = await deps.gh([
556
- "pr",
557
- "view",
558
- pr,
559
- "--json",
560
- "title,headRefName,mergeStateStatus,reviewDecision,statusCheckRollup",
561
- "--jq",
562
- '" \\(.title)\\n branch: \\(.headRefName)\\n mergeable: \\(.mergeStateStatus) review: \\(.reviewDecision)"',
563
- ]);
564
- // The live-state query is AUTHORITATIVE — do NOT suppress its failure (a
565
- // silent blank would let the operator proceed on the note alone).
566
- if (view.code !== 0) {
567
- throw new HandoffError(`could not derive live state for #${pr} (gh error) — fix gh/network and retry; do not proceed on the note alone.`);
568
- }
569
- const liveState = view.stdout.replace(/\n$/, "");
570
- const checksRes = await deps.gh(["pr", "checks", pr]);
571
- // Non-zero == checks failing (informational), not a script error.
572
- const checks = checksRes.stdout.replace(/\n$/, "");
573
- // Now fetch the MOST RECENT marked comment (consistent with upsert).
574
- // Non-fatal: live state is already derived above.
575
- let note;
576
- try {
577
- const raw = await markerCommentBody(deps, pr);
578
- note = raw !== undefined ? stripControlChars(raw) : undefined;
579
- }
580
- catch {
581
- note = undefined; // note-fetch failure is context loss, not a hard error
582
- }
583
- return {
584
- pr,
585
- liveState,
586
- checks,
587
- ...(note !== undefined ? { note } : {}),
588
- checkoutHint: `gh pr checkout ${pr}`,
589
- };
590
- }
591
- export async function runAccept(input, deps) {
592
- await requireTools(deps);
593
- const pr = input.pr;
594
- if (!pr) {
595
- throw new HandoffError("which one? run df handoffs to see the stack, then df accept <pr>.");
596
- }
597
- requirePrNumber(pr);
598
- const warnings = [];
599
- const assign = await deps.gh(["pr", "edit", pr, "--add-assignee", "@me"]);
600
- if (assign.code !== 0) {
601
- throw new HandoffError(`could not assign yourself to #${pr}: ${assign.stderr.trim() || `exit ${assign.code}`}`);
602
- }
603
- // Three distinct cases: (a) label-query gh error → say so; (b) label
604
- // present → remove it; (c) label absent → "wasn't on the stack".
605
- let removedLabel = false;
606
- const labelsRes = await deps.gh([
607
- "pr",
608
- "view",
609
- pr,
610
- "--json",
611
- "labels",
612
- "--jq",
613
- ".labels[].name",
614
- ]);
615
- if (labelsRes.code === 0) {
616
- const labels = labelsRes.stdout
617
- .split("\n")
618
- .map((s) => s.trim())
619
- .filter((s) => s.length > 0);
620
- if (labels.includes(HANDOFF_LABEL)) {
621
- const rm = await deps.gh([
622
- "pr",
623
- "edit",
624
- pr,
625
- "--remove-label",
626
- HANDOFF_LABEL,
627
- ]);
628
- if (rm.code === 0) {
629
- removedLabel = true;
630
- }
631
- else {
632
- const w = `couldn't remove the '${HANDOFF_LABEL}' label from #${pr} (gh error) — you're assigned; verify with df handoffs.`;
633
- warnings.push(w);
634
- deps.log(w);
635
- }
636
- }
637
- else {
638
- const w = `#${pr} wasn't on the handoff stack (no '${HANDOFF_LABEL}' label) — assigning you anyway.`;
639
- warnings.push(w);
640
- deps.log(w);
641
- }
642
- }
643
- else {
644
- const w = `couldn't check #${pr}'s labels (gh error) — you're assigned; verify the stack with df handoffs.`;
645
- warnings.push(w);
646
- deps.log(w);
647
- }
648
- deps.log(`accepted #${pr} — assigned to you. Rehydrating…`);
649
- // accept CONTAINS rehydrate.
650
- const rehydrate = await runRehydrate({ pr }, deps);
651
- return { pr, removedLabel, warnings, rehydrate };
652
- }
28
+ export { runHandoff } from "./handoff-verb.js";
29
+ export { runAccept } from "./accept-verb.js";
30
+ export { runRehydrate } from "./rehydrate-verb.js";
31
+ export { runHandoffs } from "./handoffs-verb.js";
32
+ export { renderRehydrateText } from "./rehydrate-render.js";
33
+ export { SpawnGhClient, SpawnGitClient, SystemClock, } from "./real-clients.js";
653
34
  //# sourceMappingURL=index.js.map