@starterculture/devkeep-actions 0.5.1 → 0.5.2

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.
@@ -25,9 +25,41 @@ async function retryOnConflict(attempt, maxAttempts = 3) {
25
25
  }
26
26
 
27
27
  // ../../modules/core/dist/tokenUsage.js
28
+ function usageForModel(model, tokens) {
29
+ return { ...tokens, byModel: { [model]: tokens } };
30
+ }
28
31
  var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
32
  function addTokenUsage(a, b) {
30
- return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
33
+ const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
34
+ const cacheWriteTokens = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
35
+ const byModel = mergeByModel(a.byModel, b.byModel);
36
+ return {
37
+ inputTokens: a.inputTokens + b.inputTokens,
38
+ outputTokens: a.outputTokens + b.outputTokens,
39
+ // Kept absent rather than zero when nothing was cached, so a ledger
40
+ // entry from before caching and one from a run that cached nothing
41
+ // look the same.
42
+ ...cacheReadTokens > 0 ? { cacheReadTokens } : {},
43
+ ...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
44
+ // Absent for the same reason: a record with no model attribution and
45
+ // one with an empty map should not read differently.
46
+ ...byModel ? { byModel } : {}
47
+ };
48
+ }
49
+ function mergeByModel(a, b) {
50
+ if (!a && !b)
51
+ return void 0;
52
+ const merged = { ...a };
53
+ for (const [model, tokens] of Object.entries(b ?? {})) {
54
+ const existing = merged[model];
55
+ merged[model] = existing ? {
56
+ inputTokens: existing.inputTokens + tokens.inputTokens,
57
+ outputTokens: existing.outputTokens + tokens.outputTokens,
58
+ ...(existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) > 0 ? { cacheReadTokens: (existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) } : {},
59
+ ...(existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) > 0 ? { cacheWriteTokens: (existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) } : {}
60
+ } : tokens;
61
+ }
62
+ return merged;
31
63
  }
32
64
 
33
65
  // ../../modules/core/dist/rollover.js
@@ -89,10 +121,10 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
89
121
  const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
90
122
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
91
123
  fullText += text;
92
- usage = addTokenUsage(usage, {
124
+ usage = addTokenUsage(usage, usageForModel(params.model, {
93
125
  inputTokens: message.usage?.input_tokens ?? 0,
94
126
  outputTokens: message.usage?.output_tokens ?? 0
95
- });
127
+ }));
96
128
  if (message.stop_reason !== "max_tokens") {
97
129
  return { text: fullText, usage };
98
130
  }
@@ -104,19 +136,19 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
104
136
  var MODEL_REGISTRY = {
105
137
  fast: {
106
138
  description: "Quick, cheap work where depth matters least.",
107
- models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
139
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", contextTokens: 2e5 }]
108
140
  },
109
141
  balanced: {
110
142
  description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
111
- models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
143
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5", contextTokens: 2e5 }]
112
144
  },
113
145
  capable: {
114
146
  description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
115
- models: [{ id: "claude-opus-5", label: "Opus 5" }]
147
+ models: [{ id: "claude-opus-5", label: "Opus 5", contextTokens: 2e5 }]
116
148
  },
117
149
  max: {
118
150
  description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
119
- models: [{ id: "claude-fable-5", label: "Fable 5" }]
151
+ models: [{ id: "claude-fable-5", label: "Fable 5", contextTokens: 2e5 }]
120
152
  }
121
153
  };
122
154
  function defaultModelFor(tier) {
@@ -280,7 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
280
312
  }
281
313
  }
282
314
 
283
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
284
316
  var EMPTY_PROJECT_USAGE = {
285
317
  draftLogEntry: ZERO_USAGE,
286
318
  consolidateRelease: ZERO_USAGE,
@@ -291,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
291
323
  syncOnboarding: ZERO_USAGE,
292
324
  releaseNotes: ZERO_USAGE,
293
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
294
327
  rollover: ZERO_ROLLOVER_USAGE
295
328
  };
296
329
  function usagePath(projectName) {
@@ -317,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
317
350
  }
318
351
  }
319
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
320
354
  return {
321
355
  ...current,
322
356
  [source]: addTokenUsage(current[source], delta),
323
- rollover: addRolloverUsage(current.rollover, delta, now)
357
+ // Both: the combined bucket stays the total it always was, and the
358
+ // per-source split is what lets the dashboard attribute it.
359
+ rollover: addRolloverUsage(current.rollover, delta, now),
360
+ rolloverBySource: {
361
+ ...bySource,
362
+ [source]: addRolloverUsage(bySource[source] ?? ZERO_ROLLOVER_USAGE, delta, now)
363
+ }
324
364
  };
325
365
  }
326
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
327
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
328
371
  await retryOnConflict(async () => {
329
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
330
373
  const updated = addUsage(current, source, delta, now);
@@ -376,6 +419,14 @@ function isNotFound2(error) {
376
419
  }
377
420
 
378
421
  // ../../modules/devlore/dist/prompt.js
422
+ var SUPERSEDED_GUIDANCE = [
423
+ "These documents record how the thinking changed over time, so they contain claims that were later corrected, and the corrected version is still physically present.",
424
+ "**A correction outranks what it corrects.** Where two passages disagree, the later-dated one is what is true now. A passage struck through, marked superseded, described as decided against, or sitting under a heading that says the earlier view was wrong, records what was believed \u2014 never what is. Do not restate any of it as current."
425
+ ].join("\n");
426
+ var SUPERSEDED_GUIDANCE_WITH_LESSONS = [
427
+ SUPERSEDED_GUIDANCE,
428
+ "A reversed decision is still worth telling a newcomer about \u2014 but as something the project learned, never as a rule they should follow. Say what is true now first, and mention the earlier version only where the fact that it changed is itself the lesson."
429
+ ].join("\n");
379
430
  function buildConsolidationPrompt(input) {
380
431
  const entriesText = input.logEntries.map((entry) => `--- ${entry.path} ---
381
432
  ${entry.content}`).join("\n\n");
@@ -499,7 +499,12 @@ async function main(deps = defaultDeps2) {
499
499
  const headSha = requireEnv(env, "HEAD_SHA");
500
500
  const projectOctokit = deps.createGithubClient(requireEnv(env, "GITHUB_TOKEN"));
501
501
  const vaultOctokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
502
- const blindIndexKey = Buffer.from(requireEnv(env, "DEVCRYPT_BLIND_INDEX_KEY"), "base64");
502
+ const rawKey = env.DEVCRYPT_BLIND_INDEX_KEY;
503
+ if (!rawKey) {
504
+ deps.log("No DEVCRYPT_BLIND_INDEX_KEY for this project \u2014 Devcrypt has not been used against this vault, so there is nothing to scan for. Re-link once it has been.");
505
+ return;
506
+ }
507
+ const blindIndexKey = Buffer.from(rawKey, "base64");
503
508
  const result = await deps.scanCryptingCandidates({
504
509
  projectOctokit,
505
510
  vaultOctokit,
@@ -25,9 +25,41 @@ async function retryOnConflict(attempt, maxAttempts = 3) {
25
25
  }
26
26
 
27
27
  // ../../modules/core/dist/tokenUsage.js
28
+ function usageForModel(model, tokens) {
29
+ return { ...tokens, byModel: { [model]: tokens } };
30
+ }
28
31
  var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
32
  function addTokenUsage(a, b) {
30
- return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
33
+ const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
34
+ const cacheWriteTokens = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
35
+ const byModel = mergeByModel(a.byModel, b.byModel);
36
+ return {
37
+ inputTokens: a.inputTokens + b.inputTokens,
38
+ outputTokens: a.outputTokens + b.outputTokens,
39
+ // Kept absent rather than zero when nothing was cached, so a ledger
40
+ // entry from before caching and one from a run that cached nothing
41
+ // look the same.
42
+ ...cacheReadTokens > 0 ? { cacheReadTokens } : {},
43
+ ...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
44
+ // Absent for the same reason: a record with no model attribution and
45
+ // one with an empty map should not read differently.
46
+ ...byModel ? { byModel } : {}
47
+ };
48
+ }
49
+ function mergeByModel(a, b) {
50
+ if (!a && !b)
51
+ return void 0;
52
+ const merged = { ...a };
53
+ for (const [model, tokens] of Object.entries(b ?? {})) {
54
+ const existing = merged[model];
55
+ merged[model] = existing ? {
56
+ inputTokens: existing.inputTokens + tokens.inputTokens,
57
+ outputTokens: existing.outputTokens + tokens.outputTokens,
58
+ ...(existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) > 0 ? { cacheReadTokens: (existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) } : {},
59
+ ...(existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) > 0 ? { cacheWriteTokens: (existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) } : {}
60
+ } : tokens;
61
+ }
62
+ return merged;
31
63
  }
32
64
 
33
65
  // ../../modules/core/dist/rollover.js
@@ -89,10 +121,10 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
89
121
  const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
90
122
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
91
123
  fullText += text;
92
- usage = addTokenUsage(usage, {
124
+ usage = addTokenUsage(usage, usageForModel(params.model, {
93
125
  inputTokens: message.usage?.input_tokens ?? 0,
94
126
  outputTokens: message.usage?.output_tokens ?? 0
95
- });
127
+ }));
96
128
  if (message.stop_reason !== "max_tokens") {
97
129
  return { text: fullText, usage };
98
130
  }
@@ -104,19 +136,19 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
104
136
  var MODEL_REGISTRY = {
105
137
  fast: {
106
138
  description: "Quick, cheap work where depth matters least.",
107
- models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
139
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", contextTokens: 2e5 }]
108
140
  },
109
141
  balanced: {
110
142
  description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
111
- models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
143
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5", contextTokens: 2e5 }]
112
144
  },
113
145
  capable: {
114
146
  description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
115
- models: [{ id: "claude-opus-5", label: "Opus 5" }]
147
+ models: [{ id: "claude-opus-5", label: "Opus 5", contextTokens: 2e5 }]
116
148
  },
117
149
  max: {
118
150
  description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
119
- models: [{ id: "claude-fable-5", label: "Fable 5" }]
151
+ models: [{ id: "claude-fable-5", label: "Fable 5", contextTokens: 2e5 }]
120
152
  }
121
153
  };
122
154
  function defaultModelFor(tier) {
@@ -280,7 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
280
312
  }
281
313
  }
282
314
 
283
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
284
316
  var EMPTY_PROJECT_USAGE = {
285
317
  draftLogEntry: ZERO_USAGE,
286
318
  consolidateRelease: ZERO_USAGE,
@@ -291,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
291
323
  syncOnboarding: ZERO_USAGE,
292
324
  releaseNotes: ZERO_USAGE,
293
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
294
327
  rollover: ZERO_ROLLOVER_USAGE
295
328
  };
296
329
  function usagePath(projectName) {
@@ -317,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
317
350
  }
318
351
  }
319
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
320
354
  return {
321
355
  ...current,
322
356
  [source]: addTokenUsage(current[source], delta),
323
- rollover: addRolloverUsage(current.rollover, delta, now)
357
+ // Both: the combined bucket stays the total it always was, and the
358
+ // per-source split is what lets the dashboard attribute it.
359
+ rollover: addRolloverUsage(current.rollover, delta, now),
360
+ rolloverBySource: {
361
+ ...bySource,
362
+ [source]: addRolloverUsage(bySource[source] ?? ZERO_ROLLOVER_USAGE, delta, now)
363
+ }
324
364
  };
325
365
  }
326
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
327
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
328
371
  await retryOnConflict(async () => {
329
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
330
373
  const updated = addUsage(current, source, delta, now);
@@ -503,6 +546,14 @@ function isNotFound3(error) {
503
546
 
504
547
  // ../../modules/devlore/dist/prompt.js
505
548
  var MAX_DIFF_CHARS = 2e4;
549
+ var SUPERSEDED_GUIDANCE = [
550
+ "These documents record how the thinking changed over time, so they contain claims that were later corrected, and the corrected version is still physically present.",
551
+ "**A correction outranks what it corrects.** Where two passages disagree, the later-dated one is what is true now. A passage struck through, marked superseded, described as decided against, or sitting under a heading that says the earlier view was wrong, records what was believed \u2014 never what is. Do not restate any of it as current."
552
+ ].join("\n");
553
+ var SUPERSEDED_GUIDANCE_WITH_LESSONS = [
554
+ SUPERSEDED_GUIDANCE,
555
+ "A reversed decision is still worth telling a newcomer about \u2014 but as something the project learned, never as a rule they should follow. Say what is true now first, and mention the earlier version only where the fact that it changed is itself the lesson."
556
+ ].join("\n");
506
557
  function buildLogEntryPrompt(input) {
507
558
  const diffText = input.files.map((file) => `--- ${file.filename} (${file.status}) ---
508
559
  ${file.patch}`).join("\n\n");
@@ -25,9 +25,41 @@ async function retryOnConflict(attempt, maxAttempts = 3) {
25
25
  }
26
26
 
27
27
  // ../../modules/core/dist/tokenUsage.js
28
+ function usageForModel(model, tokens) {
29
+ return { ...tokens, byModel: { [model]: tokens } };
30
+ }
28
31
  var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
32
  function addTokenUsage(a, b) {
30
- return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
33
+ const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
34
+ const cacheWriteTokens = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
35
+ const byModel = mergeByModel(a.byModel, b.byModel);
36
+ return {
37
+ inputTokens: a.inputTokens + b.inputTokens,
38
+ outputTokens: a.outputTokens + b.outputTokens,
39
+ // Kept absent rather than zero when nothing was cached, so a ledger
40
+ // entry from before caching and one from a run that cached nothing
41
+ // look the same.
42
+ ...cacheReadTokens > 0 ? { cacheReadTokens } : {},
43
+ ...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
44
+ // Absent for the same reason: a record with no model attribution and
45
+ // one with an empty map should not read differently.
46
+ ...byModel ? { byModel } : {}
47
+ };
48
+ }
49
+ function mergeByModel(a, b) {
50
+ if (!a && !b)
51
+ return void 0;
52
+ const merged = { ...a };
53
+ for (const [model, tokens] of Object.entries(b ?? {})) {
54
+ const existing = merged[model];
55
+ merged[model] = existing ? {
56
+ inputTokens: existing.inputTokens + tokens.inputTokens,
57
+ outputTokens: existing.outputTokens + tokens.outputTokens,
58
+ ...(existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) > 0 ? { cacheReadTokens: (existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) } : {},
59
+ ...(existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) > 0 ? { cacheWriteTokens: (existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) } : {}
60
+ } : tokens;
61
+ }
62
+ return merged;
31
63
  }
32
64
 
33
65
  // ../../modules/core/dist/rollover.js
@@ -89,10 +121,10 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
89
121
  const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
90
122
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
91
123
  fullText += text;
92
- usage = addTokenUsage(usage, {
124
+ usage = addTokenUsage(usage, usageForModel(params.model, {
93
125
  inputTokens: message.usage?.input_tokens ?? 0,
94
126
  outputTokens: message.usage?.output_tokens ?? 0
95
- });
127
+ }));
96
128
  if (message.stop_reason !== "max_tokens") {
97
129
  return { text: fullText, usage };
98
130
  }
@@ -104,19 +136,19 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
104
136
  var MODEL_REGISTRY = {
105
137
  fast: {
106
138
  description: "Quick, cheap work where depth matters least.",
107
- models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
139
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", contextTokens: 2e5 }]
108
140
  },
109
141
  balanced: {
110
142
  description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
111
- models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
143
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5", contextTokens: 2e5 }]
112
144
  },
113
145
  capable: {
114
146
  description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
115
- models: [{ id: "claude-opus-5", label: "Opus 5" }]
147
+ models: [{ id: "claude-opus-5", label: "Opus 5", contextTokens: 2e5 }]
116
148
  },
117
149
  max: {
118
150
  description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
119
- models: [{ id: "claude-fable-5", label: "Fable 5" }]
151
+ models: [{ id: "claude-fable-5", label: "Fable 5", contextTokens: 2e5 }]
120
152
  }
121
153
  };
122
154
  function defaultModelFor(tier) {
@@ -280,30 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
280
312
  }
281
313
  }
282
314
 
283
- // ../../modules/devlore/dist/prompt.js
284
- function buildReleaseNotesPrompt(input) {
285
- return [
286
- `Project: ${input.projectName}`,
287
- `Release: ${input.tag}`,
288
- 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.",
289
- "",
290
- "Below are the decision log entries Devkeep recorded for the work in this release.",
291
- "",
292
- ...input.logEntries,
293
- "",
294
- "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.",
295
- "",
296
- "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.",
297
- "",
298
- '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.',
299
- "",
300
- "Do NOT include a top-level heading \u2014 one is added automatically. Start directly with the content.",
301
- "",
302
- "Return the notes only \u2014 no preamble, no meta-commentary."
303
- ].join("\n");
304
- }
305
-
306
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
307
316
  var EMPTY_PROJECT_USAGE = {
308
317
  draftLogEntry: ZERO_USAGE,
309
318
  consolidateRelease: ZERO_USAGE,
@@ -314,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
314
323
  syncOnboarding: ZERO_USAGE,
315
324
  releaseNotes: ZERO_USAGE,
316
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
317
327
  rollover: ZERO_ROLLOVER_USAGE
318
328
  };
319
329
  function usagePath(projectName) {
@@ -340,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
340
350
  }
341
351
  }
342
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
343
354
  return {
344
355
  ...current,
345
356
  [source]: addTokenUsage(current[source], delta),
346
- rollover: addRolloverUsage(current.rollover, delta, now)
357
+ // Both: the combined bucket stays the total it always was, and the
358
+ // per-source split is what lets the dashboard attribute it.
359
+ rollover: addRolloverUsage(current.rollover, delta, now),
360
+ rolloverBySource: {
361
+ ...bySource,
362
+ [source]: addRolloverUsage(bySource[source] ?? ZERO_ROLLOVER_USAGE, delta, now)
363
+ }
347
364
  };
348
365
  }
349
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
350
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
351
371
  await retryOnConflict(async () => {
352
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
353
373
  const updated = addUsage(current, source, delta, now);
@@ -366,6 +386,37 @@ function isNotFound(error) {
366
386
  return typeof error === "object" && error !== null && "status" in error && error.status === 404;
367
387
  }
368
388
 
389
+ // ../../modules/devlore/dist/prompt.js
390
+ var SUPERSEDED_GUIDANCE = [
391
+ "These documents record how the thinking changed over time, so they contain claims that were later corrected, and the corrected version is still physically present.",
392
+ "**A correction outranks what it corrects.** Where two passages disagree, the later-dated one is what is true now. A passage struck through, marked superseded, described as decided against, or sitting under a heading that says the earlier view was wrong, records what was believed \u2014 never what is. Do not restate any of it as current."
393
+ ].join("\n");
394
+ var SUPERSEDED_GUIDANCE_WITH_LESSONS = [
395
+ SUPERSEDED_GUIDANCE,
396
+ "A reversed decision is still worth telling a newcomer about \u2014 but as something the project learned, never as a rule they should follow. Say what is true now first, and mention the earlier version only where the fact that it changed is itself the lesson."
397
+ ].join("\n");
398
+ function buildReleaseNotesPrompt(input) {
399
+ return [
400
+ `Project: ${input.projectName}`,
401
+ `Release: ${input.tag}`,
402
+ 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.",
403
+ "",
404
+ "Below are the decision log entries Devkeep recorded for the work in this release.",
405
+ "",
406
+ ...input.logEntries,
407
+ "",
408
+ "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.",
409
+ "",
410
+ "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.",
411
+ "",
412
+ '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.',
413
+ "",
414
+ "Do NOT include a top-level heading \u2014 one is added automatically. Start directly with the content.",
415
+ "",
416
+ "Return the notes only \u2014 no preamble, no meta-commentary."
417
+ ].join("\n");
418
+ }
419
+
369
420
  // ../../modules/devlore/dist/logEntries.js
370
421
  function logEntriesDir(projectName) {
371
422
  return `projects/${projectName}/log`;
@@ -25,9 +25,41 @@ async function retryOnConflict(attempt, maxAttempts = 3) {
25
25
  }
26
26
 
27
27
  // ../../modules/core/dist/tokenUsage.js
28
+ function usageForModel(model, tokens) {
29
+ return { ...tokens, byModel: { [model]: tokens } };
30
+ }
28
31
  var ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
29
32
  function addTokenUsage(a, b) {
30
- return { inputTokens: a.inputTokens + b.inputTokens, outputTokens: a.outputTokens + b.outputTokens };
33
+ const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
34
+ const cacheWriteTokens = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
35
+ const byModel = mergeByModel(a.byModel, b.byModel);
36
+ return {
37
+ inputTokens: a.inputTokens + b.inputTokens,
38
+ outputTokens: a.outputTokens + b.outputTokens,
39
+ // Kept absent rather than zero when nothing was cached, so a ledger
40
+ // entry from before caching and one from a run that cached nothing
41
+ // look the same.
42
+ ...cacheReadTokens > 0 ? { cacheReadTokens } : {},
43
+ ...cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
44
+ // Absent for the same reason: a record with no model attribution and
45
+ // one with an empty map should not read differently.
46
+ ...byModel ? { byModel } : {}
47
+ };
48
+ }
49
+ function mergeByModel(a, b) {
50
+ if (!a && !b)
51
+ return void 0;
52
+ const merged = { ...a };
53
+ for (const [model, tokens] of Object.entries(b ?? {})) {
54
+ const existing = merged[model];
55
+ merged[model] = existing ? {
56
+ inputTokens: existing.inputTokens + tokens.inputTokens,
57
+ outputTokens: existing.outputTokens + tokens.outputTokens,
58
+ ...(existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) > 0 ? { cacheReadTokens: (existing.cacheReadTokens ?? 0) + (tokens.cacheReadTokens ?? 0) } : {},
59
+ ...(existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) > 0 ? { cacheWriteTokens: (existing.cacheWriteTokens ?? 0) + (tokens.cacheWriteTokens ?? 0) } : {}
60
+ } : tokens;
61
+ }
62
+ return merged;
31
63
  }
32
64
 
33
65
  // ../../modules/core/dist/rollover.js
@@ -89,10 +121,10 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
89
121
  const message = requestOptions ? await claude.messages.create({ ...params, messages }, requestOptions) : await claude.messages.create({ ...params, messages });
90
122
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
91
123
  fullText += text;
92
- usage = addTokenUsage(usage, {
124
+ usage = addTokenUsage(usage, usageForModel(params.model, {
93
125
  inputTokens: message.usage?.input_tokens ?? 0,
94
126
  outputTokens: message.usage?.output_tokens ?? 0
95
- });
127
+ }));
96
128
  if (message.stop_reason !== "max_tokens") {
97
129
  return { text: fullText, usage };
98
130
  }
@@ -104,19 +136,19 @@ async function createCompleteMessage(claude, params, { maxContinuations = DEFAUL
104
136
  var MODEL_REGISTRY = {
105
137
  fast: {
106
138
  description: "Quick, cheap work where depth matters least.",
107
- models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" }]
139
+ models: [{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", contextTokens: 2e5 }]
108
140
  },
109
141
  balanced: {
110
142
  description: "The everyday tier: chat, log entries, release notes, the daily digest. Frequent enough that cost matters.",
111
- models: [{ id: "claude-sonnet-5", label: "Sonnet 5" }]
143
+ models: [{ id: "claude-sonnet-5", label: "Sonnet 5", contextTokens: 2e5 }]
112
144
  },
113
145
  capable: {
114
146
  description: "Deeper reasoning for infrequent, high-value work where getting it right outweighs the cost.",
115
- models: [{ id: "claude-opus-5", label: "Opus 5" }]
147
+ models: [{ id: "claude-opus-5", label: "Opus 5", contextTokens: 2e5 }]
116
148
  },
117
149
  max: {
118
150
  description: "Maximum depth, for long-running agentic work. Opt-in \u2014 the most expensive tier.",
119
- models: [{ id: "claude-fable-5", label: "Fable 5" }]
151
+ models: [{ id: "claude-fable-5", label: "Fable 5", contextTokens: 2e5 }]
120
152
  }
121
153
  };
122
154
  function defaultModelFor(tier) {
@@ -280,66 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
280
312
  }
281
313
  }
282
314
 
283
- // ../../modules/devlore/dist/decisionEntries.js
284
- function decisionEntriesDir(projectName) {
285
- return `projects/${projectName}/decisions`;
286
- }
287
- async function listDecisionEntries(octokit, { owner, repo, projectName }) {
288
- const dir = decisionEntriesDir(projectName);
289
- let files;
290
- try {
291
- const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dir });
292
- if (!Array.isArray(data)) {
293
- return [];
294
- }
295
- files = data;
296
- } catch (error) {
297
- if (isNotFound(error)) {
298
- return [];
299
- }
300
- throw error;
301
- }
302
- const entries = await Promise.all(files.filter((file) => file.type === "file").map(async (file) => {
303
- const { data } = await octokit.rest.repos.getContent({ owner, repo, path: file.path });
304
- if (Array.isArray(data) || data.type !== "file") {
305
- return null;
306
- }
307
- return { path: file.path, content: Buffer.from(data.content, "base64").toString("utf-8") };
308
- }));
309
- return entries.filter((entry) => entry !== null).sort((a, b) => a.path.localeCompare(b.path));
310
- }
311
- function isNotFound(error) {
312
- return typeof error === "object" && error !== null && "status" in error && error.status === 404;
313
- }
314
-
315
- // ../../modules/devlore/dist/prompt.js
316
- function buildOnboardingGuidePrompt(input) {
317
- return [
318
- `Project: ${input.projectName}`,
319
- "",
320
- '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.',
321
- "",
322
- input.currentState ? `--- Current state ---
323
- ${input.currentState}` : "",
324
- input.canonicalDocs ? `--- Canonical docs ---
325
- ${input.canonicalDocs}` : "",
326
- input.decisions.length > 0 ? `--- Decisions ---
327
- ${input.decisions.join("\n\n")}` : "",
328
- "",
329
- "Cover, in whatever order serves a newcomer best:",
330
- "",
331
- "- Where the important logic lives, and what to read first.",
332
- "- Why the significant past decisions were made \u2014 a newcomer who does not know the reasoning will undo it.",
333
- "- Common gotchas: the things that look wrong but are deliberate, and the mistakes this codebase has actually made before.",
334
- "- How to get from a clean checkout to a working change.",
335
- "",
336
- "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.",
337
- "",
338
- "Return the guide only \u2014 no preamble, no meta-commentary."
339
- ].filter((line) => line !== "").join("\n");
340
- }
341
-
342
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
343
316
  var EMPTY_PROJECT_USAGE = {
344
317
  draftLogEntry: ZERO_USAGE,
345
318
  consolidateRelease: ZERO_USAGE,
@@ -350,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
350
323
  syncOnboarding: ZERO_USAGE,
351
324
  releaseNotes: ZERO_USAGE,
352
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
353
327
  rollover: ZERO_ROLLOVER_USAGE
354
328
  };
355
329
  function usagePath(projectName) {
@@ -369,21 +343,31 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
369
343
  const usage = { ...EMPTY_PROJECT_USAGE, ...parsed };
370
344
  return { usage, sha: data.sha };
371
345
  } catch (error) {
372
- if (isNotFound2(error)) {
346
+ if (isNotFound(error)) {
373
347
  return { usage: EMPTY_PROJECT_USAGE };
374
348
  }
375
349
  throw error;
376
350
  }
377
351
  }
378
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
379
354
  return {
380
355
  ...current,
381
356
  [source]: addTokenUsage(current[source], delta),
382
- rollover: addRolloverUsage(current.rollover, delta, now)
357
+ // Both: the combined bucket stays the total it always was, and the
358
+ // per-source split is what lets the dashboard attribute it.
359
+ rollover: addRolloverUsage(current.rollover, delta, now),
360
+ rolloverBySource: {
361
+ ...bySource,
362
+ [source]: addRolloverUsage(bySource[source] ?? ZERO_ROLLOVER_USAGE, delta, now)
363
+ }
383
364
  };
384
365
  }
385
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
386
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
387
371
  await retryOnConflict(async () => {
388
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
389
373
  const updated = addUsage(current, source, delta, now);
@@ -398,10 +382,78 @@ async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */
398
382
  });
399
383
  });
400
384
  }
385
+ function isNotFound(error) {
386
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
387
+ }
388
+
389
+ // ../../modules/devlore/dist/decisionEntries.js
390
+ function decisionEntriesDir(projectName) {
391
+ return `projects/${projectName}/decisions`;
392
+ }
393
+ async function listDecisionEntries(octokit, { owner, repo, projectName }) {
394
+ const dir = decisionEntriesDir(projectName);
395
+ let files;
396
+ try {
397
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dir });
398
+ if (!Array.isArray(data)) {
399
+ return [];
400
+ }
401
+ files = data;
402
+ } catch (error) {
403
+ if (isNotFound2(error)) {
404
+ return [];
405
+ }
406
+ throw error;
407
+ }
408
+ const entries = await Promise.all(files.filter((file) => file.type === "file").map(async (file) => {
409
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path: file.path });
410
+ if (Array.isArray(data) || data.type !== "file") {
411
+ return null;
412
+ }
413
+ return { path: file.path, content: Buffer.from(data.content, "base64").toString("utf-8") };
414
+ }));
415
+ return entries.filter((entry) => entry !== null).sort((a, b) => a.path.localeCompare(b.path));
416
+ }
401
417
  function isNotFound2(error) {
402
418
  return typeof error === "object" && error !== null && "status" in error && error.status === 404;
403
419
  }
404
420
 
421
+ // ../../modules/devlore/dist/prompt.js
422
+ var SUPERSEDED_GUIDANCE = [
423
+ "These documents record how the thinking changed over time, so they contain claims that were later corrected, and the corrected version is still physically present.",
424
+ "**A correction outranks what it corrects.** Where two passages disagree, the later-dated one is what is true now. A passage struck through, marked superseded, described as decided against, or sitting under a heading that says the earlier view was wrong, records what was believed \u2014 never what is. Do not restate any of it as current."
425
+ ].join("\n");
426
+ var SUPERSEDED_GUIDANCE_WITH_LESSONS = [
427
+ SUPERSEDED_GUIDANCE,
428
+ "A reversed decision is still worth telling a newcomer about \u2014 but as something the project learned, never as a rule they should follow. Say what is true now first, and mention the earlier version only where the fact that it changed is itself the lesson."
429
+ ].join("\n");
430
+ function buildOnboardingGuidePrompt(input) {
431
+ return [
432
+ `Project: ${input.projectName}`,
433
+ "",
434
+ '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.',
435
+ "",
436
+ input.currentState ? `--- Current state ---
437
+ ${input.currentState}` : "",
438
+ input.canonicalDocs ? `--- Canonical docs ---
439
+ ${input.canonicalDocs}` : "",
440
+ input.decisions.length > 0 ? `--- Decisions ---
441
+ ${input.decisions.join("\n\n")}` : "",
442
+ "",
443
+ "Cover, in whatever order serves a newcomer best:",
444
+ "",
445
+ "- Where the important logic lives, and what to read first.",
446
+ "- Why the significant past decisions were made \u2014 a newcomer who does not know the reasoning will undo it.",
447
+ "- Common gotchas: the things that look wrong but are deliberate, and the mistakes this codebase has actually made before.",
448
+ "- How to get from a clean checkout to a working change.",
449
+ "",
450
+ "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.",
451
+ SUPERSEDED_GUIDANCE_WITH_LESSONS,
452
+ "",
453
+ "Return the guide only \u2014 no preamble, no meta-commentary."
454
+ ].filter((line) => line !== "").join("\n");
455
+ }
456
+
405
457
  // ../../modules/devlore/dist/canonicalDocs.js
406
458
  function canonicalDocsPath(projectName) {
407
459
  return `projects/${projectName}/canonical/README.md`;
@@ -511,11 +563,14 @@ async function syncOnboarding({ projectOctokit, vaultOctokit, claude, projectOwn
511
563
  ${text.trim()}
512
564
  `;
513
565
  const existing = await getExistingOnboarding(projectOctokit, { owner: projectOwner, repo: projectRepoName });
566
+ const releaseTag = releaseTagFromEnvironment();
567
+ const wrote = existing ? "Update" : "Add";
568
+ const message = releaseTag ? `${wrote} onboarding guide (release ${releaseTag})` : `${wrote} onboarding guide`;
514
569
  await projectOctokit.rest.repos.createOrUpdateFileContents({
515
570
  owner: projectOwner,
516
571
  repo: projectRepoName,
517
572
  path: ONBOARDING_PATH,
518
- message: existing ? "Update onboarding guide" : "Add onboarding guide",
573
+ message,
519
574
  content: Buffer.from(content, "utf-8").toString("base64"),
520
575
  sha: existing?.sha
521
576
  });
@@ -531,7 +586,7 @@ ${text.trim()}
531
586
  owner: vaultOwner,
532
587
  repo: vaultRepoName,
533
588
  path: vaultPath,
534
- message: `Mirror onboarding guide: ${projectName}`,
589
+ message: releaseTag ? `Mirror onboarding guide: ${projectName} (release ${releaseTag})` : `Mirror onboarding guide: ${projectName}`,
535
590
  content: Buffer.from(mirrored, "utf-8").toString("base64"),
536
591
  branch: VAULT_BRANCH2,
537
592
  sha: existingVault.sha
@@ -542,6 +597,12 @@ ${text.trim()}
542
597
  }
543
598
  return { path: ONBOARDING_PATH, vaultPath, content, usage };
544
599
  }
600
+ function releaseTagFromEnvironment() {
601
+ if (process.env.GITHUB_REF_TYPE !== "tag")
602
+ return null;
603
+ const name = process.env.GITHUB_REF_NAME?.trim();
604
+ return name ? name : null;
605
+ }
545
606
 
546
607
  // ../../modules/devlore/dist/bin/syncOnboardingCli.js
547
608
  function requireEnv(env, name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starterculture/devkeep-actions",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Devkeep's GitHub Actions entrypoints, bundled for CI runners.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",