forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- 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 +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -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/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -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 +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/pr-monitor/gather.js
CHANGED
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
|
|
18
18
|
const { gatherPrSnapshot } = require('../pr-pull');
|
|
19
19
|
const { isFailed, isGreen } = require('../pr-shepherd');
|
|
20
|
+
const { classifyReviewActor, normalizeEvidenceText } = require('../review-adapter');
|
|
21
|
+
|
|
22
|
+
const SNAPSHOT_LIMITS = Object.freeze({
|
|
23
|
+
maxChecks: 100,
|
|
24
|
+
maxThreads: 100,
|
|
25
|
+
maxReviews: 100,
|
|
26
|
+
maxComments: 100,
|
|
27
|
+
maxDegraded: 32,
|
|
28
|
+
maxTextChars: 256,
|
|
29
|
+
});
|
|
20
30
|
|
|
21
31
|
/** green | failed | pending — reuses the SAME predicates as the verdict core. */
|
|
22
32
|
function classifyCheck(check) {
|
|
@@ -25,45 +35,200 @@ function classifyCheck(check) {
|
|
|
25
35
|
return 'pending';
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
function
|
|
29
|
-
return (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
function compareBy(field) {
|
|
39
|
+
return (left, right) => {
|
|
40
|
+
const a = String(left[field] ?? '');
|
|
41
|
+
const b = String(right[field] ?? '');
|
|
42
|
+
if (a < b) return -1;
|
|
43
|
+
if (a > b) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function evidenceText(value) {
|
|
49
|
+
return normalizeEvidenceText(value, { maxChars: SNAPSHOT_LIMITS.maxTextChars });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function identifiedSurface(value) {
|
|
53
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
54
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
55
|
+
return value.source || value.surface || null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addIdentifiedSurface(value, add, error) {
|
|
61
|
+
const surface = identifiedSurface(value);
|
|
62
|
+
if (surface) add(surface, error);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createEvidenceAdder(degraded, incompleteSurfaces) {
|
|
66
|
+
return (surface, error) => {
|
|
67
|
+
const safeSurface = evidenceText(surface) || 'unknown';
|
|
68
|
+
const safeError = evidenceText(error) || 'evidence unavailable';
|
|
69
|
+
incompleteSurfaces.add(safeSurface);
|
|
70
|
+
if (!degraded.some((item) => item.surface === safeSurface && item.error === safeError)
|
|
71
|
+
&& degraded.length < SNAPSHOT_LIMITS.maxDegraded) {
|
|
72
|
+
degraded.push({ surface: safeSurface, error: safeError });
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function trackDegradedItem(item, add) {
|
|
78
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
79
|
+
add('degraded', 'degraded evidence item is malformed');
|
|
80
|
+
addIdentifiedSurface(item, add, 'degraded evidence metadata is malformed');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
add(item.source || item.surface || 'unknown', item.error || 'evidence unavailable');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function trackInitialDegraded(initialDegraded, add) {
|
|
87
|
+
if (!Array.isArray(initialDegraded)) {
|
|
88
|
+
add('degraded', 'degraded evidence is malformed');
|
|
89
|
+
addIdentifiedSurface(initialDegraded, add, 'degraded evidence metadata is malformed');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const item of initialDegraded.slice(0, SNAPSHOT_LIMITS.maxDegraded)) {
|
|
93
|
+
trackDegradedItem(item, add);
|
|
94
|
+
}
|
|
95
|
+
if (initialDegraded.length > SNAPSHOT_LIMITS.maxDegraded) {
|
|
96
|
+
add('degraded', 'degraded evidence exceeded item limit');
|
|
97
|
+
}
|
|
33
98
|
}
|
|
34
99
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
actionable: !t.isResolved && !t.isOutdated,
|
|
42
|
-
path: t.path || null,
|
|
43
|
-
}));
|
|
100
|
+
function createEvidenceTracker(initialDegraded) {
|
|
101
|
+
const degraded = [];
|
|
102
|
+
const incompleteSurfaces = new Set();
|
|
103
|
+
const add = createEvidenceAdder(degraded, incompleteSurfaces);
|
|
104
|
+
trackInitialDegraded(initialDegraded, add);
|
|
105
|
+
return { add, degraded, incompleteSurfaces };
|
|
44
106
|
}
|
|
45
107
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
108
|
+
function trackUnreadable(value, tracker) {
|
|
109
|
+
if (!Array.isArray(value)) {
|
|
110
|
+
tracker.add('unreadable', 'unreadable evidence is malformed');
|
|
111
|
+
const surface = identifiedSurface(value);
|
|
112
|
+
if (surface) tracker.add(surface, 'unreadable evidence metadata is malformed');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const item of value) {
|
|
116
|
+
if (typeof item === 'string' && item.trim()) {
|
|
117
|
+
tracker.add(item, 'provider read was incomplete');
|
|
118
|
+
} else {
|
|
119
|
+
tracker.add('unreadable', 'unreadable evidence item is malformed');
|
|
120
|
+
const surface = identifiedSurface(item);
|
|
121
|
+
if (surface) tracker.add(surface, 'unreadable evidence metadata is malformed');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
53
124
|
}
|
|
54
125
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
126
|
+
function boundedNormalize(value, { surface, limit, normalize, tracker, sortField }) {
|
|
127
|
+
if (!Array.isArray(value)) {
|
|
128
|
+
tracker.add(surface, `${surface} evidence is missing or malformed`);
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
if (value.length > limit) tracker.add(surface, `${surface} evidence exceeded ${limit} item limit`);
|
|
132
|
+
const output = [];
|
|
133
|
+
for (const item of value.slice(0, limit)) {
|
|
134
|
+
try {
|
|
135
|
+
output.push(normalize(item));
|
|
136
|
+
} catch (_error) {
|
|
137
|
+
tracker.add(surface, `${surface} evidence item is malformed`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return output.sort(compareBy(sortField));
|
|
60
141
|
}
|
|
61
142
|
|
|
62
|
-
function
|
|
63
|
-
return (
|
|
64
|
-
surface:
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
function normalizeChecks(checks, tracker) {
|
|
144
|
+
return boundedNormalize(checks, {
|
|
145
|
+
surface: 'checks',
|
|
146
|
+
limit: SNAPSHOT_LIMITS.maxChecks,
|
|
147
|
+
tracker,
|
|
148
|
+
sortField: 'name',
|
|
149
|
+
normalize: (check) => {
|
|
150
|
+
if (!check || typeof check !== 'object' || Array.isArray(check)
|
|
151
|
+
|| typeof check.name !== 'string' || !check.name.trim()
|
|
152
|
+
|| (typeof check.status !== 'string' && typeof check.conclusion !== 'string')) {
|
|
153
|
+
throw new TypeError('malformed check');
|
|
154
|
+
}
|
|
155
|
+
return { name: evidenceText(check.name), class: classifyCheck(check) };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeActor(value) {
|
|
161
|
+
const author = evidenceText(value.author);
|
|
162
|
+
const actorKind = classifyReviewActor(value);
|
|
163
|
+
if (!author || actorKind === 'unknown') throw new TypeError('malformed actor');
|
|
164
|
+
return { author, actorKind };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeThreads(threads, tracker) {
|
|
168
|
+
return boundedNormalize(threads, {
|
|
169
|
+
surface: 'threads',
|
|
170
|
+
limit: SNAPSHOT_LIMITS.maxThreads,
|
|
171
|
+
tracker,
|
|
172
|
+
sortField: 'threadId',
|
|
173
|
+
normalize: (thread) => {
|
|
174
|
+
if (!thread || typeof thread !== 'object' || Array.isArray(thread)
|
|
175
|
+
|| typeof thread.threadId !== 'string' || !thread.threadId
|
|
176
|
+
|| typeof thread.isResolved !== 'boolean'
|
|
177
|
+
|| typeof thread.isOutdated !== 'boolean'
|
|
178
|
+
|| !Array.isArray(thread.comments) || thread.comments.length === 0
|
|
179
|
+
|| (thread.path !== null && thread.path !== undefined && typeof thread.path !== 'string')) {
|
|
180
|
+
throw new TypeError('malformed thread');
|
|
181
|
+
}
|
|
182
|
+
const actor = normalizeActor(thread.comments[0]);
|
|
183
|
+
return {
|
|
184
|
+
threadId: evidenceText(thread.threadId),
|
|
185
|
+
isResolved: thread.isResolved,
|
|
186
|
+
isOutdated: thread.isOutdated,
|
|
187
|
+
commentCount: thread.comments.length,
|
|
188
|
+
actionable: !thread.isResolved && !thread.isOutdated,
|
|
189
|
+
path: thread.path ? evidenceText(thread.path) : null,
|
|
190
|
+
...actor,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeReviews(reviews, tracker) {
|
|
197
|
+
return boundedNormalize(reviews, {
|
|
198
|
+
surface: 'reviews',
|
|
199
|
+
limit: SNAPSHOT_LIMITS.maxReviews,
|
|
200
|
+
tracker,
|
|
201
|
+
sortField: 'author',
|
|
202
|
+
normalize: (review) => {
|
|
203
|
+
if (!review || typeof review !== 'object' || Array.isArray(review)
|
|
204
|
+
|| typeof review.state !== 'string' || !review.state) {
|
|
205
|
+
throw new TypeError('malformed review');
|
|
206
|
+
}
|
|
207
|
+
const actor = normalizeActor(review);
|
|
208
|
+
return {
|
|
209
|
+
...actor,
|
|
210
|
+
state: evidenceText(review.state),
|
|
211
|
+
commitOid: review.commitOid == null ? null : evidenceText(review.commitOid),
|
|
212
|
+
submittedAt: review.submittedAt == null ? null : evidenceText(review.submittedAt),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeComments(comments, tracker) {
|
|
219
|
+
return boundedNormalize(comments, {
|
|
220
|
+
surface: 'comments',
|
|
221
|
+
limit: SNAPSHOT_LIMITS.maxComments,
|
|
222
|
+
tracker,
|
|
223
|
+
sortField: 'id',
|
|
224
|
+
normalize: (comment) => {
|
|
225
|
+
if (!comment || typeof comment !== 'object' || Array.isArray(comment)
|
|
226
|
+
|| typeof comment.id !== 'string' || !comment.id) {
|
|
227
|
+
throw new TypeError('malformed comment');
|
|
228
|
+
}
|
|
229
|
+
return { id: evidenceText(comment.id), ...normalizeActor(comment) };
|
|
230
|
+
},
|
|
231
|
+
});
|
|
67
232
|
}
|
|
68
233
|
|
|
69
234
|
/**
|
|
@@ -76,6 +241,30 @@ function conflictBool(conflicts) {
|
|
|
76
241
|
return null;
|
|
77
242
|
}
|
|
78
243
|
|
|
244
|
+
function objectOrEmpty(value) {
|
|
245
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createSnapshotTracker(source, state) {
|
|
249
|
+
const tracker = createEvidenceTracker(Object.hasOwn(source, 'degraded') ? source.degraded : []);
|
|
250
|
+
if (state !== source.state) tracker.add('state', 'PR state evidence is missing or malformed');
|
|
251
|
+
if (Object.hasOwn(source, 'unreadable')) trackUnreadable(source.unreadable, tracker);
|
|
252
|
+
return tracker;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeBehind(source, tracker) {
|
|
256
|
+
const behind = Number.isSafeInteger(source.behind) && source.behind >= 0 ? source.behind : 0;
|
|
257
|
+
if (behind !== source.behind && source.behind !== undefined) {
|
|
258
|
+
tracker.add('branch', 'branch divergence evidence is malformed');
|
|
259
|
+
}
|
|
260
|
+
return behind;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function deriveThreadState(tracker, openThreadCount) {
|
|
264
|
+
if (tracker.incompleteSurfaces.has('threads')) return 'INCOMPLETE';
|
|
265
|
+
return openThreadCount > 0 ? 'OPEN' : 'ZERO';
|
|
266
|
+
}
|
|
267
|
+
|
|
79
268
|
/**
|
|
80
269
|
* Normalize a raw `gatherPrSnapshot` result into the monitor diff subject.
|
|
81
270
|
*
|
|
@@ -84,21 +273,38 @@ function conflictBool(conflicts) {
|
|
|
84
273
|
* @returns {object}
|
|
85
274
|
*/
|
|
86
275
|
function normalizeSnapshot(snap, ctx) {
|
|
87
|
-
const
|
|
276
|
+
const source = objectOrEmpty(snap);
|
|
277
|
+
const state = objectOrEmpty(source.state);
|
|
278
|
+
const tracker = createSnapshotTracker(source, state);
|
|
279
|
+
const checks = normalizeChecks(state.checks, tracker);
|
|
280
|
+
const threads = normalizeThreads(source.threads, tracker);
|
|
281
|
+
const reviews = normalizeReviews(source.reviews, tracker);
|
|
282
|
+
const comments = normalizeComments(source.issueComments, tracker);
|
|
283
|
+
const behind = normalizeBehind(source, tracker);
|
|
284
|
+
const openThreadCount = threads.filter((thread) => thread.actionable).length;
|
|
285
|
+
const evidenceStatus = tracker.degraded.length === 0 ? 'COMPLETE' : 'INCOMPLETE';
|
|
286
|
+
const threadState = deriveThreadState(tracker, openThreadCount);
|
|
287
|
+
const degraded = [...tracker.degraded].sort(compareBy('surface'));
|
|
88
288
|
return {
|
|
89
|
-
repo: ctx
|
|
90
|
-
pr:
|
|
91
|
-
headSha: state.headSha
|
|
289
|
+
repo: evidenceText(ctx?.repo),
|
|
290
|
+
pr: evidenceText(ctx?.pr),
|
|
291
|
+
headSha: evidenceText(state.headSha),
|
|
92
292
|
prState: String(state.state || 'OPEN').toUpperCase(),
|
|
93
|
-
draft: Boolean(
|
|
94
|
-
verdict: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
293
|
+
draft: Boolean(source.draft),
|
|
294
|
+
verdict: {
|
|
295
|
+
state: evidenceStatus === 'COMPLETE' ? (source.verdict || 'UNKNOWN') : 'UNKNOWN',
|
|
296
|
+
reason: evidenceStatus === 'COMPLETE' ? null : 'review evidence incomplete',
|
|
297
|
+
},
|
|
298
|
+
evidenceStatus,
|
|
299
|
+
threadState,
|
|
300
|
+
openThreadCount,
|
|
301
|
+
checks,
|
|
302
|
+
threads,
|
|
303
|
+
reviews,
|
|
304
|
+
comments,
|
|
305
|
+
behind,
|
|
306
|
+
conflicts: conflictBool(source.conflicts),
|
|
307
|
+
degraded,
|
|
102
308
|
};
|
|
103
309
|
}
|
|
104
310
|
|
|
@@ -117,6 +323,7 @@ async function gatherMonitorSnapshot(ctx) {
|
|
|
117
323
|
}
|
|
118
324
|
|
|
119
325
|
module.exports = {
|
|
326
|
+
SNAPSHOT_LIMITS,
|
|
120
327
|
gatherMonitorSnapshot,
|
|
121
328
|
normalizeSnapshot,
|
|
122
329
|
classifyCheck,
|
|
@@ -35,21 +35,35 @@ function sanitize(part) {
|
|
|
35
35
|
return String(part || '').replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the one journal authority shared by every worktree. Standard repos
|
|
40
|
+
* use `<main-root>/.forge/pr-monitor`; a nonstandard common dir centralizes
|
|
41
|
+
* beneath that shared metadata directory. No common dir preserves legacy use.
|
|
42
|
+
*/
|
|
43
|
+
function resolveJournalRoot({ root, gitCommonDir }) {
|
|
44
|
+
const fallbackRoot = path.resolve(root);
|
|
45
|
+
if (!gitCommonDir) return path.join(fallbackRoot, '.forge', 'pr-monitor');
|
|
46
|
+
const commonDir = path.resolve(gitCommonDir);
|
|
47
|
+
if (path.basename(commonDir).toLowerCase() === '.git') {
|
|
48
|
+
return path.join(path.dirname(commonDir), '.forge', 'pr-monitor');
|
|
49
|
+
}
|
|
50
|
+
return path.join(commonDir, 'forge', 'pr-monitor');
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* Resolve (and create) the per-PR journal directory.
|
|
40
55
|
*
|
|
41
56
|
* @param {{ root: string, repo: string, pr: string|number }} ctx
|
|
42
57
|
* @returns {string} absolute directory path
|
|
43
58
|
*/
|
|
44
|
-
function journalDir({ root, repo, pr }) {
|
|
45
|
-
const dir = path.join(root,
|
|
59
|
+
function journalDir({ root, gitCommonDir, repo, pr }) {
|
|
60
|
+
const dir = path.join(resolveJournalRoot({ root, gitCommonDir }), `${sanitize(repo)}-${sanitize(pr)}`);
|
|
46
61
|
fs.mkdirSync(dir, { recursive: true });
|
|
47
62
|
return dir;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
function journalPath(dir) { return path.join(dir, 'events.ndjson'); }
|
|
51
66
|
function snapshotPath(dir) { return path.join(dir, 'snapshot.json'); }
|
|
52
|
-
function pidPath(dir) { return path.join(dir, 'watch.pid'); }
|
|
53
67
|
function lockPath(dir) { return path.join(dir, 'journal.lock'); }
|
|
54
68
|
|
|
55
69
|
/**
|
|
@@ -239,49 +253,18 @@ async function withJournalLock(dir, fn, opts = {}) {
|
|
|
239
253
|
}
|
|
240
254
|
}
|
|
241
255
|
|
|
242
|
-
/** Write the watcher pid file. */
|
|
243
|
-
function writePid(dir, pid) {
|
|
244
|
-
fs.writeFileSync(pidPath(dir), String(pid == null ? process.pid : pid));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Read the watcher pid (number) or null. */
|
|
248
|
-
function readPid(dir) {
|
|
249
|
-
const file = pidPath(dir);
|
|
250
|
-
if (!fs.existsSync(file)) return null;
|
|
251
|
-
const n = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
|
|
252
|
-
return Number.isFinite(n) ? n : null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Remove the watcher pid file (best-effort). */
|
|
256
|
-
function removePid(dir) {
|
|
257
|
-
try { fs.unlinkSync(pidPath(dir)); } catch { /* already gone */ }
|
|
258
|
-
}
|
|
259
|
-
|
|
260
256
|
/** Is `pid` a live process? `process.kill(pid, 0)` probes without signaling. */
|
|
261
257
|
function pidAlive(pid) {
|
|
262
258
|
if (!pid) return false;
|
|
263
259
|
try { process.kill(pid, 0); return true; } catch (err) { return err?.code === 'EPERM'; }
|
|
264
260
|
}
|
|
265
261
|
|
|
266
|
-
/**
|
|
267
|
-
* A watcher is "running" when the pid file names a live process that is NOT us.
|
|
268
|
-
* Stale pid files (dead process) return false so a poll falls back to an inline
|
|
269
|
-
* pass.
|
|
270
|
-
*
|
|
271
|
-
* @param {string} dir
|
|
272
|
-
* @returns {boolean}
|
|
273
|
-
*/
|
|
274
|
-
function watcherRunning(dir) {
|
|
275
|
-
const pid = readPid(dir);
|
|
276
|
-
return pidAlive(pid) && pid !== process.pid;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
262
|
module.exports = {
|
|
280
263
|
sanitize,
|
|
264
|
+
resolveJournalRoot,
|
|
281
265
|
journalDir,
|
|
282
266
|
journalPath,
|
|
283
267
|
snapshotPath,
|
|
284
|
-
pidPath,
|
|
285
268
|
lockPath,
|
|
286
269
|
withJournalLock,
|
|
287
270
|
readAllEvents,
|
|
@@ -291,9 +274,5 @@ module.exports = {
|
|
|
291
274
|
appendEvents,
|
|
292
275
|
readSnapshot,
|
|
293
276
|
writeSnapshot,
|
|
294
|
-
writePid,
|
|
295
|
-
readPid,
|
|
296
|
-
removePid,
|
|
297
277
|
pidAlive,
|
|
298
|
-
watcherRunning,
|
|
299
278
|
};
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
const { finalizeEvent, eventIdentity, fingerprint } = require('./events');
|
|
12
12
|
const { diffSnapshots } = require('./differ');
|
|
13
13
|
const journal = require('./journal');
|
|
14
|
+
const { launchGateComplete } = require('./watch-lifecycle');
|
|
15
|
+
|
|
16
|
+
const MAX_EVENT_DELTAS = 128;
|
|
17
|
+
const ACTIVE_OWNER_PHASES = new Set(['starting', 'running', 'stop_requested', 'terminal_pending']);
|
|
14
18
|
|
|
15
19
|
/** ISO-8601 timestamp; injectable via ctx.now for deterministic tests. */
|
|
16
20
|
function defaultNow() { return new Date().toISOString(); }
|
|
@@ -37,6 +41,35 @@ function finalizeRecords(candidates, { baseSeq, ts, snapshot }) {
|
|
|
37
41
|
}));
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function createCompatibilityAppender(dir) {
|
|
45
|
+
const deliveredIds = new Set(journal.readAllEvents(dir)
|
|
46
|
+
.map(event => event.memoryEventId).filter(Boolean));
|
|
47
|
+
const snapshotRecord = journal.readSnapshot(dir);
|
|
48
|
+
const appliedSeq = snapshotRecord?.appliedSeq || 0;
|
|
49
|
+
const seen = journal.seenIdentities(dir, appliedSeq);
|
|
50
|
+
const journalCursor = journal.lastSeq(dir);
|
|
51
|
+
let nextSeq = journalCursor;
|
|
52
|
+
return {
|
|
53
|
+
journalCursor,
|
|
54
|
+
append(record, memoryEventId) {
|
|
55
|
+
const identity = eventIdentity(record);
|
|
56
|
+
if ((memoryEventId && deliveredIds.has(memoryEventId)) || seen.has(identity)) return;
|
|
57
|
+
nextSeq += 1;
|
|
58
|
+
journal.appendEvents(dir, [{ ...record, seq: nextSeq, ...(memoryEventId ? { memoryEventId } : {}) }]);
|
|
59
|
+
if (memoryEventId) deliveredIds.add(memoryEventId);
|
|
60
|
+
seen.add(identity);
|
|
61
|
+
},
|
|
62
|
+
finish(snapshot) {
|
|
63
|
+
if (!snapshot) return;
|
|
64
|
+
journal.writeSnapshot(dir, {
|
|
65
|
+
snapshot,
|
|
66
|
+
fingerprint: fingerprint(snapshot),
|
|
67
|
+
appliedSeq: nextSeq,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
/**
|
|
41
74
|
* The read→diff→dedup→sequence→append→snapshot critical section of a single
|
|
42
75
|
* pass. Runs ONLY while holding the journal lock (see `runMonitorPass`), so it
|
|
@@ -105,12 +138,40 @@ async function runMonitorPassLocked(ctx) {
|
|
|
105
138
|
* @returns {Promise<{ events: object[], changed: boolean, fingerprint: string }>}
|
|
106
139
|
*/
|
|
107
140
|
async function runMonitorPass(ctx) {
|
|
108
|
-
|
|
141
|
+
if (ctx.store) {
|
|
142
|
+
const { runFlowMonitorPass } = require('./flow-monitor');
|
|
143
|
+
return journal.withJournalLock(ctx.dir, async () => {
|
|
144
|
+
let latestSnapshot;
|
|
145
|
+
const compatibility = createCompatibilityAppender(ctx.dir);
|
|
146
|
+
const deliverLegacy = ctx.deliverLegacy || (async (record, memoryEventId) => {
|
|
147
|
+
compatibility.append(record, memoryEventId);
|
|
148
|
+
});
|
|
149
|
+
const result = await runFlowMonitorPass({
|
|
150
|
+
...ctx,
|
|
151
|
+
gather: async () => {
|
|
152
|
+
const snapshot = await ctx.gather();
|
|
153
|
+
latestSnapshot = snapshot;
|
|
154
|
+
return snapshot;
|
|
155
|
+
},
|
|
156
|
+
deliverLegacy,
|
|
157
|
+
});
|
|
158
|
+
if (!ctx.deliverLegacy) compatibility.finish(latestSnapshot);
|
|
159
|
+
return { ...result, journalCursor: compatibility.journalCursor };
|
|
160
|
+
}, ctx.lockOpts);
|
|
161
|
+
}
|
|
162
|
+
return journal.withJournalLock(ctx.dir, async () => {
|
|
163
|
+
const journalCursor = journal.lastSeq(ctx.dir);
|
|
164
|
+
const result = await runMonitorPassLocked(ctx);
|
|
165
|
+
return { ...result, journalCursor };
|
|
166
|
+
}, ctx.lockOpts);
|
|
109
167
|
}
|
|
110
168
|
|
|
111
169
|
/**
|
|
112
|
-
* `forge shepherd events <pr> --since <seq>` core: run one inline pass when
|
|
113
|
-
* watcher owns this PR
|
|
170
|
+
* `forge shepherd events <pr> --since <seq>` core: run one inline pass when the
|
|
171
|
+
* Kernel owner row proves no watcher owns this PR AND the shared migration gate
|
|
172
|
+
* proves EXACTLY complete, then return every journaled
|
|
173
|
+
* event with `seq > since`. PID files are compatibility evidence only and are
|
|
174
|
+
* never consulted for this decision.
|
|
114
175
|
* This is the agent-agnostic PULL surface — stdout NDJSON, nothing under .claude.
|
|
115
176
|
*
|
|
116
177
|
* @param {object} ctx
|
|
@@ -118,22 +179,67 @@ async function runMonitorPass(ctx) {
|
|
|
118
179
|
* @param {() => Promise<object>} ctx.gather
|
|
119
180
|
* @param {number} [ctx.since]
|
|
120
181
|
* @param {() => string} [ctx.now]
|
|
121
|
-
* @param {(dir: string) => boolean} [ctx.watcherRunning]
|
|
122
182
|
* @param {(records: object[]) => Promise<void>|void} [ctx.enrich]
|
|
123
|
-
* @returns {Promise<{ events: object[], since: number, ranPass: boolean
|
|
183
|
+
* @returns {Promise<{ events: object[], since: number, ranPass: boolean,
|
|
184
|
+
* migrationGateIncomplete?: boolean }>}
|
|
124
185
|
*/
|
|
125
186
|
async function pollEvents(ctx) {
|
|
126
187
|
const { dir, gather, since = 0 } = ctx;
|
|
127
|
-
const
|
|
188
|
+
const checkOwner = typeof ctx.isOwnerRunning === 'function'
|
|
189
|
+
? ctx.isOwnerRunning
|
|
190
|
+
: (ctx.owner && typeof ctx.owner.readOwner === 'function' && ctx.repo && Number(ctx.pr) > 0
|
|
191
|
+
? async () => {
|
|
192
|
+
const result = await ctx.owner.readOwner(
|
|
193
|
+
{ repo: ctx.repo, pr: Number(ctx.pr) },
|
|
194
|
+
ctx.ownerOptions || {},
|
|
195
|
+
);
|
|
196
|
+
if (!result?.ok) return { ok: false, reason: result?.reason || 'authority_unavailable' };
|
|
197
|
+
const record = result.record;
|
|
198
|
+
return record != null && (ACTIVE_OWNER_PHASES.has(record.phase) || record.phase === 'blocked');
|
|
199
|
+
}
|
|
200
|
+
: null);
|
|
201
|
+
let authorityUnavailable = typeof checkOwner !== 'function';
|
|
202
|
+
let isRunning = true;
|
|
203
|
+
if (!authorityUnavailable) {
|
|
204
|
+
try {
|
|
205
|
+
const result = await checkOwner();
|
|
206
|
+
authorityUnavailable = result && typeof result === 'object' && result.ok === false;
|
|
207
|
+
isRunning = authorityUnavailable ? true : result === true || result?.running === true;
|
|
208
|
+
} catch {
|
|
209
|
+
authorityUnavailable = true;
|
|
210
|
+
isRunning = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
128
213
|
let ranPass = false;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
214
|
+
let pass = null;
|
|
215
|
+
// An idle owner row is necessary but NOT sufficient: until the lease/owner
|
|
216
|
+
// migration gate is EXACTLY `complete`, legacy watchers may still be running
|
|
217
|
+
// outside Kernel authority, so an inline pass could duplicate one. Reuse the
|
|
218
|
+
// one shared predicate every launcher uses (`launchGateComplete`) rather than
|
|
219
|
+
// re-deriving "not blocked" here — partial, unknown and unreadable all fail closed.
|
|
220
|
+
let migrationGateIncomplete = false;
|
|
221
|
+
if (!authorityUnavailable && !isRunning) {
|
|
222
|
+
if (await launchGateComplete(ctx.owner || undefined, ctx.ownerOptions || {})) {
|
|
223
|
+
pass = await runMonitorPass({ ...ctx, dir, gather, now: ctx.now, enrich: ctx.enrich });
|
|
224
|
+
ranPass = true;
|
|
225
|
+
} else {
|
|
226
|
+
migrationGateIncomplete = true;
|
|
227
|
+
}
|
|
132
228
|
}
|
|
229
|
+
const readEventsSince = ctx.readEventsSince || journal.readEventsSince;
|
|
230
|
+
const available = readEventsSince(dir, since);
|
|
231
|
+
const overflow = available.length > MAX_EVENT_DELTAS;
|
|
232
|
+
const events = overflow ? available.slice(-MAX_EVENT_DELTAS) : available;
|
|
133
233
|
return {
|
|
134
|
-
events
|
|
234
|
+
events,
|
|
135
235
|
since: Number(since) || 0,
|
|
136
236
|
ranPass,
|
|
237
|
+
...(authorityUnavailable ? { authorityUnavailable: true } : {}),
|
|
238
|
+
...(migrationGateIncomplete ? { migrationGateIncomplete: true } : {}),
|
|
239
|
+
overflow,
|
|
240
|
+
receiptIds: Array.isArray(pass?.receiptIds) ? pass.receiptIds.slice(0, MAX_EVENT_DELTAS) : [],
|
|
241
|
+
continuationPending: pass?.continuationPending === true,
|
|
242
|
+
...(pass?.terminalReceiptId ? { terminalReceiptId: pass.terminalReceiptId } : {}),
|
|
137
243
|
};
|
|
138
244
|
}
|
|
139
245
|
|
|
@@ -143,4 +249,5 @@ module.exports = {
|
|
|
143
249
|
dedupeAgainstJournal,
|
|
144
250
|
finalizeRecords,
|
|
145
251
|
defaultNow,
|
|
252
|
+
MAX_EVENT_DELTAS,
|
|
146
253
|
};
|