forge-workflow 0.1.0-beta.2 → 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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- 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 +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- 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 +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- 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 +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- 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/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- 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/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
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,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR-monitor Tier-2 auto-actions — the SAFE, fail-closed *action* half of the
|
|
5
|
+
* shepherd (issue addf5297, epic c2d398e5). Tier-1 (lib/pr-pull.js computeVerdict
|
|
6
|
+
* + the pr-monitor workflow) LABELS a PR's state but takes no action, so PRs that
|
|
7
|
+
* only need "master merged in" sit untended until a human/agent nudges them.
|
|
8
|
+
*
|
|
9
|
+
* This module decides — PURELY, from the SAME `forge shepherd <pr> --pull --json`
|
|
10
|
+
* payload the monitor already computes — whether the monitor may take one of two
|
|
11
|
+
* surface-safe actions:
|
|
12
|
+
* 1. `updateBranch` — merge base into an OTHERWISE-CLEAN-but-BEHIND PR (the
|
|
13
|
+
* "last mile" case). This is the highest-value, safest action: it clears the
|
|
14
|
+
* BEHIND churn without ever touching a PR that has a real blocker.
|
|
15
|
+
* 2. `rerunFlaky` — re-run a required check whose failure is INFRASTRUCTURAL
|
|
16
|
+
* (cancelled / timed-out / stale / startup-failure), never a real test
|
|
17
|
+
* FAILURE/ERROR.
|
|
18
|
+
*
|
|
19
|
+
* It NEVER merges, NEVER resolves review threads, NEVER force-pushes, NEVER edits
|
|
20
|
+
* code. It only decides; the workflow executes the `gh` calls (and owns
|
|
21
|
+
* per-head-SHA idempotency markers). Every gate below is fail-CLOSED: any missing
|
|
22
|
+
* field, degraded read, real failure, fork, draft, or unclassifiable signal
|
|
23
|
+
* yields `should:false`.
|
|
24
|
+
*
|
|
25
|
+
* @module pr-monitor/auto-actions
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check conclusions that are INFRASTRUCTURAL flakes — a re-run may legitimately
|
|
30
|
+
* turn them green. Mirrors lib/pr-shepherd.js `isFailed`'s not-green terminal
|
|
31
|
+
* conclusions MINUS the genuinely-broken ones (FAILURE/ERROR/ACTION_REQUIRED).
|
|
32
|
+
*/
|
|
33
|
+
const INFRA_CONCLUSIONS = new Set(['CANCELLED', 'TIMED_OUT', 'STALE', 'STARTUP_FAILURE']);
|
|
34
|
+
|
|
35
|
+
/** Conclusions that mean the code is genuinely broken — NEVER auto-rerun these. */
|
|
36
|
+
const REAL_FAILURE_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'ACTION_REQUIRED']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull the numeric Actions run id from a job/run "details" URL
|
|
40
|
+
* (`.../actions/runs/<run>/job/<job>` or `.../actions/runs/<run>`). Returns null
|
|
41
|
+
* when absent — a null run id fails the rerun decision closed.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} url
|
|
44
|
+
* @returns {string | null}
|
|
45
|
+
*/
|
|
46
|
+
function runIdFromUrl(url) {
|
|
47
|
+
const m = String(url || '').match(/\/runs\/(\d+)/);
|
|
48
|
+
return m ? m[1] : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A payload is DEGRADED (some verdict-relevant read failed or the head moved
|
|
53
|
+
* mid-gather) when its evidence lists unreadable sources or a torn read. Acting
|
|
54
|
+
* on a degraded gather could act on stale/false state, so both actions fail
|
|
55
|
+
* closed on it — even though `verdict==='BEHIND'` already implies a clean read,
|
|
56
|
+
* this stays an explicit, independent guard.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} payload
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isDegraded(payload) {
|
|
62
|
+
const ev = (payload && payload.evidence) || {};
|
|
63
|
+
const unreadable = Array.isArray(ev.unreadable) ? ev.unreadable : [];
|
|
64
|
+
return unreadable.length > 0 || ev.tornRead === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decide whether to auto-update (merge base into) an otherwise-clean-but-BEHIND
|
|
69
|
+
* PR. Fires ONLY for the "last mile" case:
|
|
70
|
+
* - verdict is exactly `BEHIND` (which itself guarantees rank-1 UNKNOWN and
|
|
71
|
+
* rank-2 BLOCKED-CONFLICT did NOT fire — i.e. the read was clean and there is
|
|
72
|
+
* no conflict);
|
|
73
|
+
* - the PR is NOT a draft and NOT a fork (a base-repo token cannot push a fork
|
|
74
|
+
* branch, and forks are out of scope);
|
|
75
|
+
* - the read is not degraded;
|
|
76
|
+
* - and the ONLY blocker is the behind-base one — every other blocker type
|
|
77
|
+
* (failing/missing/skipped/pending required checks, bot-status gates,
|
|
78
|
+
* unresolved threads, changes-requested / review-required, conflict) is
|
|
79
|
+
* absent. `blockers[]` is computed independently of the verdict precedence,
|
|
80
|
+
* so it still lists lower-precedence blockers that `BEHIND` masks — which is
|
|
81
|
+
* exactly why we key on it rather than on the single verdict string.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} payload - the `--pull --json` payload.
|
|
84
|
+
* @param {{ isFork?: boolean }} [opts]
|
|
85
|
+
* @returns {{ should: boolean, reason: string }}
|
|
86
|
+
*/
|
|
87
|
+
function decideUpdateBranch(payload, opts = {}) {
|
|
88
|
+
const skip = (reason) => ({ should: false, reason });
|
|
89
|
+
if (!payload || typeof payload !== 'object') return skip('no payload — fail closed');
|
|
90
|
+
if (opts.isFork) return skip('fork PR — a base-repo token cannot update a fork branch');
|
|
91
|
+
if (isDegraded(payload)) return skip('degraded/torn read — fail closed');
|
|
92
|
+
if (payload.verdict !== 'BEHIND') return skip(`verdict ${payload.verdict || 'UNKNOWN'} is not BEHIND`);
|
|
93
|
+
if (payload.draft === true) return skip('draft PR — not ready to advance');
|
|
94
|
+
if (!Array.isArray(payload.blockers)) return skip('blockers[] unavailable — fail closed');
|
|
95
|
+
const others = payload.blockers.filter((b) => b && b.type !== 'behind');
|
|
96
|
+
if (others.length > 0) {
|
|
97
|
+
return skip(`other blocker(s) present: ${others.map((b) => b.type).join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
return { should: true, reason: 'otherwise-clean-behind — only blocker is behind-base; merge base in' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Decide whether to re-run flaky REQUIRED checks. Fires ONLY when EVERY failing
|
|
104
|
+
* required check is infrastructural (cancelled/timed-out/stale/startup-failure)
|
|
105
|
+
* with a derivable run id, and NONE is a real FAILURE/ERROR/ACTION_REQUIRED. A
|
|
106
|
+
* single real failure, an unclassifiable conclusion, a required-failing check
|
|
107
|
+
* with no matching `failures[]` entry, or a missing run id fails the WHOLE
|
|
108
|
+
* decision closed (never rerun a genuinely-broken PR, never loop on a real bug).
|
|
109
|
+
*
|
|
110
|
+
* @param {object} payload - the `--pull --json` payload.
|
|
111
|
+
* @returns {{ should: boolean, checks: Array<{name:string,conclusion:string,runId:string|null}>, runIds: string[], reason: string }}
|
|
112
|
+
*/
|
|
113
|
+
function decideRerun(payload) {
|
|
114
|
+
const empty = (reason) => ({ should: false, checks: [], runIds: [], reason });
|
|
115
|
+
if (!payload || typeof payload !== 'object') return empty('no payload — fail closed');
|
|
116
|
+
if (isDegraded(payload)) return empty('degraded/torn read — fail closed');
|
|
117
|
+
|
|
118
|
+
const rc = payload.requiredChecks || {};
|
|
119
|
+
const failingNames = Array.isArray(rc.failing) ? rc.failing : [];
|
|
120
|
+
if (failingNames.length === 0) return empty('no failing required checks');
|
|
121
|
+
|
|
122
|
+
const failures = Array.isArray(payload.failures) ? payload.failures : [];
|
|
123
|
+
const conclByName = new Map();
|
|
124
|
+
const urlByName = new Map();
|
|
125
|
+
for (const f of failures) {
|
|
126
|
+
if (!f || !f.name) continue;
|
|
127
|
+
if (!conclByName.has(f.name)) {
|
|
128
|
+
conclByName.set(f.name, String(f.conclusion || '').toUpperCase());
|
|
129
|
+
urlByName.set(f.name, f.jobUrl || f.detailsUrl || null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const picked = [];
|
|
134
|
+
for (const name of failingNames) {
|
|
135
|
+
const concl = conclByName.get(name);
|
|
136
|
+
if (!concl) return empty(`required check "${name}" has no known conclusion — cannot confirm flaky, fail closed`);
|
|
137
|
+
if (REAL_FAILURE_CONCLUSIONS.has(concl)) return empty(`required check "${name}" is a real failure (${concl}) — never rerun`);
|
|
138
|
+
if (!INFRA_CONCLUSIONS.has(concl)) return empty(`required check "${name}" conclusion ${concl} is not classified infrastructural — fail closed`);
|
|
139
|
+
picked.push({ name, conclusion: concl, runId: runIdFromUrl(urlByName.get(name)) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runIds = [...new Set(picked.map((p) => p.runId).filter(Boolean))];
|
|
143
|
+
if (runIds.length === 0) return empty('no run id derivable from failure jobUrl — fail closed');
|
|
144
|
+
return {
|
|
145
|
+
should: true,
|
|
146
|
+
checks: picked,
|
|
147
|
+
runIds,
|
|
148
|
+
reason: `all ${picked.length} failing required check(s) are infrastructural (${picked.map((p) => p.conclusion).join(', ')})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compute the full auto-action decision from a `--pull --json` payload. Pure and
|
|
154
|
+
* independently testable — no I/O, no `gh`, no side effects.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} payload
|
|
157
|
+
* @param {{ isFork?: boolean }} [opts]
|
|
158
|
+
* @returns {{ updateBranch: object, rerunFlaky: object }}
|
|
159
|
+
*/
|
|
160
|
+
function decideAutoActions(payload, opts = {}) {
|
|
161
|
+
return {
|
|
162
|
+
updateBranch: decideUpdateBranch(payload, opts),
|
|
163
|
+
rerunFlaky: decideRerun(payload),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
decideAutoActions,
|
|
169
|
+
decideUpdateBranch,
|
|
170
|
+
decideRerun,
|
|
171
|
+
runIdFromUrl,
|
|
172
|
+
isDegraded,
|
|
173
|
+
INFRA_CONCLUSIONS,
|
|
174
|
+
REAL_FAILURE_CONCLUSIONS,
|
|
175
|
+
};
|