@kingkyylian/handoffkit 0.1.1 → 0.3.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 CHANGED
@@ -7,6 +7,64 @@ import { Command as Command6 } from "commander";
7
7
  import { Command } from "commander";
8
8
  import { z as z2 } from "zod";
9
9
 
10
+ // src/core/cache.ts
11
+ import { mkdir, writeFile } from "fs/promises";
12
+ import { join } from "path";
13
+
14
+ // src/core/redact.ts
15
+ var REDACTION = "[REDACTED]";
16
+ 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;
17
+ var TOKEN_PATTERNS = [
18
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
19
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
20
+ /\bgh[pousr]_[A-Za-z0-9_]{16,}/g,
21
+ /\bnpm_[A-Za-z0-9_-]{16,}/g,
22
+ /\bxox[baprs]-[A-Za-z0-9-]{16,}/g,
23
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
24
+ /\bAKIA[0-9A-Z]{16}\b/g,
25
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
26
+ /\/\/([^/\s:@]+):([^@\s/]+)@/g
27
+ ];
28
+ function redactText(input) {
29
+ let output = input.replace(
30
+ /-----BEGIN ([A-Z ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g,
31
+ (_match, keyType) => `-----BEGIN ${keyType}-----
32
+ ${REDACTION}
33
+ -----END ${keyType}-----`
34
+ );
35
+ output = output.replace(SECRET_KEY_PATTERN, (_match, prefix, quote) => {
36
+ return `${prefix}${quote}${REDACTION}${quote}`;
37
+ });
38
+ output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer [REDACTED]");
39
+ output = output.replace(/\/\/([^/\s:@]+):([^@\s/]+)@/g, "//[REDACTED]@");
40
+ for (const pattern of TOKEN_PATTERNS.slice(1, -1)) {
41
+ output = output.replace(pattern, REDACTION);
42
+ }
43
+ return output;
44
+ }
45
+
46
+ // src/core/cache.ts
47
+ async function writeCacheArtifact(root, kind, data, options = {}) {
48
+ const createdAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
49
+ const envelope = {
50
+ version: 1,
51
+ kind,
52
+ createdAt,
53
+ data
54
+ };
55
+ const cacheDir = join(root, ".handoffkit", kind);
56
+ const artifactPath = join(cacheDir, `${cacheTimestamp(createdAt)}.json`);
57
+ const latestPath = join(cacheDir, "latest.json");
58
+ const contents = `${redactText(JSON.stringify(envelope, null, 2))}
59
+ `;
60
+ await mkdir(cacheDir, { recursive: true });
61
+ await Promise.all([writeFile(artifactPath, contents, "utf8"), writeFile(latestPath, contents, "utf8")]);
62
+ return { artifactPath, latestPath };
63
+ }
64
+ function cacheTimestamp(timestamp) {
65
+ return timestamp.replace(/[:.]/g, "-");
66
+ }
67
+
10
68
  // src/core/git.ts
11
69
  import { readFile } from "fs/promises";
12
70
  import { basename } from "path";
@@ -26,7 +84,7 @@ function formatCliError(error) {
26
84
 
27
85
  // src/core/git.ts
28
86
  var UNTRACKED_PATCH_CHAR_LIMIT = 2e4;
29
- var IGNORED_CHANGED_PATH_PREFIXES = ["node_modules/", "dist/", "coverage/", ".git/"];
87
+ var IGNORED_CHANGED_PATH_PREFIXES = ["node_modules/", "dist/", "coverage/", ".git/", ".handoffkit/"];
30
88
  async function findGitRoot(cwd) {
31
89
  const result = await execa("git", ["rev-parse", "--show-toplevel"], {
32
90
  cwd,
@@ -160,40 +218,6 @@ function isIgnoredChangedPath(file) {
160
218
  // src/core/instructions.ts
161
219
  import { readFile as readFile2, stat } from "fs/promises";
162
220
  import fg from "fast-glob";
163
-
164
- // src/core/redact.ts
165
- var REDACTION = "[REDACTED]";
166
- 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;
167
- var TOKEN_PATTERNS = [
168
- /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
169
- /\bsk-[A-Za-z0-9_-]{16,}/g,
170
- /\bgh[pousr]_[A-Za-z0-9_]{16,}/g,
171
- /\bnpm_[A-Za-z0-9_-]{16,}/g,
172
- /\bxox[baprs]-[A-Za-z0-9-]{16,}/g,
173
- /\bAIza[0-9A-Za-z_-]{20,}/g,
174
- /\bAKIA[0-9A-Z]{16}\b/g,
175
- /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
176
- /\/\/([^/\s:@]+):([^@\s/]+)@/g
177
- ];
178
- function redactText(input) {
179
- let output = input.replace(
180
- /-----BEGIN ([A-Z ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g,
181
- (_match, keyType) => `-----BEGIN ${keyType}-----
182
- ${REDACTION}
183
- -----END ${keyType}-----`
184
- );
185
- output = output.replace(SECRET_KEY_PATTERN, (_match, prefix, quote) => {
186
- return `${prefix}${quote}${REDACTION}${quote}`;
187
- });
188
- output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer [REDACTED]");
189
- output = output.replace(/\/\/([^/\s:@]+):([^@\s/]+)@/g, "//[REDACTED]@");
190
- for (const pattern of TOKEN_PATTERNS.slice(1, -1)) {
191
- output = output.replace(pattern, REDACTION);
192
- }
193
- return output;
194
- }
195
-
196
- // src/core/instructions.ts
197
221
  var INSTRUCTION_PATTERNS = [
198
222
  "**/AGENTS.md",
199
223
  "**/CLAUDE.md",
@@ -253,7 +277,7 @@ function instructionKind(path) {
253
277
 
254
278
  // src/core/package-json.ts
255
279
  import { access, readFile as readFile3 } from "fs/promises";
256
- import { join } from "path";
280
+ import { join as join2 } from "path";
257
281
  import { z } from "zod";
258
282
  var PackageJsonSchema = z.object({
259
283
  name: z.string().optional(),
@@ -263,7 +287,7 @@ var PackageJsonSchema = z.object({
263
287
  var VERIFY_SCRIPT_ORDER = ["build", "test", "typecheck", "lint", "check", "verify", "ci"];
264
288
  var VERIFY_SCRIPT_PREFIX = /^(build|test|typecheck|lint|check|verify|ci)(:|$)/;
265
289
  async function detectPackageInfo(root) {
266
- const packageJsonPath = join(root, "package.json");
290
+ const packageJsonPath = join2(root, "package.json");
267
291
  if (!await pathExists(packageJsonPath)) {
268
292
  return void 0;
269
293
  }
@@ -289,7 +313,7 @@ async function detectPackageManager(root, packageManagerField) {
289
313
  ["bun.lockb", "bun"]
290
314
  ];
291
315
  for (const [lockfile, manager] of lockfiles) {
292
- if (await pathExists(join(root, lockfile))) {
316
+ if (await pathExists(join2(root, lockfile))) {
293
317
  return manager;
294
318
  }
295
319
  }
@@ -320,29 +344,73 @@ function orderIndex(name) {
320
344
  }
321
345
 
322
346
  // src/core/risk.ts
347
+ var RISK_RULES = [
348
+ {
349
+ severity: "high",
350
+ title: "Security-sensitive code changed",
351
+ detail: "Review redaction, auth, token, or secret-handling changes carefully before handoff.",
352
+ matches: (file) => /(^|\/)(redact|secret|auth|token|security)/i.test(file)
353
+ },
354
+ {
355
+ severity: "high",
356
+ title: "Release or package publishing path changed",
357
+ detail: "Release and package changes can break install, provenance, or publish flow; run pnpm pack:dry-run and pnpm smoke:release before tagging or publishing.",
358
+ matches: isReleaseOrPackageFile
359
+ },
360
+ {
361
+ severity: "medium",
362
+ title: "CI workflow changed",
363
+ detail: "Workflow changes can fail only after push; confirm GitHub Actions still passes on the target branch.",
364
+ matches: (file) => file.startsWith(".github/workflows/")
365
+ },
366
+ {
367
+ severity: "medium",
368
+ title: "Build tooling or TypeScript config changed",
369
+ detail: "Tooling changes can break typecheck, lint, build output, or package entrypoints; run the full local check command.",
370
+ matches: isBuildToolingFile
371
+ },
372
+ {
373
+ severity: "medium",
374
+ title: "CLI behavior changed",
375
+ detail: "CLI entrypoint or command changes can break user-facing flags and output contracts; cover the changed command with unit or integration tests.",
376
+ matches: (file) => file.startsWith("src/cli/")
377
+ },
378
+ {
379
+ severity: "medium",
380
+ title: "Resume parsing changed",
381
+ detail: "Resume parser changes can drop handoff context; verify completed work, next steps, failures, and open questions are still extracted.",
382
+ matches: (file) => file === "src/core/resume.ts" || file.includes("/resume")
383
+ },
384
+ {
385
+ severity: "medium",
386
+ title: "Handoff report rendering changed",
387
+ detail: "Report rendering changes can hide critical context; verify Markdown and JSON output still include repository, verification, risk, and next-step sections.",
388
+ matches: (file) => file.startsWith("src/report/")
389
+ },
390
+ {
391
+ severity: "medium",
392
+ title: "Generated artifact or ignore policy changed",
393
+ detail: "Ignore/cache policy changes can pollute changedFiles or published packages; verify generated directories remain ignored and excluded from reports.",
394
+ matches: isGeneratedOrIgnorePolicyFile
395
+ },
396
+ {
397
+ severity: "low",
398
+ title: "Documentation changed",
399
+ detail: "Documentation-only changes still need examples, command names, and release instructions checked against the current CLI behavior.",
400
+ matches: isDocumentationFile
401
+ }
402
+ ];
323
403
  function analyzeRisk(report) {
324
404
  const files = report.repository.changedFiles;
325
405
  const notes = [];
326
- if (files.some((file) => /(^|\/)(redact|secret|auth|token|security)/i.test(file))) {
327
- notes.push({
328
- severity: "high",
329
- title: "Security-sensitive code changed",
330
- detail: "Review redaction, auth, token, or secret-handling changes carefully before handoff."
331
- });
332
- }
333
- if (files.some((file) => file === "package.json" || file.endsWith("-lock.yaml") || file.endsWith("lock.json"))) {
334
- notes.push({
335
- severity: "medium",
336
- title: "Dependency or package metadata changed",
337
- detail: "Run install/build verification and check package publishing metadata."
338
- });
339
- }
340
- if (files.some((file) => file.startsWith(".github/workflows/"))) {
341
- notes.push({
342
- severity: "medium",
343
- title: "CI workflow changed",
344
- detail: "Confirm GitHub Actions still passes after push."
345
- });
406
+ for (const rule of RISK_RULES) {
407
+ if (files.some(rule.matches)) {
408
+ notes.push({
409
+ severity: rule.severity,
410
+ title: rule.title,
411
+ detail: rule.detail
412
+ });
413
+ }
346
414
  }
347
415
  const sourceFiles = files.filter((file) => file.startsWith("src/") && file.endsWith(".ts"));
348
416
  const testFiles = files.filter((file) => file.startsWith("tests/") && file.endsWith(".test.ts"));
@@ -362,21 +430,34 @@ function analyzeRisk(report) {
362
430
  }
363
431
  return { notes };
364
432
  }
433
+ function isReleaseOrPackageFile(file) {
434
+ return file === "package.json" || file === "CHANGELOG.md" || file === "docs/RELEASE.md" || file === "scripts/release-smoke.mjs" || /^\.github\/workflows\/.*release.*\.ya?ml$/i.test(file) || /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/i.test(file);
435
+ }
436
+ function isBuildToolingFile(file) {
437
+ return /(^|\/)(tsconfig(?:\.[^/]*)?\.json|tsup\.config\.ts|vitest\.config\.ts|eslint\.config\.[cm]?[jt]s|pnpm-workspace\.yaml)$/i.test(file) || file.startsWith("scripts/");
438
+ }
439
+ function isGeneratedOrIgnorePolicyFile(file) {
440
+ return file === ".gitignore" || file === ".npmignore" || file.startsWith(".handoffkit/") || file.startsWith("docs/checkpoints/") || /(^|\/)(dist|coverage|node_modules|\.tmp-tests)\//.test(file);
441
+ }
442
+ function isDocumentationFile(file) {
443
+ return file === "README.md" || file === "ROADMAP.md" || file === "CONTRIBUTING.md" || file === "SECURITY.md" || file.startsWith("docs/");
444
+ }
365
445
 
366
446
  // src/core/scanners.ts
367
447
  import { mkdtemp, readFile as readFile4 } from "fs/promises";
368
448
  import { tmpdir } from "os";
369
- import { relative, join as join2 } from "path";
449
+ import { relative, join as join3 } from "path";
370
450
  import { performance } from "perf_hooks";
371
451
  import { execa as execa2 } from "execa";
452
+ import fg2 from "fast-glob";
372
453
  var MAX_FINDINGS = 20;
373
454
  var ERROR_LIMIT = 2e3;
374
- async function detectSecretScanners() {
375
- const [gitleaks, secretlint] = await Promise.all([scannerStatus("gitleaks"), scannerStatus("secretlint")]);
455
+ async function detectSecretScanners(root = process.cwd()) {
456
+ const [gitleaks, secretlint] = await Promise.all([scannerStatus("gitleaks", root), scannerStatus("secretlint", root)]);
376
457
  return { scanners: [gitleaks, secretlint] };
377
458
  }
378
459
  async function runSecretScanners(root) {
379
- const report = await detectSecretScanners();
460
+ const report = await detectSecretScanners(root);
380
461
  const scans = await Promise.all(report.scanners.map((scanner) => runScanner(root, scanner)));
381
462
  return { ...report, scans };
382
463
  }
@@ -422,14 +503,18 @@ function normalizeSecretlintFindings(rawJson, limit = MAX_FINDINGS, root) {
422
503
  }
423
504
  return findings;
424
505
  }
425
- async function scannerStatus(name) {
506
+ async function scannerStatus(name, root) {
426
507
  const result = await execa2(name, ["--version"], {
427
508
  reject: false
428
509
  }).catch(() => void 0);
510
+ const configFiles = await scannerConfigFiles(name, root);
429
511
  return {
430
512
  name,
431
513
  available: Boolean(result && result.exitCode === 0),
432
- ...result?.stdout ? { version: result.stdout.trim() } : {}
514
+ ...result?.stdout ? { version: result.stdout.trim() } : {},
515
+ configFiles,
516
+ configHint: configHint(name, configFiles),
517
+ installHint: installHint(name)
433
518
  };
434
519
  }
435
520
  async function runScanner(root, scanner) {
@@ -447,8 +532,8 @@ async function runScanner(root, scanner) {
447
532
  }
448
533
  async function runGitleaks(root) {
449
534
  const started = performance.now();
450
- const tempDir = await mkdtemp(join2(tmpdir(), "handoffkit-gitleaks-"));
451
- const reportPath = join2(tempDir, "report.json");
535
+ const tempDir = await mkdtemp(join3(tmpdir(), "handoffkit-gitleaks-"));
536
+ const reportPath = join3(tempDir, "report.json");
452
537
  const result = await execa2(
453
538
  "gitleaks",
454
539
  ["dir", root, "--no-banner", "--no-color", "--redact=100", "--report-format", "json", "--report-path", reportPath, "--max-target-megabytes", "2"],
@@ -518,6 +603,25 @@ function trimError(output) {
518
603
  return trimmed.length > ERROR_LIMIT ? `${trimmed.slice(0, ERROR_LIMIT)}
519
604
  [truncated]` : trimmed;
520
605
  }
606
+ async function scannerConfigFiles(name, root) {
607
+ const patterns = name === "gitleaks" ? ["gitleaks.toml", ".gitleaks.toml", ".gitleaksignore", ".config/gitleaks/*.toml"] : [".secretlintrc", ".secretlintrc.*", "secretlint.config.*"];
608
+ const matches = await fg2(patterns, {
609
+ cwd: root,
610
+ dot: true,
611
+ onlyFiles: true,
612
+ unique: true
613
+ });
614
+ return matches.sort();
615
+ }
616
+ function configHint(name, configFiles) {
617
+ if (configFiles.length > 0) {
618
+ return `config: ${configFiles.join(", ")}`;
619
+ }
620
+ return name === "gitleaks" ? "config: none detected; optional files include .gitleaks.toml, gitleaks.toml, or .config/gitleaks/*.toml" : "config: none detected; optional files include .secretlintrc.*, .secretlintrc, or secretlint.config.*";
621
+ }
622
+ function installHint(name) {
623
+ return name === "gitleaks" ? "Install gitleaks from https://github.com/gitleaks/gitleaks, then rerun with --scan-secrets." : "Install secretlint from https://github.com/secretlint/secretlint, then rerun with --scan-secrets.";
624
+ }
521
625
 
522
626
  // src/core/verify.ts
523
627
  import { performance as performance2 } from "perf_hooks";
@@ -572,7 +676,7 @@ async function collectHandoffReport(options) {
572
676
  }),
573
677
  detectInstructionFiles(root),
574
678
  detectPackageInfo(root),
575
- options.scanSecrets ? runSecretScanners(root) : detectSecretScanners()
679
+ options.scanSecrets ? runSecretScanners(root) : detectSecretScanners(root)
576
680
  ]);
577
681
  const report = {
578
682
  goal: options.goal,
@@ -594,7 +698,7 @@ async function collectHandoffReport(options) {
594
698
  }
595
699
 
596
700
  // src/cli/output.ts
597
- import { mkdir, writeFile } from "fs/promises";
701
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
598
702
  import { dirname, resolve } from "path";
599
703
 
600
704
  // src/core/budget.ts
@@ -625,84 +729,176 @@ function renderJsonReport(report) {
625
729
  `;
626
730
  }
627
731
 
732
+ // src/report/profiles.ts
733
+ var genericOrder = [
734
+ "goal",
735
+ "repository",
736
+ "gitStatus",
737
+ "recentCommits",
738
+ "changedFiles",
739
+ "branchDelta",
740
+ "diffSummary",
741
+ "includedBranchDelta",
742
+ "includedDiff",
743
+ "instructionFiles",
744
+ "package",
745
+ "resume",
746
+ "verification",
747
+ "risk",
748
+ "secretScanning"
749
+ ];
750
+ var profiles = {
751
+ generic: {
752
+ title: "Handoff Packet",
753
+ sectionOrder: genericOrder,
754
+ nextAgentNotes: [
755
+ "Use this packet as the starting context for the next coding session.",
756
+ "Verify commands locally before claiming completion."
757
+ ]
758
+ },
759
+ codex: {
760
+ title: "Codex Handoff Packet",
761
+ sectionOrder: [
762
+ "goal",
763
+ "repository",
764
+ "gitStatus",
765
+ "changedFiles",
766
+ "verification",
767
+ "risk",
768
+ "branchDelta",
769
+ "diffSummary",
770
+ "includedBranchDelta",
771
+ "includedDiff",
772
+ "instructionFiles",
773
+ "package",
774
+ "resume",
775
+ "secretScanning",
776
+ "recentCommits"
777
+ ],
778
+ nextAgentNotes: [
779
+ "Start by reading the goal, repository status, changed files, and verification state.",
780
+ "Use local tools to inspect files before editing; do not assume hidden context.",
781
+ "Keep edits scoped and rerun the relevant verification before reporting completion."
782
+ ]
783
+ },
784
+ claude: {
785
+ title: "Claude Code Handoff Packet",
786
+ sectionOrder: [
787
+ "goal",
788
+ "resume",
789
+ "repository",
790
+ "verification",
791
+ "risk",
792
+ "changedFiles",
793
+ "gitStatus",
794
+ "branchDelta",
795
+ "diffSummary",
796
+ "includedBranchDelta",
797
+ "includedDiff",
798
+ "instructionFiles",
799
+ "package",
800
+ "secretScanning",
801
+ "recentCommits"
802
+ ],
803
+ nextAgentNotes: [
804
+ "Treat this as concise project memory plus current branch state.",
805
+ "Use the resume state to separate completed work from remaining work.",
806
+ "Ask for clarification only when the packet leaves a blocking ambiguity."
807
+ ]
808
+ },
809
+ cursor: {
810
+ title: "Cursor Handoff Packet",
811
+ sectionOrder: [
812
+ "goal",
813
+ "repository",
814
+ "changedFiles",
815
+ "gitStatus",
816
+ "includedDiff",
817
+ "diffSummary",
818
+ "branchDelta",
819
+ "includedBranchDelta",
820
+ "instructionFiles",
821
+ "package",
822
+ "verification",
823
+ "risk",
824
+ "resume",
825
+ "secretScanning",
826
+ "recentCommits"
827
+ ],
828
+ nextAgentNotes: [
829
+ "Open the changed files first to build editor context.",
830
+ "Use instruction files and package scripts to keep edits aligned with the workspace.",
831
+ "Prefer small edits and rerun the detected verification scripts."
832
+ ]
833
+ }
834
+ };
835
+ function profileForTarget(target) {
836
+ return profiles[target] ?? profiles.generic;
837
+ }
838
+
628
839
  // src/report/markdown.ts
629
840
  function renderMarkdownReport(report) {
841
+ const profile = profileForTarget(report.target);
630
842
  const lines = [
631
- `# ${titleForTarget(report.target)}`,
632
- "",
633
- "## Goal",
634
- report.goal,
635
- "",
636
- "## Repository",
637
- `- Repository: \`${report.repository.name}\``,
638
- `- Branch: \`${report.repository.branch}\``,
639
- `- Changed files: ${report.repository.changedFiles.length}`,
640
- "",
641
- "## Git Status",
642
- codeBlock(report.repository.status || "Clean working tree."),
643
- "",
644
- "## Recent Commits",
645
- listOrNone(report.repository.recentCommits.map((commit) => `- ${commit}`)),
646
- "",
647
- "## Changed Files",
648
- listOrNone(report.repository.changedFiles.map((file) => `- \`${file}\``)),
649
- "",
650
- ...renderBaseDiffSummary(report),
651
- "## Diff Summary",
652
- "### Staged",
653
- codeBlock(report.repository.stagedDiffSummary || "No staged diff."),
654
- "",
655
- "### Unstaged",
656
- codeBlock(report.repository.unstagedDiffSummary || "No unstaged diff."),
657
- "",
658
- "## Instruction Files",
659
- renderInstructionFiles(report.instructionFiles),
843
+ `# ${profile.title}`,
660
844
  "",
661
- "## Package",
662
- renderPackage(report.packageInfo),
663
- "",
664
- ...renderResumeSource(report),
665
- ...renderVerification(report),
666
- ...renderRisk(report),
667
- ...renderSecretScanning(report),
845
+ ...profile.sectionOrder.flatMap((section) => renderSection(section, report)),
668
846
  "## Next Agent Notes",
847
+ ...profile.nextAgentNotes.map((note) => `- ${note}`),
669
848
  "- This packet was generated from local git and filesystem state.",
670
849
  "- Likely secrets were redacted from generated output.",
671
850
  "- No LLM APIs were called."
672
851
  ];
673
- if (report.repository.includeDiff && report.repository.diff) {
674
- lines.splice(
675
- lines.indexOf("## Instruction Files"),
676
- 0,
677
- "## Included Diff",
678
- "### Staged Patch",
679
- codeBlock(report.repository.diff.staged || "No staged patch."),
680
- "",
681
- "### Unstaged Patch",
682
- codeBlock(report.repository.diff.unstaged || "No unstaged patch."),
683
- ""
684
- );
685
- }
686
- if (report.repository.includeDiff && report.repository.baseDiff) {
687
- lines.splice(
688
- lines.indexOf("## Included Diff"),
689
- 0,
690
- `## Included Branch Delta Since \`${report.repository.baseRef}\``,
691
- codeBlock(report.repository.baseDiff),
692
- ""
693
- );
694
- }
695
852
  return `${lines.join("\n")}
696
853
  `;
697
854
  }
698
- function titleForTarget(target = "generic") {
699
- const labels = {
700
- generic: "Handoff Packet",
701
- codex: "Codex Handoff Packet",
702
- claude: "Claude Handoff Packet",
703
- cursor: "Cursor Handoff Packet"
704
- };
705
- return labels[target] ?? "Handoff Packet";
855
+ function renderSection(section, report) {
856
+ switch (section) {
857
+ case "goal":
858
+ return ["## Goal", report.goal, ""];
859
+ case "repository":
860
+ return [
861
+ "## Repository",
862
+ `- Repository: \`${report.repository.name}\``,
863
+ `- Branch: \`${report.repository.branch}\``,
864
+ `- Changed files: ${report.repository.changedFiles.length}`,
865
+ ""
866
+ ];
867
+ case "gitStatus":
868
+ return ["## Git Status", codeBlock(report.repository.status || "Clean working tree."), ""];
869
+ case "recentCommits":
870
+ return ["## Recent Commits", listOrNone(report.repository.recentCommits.map((commit) => `- ${commit}`)), ""];
871
+ case "changedFiles":
872
+ return ["## Changed Files", listOrNone(report.repository.changedFiles.map((file) => `- \`${file}\``)), ""];
873
+ case "branchDelta":
874
+ return renderBaseDiffSummary(report);
875
+ case "diffSummary":
876
+ return [
877
+ "## Diff Summary",
878
+ "### Staged",
879
+ codeBlock(report.repository.stagedDiffSummary || "No staged diff."),
880
+ "",
881
+ "### Unstaged",
882
+ codeBlock(report.repository.unstagedDiffSummary || "No unstaged diff."),
883
+ ""
884
+ ];
885
+ case "includedBranchDelta":
886
+ return renderIncludedBranchDelta(report);
887
+ case "includedDiff":
888
+ return renderIncludedDiff(report);
889
+ case "instructionFiles":
890
+ return ["## Instruction Files", renderInstructionFiles(report.instructionFiles), ""];
891
+ case "package":
892
+ return ["## Package", renderPackage(report.packageInfo), ""];
893
+ case "resume":
894
+ return renderResumeSource(report);
895
+ case "verification":
896
+ return renderVerification(report);
897
+ case "risk":
898
+ return renderRisk(report);
899
+ case "secretScanning":
900
+ return renderSecretScanning(report);
901
+ }
706
902
  }
707
903
  function renderBaseDiffSummary(report) {
708
904
  if (!report.repository.baseRef) {
@@ -714,6 +910,30 @@ function renderBaseDiffSummary(report) {
714
910
  ""
715
911
  ];
716
912
  }
913
+ function renderIncludedBranchDelta(report) {
914
+ if (!report.repository.includeDiff || !report.repository.baseDiff) {
915
+ return [];
916
+ }
917
+ return [
918
+ `## Included Branch Delta Since \`${report.repository.baseRef}\``,
919
+ codeBlock(report.repository.baseDiff),
920
+ ""
921
+ ];
922
+ }
923
+ function renderIncludedDiff(report) {
924
+ if (!report.repository.includeDiff || !report.repository.diff) {
925
+ return [];
926
+ }
927
+ return [
928
+ "## Included Diff",
929
+ "### Staged Patch",
930
+ codeBlock(report.repository.diff.staged || "No staged patch."),
931
+ "",
932
+ "### Unstaged Patch",
933
+ codeBlock(report.repository.diff.unstaged || "No unstaged patch."),
934
+ ""
935
+ ];
936
+ }
717
937
  function renderPackage(packageInfo) {
718
938
  if (!packageInfo) {
719
939
  return "No package.json detected.";
@@ -801,12 +1021,16 @@ function renderSecretScanning(report) {
801
1021
  }
802
1022
  function renderSecretScannerReport(secretScanning) {
803
1023
  if (!secretScanning.scans) {
804
- return secretScanning.scanners.map((scanner) => `- ${scanner.name}: ${scanner.available ? "available" : "not found"}`).join("\n");
1024
+ return secretScanning.scanners.map((scanner) => `- ${scannerStatusLine(scanner)}`).join("\n");
805
1025
  }
806
1026
  return secretScanning.scans.map((scan) => {
1027
+ const status = secretScanning.scanners.find((scanner) => scanner.name === scan.name);
807
1028
  const lines = [
808
1029
  `- ${scan.name}: ${scan.ran ? `${scan.findings.length} finding(s), exit ${scan.exitCode}` : scan.error ?? "not run"}`
809
1030
  ];
1031
+ if (status) {
1032
+ lines.push(...scannerGuidanceLines(status));
1033
+ }
810
1034
  for (const finding of scan.findings) {
811
1035
  lines.push(` - ${finding.ruleId ? `${finding.ruleId}: ` : ""}${finding.message}${finding.file ? ` (${finding.file}${finding.line ? `:${finding.line}` : ""})` : ""}`);
812
1036
  }
@@ -816,6 +1040,23 @@ function renderSecretScannerReport(secretScanning) {
816
1040
  return lines.join("\n");
817
1041
  }).join("\n");
818
1042
  }
1043
+ function scannerStatusLine(scanner) {
1044
+ const config = scanner.configFiles.length > 0 ? `; config: ${scanner.configFiles.join(", ")}` : scanner.available ? "" : `; ${scanner.configHint}`;
1045
+ const install = scanner.available ? "" : `; ${scanner.installHint}`;
1046
+ return `${scanner.name}: ${scanner.available ? "available" : "not found"}${config}${install}`;
1047
+ }
1048
+ function scannerGuidanceLines(scanner) {
1049
+ const lines = [];
1050
+ if (scanner.configFiles.length > 0) {
1051
+ lines.push(` - config: ${scanner.configFiles.join(", ")}`);
1052
+ } else if (!scanner.available) {
1053
+ lines.push(` - ${scanner.configHint}`);
1054
+ }
1055
+ if (!scanner.available) {
1056
+ lines.push(` - ${scanner.installHint}`);
1057
+ }
1058
+ return lines;
1059
+ }
819
1060
  function codeBlock(text) {
820
1061
  return ["```text", text, "```"].join("\n");
821
1062
  }
@@ -828,8 +1069,8 @@ async function writeRenderedReport(report, format, budget, output) {
828
1069
  const rendered = redactText(renderOutput(report, format, budget));
829
1070
  if (output) {
830
1071
  const outputPath = resolve(process.cwd(), output);
831
- await mkdir(dirname(outputPath), { recursive: true });
832
- await writeFile(outputPath, rendered, "utf8");
1072
+ await mkdir2(dirname(outputPath), { recursive: true });
1073
+ await writeFile2(outputPath, rendered, "utf8");
833
1074
  process.stderr.write(`Wrote handoff packet to ${outputPath}
834
1075
  `);
835
1076
  return;
@@ -863,11 +1104,13 @@ var PackCliOptionsSchema = z2.object({
863
1104
  diff: z2.boolean().default(true),
864
1105
  since: z2.string().trim().min(1).optional(),
865
1106
  verify: z2.boolean().default(false),
866
- scanSecrets: z2.boolean().default(false)
1107
+ scanSecrets: z2.boolean().default(false),
1108
+ cache: z2.boolean().default(false)
867
1109
  });
868
1110
  function createPackCommand() {
869
- return new Command("pack").description("Create a safe local handoff packet for another AI assistant.").summary("Create a Markdown or JSON packet from the current git state.").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) => {
1111
+ return new Command("pack").description("Create a safe local handoff packet for another AI assistant.").summary("Create a Markdown or JSON packet from the current git state.").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("--cache", "write explicit local cache artifacts under .handoffkit when available").option("--include-diff", "include full staged and unstaged patches", false).option("--no-diff", "omit diff summaries and full patches").action(async (rawOptions) => {
870
1112
  const options = parseOptions(rawOptions);
1113
+ const root = options.cache ? await findGitRoot(process.cwd()) : void 0;
871
1114
  const report = await collectHandoffReport({
872
1115
  goal: options.goal,
873
1116
  cwd: process.cwd(),
@@ -881,6 +1124,15 @@ function createPackCommand() {
881
1124
  includeVerification: options.verify,
882
1125
  scanSecrets: options.scanSecrets
883
1126
  });
1127
+ if (options.cache && root && report.verification) {
1128
+ const cache = await writeCacheArtifact(root, "verification", {
1129
+ goal: report.goal,
1130
+ target: report.target,
1131
+ verification: report.verification
1132
+ });
1133
+ process.stderr.write(`Wrote verification cache to ${cache.latestPath}
1134
+ `);
1135
+ }
884
1136
  await writeRenderedReport(report, options.format, options.budget, options.output);
885
1137
  });
886
1138
  }
@@ -909,11 +1161,20 @@ import { z as z3 } from "zod";
909
1161
  // src/core/resume.ts
910
1162
  var RESUME_PREVIEW_LIMIT = 3e3;
911
1163
  var SECTION_ALIASES = {
912
- completed: [/^completed$/i, /^done$/i, /^done this session$/i, /^what changed$/i, /^implemented$/i],
913
- remaining: [/^remaining$/i, /^next steps$/i, /^todo$/i, /^to do$/i],
914
- failedCommands: [/^failed commands$/i, /^failures$/i, /^errors$/i],
915
- openQuestions: [/^open questions$/i, /^open questions \/ risks$/i, /^open questions and risks$/i, /^questions$/i, /^blockers$/i],
916
- verification: [/^verification$/i, /^tests$/i, /^validation$/i]
1164
+ completed: [/^completed$/i, /^completed work$/i, /^done$/i, /^done this session$/i, /^what changed$/i, /^what i changed$/i, /^implemented$/i],
1165
+ remaining: [/^remaining$/i, /^remaining work$/i, /^next steps$/i, /^next action$/i, /^next safest action$/i, /^todo$/i, /^to do$/i],
1166
+ failedCommands: [/^failed command$/i, /^failed commands$/i, /^command failed$/i, /^commands failed$/i, /^failure$/i, /^failures$/i, /^error$/i, /^errors$/i],
1167
+ openQuestions: [
1168
+ /^open question$/i,
1169
+ /^open questions$/i,
1170
+ /^open questions \/ risks$/i,
1171
+ /^open questions and risks$/i,
1172
+ /^question$/i,
1173
+ /^questions$/i,
1174
+ /^blocker$/i,
1175
+ /^blockers$/i
1176
+ ],
1177
+ verification: [/^verification$/i, /^tests$/i, /^tests run$/i, /^validation$/i]
917
1178
  };
918
1179
  function createResumeSource(path, content) {
919
1180
  const normalized = content.replace(/\r\n/g, "\n").trim();
@@ -948,12 +1209,22 @@ function parseResumeState(content) {
948
1209
  section = sectionForHeading(heading);
949
1210
  continue;
950
1211
  }
1212
+ const transcriptLine = stripTranscriptPrefix(line);
1213
+ const labeled = parseLabeledTranscriptLine(transcriptLine);
1214
+ if (labeled) {
1215
+ heading = labeled.heading;
1216
+ section = labeled.section;
1217
+ if (labeled.item) {
1218
+ appendResumeItem(state, section, labeled.item, heading);
1219
+ }
1220
+ continue;
1221
+ }
951
1222
  if (!section) {
952
1223
  continue;
953
1224
  }
954
- const item = normalizeListItem(line);
1225
+ const item = normalizeListItem(transcriptLine);
955
1226
  if (item) {
956
- state[section].push({ text: redactText(item), ...heading ? { sourceHeading: redactText(heading) } : {} });
1227
+ appendResumeItem(state, section, item, heading);
957
1228
  }
958
1229
  }
959
1230
  const next = state.remaining[0] ?? state.openQuestions[0] ?? state.failedCommands[0];
@@ -971,10 +1242,41 @@ function sectionForHeading(heading) {
971
1242
  }
972
1243
  return void 0;
973
1244
  }
1245
+ function parseLabeledTranscriptLine(line) {
1246
+ const match = line.match(/^([^:]{1,80}):(?:\s*(.*))?$/);
1247
+ if (!match?.[1]) {
1248
+ return void 0;
1249
+ }
1250
+ const heading = match[1].trim();
1251
+ const section = sectionForHeading(heading);
1252
+ if (!section) {
1253
+ return void 0;
1254
+ }
1255
+ const item = match[2]?.trim();
1256
+ return {
1257
+ section,
1258
+ heading,
1259
+ ...item ? { item } : {}
1260
+ };
1261
+ }
1262
+ function appendResumeItem(state, section, item, heading) {
1263
+ state[section].push({ text: redactText(item), ...heading ? { sourceHeading: redactText(heading) } : {} });
1264
+ }
974
1265
  function normalizeListItem(line) {
975
1266
  const match = line.match(/^[-*]\s+(.+)$/) ?? line.match(/^\d+\.\s+(.+)$/);
976
1267
  return match?.[1]?.trim();
977
1268
  }
1269
+ function stripTranscriptPrefix(line) {
1270
+ let current = line.trim();
1271
+ for (let i = 0; i < 4; i += 1) {
1272
+ const next = current.replace(/^\[[^\]\n]{1,60}\]\s*/, "").replace(/^(?:user|assistant|system|developer|tool|terminal|command|cmd|result|codex|claude|cursor|gemini)(?:\s*\([^)]*\))?\s*[:>]\s*/i, "").trim();
1273
+ if (next === current) {
1274
+ return current;
1275
+ }
1276
+ current = next;
1277
+ }
1278
+ return current;
1279
+ }
978
1280
  function normalizeHeading(heading) {
979
1281
  return heading.trim().replace(/:$/, "").replace(/\s*\/\s*/g, " / ").replace(/\s+/g, " ");
980
1282
  }
@@ -988,12 +1290,14 @@ var ResumeOptionsSchema = z3.object({
988
1290
  output: z3.string().optional(),
989
1291
  format: z3.enum(["markdown", "json"]).default("markdown"),
990
1292
  for: z3.enum(["generic", "codex", "claude", "cursor"]).default("generic"),
991
- budget: z3.number().int().positive().default(4e3)
1293
+ budget: z3.number().int().positive().default(4e3),
1294
+ cache: z3.boolean().default(false)
992
1295
  });
993
1296
  function createResumeCommand() {
994
- return new Command2("resume").description("Create a fresh handoff packet using a previous handoff as resume context.").summary("Merge a previous handoff or transcript with fresh repo state.").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) => {
1297
+ return new Command2("resume").description("Create a fresh handoff packet using a previous handoff as resume context.").summary("Merge a previous handoff or transcript with fresh repo state.").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).option("--cache", "write a local resume artifact under .handoffkit/resume").action(async (path, rawOptions) => {
995
1298
  const options = ResumeOptionsSchema.parse(rawOptions);
996
1299
  const source = createResumeSource(path, await readFile5(path, "utf8"));
1300
+ const root = options.cache ? await findGitRoot(process.cwd()) : void 0;
997
1301
  const report = await collectHandoffReport({
998
1302
  goal: options.goal,
999
1303
  cwd: process.cwd(),
@@ -1007,6 +1311,15 @@ function createResumeCommand() {
1007
1311
  scanSecrets: false,
1008
1312
  resumeSource: source
1009
1313
  });
1314
+ if (options.cache && root) {
1315
+ const cache = await writeCacheArtifact(root, "resume", {
1316
+ goal: report.goal,
1317
+ target: report.target,
1318
+ source
1319
+ });
1320
+ process.stderr.write(`Wrote resume cache to ${cache.latestPath}
1321
+ `);
1322
+ }
1010
1323
  await writeRenderedReport(report, options.format, options.budget, options.output);
1011
1324
  });
1012
1325
  }
@@ -1072,7 +1385,11 @@ function createScanSecretsCommand() {
1072
1385
  function renderScanMarkdown(report) {
1073
1386
  const lines = ["# Secret Scan Results", ""];
1074
1387
  for (const scan of report.scans ?? []) {
1388
+ const status = report.scanners.find((scanner) => scanner.name === scan.name);
1075
1389
  lines.push(`- ${scan.name}: ${scan.ran ? `${scan.findings.length} finding(s), exit ${scan.exitCode}` : scan.error ?? "not run"}`);
1390
+ if (status) {
1391
+ lines.push(...scannerGuidanceLines2(status));
1392
+ }
1076
1393
  for (const finding of scan.findings) {
1077
1394
  lines.push(` - ${finding.ruleId ? `${finding.ruleId}: ` : ""}${finding.message}${finding.file ? ` (${finding.file}${finding.line ? `:${finding.line}` : ""})` : ""}`);
1078
1395
  }
@@ -1080,18 +1397,36 @@ function renderScanMarkdown(report) {
1080
1397
  return `${lines.join("\n")}
1081
1398
  `;
1082
1399
  }
1400
+ function scannerGuidanceLines2(scanner) {
1401
+ const lines = [];
1402
+ if (scanner.configFiles.length > 0) {
1403
+ lines.push(` - config: ${scanner.configFiles.join(", ")}`);
1404
+ } else if (!scanner.available) {
1405
+ lines.push(` - ${scanner.configHint}`);
1406
+ }
1407
+ if (!scanner.available) {
1408
+ lines.push(` - ${scanner.installHint}`);
1409
+ }
1410
+ return lines;
1411
+ }
1083
1412
 
1084
1413
  // src/cli/commands/verify.ts
1085
1414
  import { Command as Command5 } from "commander";
1086
1415
  import { z as z6 } from "zod";
1087
1416
  var VerifyOptionsSchema = z6.object({
1088
- format: z6.enum(["markdown", "json"]).default("markdown")
1417
+ format: z6.enum(["markdown", "json"]).default("markdown"),
1418
+ cache: z6.boolean().default(false)
1089
1419
  });
1090
1420
  function createVerifyCommand() {
1091
- return new Command5("verify").description("Run safe local verification scripts.").summary("Run safe detected verification scripts.").option("--format <format>", "output format: markdown or json", "markdown").action(async (rawOptions) => {
1421
+ return new Command5("verify").description("Run safe local verification scripts.").summary("Run safe detected verification scripts.").option("--format <format>", "output format: markdown or json", "markdown").option("--cache", "write a local verification artifact under .handoffkit/verification").action(async (rawOptions) => {
1092
1422
  const options = VerifyOptionsSchema.parse(rawOptions);
1093
1423
  const root = await findGitRoot(process.cwd());
1094
1424
  const verification = await runVerification(root);
1425
+ if (options.cache) {
1426
+ const cache = await writeCacheArtifact(root, "verification", verification);
1427
+ process.stderr.write(`Wrote verification cache to ${cache.latestPath}
1428
+ `);
1429
+ }
1095
1430
  if (options.format === "json") {
1096
1431
  process.stdout.write(redactText(`${JSON.stringify(verification, null, 2)}
1097
1432
  `));
@@ -1114,7 +1449,7 @@ function renderVerificationMarkdown(commands) {
1114
1449
  }
1115
1450
 
1116
1451
  // src/cli/index.ts
1117
- var program = new Command6().name("handoffkit").description("Create safe local handoff packets for AI-assisted coding sessions.").summary("Create local-first AI coding session handoff packets.").showHelpAfterError("(run with --help for usage)").version("0.1.1");
1452
+ var program = new Command6().name("handoffkit").description("Create safe local handoff packets for AI-assisted coding sessions.").summary("Create local-first AI coding session handoff packets.").showHelpAfterError("(run with --help for usage)").version("0.3.0");
1118
1453
  program.addCommand(createPackCommand());
1119
1454
  program.addCommand(createVerifyCommand());
1120
1455
  program.addCommand(createRiskCommand());