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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
PRAGMA user_version = 1;
|
|
2
|
+
CREATE TABLE kernel_migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL);
|
|
3
|
+
CREATE TABLE kernel_issues (id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT NOT NULL, entity_revision INTEGER NOT NULL);
|
|
4
|
+
CREATE TABLE kernel_comments (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, body TEXT NOT NULL, actor TEXT NOT NULL);
|
|
5
|
+
CREATE TABLE kernel_dependencies (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, blocks_issue_id TEXT NOT NULL);
|
|
6
|
+
CREATE TABLE kernel_claims (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, actor TEXT NOT NULL, state TEXT NOT NULL);
|
|
7
|
+
CREATE TABLE kernel_worktrees (id TEXT PRIMARY KEY, path TEXT NOT NULL, branch TEXT NOT NULL, state TEXT NOT NULL);
|
|
8
|
+
CREATE TABLE kernel_stage_runs (id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL);
|
|
9
|
+
CREATE TABLE kernel_projections (id TEXT PRIMARY KEY, target TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, status TEXT NOT NULL);
|
|
10
|
+
CREATE TABLE kernel_events (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, event_type TEXT NOT NULL);
|
|
11
|
+
INSERT INTO kernel_migrations VALUES ('001_initial_kernel_schema', '2026-08-09T00:00:00.000Z');
|
|
12
|
+
INSERT INTO kernel_issues VALUES ('synthetic-issue-1', 'Synthetic issue', 'open', 2); -- NOSONAR S1192: repeated synthetic foreign key preserves fixture readability.
|
|
13
|
+
INSERT INTO kernel_issues VALUES ('synthetic-issue-2', 'Synthetic dependency', 'done', 1);
|
|
14
|
+
INSERT INTO kernel_comments VALUES ('synthetic-comment-1', 'synthetic-issue-1', 'Synthetic comment', 'fixture');
|
|
15
|
+
INSERT INTO kernel_dependencies VALUES ('synthetic-dependency-1', 'synthetic-issue-1', 'synthetic-issue-2');
|
|
16
|
+
INSERT INTO kernel_claims VALUES ('synthetic-claim-1', 'synthetic-issue-1', 'fixture', 'active');
|
|
17
|
+
INSERT INTO kernel_worktrees VALUES ('synthetic-worktree-1', '/synthetic/worktree', 'fixture/branch', 'active');
|
|
18
|
+
INSERT INTO kernel_stage_runs VALUES ('synthetic-run-1', 'synthetic-issue-1', 'dev', 'done');
|
|
19
|
+
INSERT INTO kernel_projections VALUES ('synthetic-projection-1', 'jsonl', 'issue', 'synthetic-issue-1', 'delivered');
|
|
20
|
+
INSERT INTO kernel_events VALUES ('synthetic-event-1', 'issue', 'synthetic-issue-1', 'issue.updated');
|
package/lib/forge-context.js
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* injectable object, making state explicit and testable.
|
|
8
8
|
*
|
|
9
9
|
* Mirrors the globals: projectRoot, FORCE_MODE, VERBOSE_MODE,
|
|
10
|
-
* NON_INTERACTIVE, SYMLINK_ONLY,
|
|
11
|
-
* PKG_MANAGER, and packageDir.
|
|
10
|
+
* NON_INTERACTIVE, SYMLINK_ONLY, actionLog, PKG_MANAGER, and packageDir.
|
|
12
11
|
*
|
|
13
12
|
* @module forge-context
|
|
14
13
|
*/
|
|
@@ -21,7 +20,6 @@ class ForgeContext { // NOSONAR — constructor-only class is intentional: serve
|
|
|
21
20
|
* @param {boolean} [options.verboseMode] - Verbose output (--verbose)
|
|
22
21
|
* @param {boolean} [options.nonInteractive] - Skip prompts (--quick / --yes)
|
|
23
22
|
* @param {boolean} [options.symlinkOnly] - Fail instead of copy fallback (--symlink)
|
|
24
|
-
* @param {boolean} [options.syncEnabled] - Scaffold Beads GitHub sync (--sync)
|
|
25
23
|
* @param {string} [options.pkgManager] - Detected package manager
|
|
26
24
|
* @param {Array} [options.actionLog] - Incremental setup action log
|
|
27
25
|
* @param {string} [options.packageDir] - Forge package directory
|
|
@@ -32,7 +30,6 @@ class ForgeContext { // NOSONAR — constructor-only class is intentional: serve
|
|
|
32
30
|
this.verboseMode = options.verboseMode || false;
|
|
33
31
|
this.nonInteractive = options.nonInteractive || false;
|
|
34
32
|
this.symlinkOnly = options.symlinkOnly || false;
|
|
35
|
-
this.syncEnabled = options.syncEnabled || false;
|
|
36
33
|
this.pkgManager = options.pkgManager || 'npm';
|
|
37
34
|
this.actionLog = options.actionLog || [];
|
|
38
35
|
this.packageDir = options.packageDir || '';
|
package/lib/forge-issues.js
CHANGED
|
@@ -7,6 +7,8 @@ const { createLocalBroker, buildLocalBrokerConfig } = require('./kernel/broker')
|
|
|
7
7
|
const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
|
|
8
8
|
const { detectWorktree } = require('./detect-worktree');
|
|
9
9
|
const { DEFAULT_LEASE_TTL_MS } = require('./kernel/lease-enforcer');
|
|
10
|
+
const { ISSUE_TYPES } = require('./kernel/issue-command-contract');
|
|
11
|
+
const { loadRuntimeGraphConfig } = require('./core/runtime-graph');
|
|
10
12
|
|
|
11
13
|
const OPERATION_METHOD_ALIASES = {
|
|
12
14
|
'dep.add': 'depAdd',
|
|
@@ -25,6 +27,7 @@ function isHelpInvocation(args = []) {
|
|
|
25
27
|
// on a missing `exec` method before ever reaching the "no tables" case.
|
|
26
28
|
function createDefaultKernelBroker(brokerContext, deps) {
|
|
27
29
|
let driver = deps.kernelDriver;
|
|
30
|
+
const ownsDriver = !driver;
|
|
28
31
|
if (!driver) {
|
|
29
32
|
const config = buildLocalBrokerConfig({
|
|
30
33
|
projectRoot: brokerContext.projectRoot,
|
|
@@ -35,7 +38,7 @@ function createDefaultKernelBroker(brokerContext, deps) {
|
|
|
35
38
|
driver = createBuiltinSQLiteDriver({ databasePath: config.databasePath });
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
const broker = createLocalBroker({
|
|
39
42
|
projectRoot: brokerContext.projectRoot,
|
|
40
43
|
gitCommonDir: deps.gitCommonDir,
|
|
41
44
|
databasePath: deps.kernelDatabasePath,
|
|
@@ -46,6 +49,10 @@ function createDefaultKernelBroker(brokerContext, deps) {
|
|
|
46
49
|
// classifier instead of paying the real Windows drive probe per per-op broker.
|
|
47
50
|
classifyFilesystem: deps.classifyFilesystem,
|
|
48
51
|
});
|
|
52
|
+
if (ownsDriver && typeof driver.close === 'function') {
|
|
53
|
+
broker.close = () => driver.close();
|
|
54
|
+
}
|
|
55
|
+
return broker;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
// Symbol marker for the memoized lazy-init promise. Stored ON the broker object
|
|
@@ -82,6 +89,11 @@ function withLazyKernelInit(broker) {
|
|
|
82
89
|
async initialize() {
|
|
83
90
|
return ensureInitialized();
|
|
84
91
|
},
|
|
92
|
+
async close() {
|
|
93
|
+
if (typeof broker.close === 'function') {
|
|
94
|
+
await broker.close();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
85
97
|
async runIssueOperation(operation, args = [], context = {}) {
|
|
86
98
|
await ensureInitialized();
|
|
87
99
|
return broker.runIssueOperation(operation, args, context);
|
|
@@ -94,11 +106,14 @@ function createKernelIssueBackend(context = {}) {
|
|
|
94
106
|
const createBroker = deps.createKernelBroker
|
|
95
107
|
|| ((brokerContext) => createDefaultKernelBroker(brokerContext, deps));
|
|
96
108
|
|
|
109
|
+
const ownsBroker = !deps.kernelBroker;
|
|
97
110
|
const broker = deps.kernelBroker || createBroker(context);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
const managedBroker = withLazyKernelInit(broker);
|
|
112
|
+
const backend = new KernelIssueAdapter({ broker: managedBroker });
|
|
113
|
+
if (ownsBroker && typeof managedBroker?.close === 'function') {
|
|
114
|
+
backend.dispose = () => managedBroker.close();
|
|
115
|
+
}
|
|
116
|
+
return backend;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
function createIssueService({ backend } = {}) {
|
|
@@ -108,6 +123,11 @@ function createIssueService({ backend } = {}) {
|
|
|
108
123
|
const resolvedBackend = backend || createKernelIssueBackend();
|
|
109
124
|
|
|
110
125
|
return {
|
|
126
|
+
async dispose() {
|
|
127
|
+
if (typeof resolvedBackend?.dispose === 'function') {
|
|
128
|
+
await resolvedBackend.dispose();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
111
131
|
async run(operation, args = [], context = {}) {
|
|
112
132
|
const methodName = operation === 'show' && typeof resolvedBackend?.show !== 'function'
|
|
113
133
|
? 'read'
|
|
@@ -181,6 +201,75 @@ function resolveLeaseTtlMs(env = {}) {
|
|
|
181
201
|
return ttl;
|
|
182
202
|
}
|
|
183
203
|
|
|
204
|
+
function readIssueContractValues(loaded, fail) {
|
|
205
|
+
const missing = Symbol('missing issue contract config');
|
|
206
|
+
const ownDataValue = (object, key, path) => {
|
|
207
|
+
if (!object || (typeof object !== 'object' && typeof object !== 'function')) return missing;
|
|
208
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
209
|
+
if (!descriptor) return missing;
|
|
210
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
211
|
+
fail(`${path} must be a data property`);
|
|
212
|
+
}
|
|
213
|
+
return descriptor.value;
|
|
214
|
+
};
|
|
215
|
+
const issues = ownDataValue(loaded?.config, 'issues', 'issues');
|
|
216
|
+
const readiness = issues === missing ? missing : ownDataValue(issues, 'readiness', 'issues.readiness');
|
|
217
|
+
const configured = readiness === missing
|
|
218
|
+
? missing
|
|
219
|
+
: ownDataValue(readiness, 'contracts', 'issues.readiness.contracts');
|
|
220
|
+
if (configured === missing || configured == null) return null;
|
|
221
|
+
if (typeof configured !== 'object'
|
|
222
|
+
|| Array.isArray(configured)
|
|
223
|
+
|| Object.getPrototypeOf(configured) !== Object.prototype) {
|
|
224
|
+
fail('issues.readiness.contracts must be a plain JSON object');
|
|
225
|
+
}
|
|
226
|
+
const allowedKeys = new Set(['enabled', 'workClasses']);
|
|
227
|
+
const ownKeys = Reflect.ownKeys(configured);
|
|
228
|
+
const unknownKeys = ownKeys.filter(key => typeof key !== 'string' || !allowedKeys.has(key));
|
|
229
|
+
if (unknownKeys.length > 0) {
|
|
230
|
+
fail(`unknown issues.readiness.contracts keys: ${unknownKeys.map(String).sort((left, right) => left.localeCompare(right)).join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
const values = Object.create(null);
|
|
233
|
+
for (const key of ownKeys) {
|
|
234
|
+
const descriptor = Object.getOwnPropertyDescriptor(configured, key);
|
|
235
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
236
|
+
fail(`issues.readiness.contracts.${key} must be a data property`);
|
|
237
|
+
}
|
|
238
|
+
values[key] = descriptor.value;
|
|
239
|
+
}
|
|
240
|
+
return values;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function resolveIssueContractPolicy(projectRoot, deps = {}) {
|
|
244
|
+
const loadConfig = deps.loadRuntimeGraphConfig || loadRuntimeGraphConfig;
|
|
245
|
+
const loaded = loadConfig({ projectRoot });
|
|
246
|
+
const fail = message => {
|
|
247
|
+
const error = new Error(`FORGE_ISSUE_CONTRACT_CONFIG_INVALID: ${message}`);
|
|
248
|
+
error.code = 'FORGE_ISSUE_CONTRACT_CONFIG_INVALID';
|
|
249
|
+
throw error;
|
|
250
|
+
};
|
|
251
|
+
if (Array.isArray(loaded?.errors) && loaded.errors.length > 0) {
|
|
252
|
+
fail(loaded.errors.map(error => error.message || error.code || String(error)).join('; '));
|
|
253
|
+
}
|
|
254
|
+
const values = readIssueContractValues(loaded, fail);
|
|
255
|
+
if (values === null) return null;
|
|
256
|
+
if (values.enabled !== undefined && typeof values.enabled !== 'boolean') {
|
|
257
|
+
fail('issues.readiness.contracts.enabled must be a boolean');
|
|
258
|
+
}
|
|
259
|
+
if (values.enabled !== true) return { enabled: false, workClasses: [] };
|
|
260
|
+
if (!Array.isArray(values.workClasses) || values.workClasses.length === 0) {
|
|
261
|
+
fail('issues.readiness.contracts.workClasses must be a non-empty array');
|
|
262
|
+
}
|
|
263
|
+
const allowedClasses = new Set(ISSUE_TYPES);
|
|
264
|
+
if (values.workClasses.some(value => typeof value !== 'string' || !allowedClasses.has(value))) {
|
|
265
|
+
fail('issues.readiness.contracts.workClasses contains an unknown work class');
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
enabled: true,
|
|
269
|
+
workClasses: [...new Set(values.workClasses)],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
184
273
|
async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
|
|
185
274
|
const injectedService = deps.createService;
|
|
186
275
|
|
|
@@ -188,6 +277,7 @@ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
|
|
|
188
277
|
backend: createKernelIssueBackend({ projectRoot, deps }),
|
|
189
278
|
}));
|
|
190
279
|
|
|
280
|
+
const contractPolicy = resolveIssueContractPolicy(projectRoot, deps);
|
|
191
281
|
const service = createService();
|
|
192
282
|
// Thread a distinct per-agent actor (and, when present, a stable session id) into the
|
|
193
283
|
// mutation context so the Kernel's claim idempotency key is scoped per agent rather than
|
|
@@ -205,36 +295,47 @@ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
|
|
|
205
295
|
const isClaim = operation === 'claim';
|
|
206
296
|
const worktreeId = isClaim ? resolveWorktreeId(projectRoot, actorEnv, deps) : undefined;
|
|
207
297
|
const leaseTtlMs = isClaim ? resolveLeaseTtlMs(actorEnv) : undefined;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
298
|
+
const isTrustedAdoption = typeof deps.verifyIssueContractAdoption === 'function'
|
|
299
|
+
? deps.verifyIssueContractAdoption
|
|
300
|
+
: undefined;
|
|
301
|
+
try {
|
|
302
|
+
const result = await service.run(operation, rawArgs, {
|
|
303
|
+
projectRoot,
|
|
304
|
+
deps,
|
|
305
|
+
...(actor ? { actor } : {}),
|
|
306
|
+
...(sessionId ? { sessionId } : {}),
|
|
307
|
+
...(worktreeId ? { worktreeId } : {}),
|
|
308
|
+
...(leaseTtlMs ? { leaseTtlMs } : {}),
|
|
309
|
+
...(contractPolicy ? { contractPolicy } : {}),
|
|
310
|
+
...(isTrustedAdoption ? { isTrustedAdoption } : {}),
|
|
311
|
+
// Kernel mutations enqueue a projection-outbox "dirty marker" that `forge export`
|
|
312
|
+
// (the D16 git-JSONL portability projection) drains under target 'jsonl'. The
|
|
313
|
+
// broker's legacy primitive default is still 'beads', so without steering the target
|
|
314
|
+
// here every Kernel-created issue would be enqueued as 'beads' and `forge export` —
|
|
315
|
+
// which drains 'jsonl' — would find nothing, silently never emitting git-tracked
|
|
316
|
+
// JSONL. Unconditional now that the Kernel is the only backend.
|
|
317
|
+
projectionTarget: 'jsonl',
|
|
318
|
+
});
|
|
223
319
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
320
|
+
const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
|
|
321
|
+
if (result?.success && typeof queueGitHubProjection === 'function') {
|
|
322
|
+
const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
|
|
323
|
+
if (projectionPlan) {
|
|
324
|
+
await queueGitHubProjection(projectionPlan, {
|
|
325
|
+
operation,
|
|
326
|
+
args: rawArgs,
|
|
327
|
+
projectRoot,
|
|
328
|
+
result,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
234
331
|
}
|
|
235
|
-
}
|
|
236
332
|
|
|
237
|
-
|
|
333
|
+
return result;
|
|
334
|
+
} finally {
|
|
335
|
+
if (!injectedService && typeof service.dispose === 'function') {
|
|
336
|
+
try { await service.dispose(); } catch { /* cleanup must not mask the operation outcome */ }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
238
339
|
}
|
|
239
340
|
|
|
240
341
|
module.exports = {
|
|
@@ -243,4 +344,5 @@ module.exports = {
|
|
|
243
344
|
runIssueOperation,
|
|
244
345
|
isHelpInvocation,
|
|
245
346
|
resolveIssueActor,
|
|
347
|
+
resolveIssueContractPolicy,
|
|
246
348
|
};
|
package/lib/gate-events.js
CHANGED
|
@@ -49,8 +49,13 @@ const GATE_EVENT_ORIGIN = 'cli';
|
|
|
49
49
|
* SAME-actor re-approval mints no duplicate, while a later reject (different
|
|
50
50
|
* decision) or a different actor is recorded as its own event.
|
|
51
51
|
*/
|
|
52
|
-
function gateIdempotencyKey(eventType, issueId, gateId, actor) {
|
|
53
|
-
|
|
52
|
+
function gateIdempotencyKey(eventType, issueId, gateId, actor, expiresAt) {
|
|
53
|
+
const base = `${eventType}:${issueId}:${gateId}:${actor}`;
|
|
54
|
+
return expiresAt ? `${base}:${expiresAt}` : base;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gateControlId(issueId, gateId) {
|
|
58
|
+
return `gate:${issueId}:${gateId}`;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/** Shape a stored kernel_events row into the gate-event view callers consume. */
|
|
@@ -67,6 +72,18 @@ function parseGateEvent(row) {
|
|
|
67
72
|
actor: row.actor,
|
|
68
73
|
created_at: row.created_at,
|
|
69
74
|
};
|
|
75
|
+
// Older direct callers supplied only the legacy row subset and consumed its
|
|
76
|
+
// exact four-field view. Stored Kernel rows always carry entity_id, so enrich
|
|
77
|
+
// those audit/status views without changing that legacy parse contract.
|
|
78
|
+
if (row.entity_id) {
|
|
79
|
+
view.issue_id = row.entity_id;
|
|
80
|
+
view.control_id = payload.control_id || gateControlId(row.entity_id, payload.gate);
|
|
81
|
+
view.decision = row.event_type === GATE_APPROVED_EVENT ? 'approved' : 'rejected';
|
|
82
|
+
view.issued_at = row.created_at;
|
|
83
|
+
view.expires_at = payload.expires_at ?? null;
|
|
84
|
+
view.request_identity = payload.request_identity ?? null;
|
|
85
|
+
view.generation = Number.isInteger(payload.generation) ? payload.generation : 0;
|
|
86
|
+
}
|
|
70
87
|
if (payload.reason !== undefined) view.reason = payload.reason;
|
|
71
88
|
return view;
|
|
72
89
|
}
|
|
@@ -76,10 +93,43 @@ function isIdempotencyRace(error) {
|
|
|
76
93
|
return /UNIQUE constraint failed/i.test(message) && /idempotency_key/i.test(message);
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
function isActiveApproval(event, now) {
|
|
97
|
+
if (!event || event.event_type !== GATE_APPROVED_EVENT) return false;
|
|
98
|
+
if (!event.expires_at) return true;
|
|
99
|
+
const nowMillis = Date.parse(now);
|
|
100
|
+
const expiryMillis = Date.parse(event.expires_at);
|
|
101
|
+
return Number.isFinite(nowMillis) && Number.isFinite(expiryMillis) && nowMillis < expiryMillis;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function planGateGeneration(events, params) {
|
|
105
|
+
const { gateId, decision, actor, requestIdentity, now } = params;
|
|
106
|
+
const gateEvents = events.filter(event => event.gate === gateId);
|
|
107
|
+
const latest = gateEvents.at(-1);
|
|
108
|
+
const maxGeneration = gateEvents.reduce(
|
|
109
|
+
(max, event) => Math.max(max, Number.isInteger(event.generation) ? event.generation : 0),
|
|
110
|
+
-1,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (decision === 'approved') {
|
|
114
|
+
const identicalActive = latest
|
|
115
|
+
&& latest.actor === actor
|
|
116
|
+
&& latest.request_identity === requestIdentity
|
|
117
|
+
&& isActiveApproval(latest, now);
|
|
118
|
+
if (identicalActive) return { duplicateEvent: latest, generation: latest.generation };
|
|
119
|
+
return { duplicateEvent: null, generation: maxGeneration + 1 };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (latest?.event_type === GATE_REJECTED_EVENT && latest.actor === actor) {
|
|
123
|
+
return { duplicateEvent: latest, generation: latest.generation };
|
|
124
|
+
}
|
|
125
|
+
return { duplicateEvent: null, generation: Math.max(maxGeneration, 0) };
|
|
126
|
+
}
|
|
127
|
+
|
|
79
128
|
/**
|
|
80
|
-
* Record a gate decision as a kernel event on the issue.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
129
|
+
* Record a gate decision as a kernel event on the issue. Active identical
|
|
130
|
+
* requests are idempotent; expiry/rejection advances the durable generation so
|
|
131
|
+
* a later approval can become authoritative. Validates the issue exists (the
|
|
132
|
+
* caller validates the gate id against the known-gate set before calling).
|
|
83
133
|
*
|
|
84
134
|
* @param {string} projectRoot
|
|
85
135
|
* @param {Object} params
|
|
@@ -93,7 +143,7 @@ function isIdempotencyRace(error) {
|
|
|
93
143
|
* @returns {Promise<{ ok: boolean, duplicate?: boolean, issueMissing?: boolean, event?: Object, actor?: string }>}
|
|
94
144
|
*/
|
|
95
145
|
async function recordGateEvent(projectRoot, params = {}) {
|
|
96
|
-
const { issueId, gateId, decision, reason, env, deps, now } = params;
|
|
146
|
+
const { issueId, gateId, decision, reason, expiresAt, requestIdentity, env, deps, now } = params;
|
|
97
147
|
const eventType = GATE_EVENT_TYPES[decision];
|
|
98
148
|
if (!eventType) {
|
|
99
149
|
throw new Error(`Unknown gate decision '${decision}' (expected 'approved' or 'rejected').`);
|
|
@@ -109,14 +159,42 @@ async function recordGateEvent(projectRoot, params = {}) {
|
|
|
109
159
|
return { ok: false, issueMissing: true, actor };
|
|
110
160
|
}
|
|
111
161
|
|
|
112
|
-
const
|
|
162
|
+
const createdAt = now || new Date().toISOString();
|
|
163
|
+
const stableRequestIdentity = requestIdentity ?? expiresAt ?? null;
|
|
164
|
+
const rows = await driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, config);
|
|
165
|
+
const generationPlan = planGateGeneration((rows || []).map(parseGateEvent), {
|
|
166
|
+
gateId,
|
|
167
|
+
decision,
|
|
168
|
+
actor,
|
|
169
|
+
requestIdentity: stableRequestIdentity,
|
|
170
|
+
now: createdAt,
|
|
171
|
+
});
|
|
172
|
+
if (generationPlan.duplicateEvent) {
|
|
173
|
+
return { ok: true, duplicate: true, event: generationPlan.duplicateEvent, actor };
|
|
174
|
+
}
|
|
175
|
+
const keyParts = [stableRequestIdentity];
|
|
176
|
+
if (generationPlan.generation > 0) keyParts.push(`generation=${generationPlan.generation}`);
|
|
177
|
+
const idempotencyKey = gateIdempotencyKey(
|
|
178
|
+
eventType,
|
|
179
|
+
issueId,
|
|
180
|
+
gateId,
|
|
181
|
+
actor,
|
|
182
|
+
keyParts.filter(Boolean).join(':') || undefined,
|
|
183
|
+
);
|
|
113
184
|
|
|
114
185
|
const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
|
|
115
186
|
if (existing) {
|
|
116
187
|
return { ok: true, duplicate: true, event: parseGateEvent(existing), actor };
|
|
117
188
|
}
|
|
118
189
|
|
|
119
|
-
const payload = {
|
|
190
|
+
const payload = {
|
|
191
|
+
gate: gateId,
|
|
192
|
+
actor,
|
|
193
|
+
control_id: gateControlId(issueId, gateId),
|
|
194
|
+
expires_at: expiresAt ?? null,
|
|
195
|
+
request_identity: stableRequestIdentity,
|
|
196
|
+
generation: generationPlan.generation,
|
|
197
|
+
};
|
|
120
198
|
if (typeof reason === 'string' && reason.length > 0) payload.reason = reason;
|
|
121
199
|
|
|
122
200
|
const event = {
|
|
@@ -128,7 +206,7 @@ async function recordGateEvent(projectRoot, params = {}) {
|
|
|
128
206
|
actor,
|
|
129
207
|
origin: GATE_EVENT_ORIGIN,
|
|
130
208
|
payload,
|
|
131
|
-
created_at:
|
|
209
|
+
created_at: createdAt,
|
|
132
210
|
};
|
|
133
211
|
|
|
134
212
|
try {
|
|
@@ -166,6 +244,12 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
|
|
|
166
244
|
}
|
|
167
245
|
}
|
|
168
246
|
|
|
247
|
+
function evaluateGateApproval(events, gateId, now = new Date().toISOString()) {
|
|
248
|
+
const decisions = events.filter(event => event.gate === gateId);
|
|
249
|
+
const event = decisions.at(-1);
|
|
250
|
+
return { approved: isActiveApproval(event, now), event };
|
|
251
|
+
}
|
|
252
|
+
|
|
169
253
|
/**
|
|
170
254
|
* True iff a `gate.approved` event exists for this issue + gate.
|
|
171
255
|
*
|
|
@@ -177,7 +261,7 @@ async function listGateEvents(projectRoot, issueId, options = {}) {
|
|
|
177
261
|
*/
|
|
178
262
|
async function isGateApproved(projectRoot, issueId, gateId, options = {}) {
|
|
179
263
|
const events = await listGateEvents(projectRoot, issueId, options);
|
|
180
|
-
return events
|
|
264
|
+
return evaluateGateApproval(events, gateId, options.now).approved;
|
|
181
265
|
}
|
|
182
266
|
|
|
183
267
|
module.exports = {
|
|
@@ -185,8 +269,12 @@ module.exports = {
|
|
|
185
269
|
GATE_REJECTED_EVENT,
|
|
186
270
|
GATE_EVENT_TYPES,
|
|
187
271
|
gateIdempotencyKey,
|
|
272
|
+
gateControlId,
|
|
273
|
+
isActiveApproval,
|
|
274
|
+
planGateGeneration,
|
|
188
275
|
parseGateEvent,
|
|
189
276
|
recordGateEvent,
|
|
190
277
|
listGateEvents,
|
|
278
|
+
evaluateGateApproval,
|
|
191
279
|
isGateApproved,
|
|
192
280
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect the default branch of the repository.
|
|
7
|
+
*
|
|
8
|
+
* Strategy (in order):
|
|
9
|
+
* 1. `git symbolic-ref refs/remotes/origin/HEAD` -> parse branch name
|
|
10
|
+
* 2. `git remote show origin` -> parse "HEAD branch:" line
|
|
11
|
+
* 3. Fall back to `'main'`
|
|
12
|
+
*
|
|
13
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
14
|
+
* @param {object} [options] - Options object.
|
|
15
|
+
* @param {Function} [options._exec] - Injected execFileSync for testing.
|
|
16
|
+
* @returns {string} The default branch name.
|
|
17
|
+
*/
|
|
18
|
+
function detectDefaultBranch(projectRoot, options = {}) {
|
|
19
|
+
const exec = options._exec || execFileSync;
|
|
20
|
+
|
|
21
|
+
// Strategy 1: symbolic-ref
|
|
22
|
+
try {
|
|
23
|
+
const out = exec('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
|
|
24
|
+
cwd: projectRoot,
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
+
});
|
|
27
|
+
const ref = out.toString().trim();
|
|
28
|
+
// refs/remotes/origin/release/2026 -> release/2026 (branch names may contain '/')
|
|
29
|
+
const prefix = 'refs/remotes/origin/';
|
|
30
|
+
if (ref.startsWith(prefix)) {
|
|
31
|
+
return ref.slice(prefix.length);
|
|
32
|
+
}
|
|
33
|
+
} catch (_e) { // NOSONAR S2486 — symbolic-ref fails when origin/HEAD is unset; fall through to strategy 2
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Strategy 2: remote show origin
|
|
37
|
+
try {
|
|
38
|
+
const out = exec('git', ['remote', 'show', 'origin'], {
|
|
39
|
+
cwd: projectRoot,
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
const text = out.toString();
|
|
43
|
+
const match = text.match(/HEAD branch:\s*(.+)/);
|
|
44
|
+
if (match) {
|
|
45
|
+
return match[1].trim();
|
|
46
|
+
}
|
|
47
|
+
} catch (_e) { // NOSONAR S2486 — 'git remote show origin' fails with no remote configured; fall through to the default
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Strategy 3: fallback
|
|
51
|
+
return 'main';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
detectDefaultBranch,
|
|
56
|
+
};
|