forge-workflow 0.1.0-beta.3 → 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 (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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
+ };
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ const { fenceUntrusted } = require('./untrusted-content');
4
+
5
+ /**
6
+ * @module memory-recall
7
+ *
8
+ * Pure selection core for the per-turn memory-recall hook (the query-relevant tier-2
9
+ * that complements the recency digest pushed at SessionStart). Kept free of stdin/fs so
10
+ * it is fully testable; lib/commands/hooks.js does the I/O wiring around it.
11
+ *
12
+ * Design constraints (verified against the Claude Code hooks contract + external memory
13
+ * research, kernel issue 781f6f65):
14
+ * - UserPromptSubmit additionalContext APPENDS to history every prompt, so a per-turn
15
+ * injector must stay tiny: a hard token budget, a relevance floor, and cross-turn
16
+ * dedupe. Below the bar -> inject NOTHING (silence is safe; a wrong memory at
17
+ * authority every turn is not).
18
+ * - Anaphora guard: a trivial query ("continue", "fix it") carries no retrieval signal,
19
+ * so ranking on it is worse than silence. Require a minimum of distinct content tokens.
20
+ * - Scope is a FILTER; relevance is the RANKER (bm25). Never sort by recency here — that
21
+ * is the recency digest's job, not tier-2's.
22
+ */
23
+
24
+ // A query needs at least this many distinct content tokens to be worth ranking on.
25
+ // Below it we treat the prompt as anaphora and inject nothing.
26
+ const MIN_QUERY_TOKENS = 2;
27
+
28
+ // Default token budget for the whole tier-2 injection. Deliberately small: it rides on
29
+ // EVERY prompt, and it must never starve the always-on SessionStart digest.
30
+ const DEFAULT_TOKEN_BUDGET = 400;
31
+
32
+ // Default relevance floor for the live hook path so it never runs floor-less. bm25 is
33
+ // more-negative-is-better, so 0 keeps every token-AND FTS match: the ACTIVE relevance gate
34
+ // today is the token-AND match plus the anaphora guard, and the numeric floor is a knob to
35
+ // be tightened (made negative) once shadow-logging measurement (781f6f65 step 0) shows where
36
+ // the corpus's relevant/irrelevant boundary sits. Named + wired so the default is explicit,
37
+ // not an accidental `undefined`.
38
+ const DEFAULT_SCORE_FLOOR = 0;
39
+
40
+ // Short/function words that carry no retrieval signal. Not exhaustive — just enough to
41
+ // stop pure anaphora ("do that now", "same for it") from clearing the guard.
42
+ const STOPWORDS = new Set([
43
+ 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at', 'by', 'is',
44
+ 'it', 'this', 'that', 'these', 'those', 'do', 'did', 'now', 'then', 'same', 'again',
45
+ 'continue', 'go', 'ok', 'okay', 'yes', 'no', 'fix', 'please', 'thanks', 'with', 'as',
46
+ 'we', 'i', 'you', 'he', 'she', 'they', 'them', 'his', 'her', 'my', 'our', 'your',
47
+ ]);
48
+
49
+ // Rough token estimate: ~4 chars/token, matching lib/memory-digest.js's convention so
50
+ // the two tiers budget on the same scale.
51
+ function estimateTokens(text) {
52
+ return Math.ceil(String(text || '').length / 4);
53
+ }
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
+
99
+ /**
100
+ * Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
101
+ * throws — any malformed input yields an empty prompt so the hook fails open.
102
+ *
103
+ * @param {string} raw
104
+ * @returns {{ prompt: string, sessionId: (string|null) }}
105
+ */
106
+ function parseHookInput(raw) {
107
+ try {
108
+ const parsed = JSON.parse(raw);
109
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
110
+ return { prompt: '', sessionId: null };
111
+ }
112
+ const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
113
+ const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id : null;
114
+ return { prompt, sessionId };
115
+ } catch {
116
+ return { prompt: '', sessionId: null };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Distinct content tokens in a query — lowercased, length >= 3, minus stopwords. The
122
+ * anaphora guard counts these; the FTS layer does its own tokenization for the actual match.
123
+ *
124
+ * @param {string} query
125
+ * @returns {string[]}
126
+ */
127
+ function meaningfulTokens(query) {
128
+ const seen = new Set();
129
+ // Unicode-aware split, matching the FTS tokenizer (/[\p{L}\p{N}]+/gu in the kernel driver)
130
+ // so non-Latin prompts (Cyrillic/CJK/accented) aren't silently stripped — otherwise the
131
+ // anaphora guard would disable recall for every non-Latin-script user.
132
+ for (const rawToken of String(query || '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
133
+ if (!rawToken) continue;
134
+ if (STOPWORDS.has(rawToken)) continue;
135
+ // The length>=3 filter suppresses ASCII noise ("it", "do"), but CJK words are 1-2 chars
136
+ // and any non-ASCII token is inherently content — keep those regardless of length.
137
+ if (rawToken.length < 3 && /^[a-z0-9]+$/.test(rawToken)) continue;
138
+ seen.add(rawToken);
139
+ }
140
+ return [...seen];
141
+ }
142
+
143
+ /**
144
+ * Choose which memories to inject this turn. PURE.
145
+ *
146
+ * @param {object} args
147
+ * @param {string} args.query — the submitted prompt
148
+ * @param {Array<{memory_id:string, content:string, score:number}>} args.hits — bm25-ordered
149
+ * (best/lowest score first), already relevance-only (token-AND matched)
150
+ * @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
151
+ * stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
152
+ * should be tuned from shadow-logging measurement, not guessed — this is the knob.
153
+ * @param {number} [args.tokenBudget]
154
+ * @param {string[]} [args.excludeKeys] — keys injected on recent turns (cross-turn dedupe)
155
+ * @returns {{ lines: string[], injectedKeys: string[] }}
156
+ */
157
+ function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT_TOKEN_BUDGET, excludeKeys = [] }) {
158
+ // Anaphora guard: a query with too little signal ranks garbage — stay silent.
159
+ if (meaningfulTokens(query).length < MIN_QUERY_TOKENS) {
160
+ return { lines: [], injectedKeys: [] };
161
+ }
162
+
163
+ const exclude = new Set(excludeKeys || []);
164
+ const lines = [];
165
+ const entries = [];
166
+ const injectedKeys = [];
167
+ const usedSections = new Set();
168
+ let spent = 0;
169
+
170
+ for (const hit of hits || []) {
171
+ const memoryId = hit?.memory_id ?? hit?.key;
172
+ if (typeof memoryId !== 'string') continue;
173
+ if (exclude.has(memoryId)) continue;
174
+ // Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
175
+ if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
176
+ continue;
177
+ }
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`));
188
+ if (spent + cost > tokenBudget) {
189
+ continue;
190
+ }
191
+ lines.push(line);
192
+ entries.push({ trust, line });
193
+ injectedKeys.push(memoryId);
194
+ usedSections.add(trust);
195
+ spent += cost;
196
+ }
197
+
198
+ return { lines, entries, injectedKeys };
199
+ }
200
+
201
+ module.exports = {
202
+ MIN_QUERY_TOKENS,
203
+ DEFAULT_TOKEN_BUDGET,
204
+ DEFAULT_SCORE_FLOOR,
205
+ estimateTokens,
206
+ memoryTrustStatus,
207
+ normalizeMemoryType,
208
+ normalizeRecallHit,
209
+ parseHookInput,
210
+ meaningfulTokens,
211
+ selectInjection,
212
+ };
@@ -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. */
@@ -0,0 +1,272 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const {
7
+ createProtectedStateAuditRecord,
8
+ recordProtectedStateAuditEvent,
9
+ writeProtectedFile,
10
+ } = require('./protected-state-surfaces');
11
+ const {
12
+ issueNpmPublishWorkflowAuthorization,
13
+ } = require('./protected-state-authority');
14
+
15
+ const NPM_PUBLISH_WORKFLOW_PATH = '.github/workflows/npm-publish.yml';
16
+
17
+ function snapshotWorkflow(projectRoot) {
18
+ const fullPath = path.resolve(projectRoot, NPM_PUBLISH_WORKFLOW_PATH);
19
+ try {
20
+ const stat = fs.lstatSync(fullPath);
21
+ return {
22
+ fullPath,
23
+ existed: true,
24
+ content: stat.isFile() ? fs.readFileSync(fullPath) : null,
25
+ };
26
+ } catch (error) {
27
+ if (error.code === 'ENOENT') return { fullPath, existed: false, content: null };
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ function rollbackWorkflow(snapshot, projectRoot, protectedWriter, writeOptions) {
33
+ if (!snapshot.existed) {
34
+ fs.rmSync(snapshot.fullPath, { force: true });
35
+ return;
36
+ }
37
+ if (!Buffer.isBuffer(snapshot.content)) {
38
+ throw new Error('Previous workflow was not a regular file');
39
+ }
40
+ const restored = protectedWriter(
41
+ projectRoot,
42
+ NPM_PUBLISH_WORKFLOW_PATH,
43
+ snapshot.content,
44
+ writeOptions,
45
+ );
46
+ if (!restored.allowed) throw new Error(restored.reason || 'Protected workflow restore was denied');
47
+ }
48
+
49
+ function renderNpmPublishWorkflow() {
50
+ return `# Generated by \`forge release generate-npm-workflow\`. Do not edit directly.
51
+ name: Bun Package
52
+
53
+ on:
54
+ release:
55
+ types: [published]
56
+
57
+ permissions:
58
+ contents: read
59
+
60
+ jobs:
61
+ resolve-release:
62
+ name: Resolve immutable release SHA
63
+ runs-on: ubuntu-latest
64
+ permissions:
65
+ contents: read
66
+ outputs:
67
+ commitSha: \${{ steps.resolve.outputs.commitSha }}
68
+ steps:
69
+ - uses: actions/checkout@v7
70
+ with:
71
+ ref: \${{ github.event.release.tag_name }}
72
+ fetch-depth: 0
73
+ persist-credentials: false
74
+ - name: Resolve release tag
75
+ id: resolve
76
+ shell: bash
77
+ env:
78
+ RELEASE_TAG: \${{ github.event.release.tag_name }}
79
+ run: |
80
+ set -euo pipefail
81
+ COMMIT_SHA="$(git rev-parse "\${RELEASE_TAG}^{commit}")"
82
+ if [ -z "$COMMIT_SHA" ] || [ "$COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
83
+ echo "::error::Release tag did not resolve to the checked out commit"
84
+ exit 1
85
+ fi
86
+ echo "commitSha=$COMMIT_SHA" >> "$GITHUB_OUTPUT"
87
+
88
+ build:
89
+ needs: resolve-release
90
+ runs-on: ubuntu-latest
91
+ permissions:
92
+ contents: read
93
+ outputs:
94
+ commitSha: \${{ steps.evidence.outputs.commitSha }}
95
+ receipt: \${{ steps.evidence.outputs.receipt }}
96
+ receiptSubject: \${{ steps.evidence.outputs.receiptSubject }}
97
+ steps:
98
+ - uses: actions/checkout@v7
99
+ with:
100
+ ref: \${{ needs.resolve-release.outputs.commitSha }}
101
+ fetch-depth: 1
102
+ persist-credentials: false
103
+ - uses: actions/setup-node@v7
104
+ with:
105
+ node-version: 22
106
+ - uses: oven-sh/setup-bun@v2
107
+ with:
108
+ bun-version: 1.3.12
109
+ - run: bun install --frozen-lockfile
110
+ - name: Setup test fixtures
111
+ shell: bash
112
+ run: bash test-env/automation/setup-fixtures.sh --force
113
+ - name: Run complete release suite
114
+ shell: bash
115
+ run: |
116
+ set -euo pipefail
117
+ node scripts/test-full-suite.js --label-prefix release-full
118
+ - name: Emit attributable release suite receipt
119
+ id: evidence
120
+ env:
121
+ EXPECTED_SHA: \${{ needs.resolve-release.outputs.commitSha }}
122
+ run: node scripts/npm-release-receipt.js emit
123
+
124
+ publish-npm:
125
+ needs: [resolve-release, build]
126
+ runs-on: ubuntu-latest
127
+ permissions:
128
+ contents: read
129
+ id-token: write
130
+ steps:
131
+ - uses: actions/checkout@v7
132
+ with:
133
+ ref: \${{ needs.resolve-release.outputs.commitSha }}
134
+ fetch-depth: 1
135
+ persist-credentials: false
136
+ - uses: actions/setup-node@v7
137
+ with:
138
+ node-version: 22
139
+ registry-url: https://registry.npmjs.org/
140
+ - name: Verify exact release suite receipt
141
+ env:
142
+ EXPECTED_SHA: \${{ needs.resolve-release.outputs.commitSha }}
143
+ VERIFIED_SHA: \${{ needs.build.outputs.commitSha }}
144
+ RECEIPT: \${{ needs.build.outputs.receipt }}
145
+ RECEIPT_SUBJECT: \${{ needs.build.outputs.receiptSubject }}
146
+ run: node scripts/npm-release-receipt.js verify
147
+ - uses: oven-sh/setup-bun@v2
148
+ with:
149
+ bun-version: 1.3.12
150
+ - run: bun install --frozen-lockfile
151
+ - name: Release tag matches package.json version
152
+ shell: bash
153
+ run: |
154
+ set -euo pipefail
155
+ PKG="$(node -p "require('./package.json').version")"
156
+ TAG="\${GITHUB_REF_NAME#v}"
157
+ if [ "$PKG" != "$TAG" ]; then
158
+ echo "::error::Release tag ($TAG) != package.json version ($PKG). Bump package.json before releasing."
159
+ exit 1
160
+ fi
161
+ - name: Release-readiness gate
162
+ run: node bin/forge.js release check --target "$(node -p "require('./package.json').version.split('-')[0]")"
163
+ - name: Verify package contents
164
+ run: npm pack --dry-run
165
+ - name: Ensure OIDC-capable npm
166
+ run: npm install --global npm@11.5.1 && npm --version
167
+ - name: Publish to npm
168
+ shell: bash
169
+ run: |
170
+ set -euo pipefail
171
+ VERSION="$(node -p "require('./package.json').version")"
172
+ if echo "$VERSION" | grep -q '-'; then
173
+ echo "Prerelease $VERSION -> publishing under the beta dist-tag"
174
+ npm publish --provenance --tag beta
175
+ else
176
+ npm publish --provenance
177
+ fi
178
+ `;
179
+ }
180
+
181
+ async function generateNpmPublishWorkflow(projectRoot, options = {}) {
182
+ const content = renderNpmPublishWorkflow();
183
+ const actor = options.actor || options.env?.FORGE_ACTOR || process.env.FORGE_ACTOR || process.env.USER || process.env.USERNAME || 'unknown';
184
+ const protectedWriter = options.writeProtectedFile || writeProtectedFile;
185
+ const auditWriter = options.recordProtectedStateAuditEvent || recordProtectedStateAuditEvent;
186
+ const authorizationWriter = options.issueNpmPublishWorkflowAuthorization || issueNpmPublishWorkflowAuthorization;
187
+ const snapshot = snapshotWorkflow(projectRoot);
188
+ const writeOptions = {
189
+ actor,
190
+ operation: 'generate_npm_workflow',
191
+ viaForgeApi: true,
192
+ surface: 'workflows',
193
+ };
194
+ let write;
195
+ try {
196
+ write = protectedWriter(projectRoot, NPM_PUBLISH_WORKFLOW_PATH, content, writeOptions);
197
+ } catch (error) {
198
+ return { success: false, error: error.message };
199
+ }
200
+ if (!write.allowed) {
201
+ return { success: false, error: write.reason, write };
202
+ }
203
+
204
+ const auditRecord = createProtectedStateAuditRecord({
205
+ actor,
206
+ surface: 'workflows',
207
+ path: NPM_PUBLISH_WORKFLOW_PATH,
208
+ content,
209
+ operation: 'generate_npm_workflow',
210
+ });
211
+ let audit;
212
+ try {
213
+ audit = auditWriter(auditRecord, { cwd: projectRoot });
214
+ } catch (error) {
215
+ audit = { success: false, error: error.message };
216
+ }
217
+ if (!audit.success) {
218
+ let rollbackError;
219
+ try {
220
+ rollbackWorkflow(snapshot, projectRoot, protectedWriter, writeOptions);
221
+ } catch (error) {
222
+ rollbackError = error.message;
223
+ }
224
+ return {
225
+ success: false,
226
+ error: `Could not record protected-state authorization: ${audit.error}`,
227
+ audit,
228
+ rollbackError,
229
+ };
230
+ }
231
+
232
+ let trustedAuthorization;
233
+ try {
234
+ trustedAuthorization = await authorizationWriter(
235
+ projectRoot,
236
+ { actor },
237
+ { deps: options.kernelDeps },
238
+ );
239
+ } catch (error) {
240
+ trustedAuthorization = { success: false, error: error.message };
241
+ }
242
+ if (!trustedAuthorization.success) {
243
+ let rollbackError;
244
+ try {
245
+ rollbackWorkflow(snapshot, projectRoot, protectedWriter, writeOptions);
246
+ } catch (error) {
247
+ rollbackError = error.message;
248
+ }
249
+ return {
250
+ success: false,
251
+ error: `Could not record trusted protected-state authorization: ${trustedAuthorization.error}`,
252
+ audit,
253
+ trustedAuthorization,
254
+ rollbackError,
255
+ };
256
+ }
257
+
258
+ return {
259
+ success: true,
260
+ path: NPM_PUBLISH_WORKFLOW_PATH,
261
+ contentHash: write.contentHash,
262
+ write,
263
+ audit,
264
+ trustedAuthorization,
265
+ };
266
+ }
267
+
268
+ module.exports = {
269
+ NPM_PUBLISH_WORKFLOW_PATH,
270
+ renderNpmPublishWorkflow,
271
+ generateNpmPublishWorkflow,
272
+ };