@starterculture/devkeep-actions 0.1.0 → 0.2.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,10 @@ 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,
131
135
  rollover: ZERO_ROLLOVER_USAGE
132
136
  };
133
137
  function usagePath(projectName) {
@@ -128,6 +128,10 @@ 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,
131
135
  rollover: ZERO_ROLLOVER_USAGE
132
136
  };
133
137
  function usagePath(projectName) {
@@ -0,0 +1,453 @@
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
+ rollover: ZERO_ROLLOVER_USAGE
218
+ };
219
+ function usagePath(projectName) {
220
+ return `projects/${projectName}/usage.json`;
221
+ }
222
+ async function getUsageFile(octokit, { owner, repo, projectName }) {
223
+ try {
224
+ const { data } = await octokit.rest.repos.getContent({
225
+ owner,
226
+ repo,
227
+ path: usagePath(projectName)
228
+ });
229
+ if (Array.isArray(data) || data.type !== "file") {
230
+ return { usage: EMPTY_PROJECT_USAGE };
231
+ }
232
+ const parsed = JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
233
+ const usage = { ...EMPTY_PROJECT_USAGE, ...parsed };
234
+ return { usage, sha: data.sha };
235
+ } catch (error) {
236
+ if (isNotFound2(error)) {
237
+ return { usage: EMPTY_PROJECT_USAGE };
238
+ }
239
+ throw error;
240
+ }
241
+ }
242
+ function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
243
+ return {
244
+ ...current,
245
+ [source]: addTokenUsage(current[source], delta),
246
+ rollover: addRolloverUsage(current.rollover, delta, now)
247
+ };
248
+ }
249
+ async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
250
+ const { owner, repo, projectName, branch } = params;
251
+ await retryOnConflict(async () => {
252
+ const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
253
+ const updated = addUsage(current, source, delta, now);
254
+ await octokit.rest.repos.createOrUpdateFileContents({
255
+ owner,
256
+ repo,
257
+ path: usagePath(projectName),
258
+ message: `Record ${source} token usage: ${projectName}`,
259
+ content: Buffer.from(JSON.stringify(updated, null, 2), "utf-8").toString("base64"),
260
+ branch,
261
+ sha
262
+ });
263
+ });
264
+ }
265
+ function isNotFound2(error) {
266
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
267
+ }
268
+
269
+ // ../../modules/devlore/dist/masterDocs.js
270
+ function masterDocsPath(projectName) {
271
+ return `projects/${projectName}/master/README.md`;
272
+ }
273
+ async function getMasterDocs(octokit, params) {
274
+ return (await getMasterDocsFile(octokit, params)).content;
275
+ }
276
+ async function getMasterDocsFile(octokit, { owner, repo, projectName }) {
277
+ try {
278
+ const { data } = await octokit.rest.repos.getContent({
279
+ owner,
280
+ repo,
281
+ path: masterDocsPath(projectName)
282
+ });
283
+ if (Array.isArray(data) || data.type !== "file") {
284
+ return { content: "" };
285
+ }
286
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
287
+ } catch (error) {
288
+ if (isNotFound3(error)) {
289
+ return { content: "" };
290
+ }
291
+ throw error;
292
+ }
293
+ }
294
+ function isNotFound3(error) {
295
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
296
+ }
297
+
298
+ // ../../modules/devlore/dist/codebaseSnapshot.js
299
+ function currentStatePath(projectName) {
300
+ return `projects/${projectName}/current-state.md`;
301
+ }
302
+
303
+ // ../../modules/devlore/dist/onboardingDoc.js
304
+ var ONBOARDING_PATH = "docs/ONBOARDING.md";
305
+ var BANNER_BLOCKQUOTE_PATTERN = /^> \*\*Do not move.*$\n?/m;
306
+ function onboardingBanner() {
307
+ 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.";
308
+ }
309
+ function stripOnboardingMeta(content) {
310
+ return content.replace(BANNER_BLOCKQUOTE_PATTERN, "").trim();
311
+ }
312
+ async function getExistingOnboarding(octokit, { owner, repo }) {
313
+ try {
314
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: ONBOARDING_PATH });
315
+ if (Array.isArray(data) || data.type !== "file") {
316
+ return null;
317
+ }
318
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
319
+ } catch (error) {
320
+ if (isNotFound4(error)) {
321
+ return null;
322
+ }
323
+ throw error;
324
+ }
325
+ }
326
+ function onboardingVaultPath(projectName) {
327
+ return `projects/${projectName}/onboarding.md`;
328
+ }
329
+ async function getVaultOnboarding(octokit, { owner, repo, projectName }) {
330
+ try {
331
+ const { data } = await octokit.rest.repos.getContent({
332
+ owner,
333
+ repo,
334
+ path: onboardingVaultPath(projectName)
335
+ });
336
+ if (Array.isArray(data) || data.type !== "file") {
337
+ return { content: "" };
338
+ }
339
+ return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
340
+ } catch (error) {
341
+ if (isNotFound4(error)) {
342
+ return { content: "" };
343
+ }
344
+ throw error;
345
+ }
346
+ }
347
+ function isNotFound4(error) {
348
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
349
+ }
350
+
351
+ // ../../modules/devlore/dist/actions/syncOnboarding.js
352
+ var MODEL_TIER = "balanced";
353
+ var MAX_TOKENS = 2e4;
354
+ var MAX_CONTINUATIONS = 8;
355
+ var VAULT_BRANCH = "main";
356
+ async function readVaultFile(octokit, owner, repo, path) {
357
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path }).catch(() => ({ data: null }));
358
+ return data && !Array.isArray(data) && data.type === "file" ? Buffer.from(data.content, "base64").toString("utf-8") : "";
359
+ }
360
+ async function syncOnboarding({ projectOctokit, vaultOctokit, claude, projectOwner, projectRepoName, vaultOwner, vaultRepoName, projectName }) {
361
+ const [currentState, masterDocs, entries] = await Promise.all([
362
+ readVaultFile(vaultOctokit, vaultOwner, vaultRepoName, currentStatePath(projectName)),
363
+ getMasterDocs(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName }).catch(() => ""),
364
+ listDecisionEntries(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName }).catch(() => [])
365
+ ]);
366
+ const prompt = buildOnboardingGuidePrompt({
367
+ projectName,
368
+ currentState: redactText(currentState),
369
+ masterDocs: redactText(masterDocs),
370
+ decisions: entries.map((entry) => redactText(entry.content))
371
+ });
372
+ const { text, usage } = await createCompleteMessage(claude, { model: defaultModelFor(MODEL_TIER), max_tokens: MAX_TOKENS, messages: [{ role: "user", content: prompt }] }, { maxContinuations: MAX_CONTINUATIONS });
373
+ const content = `${onboardingBanner()}
374
+
375
+ ${text.trim()}
376
+ `;
377
+ const existing = await getExistingOnboarding(projectOctokit, { owner: projectOwner, repo: projectRepoName });
378
+ await projectOctokit.rest.repos.createOrUpdateFileContents({
379
+ owner: projectOwner,
380
+ repo: projectRepoName,
381
+ path: ONBOARDING_PATH,
382
+ message: existing ? "Update onboarding guide" : "Add onboarding guide",
383
+ content: Buffer.from(content, "utf-8").toString("base64"),
384
+ sha: existing?.sha
385
+ });
386
+ const vaultPath = onboardingVaultPath(projectName);
387
+ const mirrored = stripOnboardingMeta(content);
388
+ const existingVault = await getVaultOnboarding(vaultOctokit, {
389
+ owner: vaultOwner,
390
+ repo: vaultRepoName,
391
+ projectName
392
+ });
393
+ if (existingVault.content !== mirrored) {
394
+ await vaultOctokit.rest.repos.createOrUpdateFileContents({
395
+ owner: vaultOwner,
396
+ repo: vaultRepoName,
397
+ path: vaultPath,
398
+ message: `Mirror onboarding guide: ${projectName}`,
399
+ content: Buffer.from(mirrored, "utf-8").toString("base64"),
400
+ branch: VAULT_BRANCH,
401
+ sha: existingVault.sha
402
+ });
403
+ }
404
+ if (usage.inputTokens > 0 || usage.outputTokens > 0) {
405
+ await recordUsage(vaultOctokit, { owner: vaultOwner, repo: vaultRepoName, projectName, branch: VAULT_BRANCH }, "syncOnboarding", usage);
406
+ }
407
+ return { path: ONBOARDING_PATH, vaultPath, content, usage };
408
+ }
409
+
410
+ // ../../modules/devlore/dist/bin/syncOnboardingCli.js
411
+ function requireEnv(env, name) {
412
+ const value = env[name];
413
+ if (!value) {
414
+ throw new Error(`${name} is not set`);
415
+ }
416
+ return value;
417
+ }
418
+ var defaultDeps = {
419
+ env: process.env,
420
+ syncOnboarding,
421
+ createGithubClient,
422
+ createClaudeClient,
423
+ log: console.log
424
+ };
425
+ async function main(deps = defaultDeps) {
426
+ const { env } = deps;
427
+ const projectName = requireEnv(env, "PROJECT_NAME");
428
+ const [projectOwner, projectRepoName] = requireEnv(env, "PROJECT_REPO").split("/");
429
+ const [vaultOwner, vaultRepoName] = requireEnv(env, "VAULT_REPO").split("/");
430
+ const octokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
431
+ const claude = deps.createClaudeClient(requireEnv(env, "ANTHROPIC_API_KEY"));
432
+ const result = await deps.syncOnboarding({
433
+ projectOctokit: octokit,
434
+ vaultOctokit: octokit,
435
+ claude,
436
+ projectOwner,
437
+ projectRepoName,
438
+ vaultOwner,
439
+ vaultRepoName,
440
+ projectName
441
+ });
442
+ deps.log(`Synced onboarding guide: ${result.path} (mirrored to ${result.vaultPath})`);
443
+ }
444
+ if (import.meta.url === `file://${process.argv[1]}`) {
445
+ main().catch((error) => {
446
+ console.error(error);
447
+ process.exit(1);
448
+ });
449
+ }
450
+ export {
451
+ main,
452
+ requireEnv
453
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starterculture/devkeep-actions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Devkeep's GitHub Actions entrypoints, bundled for CI runners.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",