@adamchanadam/agent-handoff-kit 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/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/agent-handoff-kit.mjs +675 -0
- package/package.json +28 -0
- package/packs/agent-governance.md +30 -0
- package/packs/coding.md +28 -0
- package/packs/communication.md +28 -0
- package/packs/knowledge.md +31 -0
- package/packs/release.md +28 -0
- package/packs/research.md +28 -0
- package/packs/safety.md +29 -0
- package/packs/writing.md +28 -0
- package/runtime-core/AGENTS.core.md +110 -0
- package/runtime-core/CLAUDE.md +13 -0
- package/runtime-core/DOC_SYNC_REGISTRY.md +23 -0
- package/runtime-core/GEMINI.md +13 -0
- package/runtime-core/PROJECT_INDEX.md +92 -0
- package/runtime-core/RULE_PACKS.md +18 -0
- package/runtime-core/SESSION_HANDOFF.md +148 -0
- package/runtime-core/SESSION_LOG.md +46 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
const mappings = [
|
|
13
|
+
["runtime-core/AGENTS.core.md", "AGENTS.md"],
|
|
14
|
+
["runtime-core/CLAUDE.md", "CLAUDE.md"],
|
|
15
|
+
["runtime-core/GEMINI.md", "GEMINI.md"],
|
|
16
|
+
["runtime-core/SESSION_HANDOFF.md", "dev/SESSION_HANDOFF.md"],
|
|
17
|
+
["runtime-core/SESSION_LOG.md", "dev/SESSION_LOG.md"],
|
|
18
|
+
["runtime-core/PROJECT_INDEX.md", "dev/PROJECT_INDEX.md"],
|
|
19
|
+
["runtime-core/DOC_SYNC_REGISTRY.md", "dev/DOC_SYNC_REGISTRY.md"],
|
|
20
|
+
["runtime-core/RULE_PACKS.md", "dev/RULE_PACKS.md"],
|
|
21
|
+
["packs/safety.md", "dev/rules/safety.md"],
|
|
22
|
+
["packs/coding.md", "dev/rules/coding.md"],
|
|
23
|
+
["packs/writing.md", "dev/rules/writing.md"],
|
|
24
|
+
["packs/research.md", "dev/rules/research.md"],
|
|
25
|
+
["packs/agent-governance.md", "dev/rules/agent-governance.md"],
|
|
26
|
+
["packs/release.md", "dev/rules/release.md"],
|
|
27
|
+
["packs/knowledge.md", "dev/rules/knowledge.md"],
|
|
28
|
+
["packs/communication.md", "dev/rules/communication.md"]
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const requiredTargets = mappings.map(([, target]) => target);
|
|
32
|
+
const managedCoreStart = "<!-- BEGIN Agent Handoff Kit managed core -->";
|
|
33
|
+
const managedCoreEnd = "<!-- END Agent Handoff Kit managed core -->";
|
|
34
|
+
|
|
35
|
+
const requiredAnchors = [
|
|
36
|
+
{
|
|
37
|
+
target: "AGENTS.md",
|
|
38
|
+
label: "startup read order",
|
|
39
|
+
snippets: [
|
|
40
|
+
"## 1. Startup Reads",
|
|
41
|
+
"dev/SESSION_HANDOFF.md",
|
|
42
|
+
"dev/SESSION_LOG.md",
|
|
43
|
+
"dev/PROJECT_INDEX.md",
|
|
44
|
+
"dev/RULE_PACKS.md",
|
|
45
|
+
"Agent Handoff Kit v<version>",
|
|
46
|
+
"continuity ready",
|
|
47
|
+
"Reachable is not the same as ingested",
|
|
48
|
+
"Do not treat unread sources as absent"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
target: "AGENTS.md",
|
|
53
|
+
label: "closeout intent and full handoff",
|
|
54
|
+
snippets: [
|
|
55
|
+
"Detect end-of-session or handoff intent",
|
|
56
|
+
"收工",
|
|
57
|
+
"wrap up",
|
|
58
|
+
"handoff",
|
|
59
|
+
"Reconcile `dev/SESSION_HANDOFF.md`",
|
|
60
|
+
"Add a concise entry to `dev/SESSION_LOG.md`",
|
|
61
|
+
"next-session opening message",
|
|
62
|
+
"fenced `text` code block",
|
|
63
|
+
"handoff saved",
|
|
64
|
+
"📋 Next session: copy and paste the whole block below",
|
|
65
|
+
"State Reconciliation Check",
|
|
66
|
+
"Do not append a new state snapshot"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
target: "dev/SESSION_HANDOFF.md",
|
|
71
|
+
label: "handoff workspace and opening message schema",
|
|
72
|
+
snippets: [
|
|
73
|
+
"ack:section:current-baseline",
|
|
74
|
+
"ack:section:durable-anchors",
|
|
75
|
+
"ack:section:closeout-reconciled-state",
|
|
76
|
+
"ack:section:task-understanding-summary",
|
|
77
|
+
"ack:section:active-objective",
|
|
78
|
+
"ack:section:next-priorities",
|
|
79
|
+
"ack:section:risks-blockers",
|
|
80
|
+
"ack:section:validation-qc",
|
|
81
|
+
"ack:section:workspace-identity",
|
|
82
|
+
"ack:section:sync-status",
|
|
83
|
+
"ack:section:next-task-required-reading",
|
|
84
|
+
"ack:section:state-reconciliation-check",
|
|
85
|
+
"ack:section:next-session-opening-message",
|
|
86
|
+
"📋 Next session: copy and paste the whole block below",
|
|
87
|
+
"```text",
|
|
88
|
+
"Read in order:",
|
|
89
|
+
"dev/DOC_SYNC_REGISTRY.md"
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
target: "dev/SESSION_LOG.md",
|
|
94
|
+
label: "session log event and opening message schema",
|
|
95
|
+
snippets: [
|
|
96
|
+
"Record what actually happened in the session",
|
|
97
|
+
"## Entry Template",
|
|
98
|
+
"- **QC:**",
|
|
99
|
+
"- **Sync:**",
|
|
100
|
+
"- **Log maintenance:**",
|
|
101
|
+
"### Next Session Opening Message",
|
|
102
|
+
"📋 Next session: copy and paste the whole block below",
|
|
103
|
+
"```text",
|
|
104
|
+
"Read in order:",
|
|
105
|
+
"dev/DOC_SYNC_REGISTRY.md"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
target: "dev/SESSION_HANDOFF.md",
|
|
110
|
+
label: "handoff log archive continuity",
|
|
111
|
+
snippets: [
|
|
112
|
+
"ack:section:handoff-sufficiency-check",
|
|
113
|
+
"ack:section:state-reconciliation-check",
|
|
114
|
+
"ack:field:stale-snapshots-left",
|
|
115
|
+
"ack:field:opening-message-matches-current-state",
|
|
116
|
+
"without searching old log history",
|
|
117
|
+
"SESSION_LOG.md` carries recent evidence",
|
|
118
|
+
"do not create an archive directory by default"
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
target: "dev/SESSION_LOG.md",
|
|
123
|
+
label: "log retention and evidence schema",
|
|
124
|
+
snippets: [
|
|
125
|
+
"kept, summarized, or archived",
|
|
126
|
+
"Do not remove validation evidence",
|
|
127
|
+
"latest opening message",
|
|
128
|
+
"not current state"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
target: "dev/PROJECT_INDEX.md",
|
|
133
|
+
label: "template version metadata",
|
|
134
|
+
snippets: [
|
|
135
|
+
"Agent Handoff Kit template version",
|
|
136
|
+
"0.1.0"
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
target: "dev/rules/safety.md",
|
|
141
|
+
label: "safety pack high-risk anchors",
|
|
142
|
+
snippets: [
|
|
143
|
+
"deleting, overwriting, moving, renaming",
|
|
144
|
+
"filesystem root, drive root",
|
|
145
|
+
"cmd /c rmdir",
|
|
146
|
+
"git reset --hard",
|
|
147
|
+
"external APIs, SDKs, CLIs",
|
|
148
|
+
"secret values"
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const schemaChecks = [
|
|
154
|
+
{
|
|
155
|
+
target: "dev/SESSION_HANDOFF.md",
|
|
156
|
+
label: "handoff required sections",
|
|
157
|
+
checks: [
|
|
158
|
+
section("current-baseline", "Current Baseline"),
|
|
159
|
+
section("durable-anchors", "Durable Anchors"),
|
|
160
|
+
section("closeout-reconciled-state", "Closeout-Reconciled State"),
|
|
161
|
+
section("task-understanding-summary", "Task Understanding Summary"),
|
|
162
|
+
section("active-objective", "Active Objective"),
|
|
163
|
+
section("next-priorities", "Next Priorities"),
|
|
164
|
+
section("next-task-required-reading", "Next Task Required Reading"),
|
|
165
|
+
section("risks-blockers", "Risks / Blockers"),
|
|
166
|
+
section("validation-qc", "Validation / QC"),
|
|
167
|
+
section("workspace-identity", "Workspace Identity"),
|
|
168
|
+
section("sync-status", "Sync Status"),
|
|
169
|
+
section("state-reconciliation-check", "State Reconciliation Check"),
|
|
170
|
+
section("handoff-sufficiency-check", "Handoff Sufficiency Check"),
|
|
171
|
+
section("next-session-opening-message", "Next Session Opening Message"),
|
|
172
|
+
marker("field", "stale-snapshots-left", "Stale snapshots left in this handoff"),
|
|
173
|
+
marker("field", "opening-message-matches-current-state", "Opening message matches current state"),
|
|
174
|
+
marker("field", "state-sections-rewritten-or-confirmed", "State sections rewritten or confirmed current"),
|
|
175
|
+
marker("field", "user-intent", "User intent:"),
|
|
176
|
+
marker("field", "task-essence", "Task essence:"),
|
|
177
|
+
marker("field", "success-criteria", "Success criteria:")
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
target: "dev/SESSION_HANDOFF.md",
|
|
182
|
+
label: "handoff opening message structure",
|
|
183
|
+
checks: [
|
|
184
|
+
includes("📋 Next session: copy and paste the whole block below"),
|
|
185
|
+
includes("```text"),
|
|
186
|
+
includes("Work in "),
|
|
187
|
+
includes("Read in order:"),
|
|
188
|
+
includes("If this root does not match the expected project root")
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
target: "dev/SESSION_LOG.md",
|
|
193
|
+
label: "session log entry fields",
|
|
194
|
+
checks: [
|
|
195
|
+
heading("Entry Template"),
|
|
196
|
+
includes("- **ID:**"),
|
|
197
|
+
includes("- **Summary:**"),
|
|
198
|
+
includes("- **Changed:**"),
|
|
199
|
+
includes("- **Done:**"),
|
|
200
|
+
includes("- **QC:**"),
|
|
201
|
+
includes("- **Sync:**"),
|
|
202
|
+
includes("- **Pending:**"),
|
|
203
|
+
includes("- **Risks:**"),
|
|
204
|
+
includes("- **Log maintenance:**")
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
target: "dev/PROJECT_INDEX.md",
|
|
209
|
+
label: "project index tables",
|
|
210
|
+
checks: [
|
|
211
|
+
heading("Stack"),
|
|
212
|
+
heading("Directory Map"),
|
|
213
|
+
heading("Entry Points"),
|
|
214
|
+
heading("Fact Base"),
|
|
215
|
+
heading("External Sources"),
|
|
216
|
+
heading("Local QC Commands"),
|
|
217
|
+
heading("Workspace Identity"),
|
|
218
|
+
heading("Change Hotspots"),
|
|
219
|
+
heading("Maintenance Rule"),
|
|
220
|
+
tableHeader("Path", "Role", "Read when"),
|
|
221
|
+
tableHeader("Source", "Role", "Required before", "Access method", "Last verified"),
|
|
222
|
+
tableHeader("Source", "Role", "Required before", "Access method", "Write-back rule", "Last verified"),
|
|
223
|
+
tableHeader("Check", "Command", "Run before", "Last verified"),
|
|
224
|
+
tableHeader("Change type", "Likely files", "Required checks")
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
target: "dev/DOC_SYNC_REGISTRY.md",
|
|
229
|
+
label: "doc sync registry status vocabulary",
|
|
230
|
+
checks: [
|
|
231
|
+
heading("Status Vocabulary"),
|
|
232
|
+
includes("confirmed"),
|
|
233
|
+
includes("unverified"),
|
|
234
|
+
includes("pending"),
|
|
235
|
+
includes("blocked"),
|
|
236
|
+
includes("not_applicable"),
|
|
237
|
+
tableHeader("Change type", "Also check/update", "Verification")
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
target: "dev/RULE_PACKS.md",
|
|
242
|
+
label: "rule pack router coverage",
|
|
243
|
+
checks: [
|
|
244
|
+
heading("Routing Rule"),
|
|
245
|
+
includes("Load the minimum set"),
|
|
246
|
+
includes("cannot weaken core safety"),
|
|
247
|
+
includes("dev/rules/safety.md"),
|
|
248
|
+
includes("dev/rules/coding.md"),
|
|
249
|
+
includes("dev/rules/research.md"),
|
|
250
|
+
includes("dev/rules/release.md")
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
main().catch((error) => {
|
|
256
|
+
console.error(`agent-handoff-kit: ${error.message}`);
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
async function main() {
|
|
261
|
+
const version = await readPackageVersion();
|
|
262
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
263
|
+
if (!command || options.help) {
|
|
264
|
+
printHelp(version);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
269
|
+
|
|
270
|
+
if (command === "init" || command === "upgrade") {
|
|
271
|
+
await runInstall(command, root, options, version);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (command === "doctor") {
|
|
276
|
+
await runDoctor(root, version);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw new Error(`unknown command "${command}"`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseArgs(args) {
|
|
284
|
+
const options = {};
|
|
285
|
+
let command;
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
288
|
+
const arg = args[i];
|
|
289
|
+
if (!command && !arg.startsWith("-")) {
|
|
290
|
+
command = arg;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (arg === "--dry-run") options.dryRun = true;
|
|
294
|
+
else if (arg === "--yes" || arg === "-y") options.yes = true;
|
|
295
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
296
|
+
else if (arg === "--root") options.root = args[++i];
|
|
297
|
+
else throw new Error(`unknown option "${arg}"`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { command, options };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function runInstall(command, root, options, version) {
|
|
304
|
+
const mode = await detectMode(root);
|
|
305
|
+
const plan = await buildPlan(root, command);
|
|
306
|
+
printPlan(command, root, mode, plan, version);
|
|
307
|
+
|
|
308
|
+
if (options.dryRun) {
|
|
309
|
+
console.log("\ndry-run: no files written");
|
|
310
|
+
if (plan.some((item) => item.action === "conflict")) process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!options.yes) {
|
|
315
|
+
const ok = await confirmWrite();
|
|
316
|
+
if (!ok) {
|
|
317
|
+
console.log("cancelled: no files written");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const created = [];
|
|
323
|
+
const merged = [];
|
|
324
|
+
const conflicts = plan.filter((item) => item.action === "conflict");
|
|
325
|
+
const stamp = migrationStamp();
|
|
326
|
+
const migrationDir = path.join(root, "dev/governance_migrations", stamp);
|
|
327
|
+
const backupDir = path.join(migrationDir, "backup");
|
|
328
|
+
|
|
329
|
+
for (const item of plan) {
|
|
330
|
+
if (item.action !== "create") continue;
|
|
331
|
+
await mkdir(path.dirname(item.targetAbs), { recursive: true });
|
|
332
|
+
await copyFile(item.sourceAbs, item.targetAbs);
|
|
333
|
+
created.push(item.targetRel);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const item of plan) {
|
|
337
|
+
if (item.action !== "merge") continue;
|
|
338
|
+
const backupPath = path.join(backupDir, item.targetRel);
|
|
339
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
340
|
+
await copyFile(item.targetAbs, backupPath);
|
|
341
|
+
await writeFile(item.targetAbs, item.mergedText, "utf8");
|
|
342
|
+
merged.push(item.targetRel);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const report = await writeMigrationReport(root, command, mode, plan, created, merged, conflicts, migrationDir, backupDir);
|
|
346
|
+
console.log(`\ncreated: ${created.length}`);
|
|
347
|
+
console.log(`merged: ${merged.length}`);
|
|
348
|
+
console.log(`skipped existing: ${plan.filter((item) => item.action === "skip").length}`);
|
|
349
|
+
console.log(`conflict: ${conflicts.length}`);
|
|
350
|
+
if (merged.length > 0) console.log(`backup: ${path.relative(root, backupDir)}`);
|
|
351
|
+
console.log(`migration report: ${path.relative(root, report)}`);
|
|
352
|
+
console.log("next: Follow AGENTS.md");
|
|
353
|
+
console.log("tip: Describe your task directly; the AI will choose the working mode and relevant rule packs.");
|
|
354
|
+
if (conflicts.length > 0) process.exitCode = 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function runDoctor(root, version) {
|
|
358
|
+
printCard(version, "doctor ready", "o.o");
|
|
359
|
+
const rows = [];
|
|
360
|
+
for (const target of requiredTargets) {
|
|
361
|
+
rows.push({ target, ok: await exists(path.join(root, target)) });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const missing = rows.filter((row) => !row.ok);
|
|
365
|
+
console.log(`root: ${root}`);
|
|
366
|
+
console.log(`required files: ${rows.length}`);
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
console.log(`${row.ok ? "ok" : "missing"} ${row.target}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (missing.length > 0) {
|
|
372
|
+
console.log(`\nstatus: failed (${missing.length} missing)`);
|
|
373
|
+
process.exitCode = 1;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const anchorRows = await checkRequiredAnchors(root);
|
|
378
|
+
const anchorFailures = anchorRows.filter((row) => !row.ok);
|
|
379
|
+
console.log(`\nrequired anchors: ${anchorRows.length}`);
|
|
380
|
+
for (const row of anchorRows) {
|
|
381
|
+
console.log(`${row.ok ? "ok" : "missing"} ${row.target} (${row.label})`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (anchorFailures.length > 0) {
|
|
385
|
+
console.log(`\nstatus: failed (${anchorFailures.length} anchor checks failed)`);
|
|
386
|
+
process.exitCode = 1;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const schemaRows = await checkSchema(root);
|
|
391
|
+
const schemaFailures = schemaRows.filter((row) => !row.ok);
|
|
392
|
+
console.log(`\nschema checks: ${schemaRows.length}`);
|
|
393
|
+
for (const row of schemaRows) {
|
|
394
|
+
console.log(`${row.ok ? "ok" : "missing"} ${row.target} (${row.label})`);
|
|
395
|
+
if (!row.ok && row.missing.length > 0) {
|
|
396
|
+
console.log(` missing: ${row.missing.join("; ")}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (schemaFailures.length > 0) {
|
|
401
|
+
console.log(`\nstatus: failed (${schemaFailures.length} schema checks failed)`);
|
|
402
|
+
process.exitCode = 1;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
console.log("\nstatus: passed");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function checkRequiredAnchors(root) {
|
|
410
|
+
const rows = [];
|
|
411
|
+
for (const rule of requiredAnchors) {
|
|
412
|
+
const filePath = path.join(root, rule.target);
|
|
413
|
+
let text = "";
|
|
414
|
+
try {
|
|
415
|
+
text = await readFile(filePath, "utf8");
|
|
416
|
+
} catch {
|
|
417
|
+
rows.push({ target: rule.target, label: rule.label, ok: false });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
rows.push({
|
|
422
|
+
target: rule.target,
|
|
423
|
+
label: rule.label,
|
|
424
|
+
ok: rule.snippets.every((snippet) => text.includes(snippet))
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return rows;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function checkSchema(root) {
|
|
431
|
+
const rows = [];
|
|
432
|
+
for (const rule of schemaChecks) {
|
|
433
|
+
const filePath = path.join(root, rule.target);
|
|
434
|
+
let text = "";
|
|
435
|
+
try {
|
|
436
|
+
text = await readFile(filePath, "utf8");
|
|
437
|
+
} catch {
|
|
438
|
+
rows.push({ target: rule.target, label: rule.label, ok: false, missing: ["file unreadable"] });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const missing = rule.checks
|
|
442
|
+
.filter((check) => !check.test(text))
|
|
443
|
+
.map((check) => check.label);
|
|
444
|
+
rows.push({
|
|
445
|
+
target: rule.target,
|
|
446
|
+
label: rule.label,
|
|
447
|
+
ok: missing.length === 0,
|
|
448
|
+
missing
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return rows;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function includes(snippet) {
|
|
455
|
+
return {
|
|
456
|
+
label: snippet,
|
|
457
|
+
test: (text) => text.includes(snippet)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function heading(title) {
|
|
462
|
+
return {
|
|
463
|
+
label: `heading: ${title}`,
|
|
464
|
+
test: (text) => new RegExp(`^## ${escapeRegExp(title)}\\s*$`, "m").test(text)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function marker(type, id, legacyText) {
|
|
469
|
+
const semanticMarker = `ack:${type}:${id}`;
|
|
470
|
+
return {
|
|
471
|
+
label: `${type}: ${id}`,
|
|
472
|
+
test: (text) => text.includes(semanticMarker) || (legacyText ? text.includes(legacyText) : false)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function section(id, legacyHeading) {
|
|
477
|
+
const check = marker("section", id, null);
|
|
478
|
+
return {
|
|
479
|
+
label: `section: ${id}`,
|
|
480
|
+
test: (text) => check.test(text) || heading(legacyHeading).test(text)
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function tableHeader(...cells) {
|
|
485
|
+
const line = `| ${cells.join(" | ")} |`;
|
|
486
|
+
return {
|
|
487
|
+
label: `table: ${cells.join(" / ")}`,
|
|
488
|
+
test: (text) => text.includes(line)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function buildPlan(root, command) {
|
|
493
|
+
const plan = [];
|
|
494
|
+
for (const [sourceRel, targetRel] of mappings) {
|
|
495
|
+
const sourceAbs = path.join(packageRoot, sourceRel);
|
|
496
|
+
const targetAbs = path.join(root, targetRel);
|
|
497
|
+
const sourceText = await readFile(sourceAbs, "utf8");
|
|
498
|
+
if (await exists(targetAbs)) {
|
|
499
|
+
const targetText = await readFile(targetAbs, "utf8");
|
|
500
|
+
plan.push(classifyExistingFile(command, sourceRel, targetRel, sourceAbs, targetAbs, sourceText, targetText));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
plan.push({
|
|
504
|
+
sourceRel,
|
|
505
|
+
targetRel,
|
|
506
|
+
sourceAbs,
|
|
507
|
+
targetAbs,
|
|
508
|
+
action: "create"
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return plan;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function classifyExistingFile(command, sourceRel, targetRel, sourceAbs, targetAbs, sourceText, targetText) {
|
|
515
|
+
const base = { sourceRel, targetRel, sourceAbs, targetAbs };
|
|
516
|
+
if (targetText === sourceText) return { ...base, action: "skip", reason: "already current" };
|
|
517
|
+
if (command !== "upgrade") return { ...base, action: "skip", reason: "init preserves existing files" };
|
|
518
|
+
if (targetRel === "AGENTS.md") {
|
|
519
|
+
if (hasRequiredAnchor(targetRel, targetText)) return { ...base, action: "skip", reason: "required anchors already present" };
|
|
520
|
+
return {
|
|
521
|
+
...base,
|
|
522
|
+
action: "merge",
|
|
523
|
+
reason: "append managed core while preserving existing AGENTS.md content",
|
|
524
|
+
mergedText: mergeManagedBlock(targetText, sourceText)
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if ((targetRel === "CLAUDE.md" || targetRel === "GEMINI.md") && !targetText.includes("AGENTS.md")) {
|
|
528
|
+
return { ...base, action: "conflict", reason: "existing bridge does not route to AGENTS.md" };
|
|
529
|
+
}
|
|
530
|
+
return { ...base, action: "skip", reason: "preserve existing file" };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function mergeManagedBlock(targetText, sourceText) {
|
|
534
|
+
const block = `${managedCoreStart}\n${sourceText.trim()}\n${managedCoreEnd}`;
|
|
535
|
+
const existingBlock = new RegExp(`${escapeRegExp(managedCoreStart)}[\\s\\S]*?${escapeRegExp(managedCoreEnd)}`);
|
|
536
|
+
if (existingBlock.test(targetText)) return `${targetText.replace(existingBlock, block).trimEnd()}\n`;
|
|
537
|
+
return `${targetText.trimEnd()}\n\n${block}\n`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function escapeRegExp(text) {
|
|
541
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function hasRequiredAnchor(targetRel, text) {
|
|
545
|
+
return requiredAnchors
|
|
546
|
+
.filter((rule) => rule.target === targetRel)
|
|
547
|
+
.every((rule) => rule.snippets.every((snippet) => text.includes(snippet)));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function detectMode(root) {
|
|
551
|
+
const hasAgents = await exists(path.join(root, "AGENTS.md"));
|
|
552
|
+
const hasProjectIndex = await exists(path.join(root, "dev/PROJECT_INDEX.md"));
|
|
553
|
+
const hasDocSync = await exists(path.join(root, "dev/DOC_SYNC_REGISTRY.md"));
|
|
554
|
+
if (!hasAgents && !hasProjectIndex && !hasDocSync) return "first-install";
|
|
555
|
+
if (hasAgents && hasProjectIndex && hasDocSync) return "upgrade-existing";
|
|
556
|
+
if (hasAgents) {
|
|
557
|
+
const text = await readFile(path.join(root, "AGENTS.md"), "utf8");
|
|
558
|
+
if (text.includes("SESSION_HANDOFF") || text.includes("SESSION_LOG")) return "migrate-monolith";
|
|
559
|
+
}
|
|
560
|
+
return "partial";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function printPlan(command, root, mode, plan, version) {
|
|
564
|
+
printCard(version, "continuity ready", "o.o");
|
|
565
|
+
console.log(`command: ${command}`);
|
|
566
|
+
console.log(`current directory: ${process.cwd()}`);
|
|
567
|
+
console.log(`selected root: ${root}`);
|
|
568
|
+
console.log(`mode: ${mode}`);
|
|
569
|
+
console.log("");
|
|
570
|
+
for (const action of ["create", "merge", "skip", "conflict"]) {
|
|
571
|
+
const items = plan.filter((item) => item.action === action);
|
|
572
|
+
console.log(`${action}: ${items.length}`);
|
|
573
|
+
for (const item of items) console.log(` ${item.targetRel}${item.reason ? ` - ${item.reason}` : ""}`);
|
|
574
|
+
}
|
|
575
|
+
console.log(`\nbackup: ${plan.filter((item) => item.action === "merge").length}`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function confirmWrite() {
|
|
579
|
+
const rl = createInterface({ input, output });
|
|
580
|
+
try {
|
|
581
|
+
const answer = await rl.question("Write missing Agent Handoff Kit files? Type yes to continue: ");
|
|
582
|
+
return answer.trim().toLowerCase() === "yes";
|
|
583
|
+
} finally {
|
|
584
|
+
rl.close();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function writeMigrationReport(root, command, mode, plan, created, merged, conflicts, migrationDir, backupDir) {
|
|
589
|
+
const reportPath = path.join(migrationDir, "migration-report.md");
|
|
590
|
+
await mkdir(migrationDir, { recursive: true });
|
|
591
|
+
const skipped = plan.filter((item) => item.action === "skip").map((item) => item.targetRel);
|
|
592
|
+
const text = [
|
|
593
|
+
"# Agent Handoff Kit Migration Report",
|
|
594
|
+
"",
|
|
595
|
+
`Command: ${command}`,
|
|
596
|
+
`Mode: ${mode}`,
|
|
597
|
+
`Root: ${root}`,
|
|
598
|
+
`Created: ${new Date().toISOString()}`,
|
|
599
|
+
"",
|
|
600
|
+
"## Created",
|
|
601
|
+
...listOrNone(created),
|
|
602
|
+
"",
|
|
603
|
+
"## Merged",
|
|
604
|
+
...listOrNone(merged),
|
|
605
|
+
"",
|
|
606
|
+
"## Skipped Existing",
|
|
607
|
+
...listOrNone(skipped),
|
|
608
|
+
"",
|
|
609
|
+
"## Conflicts",
|
|
610
|
+
...listOrNone(conflicts.map((item) => `${item.targetRel} - ${item.reason}`)),
|
|
611
|
+
"",
|
|
612
|
+
"## Backup",
|
|
613
|
+
merged.length > 0 ? `- ${path.relative(root, backupDir)}` : "- none",
|
|
614
|
+
"",
|
|
615
|
+
"## Notes",
|
|
616
|
+
"- Existing files are preserved unless the installer can perform a bounded merge.",
|
|
617
|
+
"- Files that cannot be safely merged are reported as conflicts and are not overwritten."
|
|
618
|
+
].join("\n");
|
|
619
|
+
await writeFile(reportPath, `${text}\n`, "utf8");
|
|
620
|
+
return reportPath;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function migrationStamp() {
|
|
624
|
+
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function readPackageVersion() {
|
|
628
|
+
try {
|
|
629
|
+
const text = await readFile(path.join(packageRoot, "package.json"), "utf8");
|
|
630
|
+
return JSON.parse(text).version ?? "version unverified";
|
|
631
|
+
} catch {
|
|
632
|
+
return "version unverified";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function printCard(version, status, eyes) {
|
|
637
|
+
console.log(` /\\_/\\ Agent Handoff Kit v${version}`);
|
|
638
|
+
console.log(` ( ${eyes} ) ${status}`);
|
|
639
|
+
console.log(" > ^ <");
|
|
640
|
+
console.log("");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function listOrNone(items) {
|
|
644
|
+
if (items.length === 0) return ["- none"];
|
|
645
|
+
return items.map((item) => `- ${item}`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function exists(filePath) {
|
|
649
|
+
try {
|
|
650
|
+
await stat(filePath);
|
|
651
|
+
return true;
|
|
652
|
+
} catch {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function printHelp(version) {
|
|
658
|
+
printCard(version, "continuity ready", "o.o");
|
|
659
|
+
console.log(`Agent Handoff Kit
|
|
660
|
+
|
|
661
|
+
Usage:
|
|
662
|
+
agent-handoff-kit init [--dry-run] [--yes] [--root <path>]
|
|
663
|
+
agent-handoff-kit upgrade [--dry-run] [--yes] [--root <path>]
|
|
664
|
+
agent-handoff-kit doctor [--root <path>]
|
|
665
|
+
|
|
666
|
+
Commands:
|
|
667
|
+
init Plan or install missing core files and rule packs.
|
|
668
|
+
upgrade Preserve existing files; merge safe core updates or report conflicts.
|
|
669
|
+
doctor Check required installed files.
|
|
670
|
+
|
|
671
|
+
Working modes:
|
|
672
|
+
Describe your task directly. The AI chooses relevant rule packs for coding,
|
|
673
|
+
research, writing, knowledge sync, release, or mixed tasks.
|
|
674
|
+
`);
|
|
675
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adamchanadam/agent-handoff-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight agent harness for project memory, handoff, and multi-session continuity.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-handoff-kit": "bin/agent-handoff-kit.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"runtime-core/",
|
|
12
|
+
"packs/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"doctor": "node bin/agent-handoff-kit.mjs doctor --root .",
|
|
18
|
+
"dry-run": "node bin/agent-handoff-kit.mjs init --dry-run --root .",
|
|
19
|
+
"qa:prototype": "node scripts/check-public-prototype.mjs",
|
|
20
|
+
"qa:packs": "node scripts/check-pack-scenarios.mjs",
|
|
21
|
+
"qa:upgrade": "node scripts/check-upgrade-safety.mjs",
|
|
22
|
+
"qa:release": "node scripts/check-release-readiness.mjs"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|