@starterculture/devkeep-actions 0.1.0 → 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.
@@ -128,6 +128,11 @@ var EMPTY_PROJECT_USAGE = {
128
128
  draftLogEntry: ZERO_USAGE,
129
129
  consolidateRelease: ZERO_USAGE,
130
130
  syncUserManual: ZERO_USAGE,
131
+ syncTestPlan: ZERO_USAGE,
132
+ syncVisualizer: ZERO_USAGE,
133
+ analyzeCodebase: ZERO_USAGE,
134
+ syncOnboarding: ZERO_USAGE,
135
+ releaseNotes: ZERO_USAGE,
131
136
  rollover: ZERO_ROLLOVER_USAGE
132
137
  };
133
138
  function usagePath(projectName) {
@@ -128,6 +128,11 @@ var EMPTY_PROJECT_USAGE = {
128
128
  draftLogEntry: ZERO_USAGE,
129
129
  consolidateRelease: ZERO_USAGE,
130
130
  syncUserManual: ZERO_USAGE,
131
+ syncTestPlan: ZERO_USAGE,
132
+ syncVisualizer: ZERO_USAGE,
133
+ analyzeCodebase: ZERO_USAGE,
134
+ syncOnboarding: ZERO_USAGE,
135
+ releaseNotes: ZERO_USAGE,
131
136
  rollover: ZERO_ROLLOVER_USAGE
132
137
  };
133
138
  function usagePath(projectName) {
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../modules/core/dist/github/client.js
4
+ import { Octokit } from "@octokit/rest";
5
+ function createGithubClient(token) {
6
+ if (!token) {
7
+ throw new Error("GitHub token is required");
8
+ }
9
+ return new Octokit({ auth: token });
10
+ }
11
+
12
+ // ../../modules/core/dist/github/retryOnConflict.js
13
+ async function retryOnConflict(attempt, maxAttempts = 3) {
14
+ for (let tryNumber = 1; ; tryNumber++) {
15
+ try {
16
+ return await attempt();
17
+ } catch (error) {
18
+ const status = error.status;
19
+ if (tryNumber >= maxAttempts || status !== 409 && status !== 422) {
20
+ throw error;
21
+ }
22
+ await new Promise((resolve) => setTimeout(resolve, 300 * tryNumber));
23
+ }
24
+ }
25
+ }
26
+
27
+ // ../../modules/core/dist/tokenUsage.js
28
+ var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
+ function addTokenUsage(a, b) {
30
+ return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
31
+ }
32
+
33
+ // ../../modules/core/dist/rollover.js
34
+ var ZERO_ROLLOVER_USAGE = {
35
+ today: { key: "", usage: ZERO_USAGE },
36
+ month: { key: "", usage: ZERO_USAGE }
37
+ };
38
+ function todayKey(now = /* @__PURE__ */ new Date()) {
39
+ const year = now.getFullYear();
40
+ const month = String(now.getMonth() + 1).padStart(2, "0");
41
+ const day = String(now.getDate()).padStart(2, "0");
42
+ return `${year}-${month}-${day}`;
43
+ }
44
+ function monthKey(now = /* @__PURE__ */ new Date()) {
45
+ return todayKey(now).slice(0, 7);
46
+ }
47
+ function addRolloverUsage(current, delta, now = /* @__PURE__ */ new Date()) {
48
+ const nowTodayKey = todayKey(now);
49
+ const nowMonthKey = monthKey(now);
50
+ return {
51
+ today: {
52
+ key: nowTodayKey,
53
+ usage: addTokenUsage(current.today.key === nowTodayKey ? current.today.usage : ZERO_USAGE, delta)
54
+ },
55
+ month: {
56
+ key: nowMonthKey,
57
+ usage: addTokenUsage(current.month.key === nowMonthKey ? current.month.usage : ZERO_USAGE, delta)
58
+ }
59
+ };
60
+ }
61
+
62
+ // ../../modules/core/dist/claude/client.js
63
+ import Anthropic from "@anthropic-ai/sdk";
64
+ function createClaudeClient(apiKey) {
65
+ const key = apiKey ?? process.env.ANTHROPIC_API_KEY;
66
+ if (!key) {
67
+ throw new Error("ANTHROPIC_API_KEY is not set");
68
+ }
69
+ return new Anthropic({ apiKey: key });
70
+ }
71
+
72
+ // ../../modules/core/dist/claude/complete.js
73
+ var DEFAULT_MAX_CONTINUATIONS = 5;
74
+ var CONTINUATION_USER_MESSAGE = "Continue exactly where you left off. Do not repeat any text already written, and do not add any preamble or commentary \u2014 resume the previous response verbatim from the exact cutoff point.";
75
+ async function createCompleteMessage(claude, params, { maxContinuations = DEFAULT_MAX_CONTINUATIONS, timeoutMs, signal } = {}) {
76
+ let fullText = "";
77
+ let usage = ZERO_USAGE;
78
+ const requestOptions = timeoutMs !== void 0 || signal !== void 0 ? { timeout: timeoutMs, signal } : void 0;
79
+ for (let attempt = 0; attempt <= maxContinuations; attempt++) {
80
+ const messages = fullText ? [
81
+ ...params.messages,
82
+ { role: "assistant", content: fullText },
83
+ { role: "user", content: CONTINUATION_USER_MESSAGE }
84
+ ] : params.messages;
85
+ const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
86
+ const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
87
+ fullText += text;
88
+ usage = addTokenUsage(usage, {
89
+ inputTokens: message.usage?.input_tokens ?? 0,
90
+ outputTokens: message.usage?.output_tokens ?? 0
91
+ });
92
+ if (message.stop_reason !== "max_tokens") {
93
+ return { text: fullText, usage };
94
+ }
95
+ }
96
+ throw new Error(`Claude response still hit max_tokens after ${maxContinuations} continuation attempts \u2014 refusing to return truncated content.`);
97
+ }
98
+
99
+ // ../../modules/core/dist/modelRegistry.js
100
+ var MODEL_REGISTRY = {
101
+ fast: {
102
+ description: "Quick, cheap work where depth matters least.",
103
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
104
+ },
105
+ balanced: {
106
+ description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
107
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
108
+ },
109
+ capable: {
110
+ description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
111
+ models: [{ id: "claude-opus-5", label: "Opus 5" }]
112
+ },
113
+ max: {
114
+ description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
115
+ models: [{ id: "claude-fable-5", label: "Fable 5" }]
116
+ }
117
+ };
118
+ function defaultModelFor(tier) {
119
+ const [current] = MODEL_REGISTRY[tier].models;
120
+ if (!current) {
121
+ throw new Error(`Model tier "${tier}" has no models registered`);
122
+ }
123
+ return current.id;
124
+ }
125
+
126
+ // ../../modules/devlore/dist/prompt.js
127
+ function buildReleaseNotesPrompt(input) {
128
+ return [
129
+ `Project: ${input.projectName}`,
130
+ `Release: ${input.tag}`,
131
+ input.previousTag ? `Previous release: ${input.previousTag}. The entries below are the work done since then.` : "This is the first release, so the entries below cover everything so far.",
132
+ "",
133
+ "Below are the decision log entries Devkeep recorded for the work in this release.",
134
+ "",
135
+ ...input.logEntries,
136
+ "",
137
+ "Write release notes for the people who use this product. Lead with what they can now do that they could not before, and what changed in behaviour they will notice. Group related changes rather than listing entries one by one.",
138
+ "",
139
+ "Plain language, not commit-history jargon: no file paths, no function names, no internal module names unless the user genuinely interacts with them by name.",
140
+ "",
141
+ 'If this release contains nothing user-facing \u2014 internal refactoring, tooling, workflow changes \u2014 say exactly that in a sentence or two. Do not manufacture user-facing significance that is not there; an honest "this release contains internal changes only" is the correct output, and inventing a feature to fill the page is worse than a short note.',
142
+ "",
143
+ "Do NOT include a top-level heading \u2014 one is added automatically. Start directly with the content.",
144
+ "",
145
+ "Return the notes only \u2014 no preamble, no meta-commentary."
146
+ ].join("\n");
147
+ }
148
+
149
+ // ../../modules/devlore/dist/redact.js
150
+ var REDACTED = "[REDACTED]";
151
+ var KNOWN_SECRET_PATTERNS = [
152
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
153
+ /gh[pousr]_[A-Za-z0-9]{36,}/g,
154
+ /github_pat_[A-Za-z0-9_]{20,}/g,
155
+ /sk-ant-[A-Za-z0-9_-]{20,}/g,
156
+ /sk-[A-Za-z0-9]{20,}/g,
157
+ /AKIA[0-9A-Z]{16}/g,
158
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g,
159
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g
160
+ ];
161
+ var SECRET_KEYWORD = "(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CREDENTIAL)";
162
+ var ASSIGNMENT_PATTERN = new RegExp(`((?:[A-Za-z0-9]+[_-])*${SECRET_KEYWORD}(?:[_-][A-Za-z0-9]+)*\\s*[:=]\\s*)(["']?)([^"'\`\\s]+)\\2`, "gi");
163
+ function redactText(text) {
164
+ let result = text;
165
+ for (const pattern of KNOWN_SECRET_PATTERNS) {
166
+ result = result.replace(pattern, REDACTED);
167
+ }
168
+ result = result.replace(ASSIGNMENT_PATTERN, (_match, prefix, quote) => `${prefix}${quote}${REDACTED}${quote}`);
169
+ return result;
170
+ }
171
+
172
+ // ../../modules/devlore/dist/ciUsage.js
173
+ var EMPTY_PROJECT_USAGE = {
174
+ draftLogEntry: ZERO_USAGE,
175
+ consolidateRelease: ZERO_USAGE,
176
+ syncUserManual: ZERO_USAGE,
177
+ syncTestPlan: ZERO_USAGE,
178
+ syncVisualizer: ZERO_USAGE,
179
+ analyzeCodebase: ZERO_USAGE,
180
+ syncOnboarding: ZERO_USAGE,
181
+ releaseNotes: ZERO_USAGE,
182
+ rollover: ZERO_ROLLOVER_USAGE
183
+ };
184
+ function usagePath(projectName) {
185
+ return `projects/${projectName}/usage.json`;
186
+ }
187
+ async function getUsageFile(octokit, { owner, repo, projectName }) {
188
+ try {
189
+ const { data } = await octokit.rest.repos.getContent({
190
+ owner,
191
+ repo,
192
+ path: usagePath(projectName)
193
+ });
194
+ if (Array.isArray(data) || data.type !== "file") {
195
+ return { usage: EMPTY_PROJECT_USAGE };
196
+ }
197
+ const parsed = JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
198
+ const usage = { ...EMPTY_PROJECT_USAGE, ...parsed };
199
+ return { usage, sha: data.sha };
200
+ } catch (error) {
201
+ if (isNotFound(error)) {
202
+ return { usage: EMPTY_PROJECT_USAGE };
203
+ }
204
+ throw error;
205
+ }
206
+ }
207
+ function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
208
+ return {
209
+ ...current,
210
+ [source]: addTokenUsage(current[source], delta),
211
+ rollover: addRolloverUsage(current.rollover, delta, now)
212
+ };
213
+ }
214
+ async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
215
+ const { owner, repo, projectName, branch } = params;
216
+ await retryOnConflict(async () => {
217
+ const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
218
+ const updated = addUsage(current, source, delta, now);
219
+ await octokit.rest.repos.createOrUpdateFileContents({
220
+ owner,
221
+ repo,
222
+ path: usagePath(projectName),
223
+ message: `Record ${source} token usage: ${projectName}`,
224
+ content: Buffer.from(JSON.stringify(updated, null, 2), "utf-8").toString("base64"),
225
+ branch,
226
+ sha
227
+ });
228
+ });
229
+ }
230
+ function isNotFound(error) {
231
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
232
+ }
233
+
234
+ // ../../modules/devlore/dist/logEntries.js
235
+ function logEntriesDir(projectName) {
236
+ return `projects/${projectName}/log`;
237
+ }
238
+ async function listLogEntries(octokit, { owner, repo, projectName }) {
239
+ const dir = logEntriesDir(projectName);
240
+ let files;
241
+ try {
242
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dir });
243
+ if (!Array.isArray(data)) {
244
+ return [];
245
+ }
246
+ files = data;
247
+ } catch (error) {
248
+ if (isNotFound2(error)) {
249
+ return [];
250
+ }
251
+ throw error;
252
+ }
253
+ const entries = await Promise.all(files.filter((file) => file.type === "file").map(async (file) => {
254
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: file.path });
255
+ if (Array.isArray(data) || data.type !== "file") {
256
+ return null;
257
+ }
258
+ return { path: file.path, content: Buffer.from(data.content, "base64").toString("utf-8") };
259
+ }));
260
+ return entries.filter((entry) => entry !== null).sort((a, b) => a.path.localeCompare(b.path));
261
+ }
262
+ function isNotFound2(error) {
263
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
264
+ }
265
+
266
+ // ../../modules/devlore/dist/releaseScope.js
267
+ var TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
268
+ function parseTag(tag) {
269
+ const match = tag.match(TAG_PATTERN);
270
+ if (!match) {
271
+ return null;
272
+ }
273
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
274
+ }
275
+ function compareTags(a, b) {
276
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
277
+ }
278
+ async function findPreviousTag(octokit, { owner, repo, currentTag }) {
279
+ const current = parseTag(currentTag);
280
+ if (!current) {
281
+ return null;
282
+ }
283
+ const tags = await octokit.paginate(octokit.rest.repos.listTags, { owner, repo, per_page: 100 });
284
+ let best = null;
285
+ for (const tag of tags) {
286
+ const version = parseTag(tag.name);
287
+ if (!version || compareTags(version, current) >= 0) {
288
+ continue;
289
+ }
290
+ if (!best || compareTags(version, best.version) > 0) {
291
+ best = { name: tag.name, version };
292
+ }
293
+ }
294
+ return best?.name ?? null;
295
+ }
296
+ async function listCommitShasSince(octokit, { owner, repo, previousTag, currentTag }) {
297
+ try {
298
+ const { data } = await octokit.rest.repos.compareCommitsWithBasehead({
299
+ owner,
300
+ repo,
301
+ basehead: `${previousTag}...${currentTag}`
302
+ });
303
+ return new Set(data.commits.map((commit) => commit.sha));
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+ function logEntryCommitSha(entry) {
309
+ const filename = entry.path.split("/").pop() ?? "";
310
+ return filename.replace(/\.md$/, "");
311
+ }
312
+ async function selectLogEntriesSincePreviousRelease({ vaultOctokit, projectOctokit, vaultOwner, vaultRepoName, projectName, projectOwner, projectRepoName, tag }) {
313
+ const allEntries = await listLogEntries(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName });
314
+ const previousTag = await findPreviousTag(projectOctokit, {
315
+ owner: projectOwner,
316
+ repo: projectRepoName,
317
+ currentTag: tag
318
+ });
319
+ if (!previousTag) {
320
+ return { logEntries: allEntries, previousTag: null };
321
+ }
322
+ const commitShas = await listCommitShasSince(projectOctokit, {
323
+ owner: projectOwner,
324
+ repo: projectRepoName,
325
+ previousTag,
326
+ currentTag: tag
327
+ });
328
+ if (!commitShas) {
329
+ return { logEntries: allEntries, previousTag };
330
+ }
331
+ return { logEntries: allEntries.filter((entry) => commitShas.has(logEntryCommitSha(entry))), previousTag };
332
+ }
333
+
334
+ // ../../modules/devlore/dist/releaseNotes.js
335
+ function releaseNotePath(projectName, tag) {
336
+ return `${releaseNotesDir(projectName)}/${tag}.md`;
337
+ }
338
+ function releaseNotesHeading(projectName, tag) {
339
+ return `# ${projectName} \u2014 ${tag}`;
340
+ }
341
+ function releaseNotesBanner() {
342
+ return "> **Do not move, rename, or edit this file.** Devkeep drafted these release notes when this tag was cut.";
343
+ }
344
+ function releaseNotesDir(projectName) {
345
+ return `projects/${projectName}/releases`;
346
+ }
347
+
348
+ // ../../modules/devlore/dist/actions/draftReleaseNotes.js
349
+ var MODEL_TIER = "balanced";
350
+ var MAX_TOKENS = 2e4;
351
+ var MAX_CONTINUATIONS = 8;
352
+ var VAULT_BRANCH = "main";
353
+ async function draftReleaseNotes({ projectOctokit, vaultOctokit, claude, projectOwner, projectRepoName, vaultOwner, vaultRepoName, projectName, tag }) {
354
+ const { logEntries, previousTag } = await selectLogEntriesSincePreviousRelease({
355
+ vaultOctokit,
356
+ projectOctokit,
357
+ vaultOwner,
358
+ vaultRepoName,
359
+ projectName,
360
+ projectOwner,
361
+ projectRepoName,
362
+ tag
363
+ });
364
+ const prompt = buildReleaseNotesPrompt({
365
+ projectName,
366
+ tag,
367
+ previousTag,
368
+ logEntries: logEntries.map((entry) => redactText(entry.content))
369
+ });
370
+ const { text, usage } = await createCompleteMessage(claude, { model: defaultModelFor(MODEL_TIER), max_tokens: MAX_TOKENS, messages: [{ role: "user", content: prompt }] }, { maxContinuations: MAX_CONTINUATIONS });
371
+ const content = `${releaseNotesBanner()}
372
+
373
+ ${releaseNotesHeading(projectName, tag)}
374
+
375
+ ${text.trim()}
376
+ `;
377
+ const path = releaseNotePath(projectName, tag);
378
+ await vaultOctokit.rest.repos.createOrUpdateFileContents({
379
+ owner: vaultOwner,
380
+ repo: vaultRepoName,
381
+ path,
382
+ message: `Draft release notes: ${projectName} ${tag}`,
383
+ content: Buffer.from(content, "utf-8").toString("base64"),
384
+ branch: VAULT_BRANCH,
385
+ sha: await getExistingSha(vaultOctokit, vaultOwner, vaultRepoName, path)
386
+ });
387
+ if (usage.inputTokens > 0 || usage.outputTokens > 0) {
388
+ await recordUsage(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName, branch: VAULT_BRANCH }, "releaseNotes", usage);
389
+ }
390
+ return { path, previousTag, entryCount: logEntries.length, content, usage };
391
+ }
392
+ async function getExistingSha(octokit, owner, repo, path) {
393
+ try {
394
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path });
395
+ return Array.isArray(data) || data.type !== "file" ? void 0 : data.sha;
396
+ } catch {
397
+ return void 0;
398
+ }
399
+ }
400
+
401
+ // ../../modules/devlore/dist/bin/draftReleaseNotesCli.js
402
+ function requireEnv(env, name) {
403
+ const value = env[name];
404
+ if (!value) {
405
+ throw new Error(`${name} is not set`);
406
+ }
407
+ return value;
408
+ }
409
+ var defaultDeps = {
410
+ env: process.env,
411
+ draftReleaseNotes,
412
+ createGithubClient,
413
+ createClaudeClient,
414
+ log: console.log
415
+ };
416
+ async function main(deps = defaultDeps) {
417
+ const { env } = deps;
418
+ const projectName = requireEnv(env, "PROJECT_NAME");
419
+ const [projectOwner, projectRepoName] = requireEnv(env, "PROJECT_REPO").split("/");
420
+ const [vaultOwner, vaultRepoName] = requireEnv(env, "VAULT_REPO").split("/");
421
+ const vaultOctokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
422
+ const projectOctokit = deps.createGithubClient(requireEnv(env, "GITHUB_TOKEN"));
423
+ const claude = deps.createClaudeClient(requireEnv(env, "ANTHROPIC_API_KEY"));
424
+ const result = await deps.draftReleaseNotes({
425
+ projectOctokit,
426
+ vaultOctokit,
427
+ claude,
428
+ projectOwner,
429
+ projectRepoName,
430
+ vaultOwner,
431
+ vaultRepoName,
432
+ projectName,
433
+ tag: requireEnv(env, "TAG")
434
+ });
435
+ deps.log(`Drafted release notes: ${result.path} (previous tag: ${result.previousTag ?? "none"}, ${result.entryCount} entries)`);
436
+ }
437
+ if (import.meta.url === `file://${process.argv[1]}`) {
438
+ main().catch((error) => {
439
+ console.error(error);
440
+ process.exit(1);
441
+ });
442
+ }
443
+ export {
444
+ main,
445
+ requireEnv
446
+ };
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../modules/core/dist/github/client.js
4
+ import { Octokit } from "@octokit/rest";
5
+ function createGithubClient(token) {
6
+ if (!token) {
7
+ throw new Error("GitHub token is required");
8
+ }
9
+ return new Octokit({ auth: token });
10
+ }
11
+
12
+ // ../../modules/core/dist/github/retryOnConflict.js
13
+ async function retryOnConflict(attempt, maxAttempts = 3) {
14
+ for (let tryNumber = 1; ; tryNumber++) {
15
+ try {
16
+ return await attempt();
17
+ } catch (error) {
18
+ const status = error.status;
19
+ if (tryNumber >= maxAttempts || status !== 409 && status !== 422) {
20
+ throw error;
21
+ }
22
+ await new Promise((resolve) => setTimeout(resolve, 300 * tryNumber));
23
+ }
24
+ }
25
+ }
26
+
27
+ // ../../modules/core/dist/tokenUsage.js
28
+ var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
+ function addTokenUsage(a, b) {
30
+ return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
31
+ }
32
+
33
+ // ../../modules/core/dist/rollover.js
34
+ var ZERO_ROLLOVER_USAGE = {
35
+ today: { key: "", usage: ZERO_USAGE },
36
+ month: { key: "", usage: ZERO_USAGE }
37
+ };
38
+ function todayKey(now = /* @__PURE__ */ new Date()) {
39
+ const year = now.getFullYear();
40
+ const month = String(now.getMonth() + 1).padStart(2, "0");
41
+ const day = String(now.getDate()).padStart(2, "0");
42
+ return `${year}-${month}-${day}`;
43
+ }
44
+ function monthKey(now = /* @__PURE__ */ new Date()) {
45
+ return todayKey(now).slice(0, 7);
46
+ }
47
+ function addRolloverUsage(current, delta, now = /* @__PURE__ */ new Date()) {
48
+ const nowTodayKey = todayKey(now);
49
+ const nowMonthKey = monthKey(now);
50
+ return {
51
+ today: {
52
+ key: nowTodayKey,
53
+ usage: addTokenUsage(current.today.key === nowTodayKey ? current.today.usage : ZERO_USAGE, delta)
54
+ },
55
+ month: {
56
+ key: nowMonthKey,
57
+ usage: addTokenUsage(current.month.key === nowMonthKey ? current.month.usage : ZERO_USAGE, delta)
58
+ }
59
+ };
60
+ }
61
+
62
+ // ../../modules/core/dist/claude/client.js
63
+ import Anthropic from "@anthropic-ai/sdk";
64
+ function createClaudeClient(apiKey) {
65
+ const key = apiKey ?? process.env.ANTHROPIC_API_KEY;
66
+ if (!key) {
67
+ throw new Error("ANTHROPIC_API_KEY is not set");
68
+ }
69
+ return new Anthropic({ apiKey: key });
70
+ }
71
+
72
+ // ../../modules/core/dist/claude/complete.js
73
+ var DEFAULT_MAX_CONTINUATIONS = 5;
74
+ var CONTINUATION_USER_MESSAGE = "Continue exactly where you left off. Do not repeat any text already written, and do not add any preamble or commentary \u2014 resume the previous response verbatim from the exact cutoff point.";
75
+ async function createCompleteMessage(claude, params, { maxContinuations = DEFAULT_MAX_CONTINUATIONS, timeoutMs, signal } = {}) {
76
+ let fullText = "";
77
+ let usage = ZERO_USAGE;
78
+ const requestOptions = timeoutMs !== void 0 || signal !== void 0 ? { timeout: timeoutMs, signal } : void 0;
79
+ for (let attempt = 0; attempt <= maxContinuations; attempt++) {
80
+ const messages = fullText ? [
81
+ ...params.messages,
82
+ { role: "assistant", content: fullText },
83
+ { role: "user", content: CONTINUATION_USER_MESSAGE }
84
+ ] : params.messages;
85
+ const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
86
+ const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
87
+ fullText += text;
88
+ usage = addTokenUsage(usage, {
89
+ inputTokens: message.usage?.input_tokens ?? 0,
90
+ outputTokens: message.usage?.output_tokens ?? 0
91
+ });
92
+ if (message.stop_reason !== "max_tokens") {
93
+ return { text: fullText, usage };
94
+ }
95
+ }
96
+ throw new Error(`Claude response still hit max_tokens after ${maxContinuations} continuation attempts \u2014 refusing to return truncated content.`);
97
+ }
98
+
99
+ // ../../modules/core/dist/modelRegistry.js
100
+ var MODEL_REGISTRY = {
101
+ fast: {
102
+ description: "Quick, cheap work where depth matters least.",
103
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
104
+ },
105
+ balanced: {
106
+ description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
107
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
108
+ },
109
+ capable: {
110
+ description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
111
+ models: [{ id: "claude-opus-5", label: "Opus 5" }]
112
+ },
113
+ max: {
114
+ description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
115
+ models: [{ id: "claude-fable-5", label: "Fable 5" }]
116
+ }
117
+ };
118
+ function defaultModelFor(tier) {
119
+ const [current] = MODEL_REGISTRY[tier].models;
120
+ if (!current) {
121
+ throw new Error(`Model tier "${tier}" has no models registered`);
122
+ }
123
+ return current.id;
124
+ }
125
+
126
+ // ../../modules/devlore/dist/decisionEntries.js
127
+ function decisionEntriesDir(projectName) {
128
+ return `projects/${projectName}/decisions`;
129
+ }
130
+ async function listDecisionEntries(octokit, { owner, repo, projectName }) {
131
+ const dir = decisionEntriesDir(projectName);
132
+ let files;
133
+ try {
134
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dir });
135
+ if (!Array.isArray(data)) {
136
+ return [];
137
+ }
138
+ files = data;
139
+ } catch (error) {
140
+ if (isNotFound(error)) {
141
+ return [];
142
+ }
143
+ throw error;
144
+ }
145
+ const entries = await Promise.all(files.filter((file) => file.type === "file").map(async (file) => {
146
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: file.path });
147
+ if (Array.isArray(data) || data.type !== "file") {
148
+ return null;
149
+ }
150
+ return { path: file.path, content: Buffer.from(data.content, "base64").toString("utf-8") };
151
+ }));
152
+ return entries.filter((entry) => entry !== null).sort((a, b) => a.path.localeCompare(b.path));
153
+ }
154
+ function isNotFound(error) {
155
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
156
+ }
157
+
158
+ // ../../modules/devlore/dist/prompt.js
159
+ function buildOnboardingGuidePrompt(input) {
160
+ return [
161
+ `Project: ${input.projectName}`,
162
+ "",
163
+ 'Write a "start here" guide for a developer joining this project. Not an end-user manual, and not a changelog \u2014 an orientation for someone about to read and change the code.',
164
+ "",
165
+ input.currentState ? `--- Current state ---
166
+ ${input.currentState}` : "",
167
+ input.masterDocs ? `--- Master docs ---
168
+ ${input.masterDocs}` : "",
169
+ input.decisions.length > 0 ? `--- Decisions ---
170
+ ${input.decisions.join("\n\n")}` : "",
171
+ "",
172
+ "Cover, in whatever order serves a newcomer best:",
173
+ "",
174
+ "- Where the important logic lives, and what to read first.",
175
+ "- Why the significant past decisions were made \u2014 a newcomer who does not know the reasoning will undo it.",
176
+ "- Common gotchas: the things that look wrong but are deliberate, and the mistakes this codebase has actually made before.",
177
+ "- How to get from a clean checkout to a working change.",
178
+ "",
179
+ "Draw only on what the documents above actually say. Where they do not cover something a newcomer would need, say so plainly rather than inventing a plausible answer \u2014 this guide is read by someone with no way to tell the difference.",
180
+ "",
181
+ "Return the guide only \u2014 no preamble, no meta-commentary."
182
+ ].filter((line) => line !== "").join("\n");
183
+ }
184
+
185
+ // ../../modules/devlore/dist/redact.js
186
+ var REDACTED = "[REDACTED]";
187
+ var KNOWN_SECRET_PATTERNS = [
188
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
189
+ /gh[pousr]_[A-Za-z0-9]{36,}/g,
190
+ /github_pat_[A-Za-z0-9_]{20,}/g,
191
+ /sk-ant-[A-Za-z0-9_-]{20,}/g,
192
+ /sk-[A-Za-z0-9]{20,}/g,
193
+ /AKIA[0-9A-Z]{16}/g,
194
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g,
195
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g
196
+ ];
197
+ var SECRET_KEYWORD = "(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CREDENTIAL)";
198
+ var ASSIGNMENT_PATTERN = new RegExp(`((?:[A-Za-z0-9]+[_-])*${SECRET_KEYWORD}(?:[_-][A-Za-z0-9]+)*\\s*[:=]\\s*)(["']?)([^"'\`\\s]+)\\2`, "gi");
199
+ function redactText(text) {
200
+ let result = text;
201
+ for (const pattern of KNOWN_SECRET_PATTERNS) {
202
+ result = result.replace(pattern, REDACTED);
203
+ }
204
+ result = result.replace(ASSIGNMENT_PATTERN, (_match, prefix, quote) => `${prefix}${quote}${REDACTED}${quote}`);
205
+ return result;
206
+ }
207
+
208
+ // ../../modules/devlore/dist/ciUsage.js
209
+ var EMPTY_PROJECT_USAGE = {
210
+ draftLogEntry: ZERO_USAGE,
211
+ consolidateRelease: ZERO_USAGE,
212
+ syncUserManual: ZERO_USAGE,
213
+ syncTestPlan: ZERO_USAGE,
214
+ syncVisualizer: ZERO_USAGE,
215
+ analyzeCodebase: ZERO_USAGE,
216
+ syncOnboarding: ZERO_USAGE,
217
+ releaseNotes: ZERO_USAGE,
218
+ rollover: ZERO_ROLLOVER_USAGE
219
+ };
220
+ function usagePath(projectName) {
221
+ return `projects/${projectName}/usage.json`;
222
+ }
223
+ async function getUsageFile(octokit, { owner, repo, projectName }) {
224
+ try {
225
+ const { data } = await octokit.rest.repos.getContent({
226
+ owner,
227
+ repo,
228
+ path: usagePath(projectName)
229
+ });
230
+ if (Array.isArray(data) || data.type !== "file") {
231
+ return { usage: EMPTY_PROJECT_USAGE };
232
+ }
233
+ const parsed = JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
234
+ const usage = { ...EMPTY_PROJECT_USAGE, ...parsed };
235
+ return { usage, sha: data.sha };
236
+ } catch (error) {
237
+ if (isNotFound2(error)) {
238
+ return { usage: EMPTY_PROJECT_USAGE };
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+ function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
244
+ return {
245
+ ...current,
246
+ [source]: addTokenUsage(current[source], delta),
247
+ rollover: addRolloverUsage(current.rollover, delta, now)
248
+ };
249
+ }
250
+ async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
251
+ const { owner, repo, projectName, branch } = params;
252
+ await retryOnConflict(async () => {
253
+ const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
254
+ const updated = addUsage(current, source, delta, now);
255
+ await octokit.rest.repos.createOrUpdateFileContents({
256
+ owner,
257
+ repo,
258
+ path: usagePath(projectName),
259
+ message: `Record ${source} token usage: ${projectName}`,
260
+ content: Buffer.from(JSON.stringify(updated, null, 2), "utf-8").toString("base64"),
261
+ branch,
262
+ sha
263
+ });
264
+ });
265
+ }
266
+ function isNotFound2(error) {
267
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
268
+ }
269
+
270
+ // ../../modules/devlore/dist/masterDocs.js
271
+ function masterDocsPath(projectName) {
272
+ return `projects/${projectName}/master/README.md`;
273
+ }
274
+ async function getMasterDocs(octokit, params) {
275
+ return (await getMasterDocsFile(octokit, params)).content;
276
+ }
277
+ async function getMasterDocsFile(octokit, { owner, repo, projectName }) {
278
+ try {
279
+ const { data } = await octokit.rest.repos.getContent({
280
+ owner,
281
+ repo,
282
+ path: masterDocsPath(projectName)
283
+ });
284
+ if (Array.isArray(data) || data.type !== "file") {
285
+ return { content: "" };
286
+ }
287
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
288
+ } catch (error) {
289
+ if (isNotFound3(error)) {
290
+ return { content: "" };
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+ function isNotFound3(error) {
296
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
297
+ }
298
+
299
+ // ../../modules/devlore/dist/codebaseSnapshot.js
300
+ function currentStatePath(projectName) {
301
+ return `projects/${projectName}/current-state.md`;
302
+ }
303
+
304
+ // ../../modules/devlore/dist/onboardingDoc.js
305
+ var ONBOARDING_PATH = "docs/ONBOARDING.md";
306
+ var BANNER_BLOCKQUOTE_PATTERN = /^> \*\*Do not move.*$\n?/m;
307
+ function onboardingBanner() {
308
+ return "> **Do not move, rename, or edit this file.** Devkeep generates and maintains this onboarding guide automatically at each release \u2014 manual edits will be overwritten the next time a release is tagged.";
309
+ }
310
+ function stripOnboardingMeta(content) {
311
+ return content.replace(BANNER_BLOCKQUOTE_PATTERN, "").trim();
312
+ }
313
+ async function getExistingOnboarding(octokit, { owner, repo }) {
314
+ try {
315
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: ONBOARDING_PATH });
316
+ if (Array.isArray(data) || data.type !== "file") {
317
+ return null;
318
+ }
319
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
320
+ } catch (error) {
321
+ if (isNotFound4(error)) {
322
+ return null;
323
+ }
324
+ throw error;
325
+ }
326
+ }
327
+ function onboardingVaultPath(projectName) {
328
+ return `projects/${projectName}/onboarding.md`;
329
+ }
330
+ async function getVaultOnboarding(octokit, { owner, repo, projectName }) {
331
+ try {
332
+ const { data } = await octokit.rest.repos.getContent({
333
+ owner,
334
+ repo,
335
+ path: onboardingVaultPath(projectName)
336
+ });
337
+ if (Array.isArray(data) || data.type !== "file") {
338
+ return { content: "" };
339
+ }
340
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
341
+ } catch (error) {
342
+ if (isNotFound4(error)) {
343
+ return { content: "" };
344
+ }
345
+ throw error;
346
+ }
347
+ }
348
+ function isNotFound4(error) {
349
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
350
+ }
351
+
352
+ // ../../modules/devlore/dist/actions/syncOnboarding.js
353
+ var MODEL_TIER = "balanced";
354
+ var MAX_TOKENS = 2e4;
355
+ var MAX_CONTINUATIONS = 8;
356
+ var VAULT_BRANCH = "main";
357
+ async function readVaultFile(octokit, owner, repo, path) {
358
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path }).catch(() => ({ data: null }));
359
+ return data && !Array.isArray(data) && data.type === "file" ? Buffer.from(data.content, "base64").toString("utf-8") : "";
360
+ }
361
+ async function syncOnboarding({ projectOctokit, vaultOctokit, claude, projectOwner, projectRepoName, vaultOwner, vaultRepoName, projectName }) {
362
+ const [currentState, masterDocs, entries] = await Promise.all([
363
+ readVaultFile(vaultOctokit, vaultOwner, vaultRepoName, currentStatePath(projectName)),
364
+ getMasterDocs(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName }).catch(() => ""),
365
+ listDecisionEntries(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName }).catch(() => [])
366
+ ]);
367
+ const prompt = buildOnboardingGuidePrompt({
368
+ projectName,
369
+ currentState: redactText(currentState),
370
+ masterDocs: redactText(masterDocs),
371
+ decisions: entries.map((entry) => redactText(entry.content))
372
+ });
373
+ const { text, usage } = await createCompleteMessage(claude, { model: defaultModelFor(MODEL_TIER), max_tokens: MAX_TOKENS, messages: [{ role: "user", content: prompt }] }, { maxContinuations: MAX_CONTINUATIONS });
374
+ const content = `${onboardingBanner()}
375
+
376
+ ${text.trim()}
377
+ `;
378
+ const existing = await getExistingOnboarding(projectOctokit, { owner: projectOwner, repo: projectRepoName });
379
+ await projectOctokit.rest.repos.createOrUpdateFileContents({
380
+ owner: projectOwner,
381
+ repo: projectRepoName,
382
+ path: ONBOARDING_PATH,
383
+ message: existing ? "Update onboarding guide" : "Add onboarding guide",
384
+ content: Buffer.from(content, "utf-8").toString("base64"),
385
+ sha: existing?.sha
386
+ });
387
+ const vaultPath = onboardingVaultPath(projectName);
388
+ const mirrored = stripOnboardingMeta(content);
389
+ const existingVault = await getVaultOnboarding(vaultOctokit, {
390
+ owner: vaultOwner,
391
+ repo: vaultRepoName,
392
+ projectName
393
+ });
394
+ if (existingVault.content !== mirrored) {
395
+ await vaultOctokit.rest.repos.createOrUpdateFileContents({
396
+ owner: vaultOwner,
397
+ repo: vaultRepoName,
398
+ path: vaultPath,
399
+ message: `Mirror onboarding guide: ${projectName}`,
400
+ content: Buffer.from(mirrored, "utf-8").toString("base64"),
401
+ branch: VAULT_BRANCH,
402
+ sha: existingVault.sha
403
+ });
404
+ }
405
+ if (usage.inputTokens > 0 || usage.outputTokens > 0) {
406
+ await recordUsage(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName, branch: VAULT_BRANCH }, "syncOnboarding", usage);
407
+ }
408
+ return { path: ONBOARDING_PATH, vaultPath, content, usage };
409
+ }
410
+
411
+ // ../../modules/devlore/dist/bin/syncOnboardingCli.js
412
+ function requireEnv(env, name) {
413
+ const value = env[name];
414
+ if (!value) {
415
+ throw new Error(`${name} is not set`);
416
+ }
417
+ return value;
418
+ }
419
+ var defaultDeps = {
420
+ env: process.env,
421
+ syncOnboarding,
422
+ createGithubClient,
423
+ createClaudeClient,
424
+ log: console.log
425
+ };
426
+ async function main(deps = defaultDeps) {
427
+ const { env } = deps;
428
+ const projectName = requireEnv(env, "PROJECT_NAME");
429
+ const [projectOwner, projectRepoName] = requireEnv(env, "PROJECT_REPO").split("/");
430
+ const [vaultOwner, vaultRepoName] = requireEnv(env, "VAULT_REPO").split("/");
431
+ const octokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
432
+ const claude = deps.createClaudeClient(requireEnv(env, "ANTHROPIC_API_KEY"));
433
+ const result = await deps.syncOnboarding({
434
+ projectOctokit: octokit,
435
+ vaultOctokit: octokit,
436
+ claude,
437
+ projectOwner,
438
+ projectRepoName,
439
+ vaultOwner,
440
+ vaultRepoName,
441
+ projectName
442
+ });
443
+ deps.log(`Synced onboarding guide: ${result.path} (mirrored to ${result.vaultPath})`);
444
+ }
445
+ if (import.meta.url === `file://${process.argv[1]}`) {
446
+ main().catch((error) => {
447
+ console.error(error);
448
+ process.exit(1);
449
+ });
450
+ }
451
+ export {
452
+ main,
453
+ requireEnv
454
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starterculture/devkeep-actions",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Devkeep's GitHub Actions entrypoints, bundled for CI runners.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",