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.
- package/AGENTS.md +1 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +5 -0
- package/docs/reference/COMMANDS.md +3 -1
- package/docs/reference/shepherd.md +72 -1
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/agents-config.js +5 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/hooks.js +155 -8
- package/lib/commands/plan.js +23 -115
- package/lib/commands/prime.js +8 -1
- package/lib/commands/release.js +1 -2
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +0 -1
- package/lib/commands/shepherd.js +36 -3
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +27 -1
- package/lib/core/runtime-graph.js +88 -14
- package/lib/forge-issues.js +13 -464
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +54 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +150 -0
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +14 -0
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +1 -1
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +70 -15
- package/lib/project-memory.js +8 -0
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +8 -9
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +1 -1
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- 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
|
+
};
|
package/lib/orientation.js
CHANGED
|
@@ -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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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,
|