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

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 (122) hide show
  1. package/AGENTS.md +1 -1
  2. package/bin/forge-cmd.js +1 -1
  3. package/bin/forge.js +5 -0
  4. package/docs/reference/COMMANDS.md +3 -1
  5. package/docs/reference/shepherd.md +72 -1
  6. package/lib/adapters/greptile-review-adapter.js +1 -1
  7. package/lib/adapters/pr-state-adapter.js +103 -8
  8. package/lib/agents-config.js +5 -0
  9. package/lib/commands/_issue.js +31 -46
  10. package/lib/commands/_manifest.js +1 -1
  11. package/lib/commands/_resolve-command-opts.js +36 -29
  12. package/lib/commands/claim.js +2 -4
  13. package/lib/commands/hooks.js +155 -8
  14. package/lib/commands/plan.js +23 -115
  15. package/lib/commands/prime.js +8 -1
  16. package/lib/commands/release.js +1 -2
  17. package/lib/commands/serve.js +5 -2
  18. package/lib/commands/setup.js +0 -1
  19. package/lib/commands/shepherd.js +36 -3
  20. package/lib/commands/skill.js +275 -0
  21. package/lib/commands/status.js +37 -32
  22. package/lib/commands/test.js +32 -0
  23. package/lib/commands/worktree.js +27 -1
  24. package/lib/core/runtime-graph.js +88 -14
  25. package/lib/forge-issues.js +13 -464
  26. package/lib/harness-capability-matrix.js +2 -2
  27. package/lib/hook-renderer.js +54 -12
  28. package/lib/issue-backend.js +42 -3
  29. package/lib/kernel/broker.js +28 -0
  30. package/lib/kernel/migrations.js +30 -2
  31. package/lib/kernel/schema.js +35 -0
  32. package/lib/kernel/sqlite-driver.js +150 -0
  33. package/lib/memory-recall.js +151 -0
  34. package/lib/orientation.js +303 -6
  35. package/lib/pr-bundle.js +6 -2
  36. package/lib/pr-monitor/reconcile-executor.js +659 -0
  37. package/lib/pr-monitor/reconcile-tick.js +138 -0
  38. package/lib/pr-monitor/reconcile.js +0 -0
  39. package/lib/pr-monitor/render-sticky.js +14 -0
  40. package/lib/pr-monitor/shepherd-lease.js +243 -0
  41. package/lib/pr-monitor/upsert-sticky.js +1 -1
  42. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  43. package/lib/pr-pull.js +70 -15
  44. package/lib/project-memory.js +8 -0
  45. package/lib/rules-sync.js +4 -0
  46. package/lib/runtime-health.js +15 -46
  47. package/lib/skill-eval.js +750 -0
  48. package/lib/status/identity.js +46 -0
  49. package/lib/status/presenter.js +0 -35
  50. package/lib/status/snapshot.js +11 -16
  51. package/lib/upgrade-safety.js +8 -9
  52. package/lib/using-forge.js +315 -0
  53. package/lib/workflow/enforce-stage.js +5 -5
  54. package/lib/workflow/state-manager.js +23 -23
  55. package/package.json +1 -1
  56. package/rules/using-forge.md +24 -0
  57. package/scripts/forge-team/index.sh +0 -5
  58. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  59. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  60. package/scripts/test.js +8 -1
  61. package/skills/claim-safety/SKILL.md +4 -0
  62. package/skills/claim-safety/evals/scorecard.json +41 -0
  63. package/skills/coverage.json +83 -0
  64. package/skills/dev/SKILL.md +4 -0
  65. package/skills/dev/evals/scorecard.json +41 -0
  66. package/skills/gates/SKILL.md +80 -0
  67. package/skills/gates/evals/evals.json +38 -0
  68. package/skills/gates/evals/scorecard.json +41 -0
  69. package/skills/hermes-forge/SKILL.md +1 -0
  70. package/skills/hermes-forge/evals/scorecard.json +41 -0
  71. package/skills/issue-basics/SKILL.md +1 -0
  72. package/skills/issue-basics/evals/scorecard.json +41 -0
  73. package/skills/kernel/SKILL.md +38 -0
  74. package/skills/kernel/evals/scorecard.json +41 -0
  75. package/skills/memory/SKILL.md +16 -1
  76. package/skills/memory/evals/scorecard.json +41 -0
  77. package/skills/parallel-deep-research/SKILL.md +1 -0
  78. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  79. package/skills/plan/SKILL.md +6 -0
  80. package/skills/plan/evals/scorecard.json +41 -0
  81. package/skills/portability/SKILL.md +47 -0
  82. package/skills/portability/evals/evals.json +34 -0
  83. package/skills/portability/evals/scorecard.json +41 -0
  84. package/skills/research/SKILL.md +1 -0
  85. package/skills/research/evals/scorecard.json +41 -0
  86. package/skills/review/SKILL.md +6 -0
  87. package/skills/review/evals/scorecard.json +41 -0
  88. package/skills/rollback/SKILL.md +1 -0
  89. package/skills/rollback/evals/scorecard.json +41 -0
  90. package/skills/setup/SKILL.md +91 -0
  91. package/skills/setup/evals/evals.json +42 -0
  92. package/skills/setup/evals/scorecard.json +41 -0
  93. package/skills/shepherd/SKILL.md +76 -36
  94. package/skills/shepherd/evals/evals.json +21 -9
  95. package/skills/shepherd/evals/scorecard.json +41 -0
  96. package/skills/ship/SKILL.md +6 -0
  97. package/skills/ship/evals/scorecard.json +41 -0
  98. package/skills/smith/SKILL.md +8 -0
  99. package/skills/smith/evals/scorecard.json +41 -0
  100. package/skills/sonarcloud/SKILL.md +1 -0
  101. package/skills/sonarcloud/evals/scorecard.json +41 -0
  102. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  103. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  104. package/skills/status/SKILL.md +3 -0
  105. package/skills/status/evals/scorecard.json +41 -0
  106. package/skills/triage-ready/SKILL.md +2 -0
  107. package/skills/triage-ready/evals/scorecard.json +41 -0
  108. package/skills/using-forge/SKILL.md +104 -0
  109. package/skills/using-forge/evals/scorecard.json +41 -0
  110. package/skills/validate/SKILL.md +4 -0
  111. package/skills/validate/evals/scorecard.json +41 -0
  112. package/skills/verify/SKILL.md +4 -0
  113. package/skills/verify/evals/scorecard.json +41 -0
  114. package/skills/worktree/SKILL.md +87 -0
  115. package/skills/worktree/evals/evals.json +38 -0
  116. package/skills/worktree/evals/scorecard.json +41 -0
  117. package/lib/adapters/beads-issue-adapter.js +0 -127
  118. package/lib/beads-nudge.js +0 -91
  119. package/lib/commands/board.js +0 -64
  120. package/lib/status/beads-snapshot.js +0 -145
  121. package/scripts/forge-team/lib/dashboard.sh +0 -316
  122. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module memory-recall
5
+ *
6
+ * Pure selection core for the per-turn memory-recall hook (the query-relevant tier-2
7
+ * that complements the recency digest pushed at SessionStart). Kept free of stdin/fs so
8
+ * it is fully testable; lib/commands/hooks.js does the I/O wiring around it.
9
+ *
10
+ * Design constraints (verified against the Claude Code hooks contract + external memory
11
+ * research, kernel issue 781f6f65):
12
+ * - UserPromptSubmit additionalContext APPENDS to history every prompt, so a per-turn
13
+ * injector must stay tiny: a hard token budget, a relevance floor, and cross-turn
14
+ * dedupe. Below the bar -> inject NOTHING (silence is safe; a wrong memory at
15
+ * authority every turn is not).
16
+ * - Anaphora guard: a trivial query ("continue", "fix it") carries no retrieval signal,
17
+ * so ranking on it is worse than silence. Require a minimum of distinct content tokens.
18
+ * - Scope is a FILTER; relevance is the RANKER (bm25). Never sort by recency here — that
19
+ * is the recency digest's job, not tier-2's.
20
+ */
21
+
22
+ // A query needs at least this many distinct content tokens to be worth ranking on.
23
+ // Below it we treat the prompt as anaphora and inject nothing.
24
+ const MIN_QUERY_TOKENS = 2;
25
+
26
+ // Default token budget for the whole tier-2 injection. Deliberately small: it rides on
27
+ // EVERY prompt, and it must never starve the always-on SessionStart digest.
28
+ const DEFAULT_TOKEN_BUDGET = 400;
29
+
30
+ // Default relevance floor for the live hook path so it never runs floor-less. bm25 is
31
+ // more-negative-is-better, so 0 keeps every token-AND FTS match: the ACTIVE relevance gate
32
+ // today is the token-AND match plus the anaphora guard, and the numeric floor is a knob to
33
+ // be tightened (made negative) once shadow-logging measurement (781f6f65 step 0) shows where
34
+ // the corpus's relevant/irrelevant boundary sits. Named + wired so the default is explicit,
35
+ // not an accidental `undefined`.
36
+ const DEFAULT_SCORE_FLOOR = 0;
37
+
38
+ // Short/function words that carry no retrieval signal. Not exhaustive — just enough to
39
+ // stop pure anaphora ("do that now", "same for it") from clearing the guard.
40
+ const STOPWORDS = new Set([
41
+ 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at', 'by', 'is',
42
+ 'it', 'this', 'that', 'these', 'those', 'do', 'did', 'now', 'then', 'same', 'again',
43
+ 'continue', 'go', 'ok', 'okay', 'yes', 'no', 'fix', 'please', 'thanks', 'with', 'as',
44
+ 'we', 'i', 'you', 'he', 'she', 'they', 'them', 'his', 'her', 'my', 'our', 'your',
45
+ ]);
46
+
47
+ // Rough token estimate: ~4 chars/token, matching lib/memory-digest.js's convention so
48
+ // the two tiers budget on the same scale.
49
+ function estimateTokens(text) {
50
+ return Math.ceil(String(text || '').length / 4);
51
+ }
52
+
53
+ /**
54
+ * Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
55
+ * throws — any malformed input yields an empty prompt so the hook fails open.
56
+ *
57
+ * @param {string} raw
58
+ * @returns {{ prompt: string, sessionId: (string|null) }}
59
+ */
60
+ function parseHookInput(raw) {
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ return { prompt: '', sessionId: null };
65
+ }
66
+ const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
67
+ const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id : null;
68
+ return { prompt, sessionId };
69
+ } catch {
70
+ return { prompt: '', sessionId: null };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Distinct content tokens in a query — lowercased, length >= 3, minus stopwords. The
76
+ * anaphora guard counts these; the FTS layer does its own tokenization for the actual match.
77
+ *
78
+ * @param {string} query
79
+ * @returns {string[]}
80
+ */
81
+ function meaningfulTokens(query) {
82
+ const seen = new Set();
83
+ // Unicode-aware split, matching the FTS tokenizer (/[\p{L}\p{N}]+/gu in the kernel driver)
84
+ // so non-Latin prompts (Cyrillic/CJK/accented) aren't silently stripped — otherwise the
85
+ // anaphora guard would disable recall for every non-Latin-script user.
86
+ for (const rawToken of String(query || '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
87
+ if (!rawToken) continue;
88
+ if (STOPWORDS.has(rawToken)) continue;
89
+ // The length>=3 filter suppresses ASCII noise ("it", "do"), but CJK words are 1-2 chars
90
+ // and any non-ASCII token is inherently content — keep those regardless of length.
91
+ if (rawToken.length < 3 && /^[a-z0-9]+$/.test(rawToken)) continue;
92
+ seen.add(rawToken);
93
+ }
94
+ return [...seen];
95
+ }
96
+
97
+ /**
98
+ * Choose which memories to inject this turn. PURE.
99
+ *
100
+ * @param {object} args
101
+ * @param {string} args.query — the submitted prompt
102
+ * @param {Array<{key:string, value:string, score:number}>} args.hits — bm25-ordered
103
+ * (best/lowest score first), already relevance-only (token-AND matched)
104
+ * @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
105
+ * stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
106
+ * should be tuned from shadow-logging measurement, not guessed — this is the knob.
107
+ * @param {number} [args.tokenBudget]
108
+ * @param {string[]} [args.excludeKeys] — keys injected on recent turns (cross-turn dedupe)
109
+ * @returns {{ lines: string[], injectedKeys: string[] }}
110
+ */
111
+ function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT_TOKEN_BUDGET, excludeKeys = [] }) {
112
+ // Anaphora guard: a query with too little signal ranks garbage — stay silent.
113
+ if (meaningfulTokens(query).length < MIN_QUERY_TOKENS) {
114
+ return { lines: [], injectedKeys: [] };
115
+ }
116
+
117
+ const exclude = new Set(excludeKeys || []);
118
+ const lines = [];
119
+ const injectedKeys = [];
120
+ let spent = 0;
121
+
122
+ for (const hit of hits || []) {
123
+ if (!hit || typeof hit.key !== 'string') continue;
124
+ if (exclude.has(hit.key)) continue;
125
+ // Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
126
+ if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
127
+ continue;
128
+ }
129
+ const body = String(hit.value == null ? '' : hit.value);
130
+ const cost = estimateTokens(body);
131
+ if (spent + cost > tokenBudget) {
132
+ // Budget exhausted; stop rather than skip-and-continue so the strongest fit.
133
+ break;
134
+ }
135
+ lines.push(body);
136
+ injectedKeys.push(hit.key);
137
+ spent += cost;
138
+ }
139
+
140
+ return { lines, injectedKeys };
141
+ }
142
+
143
+ module.exports = {
144
+ MIN_QUERY_TOKENS,
145
+ DEFAULT_TOKEN_BUDGET,
146
+ DEFAULT_SCORE_FLOOR,
147
+ estimateTokens,
148
+ parseHookInput,
149
+ meaningfulTokens,
150
+ selectInjection,
151
+ };
@@ -761,11 +761,16 @@ function buildIssueRecap(projectRoot, issueId, options = {}) {
761
761
 
762
762
  function buildPrime(projectRoot, options = {}) {
763
763
  const { project, sections } = buildOrientationSections(projectRoot, options);
764
- const orientation = assembleOrientationResult(
765
- project,
766
- [...sections, buildPrimeKeyCommandsSection()],
767
- options
768
- );
764
+ // Prime is the session-entry command, so it LEADS the COMPLETE orientation with LIVE state
765
+ // (stage / claims / ready / gates / one adoption nudge) when the caller supplied it — the
766
+ // live-state section is prepended to the full section list (not just the extra sections), so
767
+ // prime leads with it in every output path. Collected async by the command handler and injected
768
+ // here so buildPrime itself stays pure and synchronous.
769
+ const keyCommands = buildPrimeKeyCommandsSection();
770
+ const allSections = options.liveState
771
+ ? [...buildPrimeLiveStateSections(options.liveState), ...sections, keyCommands]
772
+ : [...sections, keyCommands];
773
+ const orientation = assembleOrientationResult(project, allSections, options);
769
774
  return {
770
775
  schema_version: 1,
771
776
  kind: 'prime',
@@ -774,6 +779,7 @@ function buildPrime(projectRoot, options = {}) {
774
779
  token_budget: orientation.token_budget,
775
780
  orientation,
776
781
  sources: orientation.sources,
782
+ ...(options.liveState ? { live_state: sanitizeLiveStateForJson(options.liveState) } : {}),
777
783
  next_commands: [
778
784
  'forge orient --json',
779
785
  'forge status --json',
@@ -782,6 +788,290 @@ function buildPrime(projectRoot, options = {}) {
782
788
  };
783
789
  }
784
790
 
791
+ // Cap on claimed issues rendered in the prime live-state block — a bounded nudge, not a dump.
792
+ const LIVE_STATE_CLAIM_LIMIT = 3;
793
+ const LIVE_STATE_GATE_LIMIT = 6;
794
+
795
+ /**
796
+ * Render the prime LIVE-state block: current stage, claimed issue(s), ready count, enabled
797
+ * gates/rails, and ONE progressive-adoption nudge. PURE and bounded — the output is always
798
+ * ≤ ~10 lines (well under the 20-line cap), with honest fallbacks for every missing field so
799
+ * a repo with no kernel data still renders a coherent block.
800
+ *
801
+ * @param {object} [liveState]
802
+ * @returns {string}
803
+ */
804
+ // Hard cap on any single EXTERNAL value (stage name, issue title, gate id) rendered into the
805
+ // live-state block. Counts alone don't bound the block: one long or multiline title/name/id could
806
+ // otherwise bloat live_state or break its one-value-per-line structure. clipValue enforces both.
807
+ const LIVE_STATE_VALUE_MAX = 60;
808
+
809
+ /** Collapse all whitespace (incl. newlines) to single spaces and hard-cap length with an ellipsis. */
810
+ /**
811
+ * Sanitized copy of the raw liveState for the `--json` envelope. The rendered text sections are
812
+ * clipped + provenance-fenced, but `forge prime --json` also emits a `live_state` object — without
813
+ * this, an attacker-influenceable title/id from Kernel/GitHub would land RAW (unbounded, with
814
+ * newlines) in the trusted session-entry envelope, bypassing the budget + fence. Clip every string
815
+ * field so the JSON copy carries the same bounded/newline-collapsed representation as the text path.
816
+ */
817
+ function sanitizeLiveStateForJson(liveState) {
818
+ if (!liveState || typeof liveState !== 'object') return liveState;
819
+ const clip = v => (typeof v === 'string' ? clipValue(v) : v);
820
+ const clipIssue = i => (i && typeof i === 'object' ? { ...i, id: clip(i.id), title: clip(i.title) } : i);
821
+ return {
822
+ ...liveState,
823
+ stage: liveState.stage && typeof liveState.stage === 'object'
824
+ ? { ...liveState.stage, id: clip(liveState.stage.id), name: clip(liveState.stage.name) }
825
+ : liveState.stage,
826
+ claimed: Array.isArray(liveState.claimed) ? liveState.claimed.map(clipIssue) : liveState.claimed,
827
+ topReady: clipIssue(liveState.topReady),
828
+ gates: Array.isArray(liveState.gates) ? liveState.gates.map(clip) : liveState.gates,
829
+ nudge: clip(liveState.nudge),
830
+ };
831
+ }
832
+
833
+ function clipValue(value, max = LIVE_STATE_VALUE_MAX) {
834
+ const flat = String(value).replace(/\s+/g, ' ').trim();
835
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
836
+ }
837
+
838
+ /** One-line "Stage: <id> — <name>" (or "not recorded"). */
839
+ function formatStageLine(stage) {
840
+ if (!stage?.id) return 'Stage: not recorded';
841
+ const suffix = stage.name ? ` — ${clipValue(stage.name)}` : '';
842
+ return `Stage: ${clipValue(stage.id)}${suffix}`;
843
+ }
844
+
845
+ /**
846
+ * Bounded "Claimed:" lines (capped, with an "…and N more" tail) for the UNTRUSTED claimed block.
847
+ * id + title are clipped (bounded + newlines collapsed); the title is NOT fenced inline. The whole
848
+ * block is emitted as a section carrying `untrustedSource`, so the shared post-budget
849
+ * fenceUntrustedSections wraps it — the ⟦END UNTRUSTED⟧ terminator then always survives a budget
850
+ * truncation, which an inline per-title fence could not guarantee. Caller guards the empty case.
851
+ */
852
+ function formatClaimedLines(claimed) {
853
+ const lines = claimed
854
+ .slice(0, LIVE_STATE_CLAIM_LIMIT)
855
+ .map(issue => {
856
+ const title = issue.title ? ` ${clipValue(issue.title)}` : '';
857
+ return `Claimed: ${clipValue(issue.id)}${title}`;
858
+ });
859
+ if (claimed.length > LIVE_STATE_CLAIM_LIMIT) {
860
+ lines.push(`Claimed: …and ${claimed.length - LIVE_STATE_CLAIM_LIMIT} more`);
861
+ }
862
+ return lines;
863
+ }
864
+
865
+ /** One-line "Ready: N issue(s) waiting" (or "none"). */
866
+ function formatReadyLine(readyCount) {
867
+ if (readyCount <= 0) return 'Ready: none';
868
+ return `Ready: ${readyCount} issue${readyCount === 1 ? '' : 's'} waiting (forge ready)`;
869
+ }
870
+
871
+ /** One-line "Gates on: <capped list>" (or "defaults"). */
872
+ function formatGatesLine(gates) {
873
+ if (gates.length === 0) return 'Gates on: defaults';
874
+ const shown = gates.slice(0, LIVE_STATE_GATE_LIMIT).map(gate => clipValue(gate)).join(', ');
875
+ return `Gates on: ${shown}${gates.length > LIVE_STATE_GATE_LIMIT ? ', …' : ''}`;
876
+ }
877
+
878
+ /**
879
+ * The TRUSTED prime live-state block: stage, ready count, enabled gates, and one adoption nudge —
880
+ * all internally sourced and safe to act on. Attacker-influenceable claimed issue TITLES are NOT
881
+ * here; they render separately via formatClaimedBlock into an untrusted, provenance-fenced section.
882
+ * When nothing is claimed there is no untrusted data, so a plain "Claimed: none" is noted here.
883
+ */
884
+ function formatPrimeLiveState(liveState = {}) {
885
+ const claimed = Array.isArray(liveState.claimed) ? liveState.claimed : [];
886
+ const readyCount = Number.isFinite(liveState.readyCount) ? liveState.readyCount : 0;
887
+ const gates = Array.isArray(liveState.gates) ? liveState.gates : [];
888
+
889
+ const lines = [formatStageLine(liveState.stage)];
890
+ if (claimed.length === 0) lines.push('Claimed: none');
891
+ lines.push(formatReadyLine(readyCount), formatGatesLine(gates));
892
+ if (liveState.nudge) lines.push(`Next: ${liveState.nudge}`);
893
+ return lines.join('\n');
894
+ }
895
+
896
+ /**
897
+ * The UNTRUSTED claimed-work block (attacker-influenceable issue titles), or '' when nothing is
898
+ * claimed. Emitted as its own section marked `untrustedSource` so fenceUntrustedSections fences it
899
+ * AFTER applyBudget — the fence terminator survives truncation.
900
+ */
901
+ function formatClaimedBlock(liveState = {}) {
902
+ const claimed = Array.isArray(liveState.claimed) ? liveState.claimed : [];
903
+ if (claimed.length === 0) return '';
904
+ return formatClaimedLines(claimed).join('\n');
905
+ }
906
+
907
+ /**
908
+ * Build the prime live-state sections: a TRUSTED `live_state` block always, plus an UNTRUSTED
909
+ * `live_state_claimed` block when work is claimed. Splitting is deliberate — only the claimed
910
+ * titles are attacker-influenceable, so only that block carries `untrustedSource` (the trusted
911
+ * stage/ready/gates/nudge must stay actionable, not fenced as "data only").
912
+ * @returns {object[]}
913
+ */
914
+ function buildPrimeLiveStateSections(liveState) {
915
+ const sections = [buildSection({
916
+ id: 'live_state',
917
+ title: 'Live State',
918
+ content: formatPrimeLiveState(liveState),
919
+ sources: [source('kernel.live_state', 'kernel_read_model', 'live_session_state', 'live_state')],
920
+ // Priority 0 so prime LEADS with live state in every output path: applyBudget orders sections
921
+ // by priority (project_identity is also 0), and the id tiebreak ('live_state' < 'project_
922
+ // identity') puts live state first — the session-entry "where am I right now" belongs on top.
923
+ priority: 0,
924
+ preserve: true,
925
+ })];
926
+ const claimedContent = formatClaimedBlock(liveState);
927
+ if (claimedContent) {
928
+ sections.push(buildSection({
929
+ id: 'live_state_claimed',
930
+ title: 'Claimed Work',
931
+ content: claimedContent,
932
+ sources: [source('kernel.live_state', 'kernel_read_model', 'live_session_state', 'live_state_claimed')],
933
+ // Issue titles are attacker-influenceable. Marking the WHOLE block untrusted lets the shared
934
+ // post-budget fenceUntrustedSections wrap it, so the ⟦END UNTRUSTED⟧ terminator always
935
+ // survives a budget cut (an inline per-title fence could be severed mid-truncation). Id
936
+ // 'live_state_claimed' sorts right after 'live_state' and before other priority-0 sections.
937
+ untrustedSource: 'issue-titles',
938
+ priority: 0,
939
+ preserve: true,
940
+ }));
941
+ }
942
+ return sections;
943
+ }
944
+
945
+ /** Deterministic, single-line progressive-adoption nudge (at-most-one) for prime live-state. */
946
+ function buildAdoptionNudge({ claimed = [], readyCount = 0, topReady = null } = {}) {
947
+ // Issue ids are attacker-influenceable (the broker accepts `--id` as a raw string), and this
948
+ // string lands in the trusted Live State `Next:` line — clip it (bound + collapse newlines) so a
949
+ // crafted id cannot break the one-value-per-line structure or inject a fake directive line.
950
+ if (claimed.length > 0) return `Resume with forge recap ${clipValue(claimed[0].id)} for full context.`;
951
+ if (readyCount > 0 && topReady && topReady.id) return `Claim work: forge claim ${clipValue(topReady.id)}, then plan or dev.`;
952
+ return 'No active or ready work — forge plan "<feature>" to start, or forge ready to check.';
953
+ }
954
+
955
+ /**
956
+ * True only when a Kernel DB ALREADY EXISTS on disk. `forge prime` is a read-only, session-entry
957
+ * command, so the live-state read must NEVER lazily create/migrate the Kernel DB (which the
958
+ * default snapshot path would otherwise do in a fresh repo). resolveKernelDatabasePath only
959
+ * COMPUTES the path (no side effects); we check the file separately. Never throws.
960
+ * @param {string} projectRoot
961
+ * @returns {boolean}
962
+ */
963
+ function hasExistingKernelDb(projectRoot) {
964
+ try {
965
+ const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
966
+ const databasePath = resolveKernelDatabasePath({ projectRoot });
967
+ return !!databasePath && fs.existsSync(databasePath);
968
+ } catch {
969
+ return false;
970
+ }
971
+ }
972
+
973
+ /**
974
+ * True when the live-state read must be SKIPPED to keep `forge prime` strictly READ-ONLY. The
975
+ * Kernel is the SOLE runtime issue backend (Beads is retired from the runtime — the only remaining
976
+ * Beads surface is the opt-in `forge migrate` path, so there is NO runtime Beads live-data source
977
+ * by design). The Kernel read lazily creates/migrates `.git/forge/kernel.sqlite`, so we read live
978
+ * ONLY when that DB already exists; otherwise prime shows honest-degraded/empty state and never
979
+ * creates a store. Never throws.
980
+ * @param {string} projectRoot
981
+ * @returns {boolean} true iff the read must be skipped.
982
+ */
983
+ function shouldSkipLiveSnapshot(projectRoot) {
984
+ return !hasExistingKernelDb(projectRoot);
985
+ }
986
+
987
+ /**
988
+ * Acquire the status snapshot for live-state WITHOUT ever creating state. An injected
989
+ * `_readSnapshot` (tests) bypasses the guards; otherwise the read is gated on a real git repo and
990
+ * an existing Kernel DB (the sole runtime issue backend — see shouldSkipLiveSnapshot), so a
991
+ * fresh/un-initialized repo returns null (honest fallback) and nothing is written. Never throws.
992
+ * @returns {Promise<object|null>}
993
+ */
994
+ async function acquireLiveSnapshot(projectRoot, env, options) {
995
+ if (options._readSnapshot) {
996
+ try { return await options._readSnapshot(); } catch { return null; }
997
+ }
998
+ if (!fs.existsSync(path.join(projectRoot, '.git'))) return null;
999
+ if (shouldSkipLiveSnapshot(projectRoot)) return null; // read-only: never create the store
1000
+ try {
1001
+ const { readStatusSnapshot } = require('./status/snapshot');
1002
+ return await readStatusSnapshot(projectRoot, { env });
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+
1008
+ /** Resolve the current stage for live-state (best-effort, non-throwing). Injectable via options. */
1009
+ function resolveLiveStage(projectRoot, claimed, options) {
1010
+ if (Object.hasOwn(options, '_workflowState')) {
1011
+ const ws = options._workflowState;
1012
+ return ws && ws.currentStage ? { id: ws.currentStage, name: ws.currentStage } : null;
1013
+ }
1014
+ try {
1015
+ const status = require('./commands/status');
1016
+ const issueId = claimed[0] ? claimed[0].id : null;
1017
+ const { workflowState } = status.resolveWorkflowState({ projectRoot, issueId });
1018
+ if (workflowState && workflowState.currentStage) {
1019
+ return { id: workflowState.currentStage, name: status.buildAuthoritativeStatus(workflowState).stageName };
1020
+ }
1021
+ } catch { /* stage stays null */ }
1022
+ return null;
1023
+ }
1024
+
1025
+ /**
1026
+ * Best-effort LIVE-state collector for prime. Async + NON-THROWING and strictly READ-ONLY: it
1027
+ * never creates or migrates the Kernel DB (a fresh repo yields honest fallbacks, not a new DB).
1028
+ * `options.liveState` bypasses all reads; `options._readSnapshot` injects a snapshot (tests).
1029
+ *
1030
+ * @param {string} projectRoot
1031
+ * @param {object} [options] - `{ liveState, env, _readSnapshot, _workflowState }` (all injectable).
1032
+ * @returns {Promise<{stage: object|null, claimed: object[], readyCount: number, gates: string[], nudge: string}>}
1033
+ */
1034
+ async function collectPrimeLiveState(projectRoot, options = {}) {
1035
+ if (options.liveState) return options.liveState;
1036
+ const env = options.env || process.env;
1037
+ const gates = readEnabledGates(projectRoot); // config-file backed — safe even with no repo/DB
1038
+
1039
+ const snapshot = await acquireLiveSnapshot(projectRoot, env, options);
1040
+ if (!snapshot) {
1041
+ return { stage: null, claimed: [], readyCount: 0, gates, nudge: buildAdoptionNudge({}) };
1042
+ }
1043
+
1044
+ const claimed = (Array.isArray(snapshot.activeAssigned) ? snapshot.activeAssigned : [])
1045
+ .map(issue => ({ id: issue.id, title: issue.title || null }));
1046
+ const readyList = Array.isArray(snapshot.ready) ? snapshot.ready : [];
1047
+ const readyCount = readyList.length;
1048
+
1049
+ return {
1050
+ stage: resolveLiveStage(projectRoot, claimed, options),
1051
+ claimed,
1052
+ readyCount,
1053
+ gates,
1054
+ nudge: buildAdoptionNudge({ claimed, readyCount, topReady: readyList[0] || null }),
1055
+ };
1056
+ }
1057
+
1058
+ /**
1059
+ * Read the enabled gate/rail ids from the resolved runtime graph (config-file backed, no kernel
1060
+ * DB — safe on a non-repo path). Never throws; returns [] on any failure.
1061
+ * @param {string} projectRoot
1062
+ * @returns {string[]}
1063
+ */
1064
+ function readEnabledGates(projectRoot) {
1065
+ try {
1066
+ const { getResolvedRuntimeGraph } = require('./core/runtime-graph');
1067
+ const graph = getResolvedRuntimeGraph({ projectRoot }) || {};
1068
+ const primitives = [...(graph.rails || []), ...(graph.gates || [])];
1069
+ return primitives.filter(p => p && p.enabled !== false).map(p => p.id).filter(Boolean);
1070
+ } catch {
1071
+ return [];
1072
+ }
1073
+ }
1074
+
785
1075
  function formatOrientationText(result) {
786
1076
  const lines = [
787
1077
  orientationTitle(result.kind),
@@ -834,9 +1124,10 @@ function readOption(args, name, fallback) {
834
1124
  return fallback;
835
1125
  }
836
1126
 
837
- function runOrientationCommand(build, args, projectRoot) {
1127
+ function runOrientationCommand(build, args, projectRoot, extraOptions = {}) {
838
1128
  const result = build(projectRoot, {
839
1129
  budgetTokens: readOption(args, '--budget', undefined),
1130
+ ...extraOptions,
840
1131
  });
841
1132
  return {
842
1133
  success: true,
@@ -848,12 +1139,18 @@ module.exports = {
848
1139
  DEFAULT_BUDGET_TOKENS,
849
1140
  applyBudget,
850
1141
  buildSection,
1142
+ buildAdoptionNudge,
851
1143
  buildIssueRecap,
852
1144
  buildMemorySection,
853
1145
  buildOrientation,
854
1146
  buildOrientationSections,
855
1147
  buildPrime,
1148
+ buildPrimeLiveStateSections,
1149
+ collectPrimeLiveState,
1150
+ shouldSkipLiveSnapshot,
856
1151
  discoverWorkFolder,
1152
+ formatPrimeLiveState,
1153
+ formatClaimedBlock,
857
1154
  estimateTokens,
858
1155
  formatOrientationText,
859
1156
  normalizeBudgetTokens,
package/lib/pr-bundle.js CHANGED
@@ -145,8 +145,9 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
145
145
  const state = await adapter.readState(pr);
146
146
  // NOTE: required-check lookup needs the base BRANCH name (`base`), not the
147
147
  // remote ref (`baseRef`); passing the ref builds a bad protection path and
148
- // silently yields a null required set.
149
- const requiredRaw = await adapter.readRequiredChecks({ owner, repo, base });
148
+ // silently yields a null required set. `pr` lets the adapter fall back to the
149
+ // rollup `isRequired` set when branch protection is unreadable in CI.
150
+ const requiredRaw = await adapter.readRequiredChecks({ owner, repo, base, pr });
150
151
  const divergence = await adapter.readDivergence({ baseRef, cwd });
151
152
  const comments = await gatherUnresolvedComments(adapter, { owner, repo, pr });
152
153
  const conflicts = await gatherConflicts(adapter, { baseRef, cwd });
@@ -171,6 +172,9 @@ async function gatherPrBundle({ pr, owner, repo, base, baseRef, cwd, adapter })
171
172
  state: String(state.state || 'OPEN').toUpperCase(),
172
173
  },
173
174
  ci: buildCi(state.checks, requiredSet),
175
+ // Which source answered the required-checks read (`protection` | `rollup` |
176
+ // null) — `rollup` is the CI path where branch protection is unreadable.
177
+ requiredSource: adapter.lastRequiredSource || null,
174
178
  branch: {
175
179
  ahead: divergence.ahead || 0,
176
180
  behind: divergence.behind || 0,