@kingkyylian/handoffkit 0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1026 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command as Command6 } from "commander";
5
+
6
+ // src/cli/commands/pack.ts
7
+ import { Command } from "commander";
8
+ import { z as z2 } from "zod";
9
+
10
+ // src/core/git.ts
11
+ import { readFile } from "fs/promises";
12
+ import { basename } from "path";
13
+ import { execa } from "execa";
14
+ var UNTRACKED_PATCH_CHAR_LIMIT = 2e4;
15
+ var IGNORED_CHANGED_PATH_PREFIXES = ["node_modules/", "dist/", "coverage/", ".git/"];
16
+ async function findGitRoot(cwd) {
17
+ const result = await execa("git", ["rev-parse", "--show-toplevel"], {
18
+ cwd,
19
+ reject: false
20
+ });
21
+ if (result.exitCode !== 0) {
22
+ throw new Error("HandoffKit must be run inside a git repository.");
23
+ }
24
+ return result.stdout.trimEnd();
25
+ }
26
+ async function collectGitInfo(root, options) {
27
+ const [branch, status, porcelainStatus, recentCommits, stagedDiffSummary, trackedUnstagedDiffSummary, baseInfo] = await Promise.all([
28
+ currentBranch(root),
29
+ git(root, ["status", "--short", "--branch"]),
30
+ git(root, ["status", "--porcelain=v1", "--untracked-files=all"]),
31
+ recentCommitLines(root, options.since),
32
+ options.includeDiffSummary ? git(root, ["diff", "--cached", "--stat"]) : Promise.resolve(""),
33
+ options.includeDiffSummary ? git(root, ["diff", "--stat"]) : Promise.resolve(""),
34
+ options.since ? collectBaseInfo(root, options.since, options.includeDiffSummary, options.includeDiff) : Promise.resolve(void 0)
35
+ ]);
36
+ const changedFiles = uniqueSorted([...baseInfo?.changedFiles ?? [], ...parseChangedFiles(porcelainStatus)]);
37
+ const untrackedFiles = parseUntrackedFiles(porcelainStatus);
38
+ const unstagedDiffSummary = options.includeDiffSummary ? joinSections([trackedUnstagedDiffSummary, renderUntrackedSummary(untrackedFiles)]) : "";
39
+ const diff = options.includeDiff ? await collectDiff(root, untrackedFiles) : void 0;
40
+ return {
41
+ name: basename(root),
42
+ branch,
43
+ ...options.since ? { baseRef: options.since } : {},
44
+ status,
45
+ recentCommits,
46
+ changedFiles,
47
+ ...baseInfo?.summary ? { baseDiffSummary: baseInfo.summary } : {},
48
+ stagedDiffSummary,
49
+ unstagedDiffSummary,
50
+ includeDiff: options.includeDiff,
51
+ ...baseInfo?.patch ? { baseDiff: baseInfo.patch } : {},
52
+ ...diff ? { diff } : {}
53
+ };
54
+ }
55
+ async function currentBranch(root) {
56
+ const branch = await git(root, ["branch", "--show-current"]);
57
+ if (branch) {
58
+ return branch;
59
+ }
60
+ const commit = await git(root, ["rev-parse", "--short", "HEAD"], { allowFailure: true });
61
+ return commit ? `detached:${commit}` : "unknown";
62
+ }
63
+ async function recentCommitLines(root, since) {
64
+ const args = since ? ["log", "--oneline", "-n", "10", `${since}..HEAD`] : ["log", "--oneline", "-n", "10"];
65
+ const output = await git(root, args, { allowFailure: true });
66
+ return output ? output.split("\n") : [];
67
+ }
68
+ async function collectBaseInfo(root, since, includeDiffSummary, includeDiff) {
69
+ await ensureRef(root, since);
70
+ const range = `${since}...HEAD`;
71
+ const [changedFiles, summary, patch] = await Promise.all([
72
+ git(root, ["diff", "--name-only", range], { allowFailure: true }),
73
+ includeDiffSummary ? git(root, ["diff", "--stat", range], { allowFailure: true }) : Promise.resolve(""),
74
+ includeDiff ? git(root, ["diff", "--patch", range], { allowFailure: true }) : Promise.resolve("")
75
+ ]);
76
+ return {
77
+ changedFiles: changedFiles ? changedFiles.split("\n").filter(Boolean) : [],
78
+ summary,
79
+ patch
80
+ };
81
+ }
82
+ async function ensureRef(root, ref) {
83
+ const result = await execa("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
84
+ cwd: root,
85
+ reject: false
86
+ });
87
+ if (result.exitCode !== 0) {
88
+ throw new Error(`Could not resolve --since ref: ${ref}`);
89
+ }
90
+ }
91
+ async function collectDiff(root, untrackedFiles) {
92
+ const [staged, unstagedTracked, untracked] = await Promise.all([
93
+ git(root, ["diff", "--cached", "--patch"]),
94
+ git(root, ["diff", "--patch"]),
95
+ untrackedPatch(root, untrackedFiles)
96
+ ]);
97
+ return { staged, unstaged: joinSections([unstagedTracked, untracked]) };
98
+ }
99
+ async function git(root, args, options = {}) {
100
+ const result = await execa("git", args, {
101
+ cwd: root,
102
+ reject: false
103
+ });
104
+ if (result.exitCode !== 0 && !options.allowFailure) {
105
+ throw new Error(result.stderr || `git ${args.join(" ")} failed`);
106
+ }
107
+ return result.stdout.trim();
108
+ }
109
+ function parseChangedFiles(status) {
110
+ const files = status.split("\n").map((line) => line.trimEnd()).filter(Boolean).map((line) => line.slice(line[2] === " " ? 3 : 2).trim()).map((path) => path.includes(" -> ") ? path.split(" -> ").at(-1) ?? path : path).map((path) => path.replace(/^"|"$/g, ""));
111
+ return uniqueSorted(files.filter((file) => !isIgnoredChangedPath(file)));
112
+ }
113
+ function parseUntrackedFiles(status) {
114
+ return status.split("\n").map((line) => line.trimEnd()).filter((line) => line.startsWith("?? ")).map((line) => line.slice(3).trim()).map((path) => path.replace(/^"|"$/g, "")).filter((file) => !isIgnoredChangedPath(file)).sort();
115
+ }
116
+ function renderUntrackedSummary(files) {
117
+ if (files.length === 0) {
118
+ return "";
119
+ }
120
+ return files.map((file) => `${file} | untracked`).join("\n");
121
+ }
122
+ async function untrackedPatch(root, files) {
123
+ const patches = await Promise.all(
124
+ files.map(async (file) => {
125
+ const content = await readFile(`${root}/${file}`, "utf8");
126
+ const trimmedContent = content.length > UNTRACKED_PATCH_CHAR_LIMIT ? `${content.slice(0, UNTRACKED_PATCH_CHAR_LIMIT).trimEnd()}
127
+ [truncated]` : content.trimEnd();
128
+ return [`Untracked file: ${file}`, "```text", trimmedContent, "```"].join("\n");
129
+ })
130
+ );
131
+ return patches.join("\n\n");
132
+ }
133
+ function joinSections(sections) {
134
+ return sections.filter(Boolean).join("\n\n").trim();
135
+ }
136
+ function uniqueSorted(files) {
137
+ return [...new Set(files.filter((file) => !isIgnoredChangedPath(file)))].sort();
138
+ }
139
+ function isIgnoredChangedPath(file) {
140
+ return IGNORED_CHANGED_PATH_PREFIXES.some((prefix) => file === prefix.slice(0, -1) || file.startsWith(prefix));
141
+ }
142
+
143
+ // src/core/instructions.ts
144
+ import { readFile as readFile2, stat } from "fs/promises";
145
+ import fg from "fast-glob";
146
+
147
+ // src/core/redact.ts
148
+ var REDACTION = "[REDACTED]";
149
+ var SECRET_KEY_PATTERN = /(\b[A-Z0-9_.-]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|COOKIE|SESSION|JWT|AUTH_TOKEN)[A-Z0-9_.-]*\b\s*(?:=|:)\s*)(["']?)([^\s"',}]+)/gi;
150
+ var TOKEN_PATTERNS = [
151
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
152
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
153
+ /\bgh[pousr]_[A-Za-z0-9_]{16,}/g,
154
+ /\bnpm_[A-Za-z0-9_-]{16,}/g,
155
+ /\bxox[baprs]-[A-Za-z0-9-]{16,}/g,
156
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
157
+ /\bAKIA[0-9A-Z]{16}\b/g,
158
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
159
+ /\/\/([^/\s:@]+):([^@\s/]+)@/g
160
+ ];
161
+ function redactText(input) {
162
+ let output = input.replace(
163
+ /-----BEGIN ([A-Z ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g,
164
+ (_match, keyType) => `-----BEGIN ${keyType}-----
165
+ ${REDACTION}
166
+ -----END ${keyType}-----`
167
+ );
168
+ output = output.replace(SECRET_KEY_PATTERN, (_match, prefix, quote) => {
169
+ return `${prefix}${quote}${REDACTION}${quote}`;
170
+ });
171
+ output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer [REDACTED]");
172
+ output = output.replace(/\/\/([^/\s:@]+):([^@\s/]+)@/g, "//[REDACTED]@");
173
+ for (const pattern of TOKEN_PATTERNS.slice(1, -1)) {
174
+ output = output.replace(pattern, REDACTION);
175
+ }
176
+ return output;
177
+ }
178
+
179
+ // src/core/instructions.ts
180
+ var INSTRUCTION_PATTERNS = [
181
+ "**/AGENTS.md",
182
+ "**/CLAUDE.md",
183
+ "**/GEMINI.md",
184
+ ".cursor/rules",
185
+ ".cursor/rules/**/*",
186
+ ".github/copilot-instructions.md"
187
+ ];
188
+ var PREVIEW_CHAR_LIMIT = 1200;
189
+ async function detectInstructionFiles(root) {
190
+ const paths = await fg(INSTRUCTION_PATTERNS, {
191
+ cwd: root,
192
+ dot: true,
193
+ onlyFiles: false,
194
+ unique: true,
195
+ ignore: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/coverage/**"]
196
+ });
197
+ return Promise.all(paths.sort().map((path) => instructionFile(root, path)));
198
+ }
199
+ async function instructionFile(root, path) {
200
+ return {
201
+ path,
202
+ kind: instructionKind(path),
203
+ preview: await readPreview(root, path)
204
+ };
205
+ }
206
+ async function readPreview(root, path) {
207
+ const fullPath = `${root}/${path}`;
208
+ const metadata = await stat(fullPath);
209
+ if (!metadata.isFile()) {
210
+ return "Directory rule set detected.";
211
+ }
212
+ const content = await readFile2(fullPath, "utf8");
213
+ const normalized = content.replace(/\r\n/g, "\n").trim();
214
+ const preview = normalized.length > PREVIEW_CHAR_LIMIT ? `${normalized.slice(0, PREVIEW_CHAR_LIMIT).trimEnd()}
215
+ [truncated]` : normalized;
216
+ return redactText(preview);
217
+ }
218
+ function instructionKind(path) {
219
+ if (path.endsWith("AGENTS.md")) {
220
+ return "agents";
221
+ }
222
+ if (path.endsWith("CLAUDE.md")) {
223
+ return "claude";
224
+ }
225
+ if (path.endsWith("GEMINI.md")) {
226
+ return "gemini";
227
+ }
228
+ if (path === ".cursor/rules" || path.startsWith(".cursor/rules/")) {
229
+ return "cursor";
230
+ }
231
+ if (path === ".github/copilot-instructions.md") {
232
+ return "copilot";
233
+ }
234
+ return "instruction";
235
+ }
236
+
237
+ // src/core/package-json.ts
238
+ import { access, readFile as readFile3 } from "fs/promises";
239
+ import { join } from "path";
240
+ import { z } from "zod";
241
+ var PackageJsonSchema = z.object({
242
+ name: z.string().optional(),
243
+ packageManager: z.string().optional(),
244
+ scripts: z.record(z.string(), z.string()).optional()
245
+ });
246
+ var VERIFY_SCRIPT_ORDER = ["build", "test", "typecheck", "lint", "check", "verify", "ci"];
247
+ var VERIFY_SCRIPT_PREFIX = /^(build|test|typecheck|lint|check|verify|ci)(:|$)/;
248
+ async function detectPackageInfo(root) {
249
+ const packageJsonPath = join(root, "package.json");
250
+ if (!await pathExists(packageJsonPath)) {
251
+ return void 0;
252
+ }
253
+ const rawPackageJson = await readFile3(packageJsonPath, "utf8");
254
+ const packageJson = PackageJsonSchema.parse(JSON.parse(rawPackageJson));
255
+ const packageManager = await detectPackageManager(root, packageJson.packageManager);
256
+ const verificationScripts = detectVerificationScripts(packageJson.scripts ?? {});
257
+ return {
258
+ ...packageJson.name ? { name: packageJson.name } : {},
259
+ ...packageManager ? { packageManager } : {},
260
+ verificationScripts
261
+ };
262
+ }
263
+ async function detectPackageManager(root, packageManagerField) {
264
+ if (packageManagerField) {
265
+ return packageManagerField.split("@")[0] || packageManagerField;
266
+ }
267
+ const lockfiles = [
268
+ ["pnpm-lock.yaml", "pnpm"],
269
+ ["yarn.lock", "yarn"],
270
+ ["package-lock.json", "npm"],
271
+ ["bun.lock", "bun"],
272
+ ["bun.lockb", "bun"]
273
+ ];
274
+ for (const [lockfile, manager] of lockfiles) {
275
+ if (await pathExists(join(root, lockfile))) {
276
+ return manager;
277
+ }
278
+ }
279
+ return void 0;
280
+ }
281
+ function detectVerificationScripts(scripts) {
282
+ return Object.entries(scripts).filter(([name]) => VERIFY_SCRIPT_PREFIX.test(name)).sort(([left], [right]) => {
283
+ const leftIndex = orderIndex(left);
284
+ const rightIndex = orderIndex(right);
285
+ if (leftIndex !== rightIndex) {
286
+ return leftIndex - rightIndex;
287
+ }
288
+ return left.localeCompare(right);
289
+ }).map(([name, command]) => ({ name, command }));
290
+ }
291
+ async function pathExists(path) {
292
+ try {
293
+ await access(path);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+ function orderIndex(name) {
300
+ const baseName = name.split(":")[0] ?? name;
301
+ const index = VERIFY_SCRIPT_ORDER.indexOf(baseName);
302
+ return index === -1 ? VERIFY_SCRIPT_ORDER.length : index;
303
+ }
304
+
305
+ // src/core/risk.ts
306
+ function analyzeRisk(report) {
307
+ const files = report.repository.changedFiles;
308
+ const notes = [];
309
+ if (files.some((file) => /(^|\/)(redact|secret|auth|token|security)/i.test(file))) {
310
+ notes.push({
311
+ severity: "high",
312
+ title: "Security-sensitive code changed",
313
+ detail: "Review redaction, auth, token, or secret-handling changes carefully before handoff."
314
+ });
315
+ }
316
+ if (files.some((file) => file === "package.json" || file.endsWith("-lock.yaml") || file.endsWith("lock.json"))) {
317
+ notes.push({
318
+ severity: "medium",
319
+ title: "Dependency or package metadata changed",
320
+ detail: "Run install/build verification and check package publishing metadata."
321
+ });
322
+ }
323
+ if (files.some((file) => file.startsWith(".github/workflows/"))) {
324
+ notes.push({
325
+ severity: "medium",
326
+ title: "CI workflow changed",
327
+ detail: "Confirm GitHub Actions still passes after push."
328
+ });
329
+ }
330
+ const sourceFiles = files.filter((file) => file.startsWith("src/") && file.endsWith(".ts"));
331
+ const testFiles = files.filter((file) => file.startsWith("tests/") && file.endsWith(".test.ts"));
332
+ if (sourceFiles.length > 0 && testFiles.length === 0) {
333
+ notes.push({
334
+ severity: "medium",
335
+ title: "Source changed without matching tests",
336
+ detail: `Review test coverage for ${sourceFiles.slice(0, 5).join(", ")}.`
337
+ });
338
+ }
339
+ if (notes.length === 0) {
340
+ notes.push({
341
+ severity: "low",
342
+ title: "No obvious local risk signals",
343
+ detail: "No deterministic risk rule matched the current changed file set."
344
+ });
345
+ }
346
+ return { notes };
347
+ }
348
+
349
+ // src/core/scanners.ts
350
+ import { mkdtemp, readFile as readFile4 } from "fs/promises";
351
+ import { tmpdir } from "os";
352
+ import { relative, join as join2 } from "path";
353
+ import { performance } from "perf_hooks";
354
+ import { execa as execa2 } from "execa";
355
+ var MAX_FINDINGS = 20;
356
+ var ERROR_LIMIT = 2e3;
357
+ async function detectSecretScanners() {
358
+ const [gitleaks, secretlint] = await Promise.all([scannerStatus("gitleaks"), scannerStatus("secretlint")]);
359
+ return { scanners: [gitleaks, secretlint] };
360
+ }
361
+ async function runSecretScanners(root) {
362
+ const report = await detectSecretScanners();
363
+ const scans = await Promise.all(report.scanners.map((scanner) => runScanner(root, scanner)));
364
+ return { ...report, scans };
365
+ }
366
+ function normalizeGitleaksFindings(rawJson, limit = MAX_FINDINGS) {
367
+ const parsed = safeJson(rawJson);
368
+ if (!Array.isArray(parsed)) {
369
+ return [];
370
+ }
371
+ return parsed.slice(0, limit).map((finding) => {
372
+ const ruleId = stringValue(finding.RuleID);
373
+ const file = stringValue(finding.File);
374
+ const line = numberValue(finding.StartLine);
375
+ return {
376
+ ...ruleId ? { ruleId } : {},
377
+ message: redactText(stringValue(finding.Description) || "Secret finding"),
378
+ ...file ? { file } : {},
379
+ ...line ? { line } : {}
380
+ };
381
+ });
382
+ }
383
+ function normalizeSecretlintFindings(rawJson, limit = MAX_FINDINGS, root) {
384
+ const parsed = safeJson(rawJson);
385
+ if (!Array.isArray(parsed)) {
386
+ return [];
387
+ }
388
+ const findings = [];
389
+ for (const fileResult of parsed) {
390
+ const messages = Array.isArray(fileResult.messages) ? fileResult.messages : [];
391
+ for (const message of messages) {
392
+ if (findings.length >= limit) {
393
+ return findings;
394
+ }
395
+ const filePath = stringValue(fileResult.filePath);
396
+ const ruleId = stringValue(message.ruleId);
397
+ const line = numberValue(message.line);
398
+ findings.push({
399
+ ...ruleId ? { ruleId } : {},
400
+ message: redactText(stringValue(message.message) || "Secret finding"),
401
+ ...filePath ? { file: root ? relative(root, filePath) || filePath : filePath } : {},
402
+ ...line ? { line } : {}
403
+ });
404
+ }
405
+ }
406
+ return findings;
407
+ }
408
+ async function scannerStatus(name) {
409
+ const result = await execa2(name, ["--version"], {
410
+ reject: false
411
+ }).catch(() => void 0);
412
+ return {
413
+ name,
414
+ available: Boolean(result && result.exitCode === 0),
415
+ ...result?.stdout ? { version: result.stdout.trim() } : {}
416
+ };
417
+ }
418
+ async function runScanner(root, scanner) {
419
+ if (!scanner.available) {
420
+ return {
421
+ name: scanner.name,
422
+ available: false,
423
+ ran: false,
424
+ findings: [],
425
+ error: "Scanner binary not found.",
426
+ truncated: false
427
+ };
428
+ }
429
+ return scanner.name === "gitleaks" ? runGitleaks(root) : runSecretlint(root);
430
+ }
431
+ async function runGitleaks(root) {
432
+ const started = performance.now();
433
+ const tempDir = await mkdtemp(join2(tmpdir(), "handoffkit-gitleaks-"));
434
+ const reportPath = join2(tempDir, "report.json");
435
+ const result = await execa2(
436
+ "gitleaks",
437
+ ["dir", root, "--no-banner", "--no-color", "--redact=100", "--report-format", "json", "--report-path", reportPath, "--max-target-megabytes", "2"],
438
+ { reject: false, all: true }
439
+ );
440
+ const rawReport = await readFile4(reportPath, "utf8").catch(() => "[]");
441
+ const findings = normalizeGitleaksFindings(rawReport);
442
+ return {
443
+ name: "gitleaks",
444
+ available: true,
445
+ ran: result.exitCode === 0 || result.exitCode === 1,
446
+ exitCode: result.exitCode ?? 1,
447
+ durationMs: Math.round(performance.now() - started),
448
+ findings,
449
+ ...result.exitCode && result.exitCode > 1 ? { error: redactText(trimError(result.all ?? result.stderr ?? "")) } : {},
450
+ truncated: countJsonArray(rawReport) > findings.length
451
+ };
452
+ }
453
+ async function runSecretlint(root) {
454
+ const started = performance.now();
455
+ const result = await execa2("secretlint", ["**/*", "--format", "json", "--no-color"], {
456
+ cwd: root,
457
+ reject: false,
458
+ all: true
459
+ });
460
+ const rawOutput = result.stdout || result.all || "[]";
461
+ const findings = normalizeSecretlintFindings(rawOutput, MAX_FINDINGS, root);
462
+ return {
463
+ name: "secretlint",
464
+ available: true,
465
+ ran: result.exitCode === 0 || result.exitCode === 1,
466
+ exitCode: result.exitCode ?? 1,
467
+ durationMs: Math.round(performance.now() - started),
468
+ findings,
469
+ ...result.exitCode && result.exitCode > 1 ? { error: redactText(trimError(result.all ?? result.stderr ?? "")) } : {},
470
+ truncated: countSecretlintMessages(rawOutput) > findings.length
471
+ };
472
+ }
473
+ function safeJson(rawJson) {
474
+ try {
475
+ return JSON.parse(rawJson || "[]");
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+ function stringValue(value) {
481
+ return typeof value === "string" ? value : "";
482
+ }
483
+ function numberValue(value) {
484
+ return typeof value === "number" ? value : void 0;
485
+ }
486
+ function countJsonArray(rawJson) {
487
+ const parsed = safeJson(rawJson);
488
+ return Array.isArray(parsed) ? parsed.length : 0;
489
+ }
490
+ function countSecretlintMessages(rawJson) {
491
+ const parsed = safeJson(rawJson);
492
+ if (!Array.isArray(parsed)) {
493
+ return 0;
494
+ }
495
+ return parsed.reduce((count, fileResult) => {
496
+ return count + (Array.isArray(fileResult.messages) ? fileResult.messages.length : 0);
497
+ }, 0);
498
+ }
499
+ function trimError(output) {
500
+ const trimmed = output.trim();
501
+ return trimmed.length > ERROR_LIMIT ? `${trimmed.slice(0, ERROR_LIMIT)}
502
+ [truncated]` : trimmed;
503
+ }
504
+
505
+ // src/core/verify.ts
506
+ import { performance as performance2 } from "perf_hooks";
507
+ import { execa as execa3 } from "execa";
508
+ var VERIFY_ORDER = ["typecheck", "lint", "test", "build"];
509
+ var OUTPUT_LIMIT = 4e3;
510
+ function selectVerificationScripts(packageInfo) {
511
+ if (!packageInfo) {
512
+ return [];
513
+ }
514
+ return VERIFY_ORDER.flatMap((name) => packageInfo.verificationScripts.filter((script) => script.name === name));
515
+ }
516
+ async function runVerification(root) {
517
+ const packageInfo = await detectPackageInfo(root);
518
+ const scripts = selectVerificationScripts(packageInfo);
519
+ const commands = [];
520
+ for (const script of scripts) {
521
+ commands.push(await runScript(root, packageInfo?.packageManager ?? "npm", script));
522
+ }
523
+ return { commands };
524
+ }
525
+ async function runScript(root, packageManager, script) {
526
+ const started = performance2.now();
527
+ const command = `${packageManager} run ${script.name}`;
528
+ const result = await execa3(packageManager, ["run", script.name], {
529
+ cwd: root,
530
+ reject: false,
531
+ all: true
532
+ });
533
+ return {
534
+ name: script.name,
535
+ command,
536
+ exitCode: result.exitCode ?? 1,
537
+ durationMs: Math.round(performance2.now() - started),
538
+ output: trimOutput(result.all ?? result.stdout ?? result.stderr ?? "")
539
+ };
540
+ }
541
+ function trimOutput(output) {
542
+ const normalized = output.trim();
543
+ return normalized.length > OUTPUT_LIMIT ? `${normalized.slice(-OUTPUT_LIMIT)}
544
+ [trimmed]` : normalized;
545
+ }
546
+
547
+ // src/core/collect.ts
548
+ async function collectHandoffReport(options) {
549
+ const root = await findGitRoot(options.cwd);
550
+ const [repository, instructionFiles, packageInfo, secretScanning] = await Promise.all([
551
+ collectGitInfo(root, {
552
+ includeDiff: options.includeDiff && options.includeDiffSummary,
553
+ includeDiffSummary: options.includeDiffSummary,
554
+ ...options.since ? { since: options.since } : {}
555
+ }),
556
+ detectInstructionFiles(root),
557
+ detectPackageInfo(root),
558
+ options.scanSecrets ? runSecretScanners(root) : detectSecretScanners()
559
+ ]);
560
+ const report = {
561
+ goal: options.goal,
562
+ target: options.target,
563
+ repository,
564
+ instructionFiles,
565
+ ...packageInfo ? { packageInfo } : {},
566
+ ...options.resumeSource ? { resumeSource: options.resumeSource } : {},
567
+ ...options.includeVerification ? { verification: await runVerification(root) } : {},
568
+ secretScanning,
569
+ budget: {
570
+ requestedTokens: options.budget,
571
+ estimatedTokens: 0,
572
+ wasTrimmed: false
573
+ }
574
+ };
575
+ report.risk = analyzeRisk(report);
576
+ return report;
577
+ }
578
+
579
+ // src/cli/output.ts
580
+ import { mkdir, writeFile } from "fs/promises";
581
+ import { dirname, resolve } from "path";
582
+
583
+ // src/core/budget.ts
584
+ function estimateTokens(text) {
585
+ return Math.ceil(text.length / 4);
586
+ }
587
+ function applyMarkdownBudget(text, budget) {
588
+ const estimatedTokens = estimateTokens(text);
589
+ if (estimatedTokens <= budget) {
590
+ return { text, estimatedTokens, wasTrimmed: false };
591
+ }
592
+ const notice = `
593
+
594
+ > Output trimmed to fit --budget ${budget}. Re-run with a larger budget or --output for the full packet.
595
+ `;
596
+ const charLimit = Math.max(0, budget * 4 - notice.length);
597
+ const trimmed = `${text.slice(0, charLimit).trimEnd()}${notice}`;
598
+ return {
599
+ text: trimmed,
600
+ estimatedTokens: estimateTokens(trimmed),
601
+ wasTrimmed: true
602
+ };
603
+ }
604
+
605
+ // src/report/json.ts
606
+ function renderJsonReport(report) {
607
+ return `${JSON.stringify(report, null, 2)}
608
+ `;
609
+ }
610
+
611
+ // src/report/markdown.ts
612
+ function renderMarkdownReport(report) {
613
+ const lines = [
614
+ `# ${titleForTarget(report.target)}`,
615
+ "",
616
+ "## Goal",
617
+ report.goal,
618
+ "",
619
+ "## Repository",
620
+ `- Repository: \`${report.repository.name}\``,
621
+ `- Branch: \`${report.repository.branch}\``,
622
+ `- Changed files: ${report.repository.changedFiles.length}`,
623
+ "",
624
+ "## Git Status",
625
+ codeBlock(report.repository.status || "Clean working tree."),
626
+ "",
627
+ "## Recent Commits",
628
+ listOrNone(report.repository.recentCommits.map((commit) => `- ${commit}`)),
629
+ "",
630
+ "## Changed Files",
631
+ listOrNone(report.repository.changedFiles.map((file) => `- \`${file}\``)),
632
+ "",
633
+ ...renderBaseDiffSummary(report),
634
+ "## Diff Summary",
635
+ "### Staged",
636
+ codeBlock(report.repository.stagedDiffSummary || "No staged diff."),
637
+ "",
638
+ "### Unstaged",
639
+ codeBlock(report.repository.unstagedDiffSummary || "No unstaged diff."),
640
+ "",
641
+ "## Instruction Files",
642
+ renderInstructionFiles(report.instructionFiles),
643
+ "",
644
+ "## Package",
645
+ renderPackage(report.packageInfo),
646
+ "",
647
+ ...renderResumeSource(report),
648
+ ...renderVerification(report),
649
+ ...renderRisk(report),
650
+ ...renderSecretScanning(report),
651
+ "## Next Agent Notes",
652
+ "- This packet was generated from local git and filesystem state.",
653
+ "- Likely secrets were redacted from generated output.",
654
+ "- No LLM APIs were called."
655
+ ];
656
+ if (report.repository.includeDiff && report.repository.diff) {
657
+ lines.splice(
658
+ lines.indexOf("## Instruction Files"),
659
+ 0,
660
+ "## Included Diff",
661
+ "### Staged Patch",
662
+ codeBlock(report.repository.diff.staged || "No staged patch."),
663
+ "",
664
+ "### Unstaged Patch",
665
+ codeBlock(report.repository.diff.unstaged || "No unstaged patch."),
666
+ ""
667
+ );
668
+ }
669
+ if (report.repository.includeDiff && report.repository.baseDiff) {
670
+ lines.splice(
671
+ lines.indexOf("## Included Diff"),
672
+ 0,
673
+ `## Included Branch Delta Since \`${report.repository.baseRef}\``,
674
+ codeBlock(report.repository.baseDiff),
675
+ ""
676
+ );
677
+ }
678
+ return `${lines.join("\n")}
679
+ `;
680
+ }
681
+ function titleForTarget(target = "generic") {
682
+ const labels = {
683
+ generic: "Handoff Packet",
684
+ codex: "Codex Handoff Packet",
685
+ claude: "Claude Handoff Packet",
686
+ cursor: "Cursor Handoff Packet"
687
+ };
688
+ return labels[target] ?? "Handoff Packet";
689
+ }
690
+ function renderBaseDiffSummary(report) {
691
+ if (!report.repository.baseRef) {
692
+ return [];
693
+ }
694
+ return [
695
+ `## Branch Delta Since \`${report.repository.baseRef}\``,
696
+ codeBlock(report.repository.baseDiffSummary || "No committed branch delta detected."),
697
+ ""
698
+ ];
699
+ }
700
+ function renderPackage(packageInfo) {
701
+ if (!packageInfo) {
702
+ return "No package.json detected.";
703
+ }
704
+ const lines = [
705
+ packageInfo.name ? `- Package: \`${packageInfo.name}\`` : void 0,
706
+ packageInfo.packageManager ? `- Package manager: \`${packageInfo.packageManager}\`` : void 0
707
+ ].filter((line) => Boolean(line));
708
+ if (packageInfo.verificationScripts.length > 0) {
709
+ const prefix = packageInfo.packageManager ?? "npm";
710
+ lines.push("- Verification scripts:");
711
+ lines.push(...packageInfo.verificationScripts.map((script) => ` - \`${prefix} ${script.name}\``));
712
+ } else {
713
+ lines.push("- Verification scripts: none detected.");
714
+ }
715
+ return lines.join("\n");
716
+ }
717
+ function renderInstructionFiles(instructionFiles) {
718
+ if (instructionFiles.length === 0) {
719
+ return "None detected.";
720
+ }
721
+ return instructionFiles.map((file) => [`- \`${file.path}\` (${file.kind})`, codeBlock(file.preview || "No preview available.")].join("\n")).join("\n\n");
722
+ }
723
+ function renderResumeSource(report) {
724
+ if (!report.resumeSource) {
725
+ return [];
726
+ }
727
+ return ["## Resume Source", `- Source: \`${report.resumeSource.path}\``, codeBlock(report.resumeSource.preview), ""];
728
+ }
729
+ function renderVerification(report) {
730
+ if (!report.verification) {
731
+ return [];
732
+ }
733
+ return [
734
+ "## Verification",
735
+ report.verification.commands.length > 0 ? report.verification.commands.map(
736
+ (command) => [`- \`${command.command}\` exited ${command.exitCode} in ${command.durationMs}ms`, codeBlock(command.output || "No output.")].join("\n")
737
+ ).join("\n\n") : "No safe verification scripts detected.",
738
+ ""
739
+ ];
740
+ }
741
+ function renderRisk(report) {
742
+ if (!report.risk) {
743
+ return [];
744
+ }
745
+ return [
746
+ "## Risk Notes",
747
+ report.risk.notes.map((note) => `- **${note.severity}**: ${note.title} - ${note.detail}`).join("\n"),
748
+ ""
749
+ ];
750
+ }
751
+ function renderSecretScanning(report) {
752
+ if (!report.secretScanning) {
753
+ return [];
754
+ }
755
+ return [
756
+ report.secretScanning.scans ? "## Secret Scan Results" : "## Secret Scanner Availability",
757
+ renderSecretScannerReport(report.secretScanning),
758
+ ""
759
+ ];
760
+ }
761
+ function renderSecretScannerReport(secretScanning) {
762
+ if (!secretScanning.scans) {
763
+ return secretScanning.scanners.map((scanner) => `- ${scanner.name}: ${scanner.available ? "available" : "not found"}`).join("\n");
764
+ }
765
+ return secretScanning.scans.map((scan) => {
766
+ const lines = [
767
+ `- ${scan.name}: ${scan.ran ? `${scan.findings.length} finding(s), exit ${scan.exitCode}` : scan.error ?? "not run"}`
768
+ ];
769
+ for (const finding of scan.findings) {
770
+ lines.push(` - ${finding.ruleId ? `${finding.ruleId}: ` : ""}${finding.message}${finding.file ? ` (${finding.file}${finding.line ? `:${finding.line}` : ""})` : ""}`);
771
+ }
772
+ if (scan.truncated) {
773
+ lines.push(" - Additional findings were truncated.");
774
+ }
775
+ return lines.join("\n");
776
+ }).join("\n");
777
+ }
778
+ function codeBlock(text) {
779
+ return ["```text", text, "```"].join("\n");
780
+ }
781
+ function listOrNone(items) {
782
+ return items.length > 0 ? items.join("\n") : "None detected.";
783
+ }
784
+
785
+ // src/cli/output.ts
786
+ async function writeRenderedReport(report, format, budget, output) {
787
+ const rendered = redactText(renderOutput(report, format, budget));
788
+ if (output) {
789
+ const outputPath = resolve(process.cwd(), output);
790
+ await mkdir(dirname(outputPath), { recursive: true });
791
+ await writeFile(outputPath, rendered, "utf8");
792
+ process.stderr.write(`Wrote handoff packet to ${outputPath}
793
+ `);
794
+ return;
795
+ }
796
+ process.stdout.write(rendered);
797
+ }
798
+ function renderOutput(report, format, budget) {
799
+ if (format === "json") {
800
+ const rendered = renderJsonReport(report);
801
+ report.budget.estimatedTokens = estimateTokens(rendered);
802
+ return renderJsonReport(report);
803
+ }
804
+ const firstRender = renderMarkdownReport(report);
805
+ const budgeted = applyMarkdownBudget(firstRender, budget);
806
+ report.budget.estimatedTokens = budgeted.estimatedTokens;
807
+ report.budget.wasTrimmed = budgeted.wasTrimmed;
808
+ if (budgeted.wasTrimmed) {
809
+ return budgeted.text;
810
+ }
811
+ return renderMarkdownReport(report);
812
+ }
813
+
814
+ // src/cli/commands/pack.ts
815
+ var PackCliOptionsSchema = z2.object({
816
+ goal: z2.string().trim().min(1).default("Make your own goal"),
817
+ output: z2.string().optional(),
818
+ format: z2.enum(["markdown", "json"]).default("markdown"),
819
+ for: z2.enum(["generic", "codex", "claude", "cursor"]).default("generic"),
820
+ budget: z2.number().int().positive().default(4e3),
821
+ includeDiff: z2.boolean().default(false),
822
+ diff: z2.boolean().default(true),
823
+ since: z2.string().trim().min(1).optional(),
824
+ verify: z2.boolean().default(false),
825
+ scanSecrets: z2.boolean().default(false)
826
+ });
827
+ function createPackCommand() {
828
+ return new Command("pack").description("Create a safe local handoff packet for another AI assistant.").option("--goal <text>", "handoff goal", "Make your own goal").option("--output <path>", "write output to a file instead of stdout").option("--format <format>", "output format: markdown or json", "markdown").option("--for <agent>", "target output: generic, codex, claude, or cursor", "generic").option("--budget <tokens>", "rough output token budget", parseBudget, 4e3).option("--since <ref>", "focus committed branch delta on a base ref").option("--verify", "run safe verification scripts and include results").option("--scan-secrets", "run optional local secret scanners and include bounded results").option("--include-diff", "include full staged and unstaged patches", false).option("--no-diff", "omit diff summaries and full patches").action(async (rawOptions) => {
829
+ const options = parseOptions(rawOptions);
830
+ const report = await collectHandoffReport({
831
+ goal: options.goal,
832
+ cwd: process.cwd(),
833
+ ...options.output ? { output: options.output } : {},
834
+ format: options.format,
835
+ target: options.for,
836
+ budget: options.budget,
837
+ includeDiff: options.includeDiff,
838
+ includeDiffSummary: options.diff,
839
+ ...options.since ? { since: options.since } : {},
840
+ includeVerification: options.verify,
841
+ scanSecrets: options.scanSecrets
842
+ });
843
+ await writeRenderedReport(report, options.format, options.budget, options.output);
844
+ });
845
+ }
846
+ function parseOptions(rawOptions) {
847
+ const result = PackCliOptionsSchema.safeParse(rawOptions);
848
+ if (!result.success) {
849
+ const message = result.error.issues.map((issue) => issue.message).join("\n");
850
+ throw new Error(`Invalid pack options:
851
+ ${message}`);
852
+ }
853
+ return result.data;
854
+ }
855
+ function parseBudget(value) {
856
+ const parsed = Number.parseInt(value, 10);
857
+ if (!Number.isFinite(parsed) || parsed <= 0) {
858
+ throw new Error("--budget must be a positive integer.");
859
+ }
860
+ return parsed;
861
+ }
862
+
863
+ // src/cli/commands/resume.ts
864
+ import { readFile as readFile5 } from "fs/promises";
865
+ import { Command as Command2 } from "commander";
866
+ import { z as z3 } from "zod";
867
+
868
+ // src/core/resume.ts
869
+ var RESUME_PREVIEW_LIMIT = 3e3;
870
+ function createResumeSource(path, content) {
871
+ const normalized = content.replace(/\r\n/g, "\n").trim();
872
+ const preview = normalized.length > RESUME_PREVIEW_LIMIT ? `${normalized.slice(0, RESUME_PREVIEW_LIMIT).trimEnd()}
873
+ [truncated]` : normalized;
874
+ return {
875
+ path,
876
+ preview: redactText(preview)
877
+ };
878
+ }
879
+
880
+ // src/cli/commands/resume.ts
881
+ var ResumeOptionsSchema = z3.object({
882
+ goal: z3.string().trim().min(1).default("Resume interrupted AI coding session"),
883
+ output: z3.string().optional(),
884
+ format: z3.enum(["markdown", "json"]).default("markdown"),
885
+ for: z3.enum(["generic", "codex", "claude", "cursor"]).default("generic"),
886
+ budget: z3.number().int().positive().default(4e3)
887
+ });
888
+ function createResumeCommand() {
889
+ return new Command2("resume").description("Create a fresh handoff packet using a previous handoff as resume context.").argument("<path>", "previous handoff or transcript file").option("--goal <text>", "new handoff goal", "Resume interrupted AI coding session").option("--output <path>", "write output to a file instead of stdout").option("--format <format>", "output format: markdown or json", "markdown").option("--for <agent>", "target output: generic, codex, claude, or cursor", "generic").option("--budget <tokens>", "rough output token budget", parseBudget2, 4e3).action(async (path, rawOptions) => {
890
+ const options = ResumeOptionsSchema.parse(rawOptions);
891
+ const source = createResumeSource(path, await readFile5(path, "utf8"));
892
+ const report = await collectHandoffReport({
893
+ goal: options.goal,
894
+ cwd: process.cwd(),
895
+ ...options.output ? { output: options.output } : {},
896
+ format: options.format,
897
+ target: options.for,
898
+ budget: options.budget,
899
+ includeDiff: false,
900
+ includeDiffSummary: true,
901
+ includeVerification: false,
902
+ scanSecrets: false,
903
+ resumeSource: source
904
+ });
905
+ await writeRenderedReport(report, options.format, options.budget, options.output);
906
+ });
907
+ }
908
+ function parseBudget2(value) {
909
+ const parsed = Number.parseInt(value, 10);
910
+ if (!Number.isFinite(parsed) || parsed <= 0) {
911
+ throw new Error("--budget must be a positive integer.");
912
+ }
913
+ return parsed;
914
+ }
915
+
916
+ // src/cli/commands/risk.ts
917
+ import { Command as Command3 } from "commander";
918
+ import { z as z4 } from "zod";
919
+ var RiskOptionsSchema = z4.object({
920
+ format: z4.enum(["markdown", "json"]).default("markdown")
921
+ });
922
+ function createRiskCommand() {
923
+ return new Command3("risk").description("Show deterministic risk notes for the current handoff.").option("--format <format>", "output format: markdown or json", "markdown").action(async (rawOptions) => {
924
+ const options = RiskOptionsSchema.parse(rawOptions);
925
+ const report = await collectHandoffReport({
926
+ goal: "Review local risk",
927
+ cwd: process.cwd(),
928
+ format: options.format,
929
+ target: "generic",
930
+ budget: 4e3,
931
+ includeDiff: false,
932
+ includeDiffSummary: false,
933
+ includeVerification: false,
934
+ scanSecrets: false
935
+ });
936
+ if (options.format === "json") {
937
+ process.stdout.write(`${JSON.stringify(report.risk, null, 2)}
938
+ `);
939
+ return;
940
+ }
941
+ process.stdout.write(`# Risk Notes
942
+
943
+ ${report.risk?.notes.map((note) => `- **${note.severity}**: ${note.title} - ${note.detail}`).join("\n")}
944
+ `);
945
+ });
946
+ }
947
+
948
+ // src/cli/commands/scan-secrets.ts
949
+ import { Command as Command4 } from "commander";
950
+ import { z as z5 } from "zod";
951
+ var ScanSecretsOptionsSchema = z5.object({
952
+ format: z5.enum(["markdown", "json"]).default("markdown")
953
+ });
954
+ function createScanSecretsCommand() {
955
+ return new Command4("scan-secrets").description("Run optional local secret scanners and print bounded redacted results.").option("--format <format>", "output format: markdown or json", "markdown").action(async (rawOptions) => {
956
+ const options = ScanSecretsOptionsSchema.parse(rawOptions);
957
+ const root = await findGitRoot(process.cwd());
958
+ const report = await runSecretScanners(root);
959
+ if (options.format === "json") {
960
+ process.stdout.write(redactText(`${JSON.stringify(report, null, 2)}
961
+ `));
962
+ return;
963
+ }
964
+ process.stdout.write(redactText(renderScanMarkdown(report)));
965
+ });
966
+ }
967
+ function renderScanMarkdown(report) {
968
+ const lines = ["# Secret Scan Results", ""];
969
+ for (const scan of report.scans ?? []) {
970
+ lines.push(`- ${scan.name}: ${scan.ran ? `${scan.findings.length} finding(s), exit ${scan.exitCode}` : scan.error ?? "not run"}`);
971
+ for (const finding of scan.findings) {
972
+ lines.push(` - ${finding.ruleId ? `${finding.ruleId}: ` : ""}${finding.message}${finding.file ? ` (${finding.file}${finding.line ? `:${finding.line}` : ""})` : ""}`);
973
+ }
974
+ }
975
+ return `${lines.join("\n")}
976
+ `;
977
+ }
978
+
979
+ // src/cli/commands/verify.ts
980
+ import { Command as Command5 } from "commander";
981
+ import { z as z6 } from "zod";
982
+ var VerifyOptionsSchema = z6.object({
983
+ format: z6.enum(["markdown", "json"]).default("markdown")
984
+ });
985
+ function createVerifyCommand() {
986
+ return new Command5("verify").description("Run safe local verification scripts.").option("--format <format>", "output format: markdown or json", "markdown").action(async (rawOptions) => {
987
+ const options = VerifyOptionsSchema.parse(rawOptions);
988
+ const root = await findGitRoot(process.cwd());
989
+ const verification = await runVerification(root);
990
+ if (options.format === "json") {
991
+ process.stdout.write(redactText(`${JSON.stringify(verification, null, 2)}
992
+ `));
993
+ return;
994
+ }
995
+ process.stdout.write(redactText(renderVerificationMarkdown(verification.commands)));
996
+ });
997
+ }
998
+ function renderVerificationMarkdown(commands) {
999
+ const lines = ["# Verification", ""];
1000
+ if (commands.length === 0) {
1001
+ lines.push("No safe verification scripts detected.");
1002
+ } else {
1003
+ for (const command of commands) {
1004
+ lines.push(`- \`${command.command}\` exited ${command.exitCode} in ${command.durationMs}ms`);
1005
+ }
1006
+ }
1007
+ return `${lines.join("\n")}
1008
+ `;
1009
+ }
1010
+
1011
+ // src/cli/index.ts
1012
+ var program = new Command6().name("handoffkit").description("Create safe local handoff packets for AI-assisted coding sessions.").version("0.1.0");
1013
+ program.addCommand(createPackCommand());
1014
+ program.addCommand(createVerifyCommand());
1015
+ program.addCommand(createRiskCommand());
1016
+ program.addCommand(createScanSecretsCommand());
1017
+ program.addCommand(createResumeCommand());
1018
+ try {
1019
+ await program.parseAsync(process.argv);
1020
+ } catch (error) {
1021
+ const message = error instanceof Error ? error.message : String(error);
1022
+ process.stderr.write(`${message}
1023
+ `);
1024
+ process.exitCode = 1;
1025
+ }
1026
+ //# sourceMappingURL=index.js.map