forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5

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.
Files changed (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -242,6 +242,21 @@ function legacyJsonlPath(projectRoot) {
242
242
  return path.join(projectRoot, ...LEGACY_JSONL_RELATIVE);
243
243
  }
244
244
 
245
+ function legacyTags(parsed) {
246
+ const tags = Array.isArray(parsed.tags)
247
+ ? parsed.tags.filter(tag => typeof tag === 'string')
248
+ : [];
249
+ if (
250
+ typeof parsed.type === 'string'
251
+ && /^[a-z0-9][a-z0-9-]{0,63}$/i.test(parsed.type)
252
+ && !tags.some(tag => tag.startsWith('type:'))
253
+ ) {
254
+ tags.push(`type:${parsed.type.toLowerCase()}`);
255
+ }
256
+ if (!tags.some(tag => tag.startsWith('trust:'))) tags.push('trust:suggested');
257
+ return tags;
258
+ }
259
+
245
260
  // A STABLE key for a legacy record that lacks an `id`, derived from its content — so a
246
261
  // re-run (or a failed rename) upserts the same row instead of double-inserting under a
247
262
  // fresh random UUID.
@@ -292,7 +307,7 @@ function migrateJsonlNotesOnce(projectRoot, options = {}) {
292
307
  key: typeof parsed.id === 'string' && parsed.id ? parsed.id : legacyContentKey(parsed),
293
308
  value: parsed.note,
294
309
  sourceAgent: IMPORT_SOURCE_AGENT,
295
- tags: Array.isArray(parsed.tags) ? parsed.tags.filter(tag => typeof tag === 'string') : [],
310
+ tags: legacyTags(parsed),
296
311
  };
297
312
  if (typeof parsed.timestamp === 'string' && parsed.timestamp && !Number.isNaN(Date.parse(parsed.timestamp))) {
298
313
  entry.timestamp = parsed.timestamp;
@@ -22,6 +22,7 @@
22
22
  const { applyBudget, buildSection, estimateTokens } = require('./orientation');
23
23
  const { fenceUntrusted } = require('./untrusted-content');
24
24
  const { collectInbox, inboxSection } = require('./inbox');
25
+ const { memoryTrustStatus } = require('./memory-recall');
25
26
 
26
27
  const DEFAULT_DIGEST_BUDGET_TOKENS = 400;
27
28
  const DEFAULT_NOTE_LIMIT = 5;
@@ -103,7 +104,13 @@ async function collectDigestData(projectRoot, opts = {}) {
103
104
  /** `- [date ]note` for a recall note. */
104
105
  function formatNoteLine(note) {
105
106
  const date = typeof note.timestamp === 'string' && note.timestamp ? `${note.timestamp.slice(0, 10)} ` : '';
106
- return `- ${date}${note.note}`;
107
+ const trust = memoryTrustStatus({
108
+ tags: note.tags,
109
+ sourceAgent: note.sourceAgent,
110
+ value: note.machine ? {} : note.note,
111
+ });
112
+ const sourceAgent = note.sourceAgent || 'unknown';
113
+ return `- [source=${sourceAgent} trust=${trust} updated=${date.trim() || 'unknown'}] ${note.note}`;
107
114
  }
108
115
 
109
116
  /** `- [label] title` for an issue row (title/id defensively resolved). */
@@ -112,18 +119,39 @@ function formatIssueLine(label, issue) {
112
119
  return `- [${label}] ${title}`;
113
120
  }
114
121
 
115
- /** Build the notes section, or null when there are no notes. */
116
- function notesSection(notes) {
117
- if (!notes.length) return null;
118
- return buildSection({
119
- id: 'digest_notes',
120
- title: 'Remembered notes',
121
- content: notes.map(formatNoteLine).join('\n'),
122
- priority: 10,
123
- preserve: false,
124
- // Untrusted: a planted note is DATA, not instructions. Fenced after truncation.
125
- untrustedSource: 'memory',
126
- });
122
+ /** Build separate confirmed/suggested note sections, skipping entries too large to fit. */
123
+ function notesSections(notes, budgetTokens) {
124
+ const eligible = notes
125
+ .map(note => ({ note, line: formatNoteLine(note) }))
126
+ .filter(entry => estimateTokens(entry.line) <= budgetTokens);
127
+ const groups = [
128
+ { id: 'digest_notes', title: 'Confirmed memory', trust: 'confirmed', priority: 10 },
129
+ {
130
+ id: 'digest_suggested_memory',
131
+ title: 'Suggested memory — verify before relying',
132
+ trust: 'suggested',
133
+ priority: 11,
134
+ },
135
+ ];
136
+ return groups.map(group => {
137
+ const content = eligible
138
+ .filter(({ note }) => memoryTrustStatus({
139
+ tags: note.tags,
140
+ sourceAgent: note.sourceAgent,
141
+ value: note.machine ? {} : note.note,
142
+ }) === group.trust)
143
+ .map(entry => entry.line)
144
+ .join('\n');
145
+ if (!content) return null;
146
+ return buildSection({
147
+ id: group.id,
148
+ title: group.title,
149
+ content,
150
+ priority: group.priority,
151
+ preserve: false,
152
+ untrustedSource: 'memory',
153
+ });
154
+ }).filter(Boolean);
127
155
  }
128
156
 
129
157
  /**
@@ -161,13 +189,17 @@ function buildMemoryDigest(data = {}, options = {}) {
161
189
  const ready = Array.isArray(data.ready) ? data.ready : [];
162
190
  const claimed = Array.isArray(data.claimed) ? data.claimed : [];
163
191
  const inbox = Array.isArray(data.inbox) ? data.inbox : [];
192
+ const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
164
193
 
165
194
  // Inbox (priority 5) is a THIRD section beside notes + issues; a fresh human directive
166
195
  // outranks stale notes (10) and the agent's own issue list (20) under budget pressure.
167
- const sections = [inboxSection(inbox), notesSection(notes), issuesSection(ready, claimed)].filter(Boolean);
196
+ const sections = [
197
+ inboxSection(inbox),
198
+ ...notesSections(notes, budgetTokens),
199
+ issuesSection(ready, claimed),
200
+ ].filter(Boolean);
168
201
  if (!sections.length) return { text: '', empty: true, tokens: 0 };
169
202
 
170
- const budgetTokens = options.budgetTokens || DEFAULT_DIGEST_BUDGET_TOKENS;
171
203
  const budgeted = applyBudget(sections, budgetTokens);
172
204
  const body = budgeted.sections
173
205
  .filter(section => section.content)
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('node:crypto');
4
+ const { spawn } = require('node:child_process');
5
+
6
+ const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
7
+ const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
8
+ const projectMemory = require('./project-memory');
9
+
10
+ const EVENT_TYPE = 'memory.recall.observed';
11
+ const OUTCOMES = new Set(['selected', 'empty', 'filtered', 'unsupported', 'timeout', 'error']);
12
+ const MAX_COUNT = 1_000_000;
13
+ const MAX_SELECTED_IDS = 20;
14
+ const MAX_MIX_KEYS = 10;
15
+ const EVENT_WRITER = "const { recordMemoryRecallPayload } = require(process.argv[1]); recordMemoryRecallPayload(process.argv[2], JSON.parse(process.argv[3])).catch(() => {});";
16
+
17
+ function boundedInteger(value) {
18
+ if (!Number.isFinite(value)) return 0;
19
+ return Math.min(MAX_COUNT, Math.max(0, Math.trunc(value)));
20
+ }
21
+
22
+ function boundedString(value, maxLength) {
23
+ return String(value ?? '').slice(0, maxLength);
24
+ }
25
+
26
+ function boundedMix(mix) {
27
+ if (!mix || typeof mix !== 'object' || Array.isArray(mix)) return {};
28
+ return Object.fromEntries(
29
+ Object.entries(mix)
30
+ .slice(0, MAX_MIX_KEYS)
31
+ .map(([label, count]) => [boundedString(label, 64), boundedInteger(count)])
32
+ .filter(([label, count]) => label && count > 0)
33
+ );
34
+ }
35
+
36
+ function buildRecallEventPayload(observation = {}) {
37
+ const selectedIds = Array.isArray(observation.selectedIds)
38
+ ? observation.selectedIds
39
+ .slice(0, MAX_SELECTED_IDS)
40
+ .map(id => boundedString(id, 128))
41
+ .filter(Boolean)
42
+ : [];
43
+ return {
44
+ outcome: OUTCOMES.has(observation.outcome) ? observation.outcome : 'error',
45
+ counts: {
46
+ candidates: boundedInteger(observation.candidateCount),
47
+ eligible: boundedInteger(observation.eligibleCount),
48
+ selected: selectedIds.length,
49
+ },
50
+ selected_ids: selectedIds,
51
+ source_mix: boundedMix(observation.sourceMix),
52
+ trust_mix: boundedMix(observation.trustMix),
53
+ token_estimate: boundedInteger(observation.tokenEstimate),
54
+ elapsed_ms: boundedInteger(observation.elapsedMs),
55
+ harness: boundedString(observation.harness || 'unknown', 32),
56
+ };
57
+ }
58
+
59
+ function normalizeRecallEventPayload(payload = {}) {
60
+ return buildRecallEventPayload({
61
+ outcome: payload.outcome,
62
+ candidateCount: payload.counts?.candidates,
63
+ eligibleCount: payload.counts?.eligible,
64
+ selectedIds: payload.selected_ids,
65
+ sourceMix: payload.source_mix,
66
+ trustMix: payload.trust_mix,
67
+ tokenEstimate: payload.token_estimate,
68
+ elapsedMs: payload.elapsed_ms,
69
+ harness: payload.harness,
70
+ });
71
+ }
72
+
73
+ async function recordMemoryRecallEvent(projectRoot, observation = {}, options = {}) {
74
+ return recordMemoryRecallPayload(projectRoot, buildRecallEventPayload(observation), options);
75
+ }
76
+
77
+ async function recordMemoryRecallPayload(projectRoot, payload, options = {}) {
78
+ let store = options.store;
79
+ let ownsStore = false;
80
+ try {
81
+ const safePayload = normalizeRecallEventPayload(payload);
82
+ const projectId = options.projectId || projectMemory.resolveProjectId(projectRoot, options);
83
+ if (!store) {
84
+ store = createBuiltinSQLiteDriver({
85
+ databasePath: resolveKernelDatabasePath({
86
+ projectRoot,
87
+ gitCommonDir: options.gitCommonDir,
88
+ databasePath: options.databasePath,
89
+ }),
90
+ });
91
+ ownsStore = true;
92
+ }
93
+ const id = (options.randomUUID || randomUUID)();
94
+ const createdAt = options.now || new Date().toISOString();
95
+ await store.insertKernelEvent({
96
+ id,
97
+ entity_type: 'project',
98
+ entity_id: projectId,
99
+ event_type: EVENT_TYPE,
100
+ idempotency_key: id,
101
+ expected_revision: 0,
102
+ actor: 'forge',
103
+ origin: 'hook',
104
+ payload: safePayload,
105
+ created_at: createdAt,
106
+ });
107
+ return { recorded: true, eventId: id };
108
+ } catch (error) {
109
+ return {
110
+ recorded: false,
111
+ reason: error && error.message ? error.message : 'telemetry unavailable',
112
+ };
113
+ } finally {
114
+ if (ownsStore && store && typeof store.close === 'function') {
115
+ try {
116
+ store.close();
117
+ } catch {
118
+ // Best-effort evidence must never block recall.
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ function launchMemoryRecallEvent(projectRoot, observation = {}, options = {}) {
125
+ try {
126
+ const child = (options.spawn || spawn)(
127
+ process.execPath,
128
+ ['-e', EVENT_WRITER, __filename, projectRoot, JSON.stringify(buildRecallEventPayload(observation))],
129
+ { detached: true, stdio: 'ignore', windowsHide: true },
130
+ );
131
+ if (child && typeof child.on === 'function') child.on('error', () => {});
132
+ if (child && typeof child.unref === 'function') child.unref();
133
+ return { launched: true };
134
+ } catch {
135
+ return { launched: false };
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ EVENT_TYPE,
141
+ buildRecallEventPayload,
142
+ launchMemoryRecallEvent,
143
+ recordMemoryRecallEvent,
144
+ recordMemoryRecallPayload,
145
+ };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { fenceUntrusted } = require('./untrusted-content');
4
+
3
5
  /**
4
6
  * @module memory-recall
5
7
  *
@@ -50,6 +52,50 @@ function estimateTokens(text) {
50
52
  return Math.ceil(String(text || '').length / 4);
51
53
  }
52
54
 
55
+ function normalizeMemoryType(entry) {
56
+ const tags = Array.isArray(entry?.tags) ? entry.tags : [];
57
+ const tagged = tags.find(tag => /^type:[a-z0-9][a-z0-9-]{0,63}$/i.test(tag));
58
+ if (tagged) return tagged.slice('type:'.length).toLowerCase();
59
+ const category = entry?.value && typeof entry.value === 'object'
60
+ ? entry.value.category
61
+ : null;
62
+ if (typeof category === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/i.test(category)) {
63
+ return category.toLowerCase();
64
+ }
65
+ return entry?.value && typeof entry.value === 'object' ? 'machine-record' : 'note';
66
+ }
67
+
68
+ function memoryTrustStatus(entry) {
69
+ const tags = Array.isArray(entry?.tags) ? entry.tags : [];
70
+ const normalizedTags = tags.map(tag => String(tag).toLowerCase());
71
+ if (normalizedTags.includes('trust:confirmed')) return 'confirmed';
72
+ if (normalizedTags.some(tag => tag.startsWith('trust:')
73
+ || tag === 'forge:auto-capture')) return 'suggested';
74
+ if (entry?.sourceAgent === 'forge remember (imported)') return 'suggested';
75
+ if (entry?.value && typeof entry.value === 'object') return 'suggested';
76
+ if (entry?.sourceAgent === 'forge remember' && typeof entry.value === 'string') return 'confirmed';
77
+ return 'suggested';
78
+ }
79
+
80
+ function normalizeRecallHit(entry, projectId) {
81
+ if (!entry) return null;
82
+ if (entry.memory_id) return entry;
83
+ const sourceRefs = Array.isArray(entry.beadsRefs) ? entry.beadsRefs : [];
84
+ return {
85
+ memory_id: entry.key,
86
+ type: normalizeMemoryType(entry),
87
+ content: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value),
88
+ scope: !entry.scope || entry.scope === 'project' ? projectId : entry.scope,
89
+ trust_status: memoryTrustStatus(entry),
90
+ provenance: {
91
+ source_agent: entry.sourceAgent || '',
92
+ source_refs: sourceRefs,
93
+ },
94
+ updated_at: entry.timestamp,
95
+ score: entry.score,
96
+ };
97
+ }
98
+
53
99
  /**
54
100
  * Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
55
101
  * throws — any malformed input yields an empty prompt so the hook fails open.
@@ -99,7 +145,7 @@ function meaningfulTokens(query) {
99
145
  *
100
146
  * @param {object} args
101
147
  * @param {string} args.query — the submitted prompt
102
- * @param {Array<{key:string, value:string, score:number}>} args.hits — bm25-ordered
148
+ * @param {Array<{memory_id:string, content:string, score:number}>} args.hits — bm25-ordered
103
149
  * (best/lowest score first), already relevance-only (token-AND matched)
104
150
  * @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
105
151
  * stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
@@ -116,28 +162,40 @@ function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT
116
162
 
117
163
  const exclude = new Set(excludeKeys || []);
118
164
  const lines = [];
165
+ const entries = [];
119
166
  const injectedKeys = [];
167
+ const usedSections = new Set();
120
168
  let spent = 0;
121
169
 
122
170
  for (const hit of hits || []) {
123
- if (!hit || typeof hit.key !== 'string') continue;
124
- if (exclude.has(hit.key)) continue;
171
+ const memoryId = hit?.memory_id ?? hit?.key;
172
+ if (typeof memoryId !== 'string') continue;
173
+ if (exclude.has(memoryId)) continue;
125
174
  // Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
126
175
  if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
127
176
  continue;
128
177
  }
129
- const body = String(hit.value == null ? '' : hit.value);
130
- const cost = estimateTokens(body);
178
+ const body = String((hit.content ?? hit.value) == null ? '' : (hit.content ?? hit.value));
179
+ const trust = hit.trust_status === 'confirmed' ? 'confirmed' : 'suggested';
180
+ const source = String(hit.provenance?.source_agent || hit.sourceAgent || 'unknown').slice(0, 80);
181
+ const updated = String(hit.updated_at || hit.timestamp || 'unknown').slice(0, 40);
182
+ const labeled = `[trust=${trust} source=${source} updated=${updated}] ${body}`;
183
+ const line = fenceUntrusted(labeled, { source: 'memory' });
184
+ const heading = trust === 'confirmed'
185
+ ? 'Confirmed memory (project-local; provenance shown)'
186
+ : 'Suggested memory — verify before relying';
187
+ const cost = estimateTokens(line) + (usedSections.has(trust) ? 0 : estimateTokens(`${heading}\n`));
131
188
  if (spent + cost > tokenBudget) {
132
- // Budget exhausted; stop rather than skip-and-continue so the strongest fit.
133
- break;
189
+ continue;
134
190
  }
135
- lines.push(body);
136
- injectedKeys.push(hit.key);
191
+ lines.push(line);
192
+ entries.push({ trust, line });
193
+ injectedKeys.push(memoryId);
194
+ usedSections.add(trust);
137
195
  spent += cost;
138
196
  }
139
197
 
140
- return { lines, injectedKeys };
198
+ return { lines, entries, injectedKeys };
141
199
  }
142
200
 
143
201
  module.exports = {
@@ -145,6 +203,9 @@ module.exports = {
145
203
  DEFAULT_TOKEN_BUDGET,
146
204
  DEFAULT_SCORE_FLOOR,
147
205
  estimateTokens,
206
+ memoryTrustStatus,
207
+ normalizeMemoryType,
208
+ normalizeRecallHit,
148
209
  parseHookInput,
149
210
  meaningfulTokens,
150
211
  selectInjection,
@@ -54,7 +54,7 @@
54
54
  * (squash | merge | rebase); and post-merge branch deletion.
55
55
  */
56
56
 
57
- const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED', 'PASS', 'PASSED']);
57
+ const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
58
58
  const MS_PER_MIN = 60_000;
59
59
 
60
60
  /** Coerce an epoch-ms number, a Date, or an ISO string to epoch ms (NaN if unparseable). */
@@ -85,11 +85,15 @@ function toAccountList(arg) {
85
85
  return [String(arg).trim().toLowerCase()];
86
86
  }
87
87
 
88
- /** A CI check is green if its conclusion is a success-class conclusion. */
88
+ /** A CI check is green only when it is terminal and explicitly successful. */
89
89
  function isCheckGreen(check) {
90
90
  if (!check || typeof check !== 'object') return false;
91
- const conclusion = String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase();
92
- return SUCCESS_CONCLUSIONS.has(conclusion);
91
+ if (Object.prototype.hasOwnProperty.call(check, 'state')
92
+ && !Object.prototype.hasOwnProperty.call(check, 'status')) {
93
+ return String(check.state || '').toUpperCase() === 'SUCCESS';
94
+ }
95
+ return String(check.status || '').toUpperCase() === 'COMPLETED'
96
+ && SUCCESS_CONCLUSIONS.has(String(check.conclusion || '').toUpperCase());
93
97
  }
94
98
 
95
99
  /** The most recent comment by timestamp; ties and unparseable stamps fall back to array order. */