@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.
- package/dist/cli.js +383 -29
- package/dist/cli.js.map +1 -1
- package/dist/handoff/accept-verb.d.ts +31 -0
- package/dist/handoff/accept-verb.d.ts.map +1 -0
- package/dist/handoff/accept-verb.js +165 -0
- package/dist/handoff/accept-verb.js.map +1 -0
- package/dist/handoff/args.d.ts +19 -0
- package/dist/handoff/args.d.ts.map +1 -0
- package/dist/handoff/args.js +68 -0
- package/dist/handoff/args.js.map +1 -0
- package/dist/handoff/assignees.d.ts +37 -0
- package/dist/handoff/assignees.d.ts.map +1 -0
- package/dist/handoff/assignees.js +46 -0
- package/dist/handoff/assignees.js.map +1 -0
- package/dist/handoff/handoff-verb.d.ts +34 -0
- package/dist/handoff/handoff-verb.d.ts.map +1 -0
- package/dist/handoff/handoff-verb.js +451 -0
- package/dist/handoff/handoff-verb.js.map +1 -0
- package/dist/handoff/handoffs-verb.d.ts +29 -0
- package/dist/handoff/handoffs-verb.d.ts.map +1 -0
- package/dist/handoff/handoffs-verb.js +84 -0
- package/dist/handoff/handoffs-verb.js.map +1 -0
- package/dist/handoff/index.d.ts +14 -143
- package/dist/handoff/index.d.ts.map +1 -1
- package/dist/handoff/index.js +28 -647
- package/dist/handoff/index.js.map +1 -1
- package/dist/handoff/iso.d.ts +9 -0
- package/dist/handoff/iso.d.ts.map +1 -0
- package/dist/handoff/iso.js +47 -0
- package/dist/handoff/iso.js.map +1 -0
- package/dist/handoff/links.d.ts +48 -0
- package/dist/handoff/links.d.ts.map +1 -0
- package/dist/handoff/links.js +211 -0
- package/dist/handoff/links.js.map +1 -0
- package/dist/handoff/markers.d.ts +50 -0
- package/dist/handoff/markers.d.ts.map +1 -0
- package/dist/handoff/markers.js +121 -0
- package/dist/handoff/markers.js.map +1 -0
- package/dist/handoff/ports.d.ts +126 -0
- package/dist/handoff/ports.d.ts.map +1 -0
- package/dist/handoff/ports.js +34 -0
- package/dist/handoff/ports.js.map +1 -0
- package/dist/handoff/real-clients.d.ts +43 -0
- package/dist/handoff/real-clients.d.ts.map +1 -0
- package/dist/handoff/real-clients.js +291 -0
- package/dist/handoff/real-clients.js.map +1 -0
- package/dist/handoff/rehydrate-core.d.ts +55 -0
- package/dist/handoff/rehydrate-core.d.ts.map +1 -0
- package/dist/handoff/rehydrate-core.js +243 -0
- package/dist/handoff/rehydrate-core.js.map +1 -0
- package/dist/handoff/rehydrate-render.d.ts +3 -0
- package/dist/handoff/rehydrate-render.d.ts.map +1 -0
- package/dist/handoff/rehydrate-render.js +107 -0
- package/dist/handoff/rehydrate-render.js.map +1 -0
- package/dist/handoff/rehydrate-verb.d.ts +31 -0
- package/dist/handoff/rehydrate-verb.d.ts.map +1 -0
- package/dist/handoff/rehydrate-verb.js +111 -0
- package/dist/handoff/rehydrate-verb.js.map +1 -0
- package/dist/handoff/scrub.d.ts +99 -0
- package/dist/handoff/scrub.d.ts.map +1 -0
- package/dist/handoff/scrub.js +127 -0
- package/dist/handoff/scrub.js.map +1 -0
- package/dist/handoff/strip-control.d.ts +4 -0
- package/dist/handoff/strip-control.d.ts.map +1 -0
- package/dist/handoff/strip-control.js +16 -0
- package/dist/handoff/strip-control.js.map +1 -0
- package/dist/mcp/server.d.ts +0 -8
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +1 -5
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/handoff.d.ts +16 -8
- package/dist/mcp/tools/handoff.d.ts.map +1 -1
- package/dist/mcp/tools/handoff.js +253 -303
- package/dist/mcp/tools/handoff.js.map +1 -1
- package/package.json +2 -2
- package/dist/handoff/cli.d.ts +0 -5
- package/dist/handoff/cli.d.ts.map +0 -1
- package/dist/handoff/cli.js +0 -227
- package/dist/handoff/cli.js.map +0 -1
package/dist/handoff/index.js
CHANGED
|
@@ -1,653 +1,34 @@
|
|
|
1
|
-
//
|
|
1
|
+
// packages/cli/src/handoff/index.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|