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/commands/gate.js
CHANGED
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
const path = require('node:path');
|
|
24
|
+
const { stripGlobalFlags } = require('../global-flags');
|
|
24
25
|
const { setConfigOverride } = require('../config-writer');
|
|
25
26
|
const { getDefaultRuntimeGraph, getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
26
27
|
const {
|
|
27
28
|
recordGateEvent,
|
|
28
29
|
listGateEvents,
|
|
29
|
-
|
|
30
|
+
evaluateGateApproval,
|
|
30
31
|
} = require('../gate-events');
|
|
31
32
|
|
|
32
33
|
// The doc-update gate folds under this noun as `gate doc` (P2, kernel issue
|
|
@@ -38,14 +39,138 @@ const docGate = require('./doc-gate');
|
|
|
38
39
|
|
|
39
40
|
const TOGGLE_ACTIONS = new Set(['enable', 'disable']);
|
|
40
41
|
const EVENT_ACTIONS = new Set(['approve', 'reject', 'status', 'check']);
|
|
42
|
+
const MAX_GATE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
43
|
+
const GATE_JSON_SCHEMA = 'forge.gate.v1';
|
|
41
44
|
|
|
42
45
|
function usage() {
|
|
43
46
|
return [
|
|
44
|
-
'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]',
|
|
47
|
+
'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--ttl <duration>] [--reason <text>] [--json]',
|
|
45
48
|
' forge gate doc <detect|check|init|okf|...> [args] (doc-update gate; = forge doc-gate, run `forge doc-gate --help`)',
|
|
46
49
|
].join('\n');
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
function parseGateTtl(raw, issuedAt) {
|
|
53
|
+
if (raw === undefined) return { ok: true, expiresAt: null, requestIdentity: null };
|
|
54
|
+
const match = /^(\d+)(s|m|h|d)$/.exec(String(raw));
|
|
55
|
+
if (!match) return { ok: false, error: 'TTL must be a positive duration such as 30s, 5m, 2h, or 1d.' };
|
|
56
|
+
const multipliers = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
57
|
+
const ttlMs = Number(match[1]) * multipliers[match[2]];
|
|
58
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_GATE_TTL_MS) {
|
|
59
|
+
return { ok: false, error: 'TTL must be greater than zero and no more than 30d.' };
|
|
60
|
+
}
|
|
61
|
+
const issuedMillis = Date.parse(issuedAt);
|
|
62
|
+
if (!Number.isFinite(issuedMillis)) return { ok: false, error: 'Cannot issue a TTL approval without a valid clock.' };
|
|
63
|
+
const expiresMillis = issuedMillis + ttlMs;
|
|
64
|
+
if (!Number.isFinite(expiresMillis)) return { ok: false, error: 'TTL expiry is outside the supported date range.' };
|
|
65
|
+
const expiryDate = new Date(expiresMillis);
|
|
66
|
+
if (!Number.isFinite(expiryDate.getTime())) {
|
|
67
|
+
return { ok: false, error: 'TTL expiry is outside the supported date range.' };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
expiresAt: expiryDate.toISOString(),
|
|
72
|
+
requestIdentity: `ttl-ms=${ttlMs}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseDecisionInput(action, rawArgs, flags) {
|
|
77
|
+
const issueId = rawArgs[0];
|
|
78
|
+
const gateId = rawArgs[1];
|
|
79
|
+
if (!issueId || !gateId || issueId.startsWith('--') || gateId.startsWith('--')) {
|
|
80
|
+
return { ok: false, error: `Missing issue id or gate id.\n${usage()}` };
|
|
81
|
+
}
|
|
82
|
+
if (flags.project !== undefined || flags['--project'] !== undefined) {
|
|
83
|
+
return { ok: false, error: 'Gate approvals are issue-scoped; --project is not supported.' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let ttl = flags.ttl ?? flags['--ttl'];
|
|
87
|
+
let reason = flags.reason ?? flags['--reason'];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (let index = 2; index < rawArgs.length; index += 1) {
|
|
90
|
+
const token = rawArgs[index];
|
|
91
|
+
if (token === '--json') {
|
|
92
|
+
if (seen.has('json')) return { ok: false, error: 'Duplicate --json flag.' };
|
|
93
|
+
seen.add('json');
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const match = /^(--ttl|--reason)(?:=(.*))?$/.exec(token);
|
|
97
|
+
if (!match) {
|
|
98
|
+
const kind = token.startsWith('--') ? 'Unknown flag' : 'Unexpected argument';
|
|
99
|
+
return { ok: false, error: `${kind} '${token}'.\n${usage()}` };
|
|
100
|
+
}
|
|
101
|
+
const name = match[1].slice(2);
|
|
102
|
+
if (seen.has(name)) return { ok: false, error: `Duplicate --${name} flag.` };
|
|
103
|
+
seen.add(name);
|
|
104
|
+
let value = match[2];
|
|
105
|
+
if (value === undefined) {
|
|
106
|
+
value = rawArgs[index + 1];
|
|
107
|
+
if (value === undefined || value.startsWith('--')) {
|
|
108
|
+
return { ok: false, error: `--${name} requires a value.` };
|
|
109
|
+
}
|
|
110
|
+
index += 1;
|
|
111
|
+
}
|
|
112
|
+
if (name === 'ttl') ttl = value;
|
|
113
|
+
if (name === 'reason') reason = value;
|
|
114
|
+
}
|
|
115
|
+
if (action !== 'approve' && ttl !== undefined) {
|
|
116
|
+
return { ok: false, error: '--ttl is valid only for gate approval.' };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, issueId, gateId, ttl, reason };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseReadInput(action, rawArgs) {
|
|
122
|
+
const operandCount = action === 'status' ? 1 : 2;
|
|
123
|
+
const issueId = rawArgs[0];
|
|
124
|
+
const gateId = operandCount === 2 ? rawArgs[1] : undefined;
|
|
125
|
+
if (!issueId || issueId.startsWith('--') || (operandCount === 2 && (!gateId || gateId.startsWith('--')))) {
|
|
126
|
+
const missing = action === 'status' ? 'issue id' : 'issue id or gate id';
|
|
127
|
+
return { ok: false, error: `Missing ${missing}.\n${usage()}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let jsonSeen = false;
|
|
131
|
+
for (const token of rawArgs.slice(operandCount)) {
|
|
132
|
+
if (token === '--json') {
|
|
133
|
+
if (jsonSeen) return { ok: false, error: 'Duplicate --json flag.' };
|
|
134
|
+
jsonSeen = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const kind = token.startsWith('--') ? 'Unknown flag' : 'Unexpected argument';
|
|
138
|
+
return { ok: false, error: `${kind} '${token}'.\n${usage()}` };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, issueId, gateId };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gateJsonResult(action, result, context = {}) {
|
|
144
|
+
const event = result.event ?? null;
|
|
145
|
+
const envelope = {
|
|
146
|
+
schema_version: GATE_JSON_SCHEMA,
|
|
147
|
+
command: `gate.${action}`,
|
|
148
|
+
ok: result.success === true,
|
|
149
|
+
issue_id: event?.issue_id ?? result.issueId ?? context.issueId ?? null,
|
|
150
|
+
};
|
|
151
|
+
const gateId = event?.gate ?? context.gateId;
|
|
152
|
+
if (action !== 'status' && gateId !== undefined && gateId !== null) {
|
|
153
|
+
envelope.gate_id = gateId;
|
|
154
|
+
}
|
|
155
|
+
if (action === 'approve' || action === 'reject') {
|
|
156
|
+
envelope.duplicate = result.duplicate === true;
|
|
157
|
+
envelope.decision = event?.decision ?? (action === 'approve' ? 'approved' : 'rejected');
|
|
158
|
+
envelope.event = event;
|
|
159
|
+
}
|
|
160
|
+
if (action === 'status') {
|
|
161
|
+
envelope.issue = envelope.issue_id;
|
|
162
|
+
envelope.events = result.events ?? [];
|
|
163
|
+
}
|
|
164
|
+
if (action === 'check') {
|
|
165
|
+
envelope.approved = result.approved === true;
|
|
166
|
+
envelope.satisfied = result.success === true;
|
|
167
|
+
envelope.disabled = result.disabled === true;
|
|
168
|
+
envelope.event = event;
|
|
169
|
+
}
|
|
170
|
+
if (result.error) envelope.error = result.error;
|
|
171
|
+
return { ...result, output: JSON.stringify(envelope, null, 2) };
|
|
172
|
+
}
|
|
173
|
+
|
|
49
174
|
// The known-toggle set is gates PLUS unlocked toggleable rails (e.g.
|
|
50
175
|
// rail.kernel_tracking): both are governed through the same `forge gate
|
|
51
176
|
// enable|disable` surface and the resolver's rail-aware workflow.gates loop.
|
|
@@ -96,22 +221,27 @@ function handleToggle(action, gateId, projectRoot) {
|
|
|
96
221
|
};
|
|
97
222
|
}
|
|
98
223
|
|
|
99
|
-
async function handleDecision(action,
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
}
|
|
224
|
+
async function handleDecision(action, input, projectRoot, opts) {
|
|
225
|
+
if (!input.ok) return { success: false, error: input.error };
|
|
226
|
+
const { issueId, gateId } = input;
|
|
103
227
|
const known = validateKnownGate(gateId);
|
|
104
228
|
if (!known.ok) return { success: false, error: known.error };
|
|
105
229
|
|
|
106
230
|
const decision = action === 'approve' ? 'approved' : 'rejected';
|
|
107
|
-
const reason = typeof
|
|
231
|
+
const reason = typeof input.reason === 'string' ? input.reason : undefined;
|
|
232
|
+
const issuedAt = opts.now || new Date().toISOString();
|
|
233
|
+
const expiry = parseGateTtl(input.ttl, issuedAt);
|
|
234
|
+
if (!expiry.ok) return { success: false, error: expiry.error };
|
|
108
235
|
const result = await recordGateEvent(projectRoot, {
|
|
109
236
|
issueId,
|
|
110
237
|
gateId,
|
|
111
238
|
decision,
|
|
112
239
|
reason,
|
|
240
|
+
expiresAt: expiry.expiresAt,
|
|
241
|
+
requestIdentity: expiry.requestIdentity,
|
|
113
242
|
env: opts.env,
|
|
114
243
|
deps: kernelDeps(opts),
|
|
244
|
+
now: issuedAt,
|
|
115
245
|
});
|
|
116
246
|
|
|
117
247
|
if (result.issueMissing) {
|
|
@@ -125,29 +255,26 @@ async function handleDecision(action, issueId, gateId, flags, projectRoot, opts)
|
|
|
125
255
|
success: true,
|
|
126
256
|
duplicate: result.duplicate === true,
|
|
127
257
|
actor: result.actor,
|
|
128
|
-
|
|
258
|
+
event: result.event,
|
|
259
|
+
output: `${verb} gate '${gateId}' for ${issueId} (control ${result.event.control_id}; actor ${result.actor}; issued ${result.event.issued_at}; expires ${result.event.expires_at || 'never'})${reasonNote}${suffix}`,
|
|
129
260
|
};
|
|
130
261
|
}
|
|
131
262
|
|
|
132
|
-
async function handleStatus(issueId,
|
|
263
|
+
async function handleStatus(issueId, _flags, projectRoot, opts) {
|
|
133
264
|
if (!issueId) {
|
|
134
265
|
return { success: false, error: `Missing issue id.\n${usage()}` };
|
|
135
266
|
}
|
|
136
267
|
const events = await listGateEvents(projectRoot, issueId, { deps: kernelDeps(opts) });
|
|
137
268
|
|
|
138
|
-
if (flags.json) {
|
|
139
|
-
return { success: true, output: JSON.stringify({ issue: issueId, events }, null, 2) };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
269
|
if (events.length === 0) {
|
|
143
|
-
return { success: true, output: `No gate events for ${issueId}.` };
|
|
270
|
+
return { success: true, issueId, events, output: `No gate events for ${issueId}.` };
|
|
144
271
|
}
|
|
145
272
|
const lines = events.map(event => {
|
|
146
273
|
const label = event.event_type === 'gate.approved' ? 'APPROVED' : 'REJECTED';
|
|
147
274
|
const reason = event.reason ? ` — ${event.reason}` : '';
|
|
148
|
-
return `${label} ${event.gate} by ${event.actor} at ${event.
|
|
275
|
+
return `${label} ${event.gate} for ${event.issue_id} (${event.control_id}) by ${event.actor} at ${event.issued_at}; expires ${event.expires_at || 'never'}${reason}`;
|
|
149
276
|
});
|
|
150
|
-
return { success: true, output: `Gate events for ${issueId}:\n${lines.join('\n')}` };
|
|
277
|
+
return { success: true, issueId, events, output: `Gate events for ${issueId}:\n${lines.join('\n')}` };
|
|
151
278
|
}
|
|
152
279
|
|
|
153
280
|
async function handleCheck(issueId, gateId, projectRoot, opts) {
|
|
@@ -162,18 +289,42 @@ async function handleCheck(issueId, gateId, projectRoot, opts) {
|
|
|
162
289
|
const resolved = getResolvedRuntimeGraph({ projectRoot });
|
|
163
290
|
const resolvedGate = [...resolved.gates, ...resolved.rails].find(gate => gate.id === gateId);
|
|
164
291
|
if (resolvedGate && resolvedGate.enabled === false) {
|
|
165
|
-
return {
|
|
292
|
+
return {
|
|
293
|
+
success: true,
|
|
294
|
+
issueId,
|
|
295
|
+
approved: false,
|
|
296
|
+
disabled: true,
|
|
297
|
+
output: `gate ${gateId} is disabled — satisfied for ${issueId}`,
|
|
298
|
+
};
|
|
166
299
|
}
|
|
167
300
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
301
|
+
const events = await listGateEvents(projectRoot, issueId, { deps: kernelDeps(opts) });
|
|
302
|
+
const decision = evaluateGateApproval(events, gateId, opts.now);
|
|
303
|
+
const event = decision.event;
|
|
304
|
+
if (decision.approved) {
|
|
305
|
+
return {
|
|
306
|
+
success: true,
|
|
307
|
+
event,
|
|
308
|
+
approved: true,
|
|
309
|
+
output: `gate ${gateId} approved for ${issueId} (control ${event.control_id}; actor ${event.actor}; decision ${event.decision}; issued ${event.issued_at}; expires ${event.expires_at || 'never'})`,
|
|
310
|
+
};
|
|
171
311
|
}
|
|
172
|
-
|
|
312
|
+
const evidence = event
|
|
313
|
+
? ` (control ${event.control_id}; actor ${event.actor}; decision ${event.decision}; issued ${event.issued_at}; expires ${event.expires_at || 'never'})`
|
|
314
|
+
: '';
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
event,
|
|
318
|
+
issueId,
|
|
319
|
+
approved: false,
|
|
320
|
+
error: `gate ${gateId} not approved for ${issueId}${evidence}`,
|
|
321
|
+
};
|
|
173
322
|
}
|
|
174
323
|
|
|
175
324
|
async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {}) {
|
|
176
|
-
const
|
|
325
|
+
const normalizedArgs = stripGlobalFlags(args);
|
|
326
|
+
const [action, ...rest] = normalizedArgs;
|
|
327
|
+
const wantsJson = flags.json === true || flags['--json'] === true || normalizedArgs.includes('--json');
|
|
177
328
|
|
|
178
329
|
// `gate doc [<doc-gate sub> ...]` → the standalone doc-gate handler, with the
|
|
179
330
|
// consumed `doc` token dropped so its own arg shape (detect/check/init/okf …)
|
|
@@ -186,13 +337,28 @@ async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {})
|
|
|
186
337
|
return handleToggle(action, rest[0], projectRoot);
|
|
187
338
|
}
|
|
188
339
|
if (EVENT_ACTIONS.has(action)) {
|
|
340
|
+
let result;
|
|
341
|
+
let input;
|
|
189
342
|
if (action === 'status') {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
343
|
+
input = parseReadInput(action, rest);
|
|
344
|
+
result = input.ok
|
|
345
|
+
? await handleStatus(input.issueId, flags, projectRoot, opts)
|
|
346
|
+
: { success: false, issueId: rest[0], error: input.error };
|
|
347
|
+
} else if (action === 'check') {
|
|
348
|
+
input = parseReadInput(action, rest);
|
|
349
|
+
result = input.ok
|
|
350
|
+
? await handleCheck(input.issueId, input.gateId, projectRoot, opts)
|
|
351
|
+
: { success: false, issueId: rest[0], approved: false, error: input.error };
|
|
352
|
+
} else {
|
|
353
|
+
input = parseDecisionInput(action, rest, flags);
|
|
354
|
+
result = await handleDecision(action, input, projectRoot, opts);
|
|
194
355
|
}
|
|
195
|
-
|
|
356
|
+
const context = input.ok
|
|
357
|
+
? { issueId: input.issueId, ...(input.gateId ? { gateId: input.gateId } : {}) }
|
|
358
|
+
: { issueId: rest[0] };
|
|
359
|
+
return wantsJson
|
|
360
|
+
? gateJsonResult(action, result, context)
|
|
361
|
+
: result;
|
|
196
362
|
}
|
|
197
363
|
|
|
198
364
|
return {
|
|
@@ -206,4 +372,8 @@ module.exports = {
|
|
|
206
372
|
description: 'Toggle a workflow gate, or record/query human-gate approval events',
|
|
207
373
|
usage: usage(),
|
|
208
374
|
handler,
|
|
375
|
+
parseGateTtl,
|
|
376
|
+
parseDecisionInput,
|
|
377
|
+
parseReadInput,
|
|
378
|
+
gateJsonResult,
|
|
209
379
|
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const { constants } = require('node:os');
|
|
5
|
+
const spawn = require('cross-spawn');
|
|
6
|
+
const {
|
|
7
|
+
createGithubContext, prepareGithubAccount, readGithubAccount,
|
|
8
|
+
writeGithubAccount, unsetGithubAccount, validateGithubLogin,
|
|
9
|
+
} = require('../github-context');
|
|
10
|
+
const { stripGlobalFlags } = require('../global-flags');
|
|
11
|
+
const { defaultResolveLocalRepository } = require('./merge');
|
|
12
|
+
|
|
13
|
+
const usage = 'forge github use <login> | status [--json] | unset | run -- <program> [args...]';
|
|
14
|
+
const ERROR_MESSAGES = {
|
|
15
|
+
INVALID_GITHUB_LOGIN: 'GitHub login must be 1-39 letters, numbers, or internal hyphens.',
|
|
16
|
+
GIT_CONFIG_FAILED: 'Unable to access this clone-local GitHub account binding.',
|
|
17
|
+
GITHUB_NOT_AUTHENTICATED: 'Account is not authenticated. Run gh auth login --hostname github.com for the account, then retry.',
|
|
18
|
+
GITHUB_AUTH_FAILED: 'Unable to verify the account. Run gh auth login --hostname github.com, then retry.',
|
|
19
|
+
GITHUB_CLI_UNSUPPORTED: 'Upgrade GitHub CLI to support gh auth token --hostname github.com --user <login>.',
|
|
20
|
+
GITHUB_ACCOUNT_MISMATCH: 'The authenticated GitHub account does not match this clone binding.',
|
|
21
|
+
GITHUB_COMMAND_FAILED: 'The selected account cannot view this repository. Check repository permissions and organization SSO.',
|
|
22
|
+
GITHUB_REPOSITORY_INVALID: 'Origin must identify a GitHub.com repository. Check its URL and SSH alias configuration.',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function safeFailure(error) {
|
|
26
|
+
const code = Object.hasOwn(ERROR_MESSAGES, error?.code) ? error.code : 'GITHUB_OPERATION_FAILED';
|
|
27
|
+
return { success: false, code, error: ERROR_MESSAGES[code] || 'GitHub account operation failed. Run forge github status for diagnostics.' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isGithubOrigin(remote, root, options) {
|
|
31
|
+
try {
|
|
32
|
+
const scpHost = /^[A-Za-z]:/.test(remote) || /^(?:git|https?|ssh|file):/i.test(remote) ? null
|
|
33
|
+
: /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9][A-Za-z0-9._-]*):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.exec(remote);
|
|
34
|
+
const parsed = scpHost ? null : new URL(remote);
|
|
35
|
+
if (parsed && !['https:', 'ssh:'].includes(parsed.protocol)) return false;
|
|
36
|
+
const host = scpHost ? scpHost[1] : parsed.hostname;
|
|
37
|
+
if (host.toLowerCase() === 'github.com') return true;
|
|
38
|
+
if ((!scpHost && parsed.protocol !== 'ssh:') || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host)) return false;
|
|
39
|
+
// Resolve configured SSH aliases locally; -G does not open a connection.
|
|
40
|
+
const run = options.runner || execFileSync;
|
|
41
|
+
const config = String(run('ssh', ['-G', host], {
|
|
42
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
|
|
43
|
+
}));
|
|
44
|
+
return /^hostname\s+github\.com\s*$/im.test(config);
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function repositoryAccess(context, root, options) {
|
|
49
|
+
const remote = readGit(root, ['remote', 'get-url', 'origin'], options);
|
|
50
|
+
const repository = defaultResolveLocalRepository({ projectRoot: root, git: () => remote });
|
|
51
|
+
if (!repository || !isGithubOrigin(remote, root, options)) {
|
|
52
|
+
throw Object.assign(new Error(ERROR_MESSAGES.GITHUB_REPOSITORY_INVALID), { code: 'GITHUB_REPOSITORY_INVALID' });
|
|
53
|
+
}
|
|
54
|
+
context.runGh(['repo', 'view', `github.com/${repository}`, '--json', 'nameWithOwner'], { cwd: root, timeout: 30000 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readGit(root, args, options) {
|
|
58
|
+
try {
|
|
59
|
+
const run = options.runner || execFileSync;
|
|
60
|
+
return String(run('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000 }) || '').trim();
|
|
61
|
+
} catch { return ''; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function boundedText(value) {
|
|
65
|
+
// Author fields are display data, never terminal control sequences.
|
|
66
|
+
return value.replace(/\p{Cc}/gu, '').slice(0, 160) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function transportOf(remote) {
|
|
70
|
+
if (!remote) return 'missing';
|
|
71
|
+
if (/^https:\/\//i.test(remote)) return 'https';
|
|
72
|
+
if (/^http:\/\//i.test(remote)) return 'http';
|
|
73
|
+
if (/^ssh:\/\//i.test(remote)
|
|
74
|
+
|| (!/^[A-Za-z]:/.test(remote) && !/^(?:git|https?|ssh|file):/i.test(remote)
|
|
75
|
+
&& /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*:[^\\/\s][^\s]*$/.test(remote))) return 'ssh';
|
|
76
|
+
return 'other';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function helperOf(helper) {
|
|
80
|
+
if (!helper) return 'none';
|
|
81
|
+
if (/\bgh(?:\.exe)?\s+auth\s+git-credential\b/i.test(helper)) return 'github-cli';
|
|
82
|
+
if (/\bmanager(?:-core)?\b/i.test(helper)) return 'credential-manager';
|
|
83
|
+
return 'other';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function localDiagnostics(root, options) {
|
|
87
|
+
return {
|
|
88
|
+
author: {
|
|
89
|
+
name: boundedText(readGit(root, ['config', '--get', 'user.name'], options)),
|
|
90
|
+
email: boundedText(readGit(root, ['config', '--get', 'user.email'], options)),
|
|
91
|
+
},
|
|
92
|
+
transport: transportOf(readGit(root, ['remote', 'get-url', 'origin'], options)),
|
|
93
|
+
credentialHelper: helperOf(readGit(root, ['config', '--get-all', 'credential.helper'], options)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderStatus(status) {
|
|
98
|
+
const lines = [
|
|
99
|
+
`GitHub: ${status.state}`,
|
|
100
|
+
`Clone account: ${status.account || 'not selected'}`,
|
|
101
|
+
`Verified login: ${status.login || 'unavailable'}`,
|
|
102
|
+
`Commit author: ${status.author.name || 'unset'} <${status.author.email || 'unset'}>`,
|
|
103
|
+
`Origin transport: ${status.transport}; credential helper: ${status.credentialHelper}`,
|
|
104
|
+
];
|
|
105
|
+
if (status.error) lines.push(status.error);
|
|
106
|
+
if (status.state === 'unbound') lines.push('Optional: authenticate with gh auth login, then select an account with forge github use <login>.');
|
|
107
|
+
if (status.transport === 'ssh') lines.push('SSH key selection is controlled by your SSH configuration.');
|
|
108
|
+
if (status.transport === 'https' && status.credentialHelper === 'github-cli') {
|
|
109
|
+
lines.push('HTTPS uses the GitHub CLI helper; a launched session may also use its selected token for Git transport.');
|
|
110
|
+
} else if (status.transport === 'https') {
|
|
111
|
+
lines.push('HTTPS Git credentials are selected separately by your credential helper.');
|
|
112
|
+
}
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function accountStatus(root, options) {
|
|
117
|
+
const status = { state: 'unbound', account: null, source: null, login: null, repositoryAccess: null, ...localDiagnostics(root, options) };
|
|
118
|
+
try {
|
|
119
|
+
const account = readGithubAccount(root, options);
|
|
120
|
+
if (!account) return status;
|
|
121
|
+
status.source = 'clone-local';
|
|
122
|
+
status.account = validateGithubLogin(account) ? account : null;
|
|
123
|
+
const context = prepareGithubAccount(root, account, options);
|
|
124
|
+
status.login = context.login;
|
|
125
|
+
status.repositoryAccess = false;
|
|
126
|
+
repositoryAccess(context, root, options);
|
|
127
|
+
status.repositoryAccess = true;
|
|
128
|
+
status.state = 'ready';
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const failure = safeFailure(error);
|
|
131
|
+
status.state = status.login ? 'no_repository_access' : 'unauthenticated';
|
|
132
|
+
if (failure.code === 'GITHUB_ACCOUNT_MISMATCH') {
|
|
133
|
+
status.state = 'mismatch';
|
|
134
|
+
const match = /^GitHub account mismatch: expected [A-Za-z0-9-]+, received ([A-Za-z0-9-]+)\.$/.exec(error.message);
|
|
135
|
+
status.login = match && validateGithubLogin(match[1]) ? match[1] : null;
|
|
136
|
+
}
|
|
137
|
+
status.code = failure.code;
|
|
138
|
+
status.error = failure.error;
|
|
139
|
+
}
|
|
140
|
+
return status;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function launchFailure(error) {
|
|
144
|
+
if (error?.code === 'ENOENT') return { success: false, code: 'ENOENT', exitCode: 127, error: 'Child program was not found. Check its installation and PATH.' };
|
|
145
|
+
if (error?.code === 'EACCES') return { success: false, code: 'EACCES', exitCode: 126, error: 'Child program is not executable.' };
|
|
146
|
+
return { success: false, code: 'CHILD_LAUNCH_FAILED', exitCode: 1, error: 'Unable to launch the child program.' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function launch(context, program, args, root, options) {
|
|
150
|
+
return new Promise(resolve => {
|
|
151
|
+
let child;
|
|
152
|
+
try {
|
|
153
|
+
child = context.runChild(program, args, { cwd: root, stdio: 'inherit', shell: false });
|
|
154
|
+
} catch (error) { resolve(launchFailure(error)); return; }
|
|
155
|
+
const signals = options.signalSource || process;
|
|
156
|
+
const forwardInt = () => child.kill('SIGINT');
|
|
157
|
+
const forwardTerm = () => child.kill('SIGTERM');
|
|
158
|
+
signals.on('SIGINT', forwardInt);
|
|
159
|
+
signals.on('SIGTERM', forwardTerm);
|
|
160
|
+
const finish = result => {
|
|
161
|
+
signals.removeListener('SIGINT', forwardInt);
|
|
162
|
+
signals.removeListener('SIGTERM', forwardTerm);
|
|
163
|
+
resolve(result);
|
|
164
|
+
};
|
|
165
|
+
child.once('error', error => finish(launchFailure(error)));
|
|
166
|
+
child.once('close', (code, signal) => {
|
|
167
|
+
const exitCode = Number.isInteger(code) ? code : 128 + (constants.signals[signal] || 1);
|
|
168
|
+
finish({ success: exitCode === 0, exitCode, signal, ...(exitCode ? { error: `Child program exited with status ${exitCode}.` } : {}) });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function githubStatusResult(projectRoot, flags, args, options) {
|
|
174
|
+
const status = accountStatus(projectRoot, options);
|
|
175
|
+
const json = flags.json || flags['--json'] || args.includes('--json');
|
|
176
|
+
return { success: ['ready', 'unbound'].includes(status.state), status,
|
|
177
|
+
output: json ? JSON.stringify(status, null, 2) : renderStatus(status),
|
|
178
|
+
...(status.error ? { error: status.error } : {}) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function handler(args = [], flags = {}, projectRoot = process.cwd(), options = {}) {
|
|
182
|
+
const delimiter = args.indexOf('--');
|
|
183
|
+
const ownArgs = stripGlobalFlags(delimiter < 0 ? args : args.slice(0, delimiter));
|
|
184
|
+
const verb = ownArgs[0];
|
|
185
|
+
if (!verb || verb === 'help' || (delimiter < 0 && (args.includes('--help') || args.includes('-h')))) {
|
|
186
|
+
return { success: true, output: usage };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
if (verb === 'use' && ownArgs.length === 2 && delimiter < 0) {
|
|
190
|
+
const context = prepareGithubAccount(projectRoot, ownArgs[1], options);
|
|
191
|
+
repositoryAccess(context, projectRoot, options);
|
|
192
|
+
writeGithubAccount(projectRoot, ownArgs[1], options);
|
|
193
|
+
return { success: true, account: context.account, output: `This clone uses GitHub account ${context.account}.` };
|
|
194
|
+
}
|
|
195
|
+
if (verb === 'status' && ownArgs.slice(1).every(arg => arg === '--json') && delimiter < 0) {
|
|
196
|
+
return githubStatusResult(projectRoot, flags, args, options);
|
|
197
|
+
}
|
|
198
|
+
if (verb === 'unset' && ownArgs.length === 1 && delimiter < 0) {
|
|
199
|
+
unsetGithubAccount(projectRoot, options);
|
|
200
|
+
return { success: true, output: 'Clone GitHub account binding removed; native account selection applies.' };
|
|
201
|
+
}
|
|
202
|
+
if (verb === 'run' && ownArgs.length === 1 && delimiter >= 0 && args[delimiter + 1]) {
|
|
203
|
+
const context = createGithubContext(projectRoot, { ...options, childRunner: options.childRunner || spawn });
|
|
204
|
+
return await launch(context, args[delimiter + 1], args.slice(delimiter + 2), projectRoot, options);
|
|
205
|
+
}
|
|
206
|
+
return { success: false, error: `Usage: ${usage}` };
|
|
207
|
+
} catch (error) { return safeFailure(error); }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
name: 'github',
|
|
212
|
+
description: 'Optional clone-local GitHub account selection and isolated session launch',
|
|
213
|
+
usage,
|
|
214
|
+
handler,
|
|
215
|
+
};
|