@starterculture/devkeep-actions 0.5.0 → 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) {
@@ -232,17 +264,15 @@ function currentRunUrl(env) {
232
264
  // ../../modules/core/dist/ciEntrypoint.js
233
265
  var VAULT_BRANCH = "main";
234
266
  function healthParams(env) {
235
- const projectName = env.PROJECT_NAME;
236
- const vaultRepo = env.VAULT_REPO;
237
- const token = env.DEVKEEP_GITHUB_TOKEN;
238
- if (!projectName || !vaultRepo || !token) {
239
- return null;
267
+ const missing = ["PROJECT_NAME", "VAULT_REPO", "DEVKEEP_GITHUB_TOKEN"].filter((name) => !env[name]);
268
+ if (missing.length > 0) {
269
+ return { missing: [...missing] };
240
270
  }
241
- const [owner, repo] = vaultRepo.split("/");
271
+ const [owner, repo] = env.VAULT_REPO.split("/");
242
272
  if (!owner || !repo) {
243
- return null;
273
+ return { missing: ["VAULT_REPO (expected owner/repo)"] };
244
274
  }
245
- return { owner, repo, projectName, branch: VAULT_BRANCH };
275
+ return { params: { owner, repo, projectName: env.PROJECT_NAME, branch: VAULT_BRANCH } };
246
276
  }
247
277
  var defaultDeps = {
248
278
  env: process.env,
@@ -253,7 +283,11 @@ var defaultDeps = {
253
283
  exit: (code) => process.exit(code)
254
284
  };
255
285
  async function runCiEntrypoint(source, main2, deps = defaultDeps) {
256
- const params = healthParams(deps.env);
286
+ const resolved = healthParams(deps.env);
287
+ const params = "params" in resolved ? resolved.params : null;
288
+ if (!params) {
289
+ deps.logError(`Health not recorded for "${source}": ${resolved.missing.join(", ")} not set.`);
290
+ }
257
291
  const octokit = params ? deps.createGithubClient(deps.env.DEVKEEP_GITHUB_TOKEN) : null;
258
292
  try {
259
293
  await main2();
@@ -278,7 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
278
312
  }
279
313
  }
280
314
 
281
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
282
316
  var EMPTY_PROJECT_USAGE = {
283
317
  draftLogEntry: ZERO_USAGE,
284
318
  consolidateRelease: ZERO_USAGE,
@@ -289,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
289
323
  syncOnboarding: ZERO_USAGE,
290
324
  releaseNotes: ZERO_USAGE,
291
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
292
327
  rollover: ZERO_ROLLOVER_USAGE
293
328
  };
294
329
  function usagePath(projectName) {
@@ -315,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
315
350
  }
316
351
  }
317
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
318
354
  return {
319
355
  ...current,
320
356
  [source]: addTokenUsage(current[source], delta),
321
- 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
+ }
322
364
  };
323
365
  }
324
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
325
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
326
371
  await retryOnConflict(async () => {
327
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
328
373
  const updated = addUsage(current, source, delta, now);
@@ -374,6 +419,14 @@ function isNotFound2(error) {
374
419
  }
375
420
 
376
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");
377
430
  function buildConsolidationPrompt(input) {
378
431
  const entriesText = input.logEntries.map((entry) => `--- ${entry.path} ---
379
432
  ${entry.content}`).join("\n\n");
@@ -129,17 +129,15 @@ function currentRunUrl(env) {
129
129
  // ../../modules/core/dist/ciEntrypoint.js
130
130
  var VAULT_BRANCH = "main";
131
131
  function healthParams(env) {
132
- const projectName = env.PROJECT_NAME;
133
- const vaultRepo = env.VAULT_REPO;
134
- const token = env.DEVKEEP_GITHUB_TOKEN;
135
- if (!projectName || !vaultRepo || !token) {
136
- return null;
132
+ const missing = ["PROJECT_NAME", "VAULT_REPO", "DEVKEEP_GITHUB_TOKEN"].filter((name) => !env[name]);
133
+ if (missing.length > 0) {
134
+ return { missing: [...missing] };
137
135
  }
138
- const [owner, repo] = vaultRepo.split("/");
136
+ const [owner, repo] = env.VAULT_REPO.split("/");
139
137
  if (!owner || !repo) {
140
- return null;
138
+ return { missing: ["VAULT_REPO (expected owner/repo)"] };
141
139
  }
142
- return { owner, repo, projectName, branch: VAULT_BRANCH };
140
+ return { params: { owner, repo, projectName: env.PROJECT_NAME, branch: VAULT_BRANCH } };
143
141
  }
144
142
  var defaultDeps = {
145
143
  env: process.env,
@@ -150,7 +148,11 @@ var defaultDeps = {
150
148
  exit: (code) => process.exit(code)
151
149
  };
152
150
  async function runCiEntrypoint(source, main2, deps = defaultDeps) {
153
- const params = healthParams(deps.env);
151
+ const resolved = healthParams(deps.env);
152
+ const params = "params" in resolved ? resolved.params : null;
153
+ if (!params) {
154
+ deps.logError(`Health not recorded for "${source}": ${resolved.missing.join(", ")} not set.`);
155
+ }
154
156
  const octokit = params ? deps.createGithubClient(deps.env.DEVKEEP_GITHUB_TOKEN) : null;
155
157
  try {
156
158
  await main2();
@@ -497,7 +499,12 @@ async function main(deps = defaultDeps2) {
497
499
  const headSha = requireEnv(env, "HEAD_SHA");
498
500
  const projectOctokit = deps.createGithubClient(requireEnv(env, "GITHUB_TOKEN"));
499
501
  const vaultOctokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
500
- 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");
501
508
  const result = await deps.scanCryptingCandidates({
502
509
  projectOctokit,
503
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) {
@@ -232,17 +264,15 @@ function currentRunUrl(env) {
232
264
  // ../../modules/core/dist/ciEntrypoint.js
233
265
  var VAULT_BRANCH = "main";
234
266
  function healthParams(env) {
235
- const projectName = env.PROJECT_NAME;
236
- const vaultRepo = env.VAULT_REPO;
237
- const token = env.DEVKEEP_GITHUB_TOKEN;
238
- if (!projectName || !vaultRepo || !token) {
239
- return null;
267
+ const missing = ["PROJECT_NAME", "VAULT_REPO", "DEVKEEP_GITHUB_TOKEN"].filter((name) => !env[name]);
268
+ if (missing.length > 0) {
269
+ return { missing: [...missing] };
240
270
  }
241
- const [owner, repo] = vaultRepo.split("/");
271
+ const [owner, repo] = env.VAULT_REPO.split("/");
242
272
  if (!owner || !repo) {
243
- return null;
273
+ return { missing: ["VAULT_REPO (expected owner/repo)"] };
244
274
  }
245
- return { owner, repo, projectName, branch: VAULT_BRANCH };
275
+ return { params: { owner, repo, projectName: env.PROJECT_NAME, branch: VAULT_BRANCH } };
246
276
  }
247
277
  var defaultDeps = {
248
278
  env: process.env,
@@ -253,7 +283,11 @@ var defaultDeps = {
253
283
  exit: (code) => process.exit(code)
254
284
  };
255
285
  async function runCiEntrypoint(source, main2, deps = defaultDeps) {
256
- const params = healthParams(deps.env);
286
+ const resolved = healthParams(deps.env);
287
+ const params = "params" in resolved ? resolved.params : null;
288
+ if (!params) {
289
+ deps.logError(`Health not recorded for "${source}": ${resolved.missing.join(", ")} not set.`);
290
+ }
257
291
  const octokit = params ? deps.createGithubClient(deps.env.DEVKEEP_GITHUB_TOKEN) : null;
258
292
  try {
259
293
  await main2();
@@ -278,7 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
278
312
  }
279
313
  }
280
314
 
281
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
282
316
  var EMPTY_PROJECT_USAGE = {
283
317
  draftLogEntry: ZERO_USAGE,
284
318
  consolidateRelease: ZERO_USAGE,
@@ -289,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
289
323
  syncOnboarding: ZERO_USAGE,
290
324
  releaseNotes: ZERO_USAGE,
291
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
292
327
  rollover: ZERO_ROLLOVER_USAGE
293
328
  };
294
329
  function usagePath(projectName) {
@@ -315,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
315
350
  }
316
351
  }
317
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
318
354
  return {
319
355
  ...current,
320
356
  [source]: addTokenUsage(current[source], delta),
321
- 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
+ }
322
364
  };
323
365
  }
324
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
325
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
326
371
  await retryOnConflict(async () => {
327
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
328
373
  const updated = addUsage(current, source, delta, now);
@@ -501,6 +546,14 @@ function isNotFound3(error) {
501
546
 
502
547
  // ../../modules/devlore/dist/prompt.js
503
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");
504
557
  function buildLogEntryPrompt(input) {
505
558
  const diffText = input.files.map((file) => `--- ${file.filename} (${file.status}) ---
506
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) {
@@ -232,17 +264,15 @@ function currentRunUrl(env) {
232
264
  // ../../modules/core/dist/ciEntrypoint.js
233
265
  var VAULT_BRANCH = "main";
234
266
  function healthParams(env) {
235
- const projectName = env.PROJECT_NAME;
236
- const vaultRepo = env.VAULT_REPO;
237
- const token = env.DEVKEEP_GITHUB_TOKEN;
238
- if (!projectName || !vaultRepo || !token) {
239
- return null;
267
+ const missing = ["PROJECT_NAME", "VAULT_REPO", "DEVKEEP_GITHUB_TOKEN"].filter((name) => !env[name]);
268
+ if (missing.length > 0) {
269
+ return { missing: [...missing] };
240
270
  }
241
- const [owner, repo] = vaultRepo.split("/");
271
+ const [owner, repo] = env.VAULT_REPO.split("/");
242
272
  if (!owner || !repo) {
243
- return null;
273
+ return { missing: ["VAULT_REPO (expected owner/repo)"] };
244
274
  }
245
- return { owner, repo, projectName, branch: VAULT_BRANCH };
275
+ return { params: { owner, repo, projectName: env.PROJECT_NAME, branch: VAULT_BRANCH } };
246
276
  }
247
277
  var defaultDeps = {
248
278
  env: process.env,
@@ -253,7 +283,11 @@ var defaultDeps = {
253
283
  exit: (code) => process.exit(code)
254
284
  };
255
285
  async function runCiEntrypoint(source, main2, deps = defaultDeps) {
256
- const params = healthParams(deps.env);
286
+ const resolved = healthParams(deps.env);
287
+ const params = "params" in resolved ? resolved.params : null;
288
+ if (!params) {
289
+ deps.logError(`Health not recorded for "${source}": ${resolved.missing.join(", ")} not set.`);
290
+ }
257
291
  const octokit = params ? deps.createGithubClient(deps.env.DEVKEEP_GITHUB_TOKEN) : null;
258
292
  try {
259
293
  await main2();
@@ -278,30 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
278
312
  }
279
313
  }
280
314
 
281
- // ../../modules/devlore/dist/prompt.js
282
- function buildReleaseNotesPrompt(input) {
283
- return [
284
- `Project: ${input.projectName}`,
285
- `Release: ${input.tag}`,
286
- 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.",
287
- "",
288
- "Below are the decision log entries Devkeep recorded for the work in this release.",
289
- "",
290
- ...input.logEntries,
291
- "",
292
- "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.",
293
- "",
294
- "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.",
295
- "",
296
- '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.',
297
- "",
298
- "Do NOT include a top-level heading \u2014 one is added automatically. Start directly with the content.",
299
- "",
300
- "Return the notes only \u2014 no preamble, no meta-commentary."
301
- ].join("\n");
302
- }
303
-
304
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
305
316
  var EMPTY_PROJECT_USAGE = {
306
317
  draftLogEntry: ZERO_USAGE,
307
318
  consolidateRelease: ZERO_USAGE,
@@ -312,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
312
323
  syncOnboarding: ZERO_USAGE,
313
324
  releaseNotes: ZERO_USAGE,
314
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
315
327
  rollover: ZERO_ROLLOVER_USAGE
316
328
  };
317
329
  function usagePath(projectName) {
@@ -338,14 +350,24 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
338
350
  }
339
351
  }
340
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
341
354
  return {
342
355
  ...current,
343
356
  [source]: addTokenUsage(current[source], delta),
344
- 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
+ }
345
364
  };
346
365
  }
347
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
348
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
349
371
  await retryOnConflict(async () => {
350
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
351
373
  const updated = addUsage(current, source, delta, now);
@@ -364,6 +386,37 @@ function isNotFound(error) {
364
386
  return typeof error === "object" && error !== null && "status" in error && error.status === 404;
365
387
  }
366
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
+
367
420
  // ../../modules/devlore/dist/logEntries.js
368
421
  function logEntriesDir(projectName) {
369
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) {
@@ -232,17 +264,15 @@ function currentRunUrl(env) {
232
264
  // ../../modules/core/dist/ciEntrypoint.js
233
265
  var VAULT_BRANCH = "main";
234
266
  function healthParams(env) {
235
- const projectName = env.PROJECT_NAME;
236
- const vaultRepo = env.VAULT_REPO;
237
- const token = env.DEVKEEP_GITHUB_TOKEN;
238
- if (!projectName || !vaultRepo || !token) {
239
- return null;
267
+ const missing = ["PROJECT_NAME", "VAULT_REPO", "DEVKEEP_GITHUB_TOKEN"].filter((name) => !env[name]);
268
+ if (missing.length > 0) {
269
+ return { missing: [...missing] };
240
270
  }
241
- const [owner, repo] = vaultRepo.split("/");
271
+ const [owner, repo] = env.VAULT_REPO.split("/");
242
272
  if (!owner || !repo) {
243
- return null;
273
+ return { missing: ["VAULT_REPO (expected owner/repo)"] };
244
274
  }
245
- return { owner, repo, projectName, branch: VAULT_BRANCH };
275
+ return { params: { owner, repo, projectName: env.PROJECT_NAME, branch: VAULT_BRANCH } };
246
276
  }
247
277
  var defaultDeps = {
248
278
  env: process.env,
@@ -253,7 +283,11 @@ var defaultDeps = {
253
283
  exit: (code) => process.exit(code)
254
284
  };
255
285
  async function runCiEntrypoint(source, main2, deps = defaultDeps) {
256
- const params = healthParams(deps.env);
286
+ const resolved = healthParams(deps.env);
287
+ const params = "params" in resolved ? resolved.params : null;
288
+ if (!params) {
289
+ deps.logError(`Health not recorded for "${source}": ${resolved.missing.join(", ")} not set.`);
290
+ }
257
291
  const octokit = params ? deps.createGithubClient(deps.env.DEVKEEP_GITHUB_TOKEN) : null;
258
292
  try {
259
293
  await main2();
@@ -278,66 +312,7 @@ async function runCiEntrypoint(source, main2, deps = defaultDeps) {
278
312
  }
279
313
  }
280
314
 
281
- // ../../modules/devlore/dist/decisionEntries.js
282
- function decisionEntriesDir(projectName) {
283
- return `projects/${projectName}/decisions`;
284
- }
285
- async function listDecisionEntries(octokit, { owner, repo, projectName }) {
286
- const dir = decisionEntriesDir(projectName);
287
- let files;
288
- try {
289
- const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dir });
290
- if (!Array.isArray(data)) {
291
- return [];
292
- }
293
- files = data;
294
- } catch (error) {
295
- if (isNotFound(error)) {
296
- return [];
297
- }
298
- throw error;
299
- }
300
- const entries = await Promise.all(files.filter((file) => file.type === "file").map(async (file) => {
301
- const { data } = await octokit.rest.repos.getContent({ owner, repo, path: file.path });
302
- if (Array.isArray(data) || data.type !== "file") {
303
- return null;
304
- }
305
- return { path: file.path, content: Buffer.from(data.content, "base64").toString("utf-8") };
306
- }));
307
- return entries.filter((entry) => entry !== null).sort((a, b) => a.path.localeCompare(b.path));
308
- }
309
- function isNotFound(error) {
310
- return typeof error === "object" && error !== null && "status" in error && error.status === 404;
311
- }
312
-
313
- // ../../modules/devlore/dist/prompt.js
314
- function buildOnboardingGuidePrompt(input) {
315
- return [
316
- `Project: ${input.projectName}`,
317
- "",
318
- '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.',
319
- "",
320
- input.currentState ? `--- Current state ---
321
- ${input.currentState}` : "",
322
- input.canonicalDocs ? `--- Canonical docs ---
323
- ${input.canonicalDocs}` : "",
324
- input.decisions.length > 0 ? `--- Decisions ---
325
- ${input.decisions.join("\n\n")}` : "",
326
- "",
327
- "Cover, in whatever order serves a newcomer best:",
328
- "",
329
- "- Where the important logic lives, and what to read first.",
330
- "- Why the significant past decisions were made \u2014 a newcomer who does not know the reasoning will undo it.",
331
- "- Common gotchas: the things that look wrong but are deliberate, and the mistakes this codebase has actually made before.",
332
- "- How to get from a clean checkout to a working change.",
333
- "",
334
- "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.",
335
- "",
336
- "Return the guide only \u2014 no preamble, no meta-commentary."
337
- ].filter((line) => line !== "").join("\n");
338
- }
339
-
340
- // ../../modules/devlore/dist/ciUsage.js
315
+ // ../../modules/core/dist/ciUsage.js
341
316
  var EMPTY_PROJECT_USAGE = {
342
317
  draftLogEntry: ZERO_USAGE,
343
318
  consolidateRelease: ZERO_USAGE,
@@ -348,6 +323,7 @@ var EMPTY_PROJECT_USAGE = {
348
323
  syncOnboarding: ZERO_USAGE,
349
324
  releaseNotes: ZERO_USAGE,
350
325
  captureBaseline: ZERO_USAGE,
326
+ devseerReview: ZERO_USAGE,
351
327
  rollover: ZERO_ROLLOVER_USAGE
352
328
  };
353
329
  function usagePath(projectName) {
@@ -367,21 +343,31 @@ async function getUsageFile(octokit, { owner, repo, projectName }) {
367
343
  const usage = { ...EMPTY_PROJECT_USAGE, ...parsed };
368
344
  return { usage, sha: data.sha };
369
345
  } catch (error) {
370
- if (isNotFound2(error)) {
346
+ if (isNotFound(error)) {
371
347
  return { usage: EMPTY_PROJECT_USAGE };
372
348
  }
373
349
  throw error;
374
350
  }
375
351
  }
376
352
  function addUsage(current, source, delta, now = /* @__PURE__ */ new Date()) {
353
+ const bySource = current.rolloverBySource ?? {};
377
354
  return {
378
355
  ...current,
379
356
  [source]: addTokenUsage(current[source], delta),
380
- 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
+ }
381
364
  };
382
365
  }
383
366
  async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */ new Date()) {
384
367
  const { owner, repo, projectName, branch } = params;
368
+ if (delta.inputTokens === 0 && delta.outputTokens === 0) {
369
+ return;
370
+ }
385
371
  await retryOnConflict(async () => {
386
372
  const { usage: current, sha } = await getUsageFile(octokit, { owner, repo, projectName });
387
373
  const updated = addUsage(current, source, delta, now);
@@ -396,10 +382,78 @@ async function recordUsage(octokit, params, source, delta, now = /* @__PURE__ */
396
382
  });
397
383
  });
398
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
+ }
399
417
  function isNotFound2(error) {
400
418
  return typeof error === "object" && error !== null && "status" in error && error.status === 404;
401
419
  }
402
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
+
403
457
  // ../../modules/devlore/dist/canonicalDocs.js
404
458
  function canonicalDocsPath(projectName) {
405
459
  return `projects/${projectName}/canonical/README.md`;
@@ -509,11 +563,14 @@ async function syncOnboarding({ projectOctokit, vaultOctokit, claude, projectOwn
509
563
  ${text.trim()}
510
564
  `;
511
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`;
512
569
  await projectOctokit.rest.repos.createOrUpdateFileContents({
513
570
  owner: projectOwner,
514
571
  repo: projectRepoName,
515
572
  path: ONBOARDING_PATH,
516
- message: existing ? "Update onboarding guide" : "Add onboarding guide",
573
+ message,
517
574
  content: Buffer.from(content, "utf-8").toString("base64"),
518
575
  sha: existing?.sha
519
576
  });
@@ -529,7 +586,7 @@ ${text.trim()}
529
586
  owner: vaultOwner,
530
587
  repo: vaultRepoName,
531
588
  path: vaultPath,
532
- message: `Mirror onboarding guide: ${projectName}`,
589
+ message: releaseTag ? `Mirror onboarding guide: ${projectName} (release ${releaseTag})` : `Mirror onboarding guide: ${projectName}`,
533
590
  content: Buffer.from(mirrored, "utf-8").toString("base64"),
534
591
  branch: VAULT_BRANCH2,
535
592
  sha: existingVault.sha
@@ -540,6 +597,12 @@ ${text.trim()}
540
597
  }
541
598
  return { path: ONBOARDING_PATH, vaultPath, content, usage };
542
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
+ }
543
606
 
544
607
  // ../../modules/devlore/dist/bin/syncOnboardingCli.js
545
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.0",
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",