coding-agent-skills 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -0
  3. package/ROADMAP.md +5 -3
  4. package/bin/coding-agent-skills +7 -0
  5. package/docs/adapters/README.md +18 -0
  6. package/docs/adapters/project-installation.md +12 -0
  7. package/docs/adapters/real-project-adoption.md +3 -2
  8. package/docs/architecture/README.md +3 -2
  9. package/docs/release/README.md +3 -2
  10. package/docs/release/npm-package.md +7 -2
  11. package/docs/safety/README.md +6 -1
  12. package/docs/testing/README.md +8 -0
  13. package/docs/usage/README.md +15 -5
  14. package/examples/command-policies/github-handoff.json +74 -0
  15. package/examples/evidence-packs/github-handoff.json +67 -0
  16. package/examples/manifests/github-handoff.json +14 -0
  17. package/examples/workflows/github-handoff.md +5 -0
  18. package/package.json +2 -1
  19. package/runs/skill-runs.md +16 -0
  20. package/schemas/project-adapter-installation.schema.json +2 -0
  21. package/schemas/project-adapter.schema.json +2 -0
  22. package/scripts/lib/github-handoff.mjs +446 -0
  23. package/scripts/lib/pack-rules.mjs +11 -2
  24. package/scripts/render-github-handoff.mjs +7 -0
  25. package/scripts/test-pack.mjs +89 -1
  26. package/scripts/validate-pack.mjs +5 -2
  27. package/skills/github-handoff/SKILL.md +95 -0
  28. package/skills/github-handoff/adapter-interface.md +18 -0
  29. package/skills/github-handoff/agents/openai.yaml +3 -0
  30. package/skills/github-handoff/checklist.md +10 -0
  31. package/skills/github-handoff/evidence-template.md +16 -0
  32. package/skills/github-handoff/examples.md +19 -0
  33. package/skills/github-handoff/failure-modes.md +8 -0
  34. package/tests/fixtures/github-handoff/adapter-project/.coding-agent/adapters/github-handoff-fixture/adapter.json +56 -0
  35. package/tests/fixtures/github-handoff/adapter-project/.coding-agent/skills.json +23 -0
  36. package/tests/fixtures/github-handoff/adapter-project/README.md +3 -0
  37. package/tests/fixtures/github-handoff/adapter-project/package.json +4 -0
  38. package/tests/fixtures/github-handoff/adapter-project/src/index.js +1 -0
  39. package/tests/fixtures/github-handoff/static-project/README.md +3 -0
  40. package/tests/fixtures/github-handoff/static-project/package.json +4 -0
  41. package/tests/fixtures/github-handoff/static-project/src/index.js +1 -0
  42. package/tests/fixtures/triggers/cases.json +14 -2
  43. package/tests/trigger/README.md +2 -0
  44. package/work-ledger.md +16 -6
@@ -0,0 +1,446 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ ADAPTER_MANIFEST_FILENAME,
8
+ readSafeJsonFile,
9
+ } from "./adapter-discovery.mjs";
10
+ import { PILOT_VERSION } from "./pack-rules.mjs";
11
+ import {
12
+ readProjectAdapterDeclaration,
13
+ validateProjectAdapters,
14
+ } from "./project-adapter-installation.mjs";
15
+
16
+ const DEFAULT_CORE_ROOT = path.resolve(
17
+ path.dirname(fileURLToPath(import.meta.url)),
18
+ "..",
19
+ "..",
20
+ );
21
+
22
+ const SKILL_ID = "github-handoff";
23
+
24
+ const REFUSED_BEHAVIOR = [
25
+ "no commits",
26
+ "no pushes",
27
+ "no tags",
28
+ "no branch changes",
29
+ "no pull request creation",
30
+ "no GitHub API mutations",
31
+ "no token reads",
32
+ "no secret-file reads",
33
+ "no project writes",
34
+ ];
35
+
36
+ const NOT_VERIFIED = [
37
+ "remote repository permissions",
38
+ "GitHub pull request state",
39
+ "GitHub issue state",
40
+ "GitHub Actions or CI status",
41
+ "whether local commits have been pushed",
42
+ "reviewer assignment or approval state",
43
+ "release publication state",
44
+ ];
45
+
46
+ function inside(root, candidate) {
47
+ const relative = path.relative(root, candidate);
48
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
49
+ }
50
+
51
+ function toPosix(relativePath) {
52
+ return relativePath.split(path.sep).join("/");
53
+ }
54
+
55
+ function safeRelativePath(candidate) {
56
+ return (
57
+ typeof candidate === "string" &&
58
+ candidate.length > 0 &&
59
+ !candidate.startsWith("/") &&
60
+ !candidate.split(/[\\/]+/).includes("..")
61
+ );
62
+ }
63
+
64
+ function secretBearingPath(relativePath) {
65
+ const normalized = toPosix(relativePath);
66
+ const basename = path.posix.basename(normalized);
67
+ return (
68
+ basename === ".env" ||
69
+ basename.startsWith(".env.") ||
70
+ basename === ".npmrc" ||
71
+ /\.(?:pem|key|p12|pfx)$/i.test(basename) ||
72
+ /(?:^|\/)(?:secrets?|credentials?|private-key|service-role|tokens?)(?:\/|$)/i.test(normalized)
73
+ );
74
+ }
75
+
76
+ function runGit(projectRoot, args) {
77
+ const result = spawnSync("git", args, {
78
+ cwd: projectRoot,
79
+ encoding: "utf8",
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ });
82
+ return {
83
+ ok: result.status === 0,
84
+ stdout: result.stdout.trim(),
85
+ };
86
+ }
87
+
88
+ function parseStatusEntry(line) {
89
+ const code = line.slice(0, 2);
90
+ const rawPath = line.slice(3).trim();
91
+ const paths = rawPath.split(" -> ");
92
+ const redacted = paths.some(secretBearingPath);
93
+ return {
94
+ code,
95
+ path: redacted ? "[REDACTED:secret-bearing-path]" : rawPath,
96
+ redacted,
97
+ category: categorizeStatus(code),
98
+ };
99
+ }
100
+
101
+ function categorizeStatus(code) {
102
+ if (code.includes("U") || code === "AA" || code === "DD") return "conflicted";
103
+ if (code === "??") return "untracked";
104
+ if (code.includes("A")) return "added";
105
+ if (code.includes("D")) return "deleted";
106
+ if (code.includes("R")) return "renamed";
107
+ if (code.includes("C")) return "copied";
108
+ if (code.includes("M")) return "modified";
109
+ return "other";
110
+ }
111
+
112
+ function summarizeChanges(entries) {
113
+ const summary = {
114
+ total: entries.length,
115
+ added: 0,
116
+ copied: 0,
117
+ deleted: 0,
118
+ modified: 0,
119
+ renamed: 0,
120
+ untracked: 0,
121
+ conflicted: 0,
122
+ other: 0,
123
+ redacted: 0,
124
+ };
125
+ for (const entry of entries) {
126
+ summary[entry.category] += 1;
127
+ if (entry.redacted) summary.redacted += 1;
128
+ }
129
+ return summary;
130
+ }
131
+
132
+ function gitSummary(projectRoot) {
133
+ const summary = {
134
+ isGitRepo: false,
135
+ root: null,
136
+ branchState: null,
137
+ branch: null,
138
+ head: null,
139
+ headShort: null,
140
+ headSubject: null,
141
+ tagsAtHead: [],
142
+ remoteNames: [],
143
+ changedFiles: [],
144
+ changeSummary: summarizeChanges([]),
145
+ warnings: [],
146
+ };
147
+
148
+ const revParse = runGit(projectRoot, ["rev-parse", "--show-toplevel"]);
149
+ if (!revParse.ok) {
150
+ summary.warnings.push("git repository not detected");
151
+ return summary;
152
+ }
153
+ summary.isGitRepo = true;
154
+ summary.root = revParse.stdout;
155
+
156
+ const status = runGit(projectRoot, ["status", "--short", "--branch"]);
157
+ if (!status.ok) {
158
+ summary.warnings.push("git status unavailable");
159
+ } else {
160
+ const lines = status.stdout.split(/\r?\n/).filter(Boolean);
161
+ summary.branchState = lines[0] ?? null;
162
+ summary.changedFiles = lines.slice(1).map(parseStatusEntry);
163
+ summary.changeSummary = summarizeChanges(summary.changedFiles);
164
+ if (summary.changedFiles.length) summary.warnings.push("working tree has local changes");
165
+ if (summary.changeSummary.redacted) {
166
+ summary.warnings.push("one or more changed paths were redacted because they look secret-bearing");
167
+ }
168
+ if (/\[(?:ahead|behind|gone|diverged)[^\]]*\]/i.test(summary.branchState ?? "")) {
169
+ summary.warnings.push("branch state indicates remote divergence; revalidate before handoff");
170
+ }
171
+ }
172
+
173
+ const branch = runGit(projectRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
174
+ if (branch.ok) summary.branch = branch.stdout;
175
+
176
+ const head = runGit(projectRoot, ["rev-parse", "HEAD"]);
177
+ if (head.ok) {
178
+ summary.head = head.stdout;
179
+ summary.headShort = head.stdout.slice(0, 12);
180
+ }
181
+
182
+ const subject = runGit(projectRoot, ["log", "-1", "--format=%s"]);
183
+ if (subject.ok) summary.headSubject = subject.stdout;
184
+
185
+ const tags = runGit(projectRoot, ["tag", "--points-at", "HEAD"]);
186
+ if (tags.ok) summary.tagsAtHead = tags.stdout ? tags.stdout.split(/\r?\n/).filter(Boolean).sort() : [];
187
+
188
+ const remotes = runGit(projectRoot, ["remote"]);
189
+ if (remotes.ok) summary.remoteNames = remotes.stdout ? remotes.stdout.split(/\r?\n/).filter(Boolean).sort() : [];
190
+
191
+ return summary;
192
+ }
193
+
194
+ function adapterContext(projectRootInput, coreRoot) {
195
+ const loaded = readProjectAdapterDeclaration(projectRootInput);
196
+ if (!loaded.ok) {
197
+ if (loaded.codes.length === 1 && loaded.codes[0] === "missing-project-declaration") {
198
+ return {
199
+ ok: true,
200
+ present: false,
201
+ enabled: false,
202
+ projectRoot: path.resolve(projectRootInput),
203
+ mode: "none",
204
+ codes: [],
205
+ };
206
+ }
207
+ return { ok: false, present: false, enabled: false, status: "failed", codes: loaded.codes };
208
+ }
209
+
210
+ const validation = validateProjectAdapters(loaded.projectRoot, { coreRoot });
211
+ if (!validation.ok) {
212
+ return {
213
+ ok: false,
214
+ present: true,
215
+ enabled: false,
216
+ status: "failed",
217
+ codes: validation.codes,
218
+ validation,
219
+ };
220
+ }
221
+
222
+ if (!validation.acceptedSkills.includes(SKILL_ID)) {
223
+ return {
224
+ ok: true,
225
+ present: true,
226
+ enabled: false,
227
+ projectRoot: loaded.projectRoot,
228
+ mode: "adapter-present-github-handoff-not-enabled",
229
+ declarationPath: loaded.declarationPath,
230
+ declaration: loaded.declaration,
231
+ validation,
232
+ codes: [`${SKILL_ID}-not-enabled-by-adapter`],
233
+ };
234
+ }
235
+
236
+ const adapters = [];
237
+ const errors = [];
238
+ const container = path.resolve(loaded.projectRoot, loaded.declaration.adapterRoot);
239
+ if (!inside(loaded.projectRoot, container) || !fs.existsSync(container)) {
240
+ errors.push("adapter-root-not-found");
241
+ } else {
242
+ for (const declaration of loaded.declaration.adapters ?? []) {
243
+ if (!(declaration.skillIds ?? []).includes(SKILL_ID)) continue;
244
+ const manifestPath = path.join(container, declaration.id, ADAPTER_MANIFEST_FILENAME);
245
+ const record = readSafeJsonFile(manifestPath);
246
+ if (!record.value) {
247
+ errors.push(...record.codes);
248
+ continue;
249
+ }
250
+ adapters.push({
251
+ declaration,
252
+ manifestPath: path.relative(loaded.projectRoot, manifestPath),
253
+ manifest: record.value,
254
+ });
255
+ }
256
+ }
257
+ if (errors.length) {
258
+ return { ok: false, present: true, enabled: false, status: "failed", codes: errors, validation };
259
+ }
260
+ return {
261
+ ok: true,
262
+ present: true,
263
+ enabled: true,
264
+ projectRoot: loaded.projectRoot,
265
+ mode: "adapter-enabled",
266
+ declarationPath: loaded.declarationPath,
267
+ declaration: loaded.declaration,
268
+ validation,
269
+ adapters,
270
+ codes: [],
271
+ };
272
+ }
273
+
274
+ function adapterEvidence(context) {
275
+ const ignoredPaths = new Set([".git", "node_modules", "dist", "build", "coverage", "validation-output"]);
276
+ const requiredEvidence = new Set(["branch state", "HEAD", "working-tree state", "changed-file summary"]);
277
+ for (const adapter of context.adapters ?? []) {
278
+ for (const candidate of adapter.manifest.extensions?.ignoredPaths ?? []) {
279
+ if (safeRelativePath(candidate)) ignoredPaths.add(candidate);
280
+ }
281
+ for (const candidate of adapter.manifest.extensions?.requiredEvidence ?? []) {
282
+ if (typeof candidate === "string" && candidate.trim()) requiredEvidence.add(candidate);
283
+ }
284
+ }
285
+ return {
286
+ ignoredPaths: [...ignoredPaths].sort(),
287
+ requiredEvidence: [...requiredEvidence].sort(),
288
+ };
289
+ }
290
+
291
+ export function buildGithubHandoffReport(projectRootInput, options = {}) {
292
+ const coreRoot = options.coreRoot ?? DEFAULT_CORE_ROOT;
293
+ const context = adapterContext(projectRootInput, coreRoot);
294
+ const projectRoot = context.projectRoot ?? path.resolve(projectRootInput ?? ".");
295
+ const git = gitSummary(projectRoot);
296
+ const base = {
297
+ coreVersion: PILOT_VERSION,
298
+ projectRoot,
299
+ adapter: context,
300
+ git,
301
+ ignoredPaths: [".git", "node_modules", "dist", "build", "coverage", "validation-output"],
302
+ requiredEvidence: ["branch state", "HEAD", "working-tree state", "changed-file summary"],
303
+ changedFiles: [],
304
+ changeSummary: summarizeChanges([]),
305
+ skipped: [],
306
+ warnings: [...git.warnings],
307
+ notVerified: NOT_VERIFIED,
308
+ refusedBehavior: REFUSED_BEHAVIOR,
309
+ };
310
+
311
+ if (!context.ok) {
312
+ return {
313
+ ...base,
314
+ status: "failed",
315
+ warnings: [...(context.codes ?? []), ...git.warnings],
316
+ };
317
+ }
318
+
319
+ if (!git.isGitRepo) {
320
+ return {
321
+ ...base,
322
+ status: "failed",
323
+ skipped: [{ path: ".", reason: "project root is not a Git repository" }],
324
+ };
325
+ }
326
+
327
+ if (context.present && !context.enabled) {
328
+ return {
329
+ ...base,
330
+ status: "partial",
331
+ skipped: [{ path: ".", reason: "project adapter is present but does not enable github-handoff" }],
332
+ warnings: [
333
+ "github-handoff is not enabled by the project adapter; changed-file details were not listed",
334
+ ...git.warnings,
335
+ ],
336
+ };
337
+ }
338
+
339
+ const evidence = context.enabled ? adapterEvidence(context) : {
340
+ ignoredPaths: base.ignoredPaths,
341
+ requiredEvidence: base.requiredEvidence,
342
+ };
343
+
344
+ return {
345
+ ...base,
346
+ status: "complete",
347
+ ignoredPaths: evidence.ignoredPaths,
348
+ requiredEvidence: evidence.requiredEvidence,
349
+ changedFiles: git.changedFiles,
350
+ changeSummary: git.changeSummary,
351
+ warnings: [
352
+ ...git.warnings,
353
+ ...(context.enabled ? ["github-handoff used adapter-declared handoff evidence metadata"] : []),
354
+ "remote URLs were not printed to avoid credential exposure",
355
+ ],
356
+ };
357
+ }
358
+
359
+ function listOrNone(items) {
360
+ return items.length ? items.join(", ") : "none";
361
+ }
362
+
363
+ function renderChangeSummary(summary) {
364
+ return [
365
+ `- Total changed entries: ${summary.total}`,
366
+ `- Added: ${summary.added}`,
367
+ `- Modified: ${summary.modified}`,
368
+ `- Deleted: ${summary.deleted}`,
369
+ `- Renamed: ${summary.renamed}`,
370
+ `- Copied: ${summary.copied}`,
371
+ `- Untracked: ${summary.untracked}`,
372
+ `- Conflicted: ${summary.conflicted}`,
373
+ `- Redacted paths: ${summary.redacted}`,
374
+ ];
375
+ }
376
+
377
+ export function renderGithubHandoffReport(report) {
378
+ const lines = [
379
+ "# GitHub Handoff Report",
380
+ "",
381
+ `Status: ${report.status}`,
382
+ `Core version: ${report.coreVersion}`,
383
+ `Project root: ${report.projectRoot}`,
384
+ "",
385
+ "## Git State",
386
+ `- Git root: ${report.git.root ?? "not detected"}`,
387
+ `- Branch state: ${report.git.branchState ?? "not detected"}`,
388
+ `- Current branch: ${report.git.branch ?? "not detected"}`,
389
+ `- HEAD: ${report.git.headShort ?? "not detected"}`,
390
+ `- HEAD subject: ${report.git.headSubject ?? "not detected"}`,
391
+ `- Tags at HEAD: ${listOrNone(report.git.tagsAtHead)}`,
392
+ `- Remote names: ${listOrNone(report.git.remoteNames)}`,
393
+ "",
394
+ "## Adapter Scope",
395
+ `- Adapter present: ${report.adapter.present ? "yes" : "no"}`,
396
+ `- Github-handoff enabled: ${report.adapter.enabled ? "yes" : "no"}`,
397
+ `- Mode: ${report.adapter.mode ?? "unknown"}`,
398
+ "",
399
+ "## Required Evidence",
400
+ ...report.requiredEvidence.map((item) => `- ${item}`),
401
+ "",
402
+ "## Ignored Paths",
403
+ ...report.ignoredPaths.map((item) => `- ${item}`),
404
+ "",
405
+ "## Change Summary",
406
+ ...renderChangeSummary(report.changeSummary),
407
+ "",
408
+ "## Changed Files",
409
+ ];
410
+
411
+ if (report.changedFiles.length) {
412
+ for (const entry of report.changedFiles) {
413
+ lines.push(`- ${entry.code} ${entry.path}`);
414
+ }
415
+ } else {
416
+ lines.push("- none listed");
417
+ }
418
+
419
+ lines.push(
420
+ "",
421
+ "## Skipped",
422
+ ...(report.skipped.length ? report.skipped.map((item) => `- ${item.path}: ${item.reason}`) : ["- none"]),
423
+ "",
424
+ "## Not Verified",
425
+ ...report.notVerified.map((item) => `- ${item}`),
426
+ "",
427
+ "## Warnings",
428
+ ...(report.warnings.length ? report.warnings.map((item) => `- ${item}`) : ["- none"]),
429
+ "",
430
+ "## Refused Behavior",
431
+ ...report.refusedBehavior.map((item) => `- ${item}`),
432
+ "",
433
+ "No commit, push, tag, branch change, pull request creation, GitHub API mutation, token read, secret-file read, or project write was performed.",
434
+ );
435
+
436
+ return `${lines.join("\n")}\n`;
437
+ }
438
+
439
+ export function githubHandoffCliResult(projectRootInput, options = {}) {
440
+ const report = buildGithubHandoffReport(projectRootInput, options);
441
+ return {
442
+ exitCode: report.status === "failed" ? 1 : 0,
443
+ report,
444
+ lines: renderGithubHandoffReport(report).trimEnd().split("\n"),
445
+ };
446
+ }
@@ -5,6 +5,7 @@ export const PILOT_SKILLS = [
5
5
  "secret-audit",
6
6
  "api-contract-audit",
7
7
  "migration-review",
8
+ "github-handoff",
8
9
  "build-verify",
9
10
  "git-preflight",
10
11
  "runtime-truth",
@@ -21,6 +22,7 @@ export const AUDIT_ONLY_SKILLS = [
21
22
  "secret-audit",
22
23
  "api-contract-audit",
23
24
  "migration-review",
25
+ "github-handoff",
24
26
  "git-preflight",
25
27
  "runtime-truth",
26
28
  "llm-drift-control",
@@ -407,6 +409,13 @@ export function classifyTrigger(prompt) {
407
409
  ) {
408
410
  return "migration-review";
409
411
  }
412
+ if (
413
+ /\b(?:github handoff|handoff report|handoff evidence|pull request handoff|pr handoff|changed files summary|git handoff|release handoff)\b/.test(
414
+ text,
415
+ )
416
+ ) {
417
+ return "github-handoff";
418
+ }
410
419
  if (
411
420
  /\b(?:unfamiliar repository|canonical repository root|canonical repo|map the current packages|map this repository|identify its entry points|nested directory)\b/.test(
412
421
  text,
@@ -548,7 +557,7 @@ function classifySegment(segment, options = {}) {
548
557
  }
549
558
  if (
550
559
  executable === "node" &&
551
- !/^node\s+(?:--check\b|--test\b|scripts\/(?:validate-pack|validate-maintainer-loop|validate-adapters|validate-project-adapters|check-adapter-upgrade|check-adapter-upgrade-chain|verify-evidence-bundle|render-evidence-archive-report|render-adapter-repo-map|render-route-trace|render-env-audit|render-secret-audit|render-api-contract-audit|render-migration-review|test-pack)\.mjs\b)/.test(
560
+ !/^node\s+(?:--check\b|--test\b|scripts\/(?:validate-pack|validate-maintainer-loop|validate-adapters|validate-project-adapters|check-adapter-upgrade|check-adapter-upgrade-chain|verify-evidence-bundle|render-evidence-archive-report|render-adapter-repo-map|render-route-trace|render-env-audit|render-secret-audit|render-api-contract-audit|render-migration-review|render-github-handoff|test-pack)\.mjs\b)/.test(
552
561
  segment,
553
562
  )
554
563
  ) {
@@ -558,7 +567,7 @@ function classifySegment(segment, options = {}) {
558
567
  ["coding-agent-skills", "bin/coding-agent-skills", "./bin/coding-agent-skills"].includes(
559
568
  executable,
560
569
  ) &&
561
- !/^(?:\.\/)?(?:bin\/)?coding-agent-skills\s+(?:validate-pack|validate-project\s+\S+|repo-map\s+\S+|route-trace\s+\S+|env-audit\s+\S+|secret-audit\s+\S+|api-contract-audit\s+\S+|migration-review\s+\S+|validate-adapters\s+\S+|help|--help|-h)\s*$/.test(
570
+ !/^(?:\.\/)?(?:bin\/)?coding-agent-skills\s+(?:validate-pack|validate-project\s+\S+|repo-map\s+\S+|route-trace\s+\S+|env-audit\s+\S+|secret-audit\s+\S+|api-contract-audit\s+\S+|migration-review\s+\S+|github-handoff\s+\S+|validate-adapters\s+\S+|help|--help|-h)\s*$/.test(
562
571
  segment,
563
572
  )
564
573
  ) {
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { githubHandoffCliResult } from "./lib/github-handoff.mjs";
3
+
4
+ const projectRoot = process.argv[2];
5
+ const result = githubHandoffCliResult(projectRoot);
6
+ process.stdout.write(`${result.lines.join("\n")}\n`);
7
+ process.exitCode = result.exitCode;
@@ -67,6 +67,11 @@ import {
67
67
  migrationReviewCliResult,
68
68
  renderMigrationReviewReport,
69
69
  } from "./lib/migration-review.mjs";
70
+ import {
71
+ buildGithubHandoffReport,
72
+ githubHandoffCliResult,
73
+ renderGithubHandoffReport,
74
+ } from "./lib/github-handoff.mjs";
70
75
  import {
71
76
  adapterUpgradeCliResult,
72
77
  checkAdapterUpgrade,
@@ -133,6 +138,27 @@ function readJson(relativePath) {
133
138
  return JSON.parse(read(relativePath));
134
139
  }
135
140
 
141
+ function runGitFixtureCommand(cwd, args) {
142
+ const result = spawnSync("git", args, {
143
+ cwd,
144
+ encoding: "utf8",
145
+ stdio: "pipe",
146
+ });
147
+ assert.equal(result.status, 0, `git ${args.join(" ")}\n${result.stderr}`);
148
+ return result.stdout.trim();
149
+ }
150
+
151
+ function createGitFixture(sourceRelativePath) {
152
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "github-handoff-fixture-"));
153
+ fs.cpSync(path.join(root, sourceRelativePath), temporary, { recursive: true });
154
+ runGitFixtureCommand(temporary, ["init", "-b", "main"]);
155
+ runGitFixtureCommand(temporary, ["config", "user.name", "Fixture User"]);
156
+ runGitFixtureCommand(temporary, ["config", "user.email", "fixture@example.invalid"]);
157
+ runGitFixtureCommand(temporary, ["add", "."]);
158
+ runGitFixtureCommand(temporary, ["commit", "-m", "initial fixture commit"]);
159
+ return temporary;
160
+ }
161
+
136
162
  function walk(directory, output = []) {
137
163
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
138
164
  if ([".git", "node_modules", "validation-output"].includes(entry.name)) continue;
@@ -286,10 +312,16 @@ test("local CLI maps approved commands to existing safe scripts", () => {
286
312
  assert.ok(cliText.includes("scripts/render-secret-audit.mjs"));
287
313
  assert.ok(cliText.includes("scripts/render-api-contract-audit.mjs"));
288
314
  assert.ok(cliText.includes("scripts/render-migration-review.mjs"));
315
+ assert.ok(cliText.includes("scripts/render-github-handoff.mjs"));
289
316
  assert.ok(cliText.includes("scripts/validate-adapters.mjs"));
290
317
  assert.ok(!cliText.includes(".env"));
291
318
 
292
319
  const fixtureRoot = path.join(root, "tests", "fixtures");
320
+ const githubHandoffFixture = createGitFixture(
321
+ path.join("tests", "fixtures", "github-handoff", "static-project"),
322
+ );
323
+ fs.appendFileSync(path.join(githubHandoffFixture, "README.md"), "\nLocal handoff change.\n");
324
+
293
325
  const commands = [
294
326
  [["validate-pack"], /pilot pack valid/],
295
327
  [
@@ -327,6 +359,10 @@ test("local CLI maps approved commands to existing safe scripts", () => {
327
359
  ["migration-review", path.join(fixtureRoot, "migration-review", "static-project")],
328
360
  /# Migration Review Report/,
329
361
  ],
362
+ [
363
+ ["github-handoff", githubHandoffFixture],
364
+ /# GitHub Handoff Report/,
365
+ ],
330
366
  ];
331
367
 
332
368
  for (const [args, expected] of commands) {
@@ -351,7 +387,7 @@ test("local CLI maps approved commands to existing safe scripts", () => {
351
387
  test("npm package metadata is public-ready and dependency-free", () => {
352
388
  const packageJson = readJson("package.json");
353
389
  assert.equal(packageJson.name, "coding-agent-skills");
354
- assert.equal(packageJson.version, "0.2.13");
390
+ assert.equal(packageJson.version, "0.2.14");
355
391
  assert.equal(
356
392
  packageJson.description,
357
393
  "Evidence-first, read-only coding-agent skills and project adapter tooling.",
@@ -368,6 +404,7 @@ test("npm package metadata is public-ready and dependency-free", () => {
368
404
  "secret-audit",
369
405
  "api-contract-audit",
370
406
  "migration-review",
407
+ "github-handoff",
371
408
  "project-adapters",
372
409
  "code-validation",
373
410
  "cli",
@@ -708,6 +745,57 @@ test("migration-review does not broaden a repo-map-only project adapter", () =>
708
745
  assert.match(renderMigrationReviewReport(result), /migration-review is not enabled/);
709
746
  });
710
747
 
748
+ test("github-handoff summarizes local git state without mutating remotes", () => {
749
+ const fixture = createGitFixture(path.join("tests", "fixtures", "github-handoff", "static-project"));
750
+ runGitFixtureCommand(fixture, ["tag", "v0.0.0"]);
751
+ fs.appendFileSync(path.join(fixture, "README.md"), "\nChanged for handoff.\n");
752
+ fs.writeFileSync(path.join(fixture, "src", "new-file.js"), "export const handoff = true;\n");
753
+
754
+ const result = buildGithubHandoffReport(fixture, { coreRoot: root });
755
+
756
+ assert.equal(result.status, "complete");
757
+ assert.equal(result.git.branch, "main");
758
+ assert.ok(result.git.head);
759
+ assert.ok(result.git.tagsAtHead.includes("v0.0.0"));
760
+ assert.equal(result.changeSummary.total, 2);
761
+ assert.equal(result.changeSummary.modified, 1);
762
+ assert.equal(result.changeSummary.untracked, 1);
763
+ assert.ok(result.changedFiles.some((record) => record.path === "README.md"));
764
+ assert.ok(result.changedFiles.some((record) => record.path === "src/new-file.js"));
765
+ assert.match(renderGithubHandoffReport(result), /No commit, push, tag/);
766
+ });
767
+
768
+ test("github-handoff respects adapter-declared handoff metadata", () => {
769
+ const fixture = createGitFixture(path.join("tests", "fixtures", "github-handoff", "adapter-project"));
770
+ fs.appendFileSync(path.join(fixture, "src", "index.js"), "\nexport const changed = true;\n");
771
+
772
+ const result = buildGithubHandoffReport(fixture, { coreRoot: root });
773
+
774
+ assert.equal(result.status, "complete");
775
+ assert.equal(result.adapter.enabled, true);
776
+ assert.ok(result.requiredEvidence.includes("handoff summary"));
777
+ assert.ok(result.ignoredPaths.includes("tmp"));
778
+ assert.equal(result.changeSummary.modified, 1);
779
+ assert.ok(result.warnings.includes("github-handoff used adapter-declared handoff evidence metadata"));
780
+ const cli = githubHandoffCliResult(fixture, { coreRoot: root });
781
+ assert.equal(cli.exitCode, 0);
782
+ assert.match(cli.lines.join("\n"), /Github-handoff enabled: yes/);
783
+ });
784
+
785
+ test("github-handoff does not broaden a repo-map-only project adapter", () => {
786
+ const fixture = createGitFixture(
787
+ path.join("tests", "fixtures", "project-adapter-installation", "valid-exact-pin"),
788
+ );
789
+ fs.appendFileSync(path.join(fixture, "README.md"), "\nShould not be listed.\n");
790
+
791
+ const result = buildGithubHandoffReport(fixture, { coreRoot: root });
792
+
793
+ assert.equal(result.status, "partial");
794
+ assert.equal(result.changedFiles.length, 0);
795
+ assert.equal(result.changeSummary.total, 0);
796
+ assert.match(renderGithubHandoffReport(result), /github-handoff is not enabled/);
797
+ });
798
+
711
799
  test("validate-pack accepts installed package trees without source-only gitignore", () => {
712
800
  const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "installed-package-"));
713
801
  const installedRoot = path.join(temporaryRoot, "coding-agent-skills");
@@ -100,6 +100,7 @@ const requiredRootFiles = [
100
100
  "scripts/render-secret-audit.mjs",
101
101
  "scripts/render-api-contract-audit.mjs",
102
102
  "scripts/render-migration-review.mjs",
103
+ "scripts/render-github-handoff.mjs",
103
104
  "scripts/check-adapter-upgrade.mjs",
104
105
  "scripts/check-adapter-upgrade-chain.mjs",
105
106
  "scripts/validate-adapters.mjs",
@@ -110,6 +111,7 @@ const requiredRootFiles = [
110
111
  "scripts/lib/secret-audit.mjs",
111
112
  "scripts/lib/api-contract-audit.mjs",
112
113
  "scripts/lib/migration-review.mjs",
114
+ "scripts/lib/github-handoff.mjs",
113
115
  "scripts/lib/adapter-upgrade.mjs",
114
116
  "scripts/lib/adapter-upgrade-chain.mjs",
115
117
  "scripts/lib/adapter-discovery.mjs",
@@ -683,8 +685,8 @@ if (packageJson) {
683
685
  if (packageJson.name !== "coding-agent-skills") {
684
686
  failures.push("package.json has unexpected package name");
685
687
  }
686
- if (packageJson.version !== "0.2.13") {
687
- failures.push("package.json version must be 0.2.13 for public package validation");
688
+ if (packageJson.version !== "0.2.14") {
689
+ failures.push("package.json version must be 0.2.14 for public package validation");
688
690
  }
689
691
  if (packageJson.type !== "module") failures.push("package.json must preserve ESM mode");
690
692
  if (packageJson.private !== false) {
@@ -707,6 +709,7 @@ if (packageJson) {
707
709
  "secret-audit",
708
710
  "api-contract-audit",
709
711
  "migration-review",
712
+ "github-handoff",
710
713
  "project-adapters",
711
714
  "code-validation",
712
715
  "cli",