forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -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/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- 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 +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- 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 +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- 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 +92 -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/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/pr-pull.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @module pr-pull
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
const { isFailed, isGreen
|
|
31
|
+
const { isFailed, isGreen } = require('./pr-shepherd');
|
|
32
32
|
const { fenceUntrusted } = require('./untrusted-content');
|
|
33
33
|
|
|
34
34
|
/** Token caps that keep the payload bounded regardless of PR size. */
|
|
@@ -43,6 +43,11 @@ const LOG_FETCH_MULTIPLIER = 3;
|
|
|
43
43
|
// re-review or a late reviewer comment inside the window means the PR is still
|
|
44
44
|
// in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
|
|
45
45
|
const DEFAULT_SETTLE_WINDOW_MS = 600000;
|
|
46
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeHeadSha(value) {
|
|
49
|
+
return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
|
|
50
|
+
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Bot logins whose review threads ARE actionable fixes (their comments tell you
|
|
@@ -320,7 +325,7 @@ function isSkipped(check) {
|
|
|
320
325
|
/** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
|
|
321
326
|
* a status context with no conclusion yet. */
|
|
322
327
|
function isPending(check) {
|
|
323
|
-
return !isGreen(check) && !isFailed(check);
|
|
328
|
+
return !isSkipped(check) && !isGreen(check) && !isFailed(check);
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
/**
|
|
@@ -680,9 +685,12 @@ function buildPullPayload({
|
|
|
680
685
|
|
|
681
686
|
return {
|
|
682
687
|
...prField(pr),
|
|
688
|
+
// DEPRECATED: `state` is the legacy decision-pass ladder, kept only for
|
|
689
|
+
// back-compat. It is now a PROJECTION of `verdict` (via verdictToLegacyState),
|
|
690
|
+
// never independently computed. Consumers MUST read `verdict` — the single,
|
|
691
|
+
// trustworthy, fail-closed merge vocabulary (never false-clean). Full removal
|
|
692
|
+
// of `state` is tracked as a follow-up kernel issue.
|
|
683
693
|
state,
|
|
684
|
-
// `verdict` is the trustworthy, fail-closed merge signal (never false-clean);
|
|
685
|
-
// `state` remains the legacy decision-pass state for back-compat.
|
|
686
694
|
...(verdict ? { verdict } : {}),
|
|
687
695
|
...(evidence ? { evidence } : {}),
|
|
688
696
|
summary,
|
|
@@ -862,12 +870,14 @@ function buildVerdictContext(input) {
|
|
|
862
870
|
/** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
|
|
863
871
|
* oid, or a torn read (head moved during the gather). */
|
|
864
872
|
function rankUnknown(v) {
|
|
865
|
-
const
|
|
873
|
+
const startValid = normalizeHeadSha(v.headOidStart) !== null;
|
|
874
|
+
const endValid = normalizeHeadSha(v.headOidEnd) !== null;
|
|
875
|
+
const torn = startValid && endValid && v.headOidStart !== v.headOidEnd;
|
|
866
876
|
v.evidence.tornRead = torn;
|
|
867
877
|
if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
|
|
868
878
|
v.evidence.unreadable.push('requiredChecks');
|
|
869
879
|
}
|
|
870
|
-
if (!
|
|
880
|
+
if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
|
|
871
881
|
v.evidence.unreadable.push('headOid');
|
|
872
882
|
}
|
|
873
883
|
return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
|
|
@@ -993,6 +1003,52 @@ function verdictLabel(verdict) {
|
|
|
993
1003
|
/** The full label reconcile set — one label per canonical verdict. */
|
|
994
1004
|
const VERDICT_LABELS = MERGE_VERDICTS.map((v) => `${VERDICT_LABEL_PREFIX}${v.toLowerCase()}`);
|
|
995
1005
|
|
|
1006
|
+
/**
|
|
1007
|
+
* The ONE place that maps the canonical 7-enum `verdict` onto the DEPRECATED
|
|
1008
|
+
* legacy `runShepherdPass` ladder (`pr-shepherd.js:34`). `verdict` is the single
|
|
1009
|
+
* consumer-facing vocabulary; the payload's `state` field is a back-compat
|
|
1010
|
+
* PROJECTION of the verdict through this map — never independently computed — so
|
|
1011
|
+
* the two can never disagree. Unknown/empty input fails closed to `UNKNOWN`.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {string} verdict - a canonical MERGE_VERDICTS value.
|
|
1014
|
+
* @returns {string} the deprecated legacy state.
|
|
1015
|
+
*/
|
|
1016
|
+
function verdictToLegacyState(verdict) {
|
|
1017
|
+
const v = String(verdict || '').toUpperCase();
|
|
1018
|
+
switch (v) {
|
|
1019
|
+
case 'CLEAN-MERGEABLE': return 'MERGE_READY';
|
|
1020
|
+
case 'REVIEW-PENDING': return 'NEEDS_REVIEW';
|
|
1021
|
+
case 'BLOCKED-THREADS': return 'NEEDS_REVIEW';
|
|
1022
|
+
case 'BLOCKED-CHECKS': return 'PENDING';
|
|
1023
|
+
// BEHIND maps to ESCALATE, not PENDING: the legacy runShepherdPass escalated a
|
|
1024
|
+
// behind head (handleBehindBase with --auto-rebase OFF, the default) so a human
|
|
1025
|
+
// rebases or opts into auto-rebase. Mapping it to PENDING would tell a legacy
|
|
1026
|
+
// `state` consumer to keep waiting on a PR that actually needs action.
|
|
1027
|
+
case 'BEHIND': return 'ESCALATE';
|
|
1028
|
+
case 'BLOCKED-CONFLICT': return 'ESCALATE';
|
|
1029
|
+
default: return 'UNKNOWN';
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* The back-compat legacy `state` for the `--pull` payload. A terminal GitHub
|
|
1035
|
+
* lifecycle (`MERGED`/`CLOSED`) wins over the merge verdict: the old dry-run
|
|
1036
|
+
* `runShepherdPass` hit `lifecycleOutcome` first (`pr-shepherd.js:403`) and
|
|
1037
|
+
* emitted `MERGED`/`CLOSED` so a legacy consumer stops polling / stops prompting a
|
|
1038
|
+
* human to merge an already-landed PR. `computeVerdict`'s 7-enum has no terminal
|
|
1039
|
+
* value, so we read the lifecycle (`adapter.readState().state`) here; only an open
|
|
1040
|
+
* PR falls through to the verdict projection.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {string} verdict - a canonical MERGE_VERDICTS value.
|
|
1043
|
+
* @param {string} [prLifecycleState] - readState().state: OPEN | MERGED | CLOSED.
|
|
1044
|
+
* @returns {string} the deprecated legacy state.
|
|
1045
|
+
*/
|
|
1046
|
+
function legacyStateFor(verdict, prLifecycleState) {
|
|
1047
|
+
const lifecycle = String(prLifecycleState || '').toUpperCase();
|
|
1048
|
+
if (lifecycle === 'MERGED' || lifecycle === 'CLOSED') return lifecycle;
|
|
1049
|
+
return verdictToLegacyState(verdict);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
996
1052
|
/**
|
|
997
1053
|
* Run an optional read and SURFACE any failure instead of swallowing it: on
|
|
998
1054
|
* throw, record `{ source, error }` into `degraded` (and optionally mark `source`
|
|
@@ -1067,7 +1123,6 @@ function gatherFailureExcerpts(runGh, checks, requiredSet, { maxFailures, maxExc
|
|
|
1067
1123
|
* @param {string} [ctx.self] - shepherd's own login
|
|
1068
1124
|
* @param {object} ctx.adapter - validated pr-state adapter
|
|
1069
1125
|
* @param {(args: string[]) => string} ctx.runGh - injected `gh` runner (args → stdout)
|
|
1070
|
-
* @param {Function} [ctx.runPass] - decision pass (default runShepherdPass), injectable for tests
|
|
1071
1126
|
* @param {number} [ctx.maxFailures]
|
|
1072
1127
|
* @param {number} [ctx.maxThreads]
|
|
1073
1128
|
* @param {number} [ctx.maxExcerptLines]
|
|
@@ -1091,9 +1146,14 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1091
1146
|
|
|
1092
1147
|
// readState is the one REQUIRED read — if it throws, the whole gather fails.
|
|
1093
1148
|
const state = await adapter.readState(pr);
|
|
1149
|
+
const headOidStart = normalizeHeadSha(state.headSha);
|
|
1150
|
+
if (!headOidStart) unreadable.push('headOid');
|
|
1151
|
+
if (state.providerEvidenceReadable === false) unreadable.push('providerEvidence');
|
|
1152
|
+
|
|
1094
1153
|
const requiredSet = await safeRead(
|
|
1095
1154
|
'requiredChecks',
|
|
1096
|
-
|
|
1155
|
+
// `pr` is included for diagnostic rollup evidence when protection is unreadable.
|
|
1156
|
+
() => adapter.readRequiredChecks({ owner, repo, base, pr }),
|
|
1097
1157
|
{ degraded, fallback: null },
|
|
1098
1158
|
);
|
|
1099
1159
|
|
|
@@ -1119,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1119
1179
|
// Branch divergence (behind base → needs an update/rebase).
|
|
1120
1180
|
const div = await safeRead(
|
|
1121
1181
|
'divergence',
|
|
1122
|
-
() => callIfPresent(
|
|
1123
|
-
|
|
1182
|
+
() => callIfPresent(
|
|
1183
|
+
adapter,
|
|
1184
|
+
'readDivergence',
|
|
1185
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1186
|
+
{ behind: 0 },
|
|
1187
|
+
),
|
|
1188
|
+
{ degraded, unreadable, fallback: { behind: 0 } },
|
|
1124
1189
|
);
|
|
1125
1190
|
const behind = div.behind || 0;
|
|
1126
1191
|
|
|
1127
1192
|
// Predicted merge conflicts (which files) — optional adapter capability.
|
|
1128
1193
|
const conflicts = await safeRead(
|
|
1129
1194
|
'conflicts',
|
|
1130
|
-
() => callIfPresent(
|
|
1131
|
-
|
|
1195
|
+
() => callIfPresent(
|
|
1196
|
+
adapter,
|
|
1197
|
+
'detectConflicts',
|
|
1198
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1199
|
+
null,
|
|
1200
|
+
),
|
|
1201
|
+
{ degraded, unreadable, fallback: null },
|
|
1132
1202
|
);
|
|
1133
1203
|
|
|
1134
1204
|
// Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
|
|
@@ -1164,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1164
1234
|
const headPushKnown = headPushTimeMs != null;
|
|
1165
1235
|
// Torn-read guard: re-read the head oid at the END of the gather.
|
|
1166
1236
|
const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
|
|
1167
|
-
const headOidEnd = endState
|
|
1237
|
+
const headOidEnd = normalizeHeadSha(endState && endState.headSha);
|
|
1238
|
+
if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
|
|
1168
1239
|
|
|
1169
1240
|
// Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
|
|
1170
1241
|
// filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
|
|
@@ -1174,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1174
1245
|
const unresolvedThreadCount = countUnresolvedThreads(threads);
|
|
1175
1246
|
|
|
1176
1247
|
const { verdict, evidence } = computeVerdict({
|
|
1177
|
-
headOidStart
|
|
1248
|
+
headOidStart,
|
|
1178
1249
|
headOidEnd,
|
|
1179
1250
|
mergeStateStatus: state.mergeStateStatus,
|
|
1180
1251
|
mergeable: state.mergeable,
|
|
@@ -1195,6 +1266,9 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1195
1266
|
unreadable,
|
|
1196
1267
|
});
|
|
1197
1268
|
|
|
1269
|
+
// Record the authoritative required-check source (`protection` or null).
|
|
1270
|
+
evidence.requiredSource = adapter.lastRequiredSource || null;
|
|
1271
|
+
|
|
1198
1272
|
return {
|
|
1199
1273
|
state, requiredSet, threads, behind, conflicts, issueComments,
|
|
1200
1274
|
botStatusBlockers, requiredChecks, pendingChecks, draft, reviewDecision,
|
|
@@ -1207,7 +1281,6 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1207
1281
|
async function gatherPullSignal(ctx) {
|
|
1208
1282
|
const {
|
|
1209
1283
|
pr, self, adapter, runGh,
|
|
1210
|
-
runPass = runShepherdPass,
|
|
1211
1284
|
maxFailures = DEFAULT_MAX_FAILURES,
|
|
1212
1285
|
maxThreads = DEFAULT_MAX_THREADS,
|
|
1213
1286
|
maxExcerptLines = DEFAULT_MAX_EXCERPT_LINES,
|
|
@@ -1230,12 +1303,12 @@ async function gatherPullSignal(ctx) {
|
|
|
1230
1303
|
requiredChecks, pendingChecks, draft, reviewDecision, verdict, evidence, degraded,
|
|
1231
1304
|
} = snap;
|
|
1232
1305
|
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1306
|
+
// `verdict` is the SINGLE consumer-facing vocabulary. The back-compat `state`
|
|
1307
|
+
// field is DERIVED from it (via legacyStateFor) — the read-only `--pull` payload
|
|
1308
|
+
// no longer runs a second decision pass, so the two vocabularies can never
|
|
1309
|
+
// disagree. A terminal lifecycle (MERGED/CLOSED) still wins over the verdict so
|
|
1310
|
+
// legacy consumers see the landed outcome, matching the old dry-run pass.
|
|
1311
|
+
const legacyState = legacyStateFor(verdict, state.state);
|
|
1239
1312
|
|
|
1240
1313
|
const failures = gatherFailureExcerpts(runGh, state.checks, requiredSet, { maxFailures, maxExcerptLines, degraded });
|
|
1241
1314
|
const reviewThreads = buildReviewThreads(threads, self, { maxThreads });
|
|
@@ -1254,7 +1327,7 @@ async function gatherPullSignal(ctx) {
|
|
|
1254
1327
|
});
|
|
1255
1328
|
|
|
1256
1329
|
const summary = summarize({
|
|
1257
|
-
state:
|
|
1330
|
+
state: legacyState,
|
|
1258
1331
|
failureCount: failures.length,
|
|
1259
1332
|
threadCount: reviewThreads.length,
|
|
1260
1333
|
blockers,
|
|
@@ -1262,11 +1335,10 @@ async function gatherPullSignal(ctx) {
|
|
|
1262
1335
|
|
|
1263
1336
|
return buildPullPayload({
|
|
1264
1337
|
pr,
|
|
1265
|
-
state:
|
|
1338
|
+
state: legacyState,
|
|
1266
1339
|
verdict,
|
|
1267
1340
|
evidence,
|
|
1268
1341
|
degraded,
|
|
1269
|
-
reason: pass.reason,
|
|
1270
1342
|
summary,
|
|
1271
1343
|
mergeable: state.mergeable || 'UNKNOWN',
|
|
1272
1344
|
mergeStateStatus: state.mergeStateStatus || 'UNKNOWN',
|
|
@@ -1299,6 +1371,8 @@ module.exports = {
|
|
|
1299
1371
|
renderPullSummary,
|
|
1300
1372
|
buildPullPayload,
|
|
1301
1373
|
computeVerdict,
|
|
1374
|
+
verdictToLegacyState,
|
|
1375
|
+
legacyStateFor,
|
|
1302
1376
|
MERGE_VERDICTS,
|
|
1303
1377
|
VERDICT_LABELS,
|
|
1304
1378
|
VERDICT_LABEL_PREFIX,
|
package/lib/pr-shepherd.js
CHANGED
|
@@ -32,6 +32,7 @@ const { fenceUntrusted } = require('./untrusted-content');
|
|
|
32
32
|
|
|
33
33
|
/** Non-erroring terminal states a pass can settle into. */
|
|
34
34
|
const TERMINAL_STATES = ['MERGE_READY', 'ESCALATE', 'PENDING', 'MERGED', 'CLOSED', 'NEEDS_REVIEW'];
|
|
35
|
+
const MERGE_READY_PROVIDER_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
|
|
35
36
|
|
|
36
37
|
// Review threads are classified BY MECHANISM, not by a bot-name list: a GitHub
|
|
37
38
|
// review THREAD is opened by a reviewer (a human OR any bot) and stays open until
|
|
@@ -72,9 +73,11 @@ function actionableComments(threads, _self) {
|
|
|
72
73
|
);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS'
|
|
76
|
+
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
|
|
76
77
|
|
|
77
78
|
function isGreen(check) {
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(check || {}, 'status')
|
|
80
|
+
|| String(check.status || '').toUpperCase() !== 'COMPLETED') return false;
|
|
78
81
|
const c = String(check.conclusion || '').toUpperCase();
|
|
79
82
|
return SUCCESS_CONCLUSIONS.has(c);
|
|
80
83
|
}
|
|
@@ -388,13 +391,18 @@ async function runShepherdPass(ctx) {
|
|
|
388
391
|
|
|
389
392
|
// Every read goes through the auth guard so 401/403-scope/rate-limit map to
|
|
390
393
|
// the documented PENDING/HARD_STOP states instead of escaping as a generic
|
|
391
|
-
// failure.
|
|
394
|
+
// failure. Other read errors are surfaced below as ESCALATE results.
|
|
392
395
|
|
|
393
396
|
// --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
|
|
394
397
|
// even when the branch-protection (required-checks) read would fail with an
|
|
395
398
|
// auth/scope error — the scheduler must always get the terminal signal for a
|
|
396
399
|
// landed/closed PR. ---
|
|
397
|
-
|
|
400
|
+
let stateRead;
|
|
401
|
+
try {
|
|
402
|
+
stateRead = await guardAuth(() => adapter.readState(pr), actions);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
|
|
405
|
+
}
|
|
398
406
|
if (stateRead.outcome) return stateRead.outcome;
|
|
399
407
|
const startState = stateRead.value;
|
|
400
408
|
const startSha = startState.headSha;
|
|
@@ -402,13 +410,24 @@ async function runShepherdPass(ctx) {
|
|
|
402
410
|
// --- Lifecycle: a merged/closed PR is terminal. ---
|
|
403
411
|
const lifecycle = lifecycleOutcome(startState.state, actions);
|
|
404
412
|
if (lifecycle) return lifecycle;
|
|
413
|
+
if (startState.providerEvidenceReadable === false) {
|
|
414
|
+
return result('ESCALATE', {
|
|
415
|
+
actions,
|
|
416
|
+
reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
405
419
|
|
|
406
420
|
// --- Required-checks set (only matters for non-terminal PRs); this is where
|
|
407
421
|
// auth/scope fails fast. ---
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
422
|
+
let requiredRead;
|
|
423
|
+
try {
|
|
424
|
+
requiredRead = await guardAuth(
|
|
425
|
+
() => adapter.readRequiredChecks({ owner, repo, base }),
|
|
426
|
+
actions,
|
|
427
|
+
);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
|
|
430
|
+
}
|
|
412
431
|
if (requiredRead.outcome) return requiredRead.outcome;
|
|
413
432
|
const required = requiredRead.value;
|
|
414
433
|
|
|
@@ -422,7 +441,7 @@ async function runShepherdPass(ctx) {
|
|
|
422
441
|
}
|
|
423
442
|
|
|
424
443
|
const divergenceRead = await guardAuth(
|
|
425
|
-
() => adapter.readDivergence({ baseRef, cwd }),
|
|
444
|
+
() => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
|
|
426
445
|
actions,
|
|
427
446
|
);
|
|
428
447
|
if (divergenceRead.outcome) return divergenceRead.outcome;
|
|
@@ -472,6 +491,13 @@ async function runShepherdPass(ctx) {
|
|
|
472
491
|
|
|
473
492
|
// --- Terminal: all required green + not behind → merge-ready handoff. ---
|
|
474
493
|
if (allRequiredGreen) {
|
|
494
|
+
const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
|
|
495
|
+
if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
|
|
496
|
+
return result('ESCALATE', {
|
|
497
|
+
actions,
|
|
498
|
+
reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
475
501
|
return result('MERGE_READY', {
|
|
476
502
|
actions,
|
|
477
503
|
reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
|
package/lib/preflight/gates.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
const crypto = require('node:crypto');
|
|
20
21
|
const { spawnSync } = require('node:child_process');
|
|
21
|
-
const { execFileSync } = require('node:child_process');
|
|
22
22
|
const nodeFs = require('node:fs');
|
|
23
|
-
const {
|
|
23
|
+
const { getTestCandidatesForChangedFile } = require('../commands/test');
|
|
24
|
+
const { selectDocAssertingTests } = require('../doc-assertions');
|
|
24
25
|
|
|
25
26
|
const IS_WINDOWS = process.platform === 'win32';
|
|
26
27
|
|
|
@@ -37,6 +38,36 @@ function statusSummary(result, okMsg, failMsg) {
|
|
|
37
38
|
: { ok: false, summary: failMsg };
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function compareStringsByCodePoint(left, right) {
|
|
42
|
+
if (left < right) return -1;
|
|
43
|
+
if (left > right) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeChangedFiles(files) {
|
|
48
|
+
if (!Array.isArray(files)) return Object.freeze([]);
|
|
49
|
+
return Object.freeze([...new Set(
|
|
50
|
+
files.map((file) => String(file).replaceAll('\\', '/')).filter((file) => file !== ''),
|
|
51
|
+
)].sort(compareStringsByCodePoint));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fingerprintChangedFiles(files) {
|
|
55
|
+
return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectAffectedTests(projectRoot, changedFiles, fs = nodeFs) {
|
|
59
|
+
const tests = new Set();
|
|
60
|
+
for (const file of changedFiles) {
|
|
61
|
+
for (const candidate of getTestCandidatesForChangedFile(file)) {
|
|
62
|
+
if (fs.existsSync(path.join(projectRoot, candidate))) tests.add(candidate);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
|
|
66
|
+
tests.add(candidate);
|
|
67
|
+
}
|
|
68
|
+
return [...tests].sort(compareStringsByCodePoint);
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* Gate 1 — ESLint on the change blast radius (or the whole tree with --all).
|
|
42
73
|
* `files === null` means "lint everything" (`eslint .`).
|
|
@@ -152,38 +183,50 @@ function runSonar(files, { projectRoot, spawn = spawnSync } = {}) {
|
|
|
152
183
|
*/
|
|
153
184
|
function runAffectedTests({
|
|
154
185
|
projectRoot,
|
|
155
|
-
changedFiles
|
|
186
|
+
changedFiles,
|
|
156
187
|
spawn = spawnSync,
|
|
157
188
|
resolveTests,
|
|
158
189
|
} = {}) {
|
|
190
|
+
const inputs = Array.isArray(changedFiles) && Object.isFrozen(changedFiles)
|
|
191
|
+
? changedFiles
|
|
192
|
+
: normalizeChangedFiles(changedFiles);
|
|
193
|
+
const inputFingerprint = fingerprintChangedFiles(inputs);
|
|
159
194
|
const resolver = typeof resolveTests === 'function'
|
|
160
195
|
? resolveTests
|
|
161
|
-
|
|
162
|
-
// otherwise a git error would masquerade as "no affected tests" (fast-lane
|
|
163
|
-
// green), a fail-OPEN. The catch below turns that throw into a closed gate.
|
|
164
|
-
: () => getAffectedTestFiles(projectRoot, execFileSync, nodeFs, { strict: true });
|
|
196
|
+
: (files) => selectAffectedTests(projectRoot, files);
|
|
165
197
|
let targets;
|
|
166
198
|
try {
|
|
167
|
-
targets = resolver() || [];
|
|
199
|
+
targets = resolver(inputs) || [];
|
|
168
200
|
} catch (err) {
|
|
169
201
|
// A resolver ERROR must NOT masquerade as "no affected tests" (green).
|
|
170
202
|
// We could not determine what to run, so fail closed — never a vacuous pass.
|
|
171
203
|
const reason = err && err.message ? err.message : String(err);
|
|
172
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
summary: `affected-test resolution failed — fail-closed (${reason})`,
|
|
207
|
+
inputFingerprint,
|
|
208
|
+
};
|
|
173
209
|
}
|
|
174
210
|
if (targets.length === 0) {
|
|
175
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
summary: 'no affected tests resolved (fast lane)',
|
|
214
|
+
inputFingerprint,
|
|
215
|
+
};
|
|
176
216
|
}
|
|
177
217
|
const result = spawn(
|
|
178
218
|
'bun',
|
|
179
219
|
['test', '--timeout', '15000', ...targets],
|
|
180
220
|
{ cwd: projectRoot, stdio: 'inherit', shell: IS_WINDOWS },
|
|
181
221
|
);
|
|
182
|
-
return
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
return {
|
|
223
|
+
...statusSummary(
|
|
224
|
+
result,
|
|
225
|
+
`${targets.length} affected test file(s) passed`,
|
|
226
|
+
'affected tests failed',
|
|
227
|
+
),
|
|
228
|
+
inputFingerprint,
|
|
229
|
+
};
|
|
187
230
|
}
|
|
188
231
|
|
|
189
232
|
/**
|
|
@@ -198,15 +241,18 @@ function runAffectedTests({
|
|
|
198
241
|
* @returns {{ name: string, run: () => Promise<{ok:boolean, summary?:string}> }[]}
|
|
199
242
|
*/
|
|
200
243
|
function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {} }) {
|
|
244
|
+
const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
|
|
245
|
+
const inputFingerprint = fingerprintChangedFiles(normalizedChangedFiles);
|
|
201
246
|
const eslint = deps.eslint || ((files) => runEslint(files, { projectRoot }));
|
|
202
247
|
const structural = deps.structural || (() => runStructural({ projectRoot }));
|
|
203
248
|
const sonar = deps.sonar || ((files) => runSonar(files, { projectRoot }));
|
|
204
|
-
const affected = deps.affected
|
|
249
|
+
const affected = deps.affected
|
|
250
|
+
|| ((files) => runAffectedTests({ projectRoot, changedFiles: files }));
|
|
205
251
|
|
|
206
252
|
// Under --all, scope BOTH lint and sonar to the whole tree (null). Otherwise
|
|
207
253
|
// sonar would receive changedFiles=[] and report a vacuous "no changed files"
|
|
208
254
|
// pass while lint scanned everything — a fail-open hole on the remedy path.
|
|
209
|
-
const scanTargets = runAll ? null :
|
|
255
|
+
const scanTargets = runAll ? null : normalizedChangedFiles;
|
|
210
256
|
|
|
211
257
|
// Affected-tests maps from the change set, which is empty under --all. Running
|
|
212
258
|
// the WHOLE suite here defeats preflight's fast purpose (and hangs on Windows),
|
|
@@ -216,8 +262,9 @@ function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {}
|
|
|
216
262
|
ok: true,
|
|
217
263
|
skipped: true,
|
|
218
264
|
summary: 'whole-tree mode (--all): affected-test mapping N/A — run the full suite (CI does)',
|
|
265
|
+
inputFingerprint,
|
|
219
266
|
})
|
|
220
|
-
: async () => affected();
|
|
267
|
+
: async () => affected(normalizedChangedFiles);
|
|
221
268
|
|
|
222
269
|
return [
|
|
223
270
|
{ name: 'lint', run: async () => eslint(scanTargets) },
|
package/lib/preflight/runner.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* @typedef {Object} GateOutcome
|
|
20
20
|
* @property {boolean} ok - Whether the gate passed.
|
|
21
21
|
* @property {string} [summary] - Short human-readable result line.
|
|
22
|
+
* @property {string} [inputFingerprint] - Exact input snapshot tested by the gate.
|
|
22
23
|
*
|
|
23
24
|
* @typedef {Object} Gate
|
|
24
25
|
* @property {string} name
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
* @property {boolean} skipped
|
|
31
32
|
* @property {string} summary
|
|
32
33
|
* @property {number} [durationMs]
|
|
34
|
+
* @property {string} [inputFingerprint]
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -66,6 +68,9 @@ async function executeGate(gate) {
|
|
|
66
68
|
skipped,
|
|
67
69
|
summary: outcome?.summary || '',
|
|
68
70
|
durationMs,
|
|
71
|
+
...(typeof outcome?.inputFingerprint === 'string'
|
|
72
|
+
? { inputFingerprint: outcome.inputFingerprint }
|
|
73
|
+
: {}),
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
|
package/lib/project-memory.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
3
5
|
const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
|
|
6
|
+
const { resolveGitCommonDir } = require('./kernel/broker');
|
|
4
7
|
const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
|
|
8
|
+
const { normalizeRecallHit } = require('./memory-recall');
|
|
5
9
|
|
|
6
10
|
// Project memory is a Forge read model persisted in the kernel store (kernel_memories),
|
|
7
11
|
// written DIRECTLY rather than through the issue CAS/guarded-event path. The store seam
|
|
@@ -30,6 +34,24 @@ function resolveStore(projectRoot, options = {}) {
|
|
|
30
34
|
return options.store ?? defaultStore(projectRoot, options);
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
function resolveProjectId(projectRoot, options = {}) {
|
|
38
|
+
const platform = options.platform || process.platform;
|
|
39
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
40
|
+
const commonDir = options.gitCommonDir || resolveGitCommonDir(projectRoot, options);
|
|
41
|
+
const absolute = pathImpl.resolve(projectRoot, commonDir);
|
|
42
|
+
const realpath = options.realpath || fs.realpathSync.native;
|
|
43
|
+
let canonical;
|
|
44
|
+
try {
|
|
45
|
+
canonical = realpath(absolute);
|
|
46
|
+
} catch {
|
|
47
|
+
canonical = absolute;
|
|
48
|
+
}
|
|
49
|
+
canonical = canonical.replaceAll('\\', '/');
|
|
50
|
+
return platform === 'win32'
|
|
51
|
+
? canonical.toLowerCase()
|
|
52
|
+
: canonical;
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
function assertEntryObject(entry) {
|
|
34
56
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
35
57
|
throw new TypeError('project memory entry must be an object');
|
|
@@ -140,6 +162,22 @@ function searchRanked(projectRoot, query, limit, options = {}) {
|
|
|
140
162
|
return resolveStore(projectRoot, options).searchMemoriesRanked(query, limit);
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
// Relevance-only BM25 recall that returns the raw bm25 `score` per entry, so a caller can
|
|
166
|
+
// apply a relevance floor. A no-match/empty query returns [] (no recency fallback). The
|
|
167
|
+
// per-turn memory-recall hook uses this to inject nothing unless a note clearly matches.
|
|
168
|
+
function searchRankedScored(projectRoot, query, limit, options = {}) {
|
|
169
|
+
const projectId = resolveProjectId(projectRoot, options);
|
|
170
|
+
const searchOptions = {
|
|
171
|
+
projectId,
|
|
172
|
+
excludeKeys: options.excludeKeys || [],
|
|
173
|
+
...(options.now ? { now: options.now } : {}),
|
|
174
|
+
...(options.busyTimeoutMs !== undefined ? { busyTimeoutMs: options.busyTimeoutMs } : {}),
|
|
175
|
+
};
|
|
176
|
+
return resolveStore(projectRoot, options)
|
|
177
|
+
.searchMemoriesRankedScored(query, limit, searchOptions)
|
|
178
|
+
.map(hit => normalizeRecallHit(hit, projectId));
|
|
179
|
+
}
|
|
180
|
+
|
|
143
181
|
// Close and forget every cached default store. The CLI process is short-lived (the OS
|
|
144
182
|
// closes the handle on exit), so this is mainly a lifecycle helper for long-lived hosts and
|
|
145
183
|
// tests — it releases the SQLite/WAL handle before a temp dir is removed.
|
|
@@ -162,5 +200,7 @@ module.exports = {
|
|
|
162
200
|
recent,
|
|
163
201
|
count,
|
|
164
202
|
searchRanked,
|
|
203
|
+
searchRankedScored,
|
|
204
|
+
resolveProjectId,
|
|
165
205
|
closeAll,
|
|
166
206
|
};
|