forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge.js +90 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +9 -0
- package/docs/reference/shepherd.md +42 -2
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adoption-profiles.js +17 -4
- package/lib/beads-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +39 -0
- package/lib/commands/_manifest.js +2 -0
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +0 -31
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +139 -4
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +48 -8
- package/lib/commands/pr.js +88 -0
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +14 -1
- package/lib/commands/remember.js +86 -20
- package/lib/commands/setup.js +135 -72
- package/lib/commands/shepherd.js +67 -2
- package/lib/commands/ship.js +40 -4
- package/lib/commands/worktree.js +60 -4
- package/lib/core/runtime-graph.js +34 -3
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/hook-renderer.js +93 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/sqlite-driver.js +37 -1
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/render-sticky.js +43 -8
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-pull.js +43 -2
- package/lib/release-readiness.js +17 -1
- package/lib/upgrade-safety.js +53 -1
- package/lib/workflow/enforce-stage.js +59 -2
- package/package.json +2 -2
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
|
@@ -14,6 +14,70 @@ const fs = require("node:fs");
|
|
|
14
14
|
const path = require("node:path");
|
|
15
15
|
const readline = require("node:readline");
|
|
16
16
|
|
|
17
|
+
// ── Config-honest enforcement (issue eda6d866) ──────────────────────────────
|
|
18
|
+
// This pre-commit gate must be INERT when the TDD rail is disabled in
|
|
19
|
+
// .forge/config.yaml. The hook is self-contained (target projects have
|
|
20
|
+
// .forge/hooks/*.js but NOT lib/), so it reads the config directly. `forge gate
|
|
21
|
+
// disable rail.tdd_intent` writes workflow.gates['rail.tdd_intent']; the `full`
|
|
22
|
+
// profile writes top-level rails.tdd_intent — honor either. Missing/unparseable
|
|
23
|
+
// config FAILS TOWARD enforcement (returns true) so a gate the user did not
|
|
24
|
+
// disable is never silently dropped.
|
|
25
|
+
function isTddEnabled(projectRoot) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = fs.readFileSync(path.join(projectRoot, ".forge", "config.yaml"), "utf8");
|
|
29
|
+
} catch {
|
|
30
|
+
return true; // no config → enforce
|
|
31
|
+
}
|
|
32
|
+
if (!raw || !raw.trim()) return true;
|
|
33
|
+
|
|
34
|
+
const railDisabled = (config) =>
|
|
35
|
+
isExplicitlyDisabled(config?.workflow?.gates?.["rail.tdd_intent"]) ||
|
|
36
|
+
isExplicitlyDisabled(config?.rails?.tdd_intent);
|
|
37
|
+
|
|
38
|
+
let YAML;
|
|
39
|
+
try {
|
|
40
|
+
YAML = require("yaml");
|
|
41
|
+
} catch {
|
|
42
|
+
// The yaml MODULE is genuinely unavailable → conservative raw-text scan for the
|
|
43
|
+
// disabled block. Parser presence and parse success are split so a MALFORMED file
|
|
44
|
+
// never reaches this fuzzy scan.
|
|
45
|
+
return !(rawKeyDisabled(raw, "rail.tdd_intent") || rawKeyDisabled(raw, "tdd_intent"));
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const parsed = YAML.parse(raw);
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return true;
|
|
50
|
+
return !railDisabled(parsed);
|
|
51
|
+
} catch {
|
|
52
|
+
// MALFORMED YAML (module present, parse threw) → FAIL TOWARD ENFORCEMENT (return
|
|
53
|
+
// true). Never fall to the raw-text scan: a broken file with a `rail.tdd_intent:
|
|
54
|
+
// enabled: false` fragment must not switch the gate off (issue eda6d866).
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isExplicitlyDisabled(node) {
|
|
60
|
+
return Boolean(node) && typeof node === "object" && node.enabled === false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Scan raw YAML for a `<key>:` block whose immediate child is `enabled: false`.
|
|
64
|
+
function rawKeyDisabled(raw, key) {
|
|
65
|
+
const lines = String(raw).split(/\r?\n/);
|
|
66
|
+
const keyRe = new RegExp(`^(\\s*)"?${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?\\s*:\\s*$`);
|
|
67
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
68
|
+
const m = lines[i].match(keyRe);
|
|
69
|
+
if (!m) continue;
|
|
70
|
+
const parentIndent = m[1].length;
|
|
71
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
72
|
+
if (!lines[j].trim()) continue;
|
|
73
|
+
const childIndent = lines[j].match(/^\s*/)[0].length;
|
|
74
|
+
if (childIndent <= parentIndent) break;
|
|
75
|
+
if (/^\s*enabled\s*:\s*false\s*$/.test(lines[j])) return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
// Get staged files using git diff --cached
|
|
18
82
|
function getStagedFiles() {
|
|
19
83
|
try {
|
|
@@ -163,6 +227,12 @@ function promptUser(question, options) {
|
|
|
163
227
|
|
|
164
228
|
// Main hook logic
|
|
165
229
|
async function main() {
|
|
230
|
+
// Project root is two levels up from this installed hook (<root>/.forge/hooks/).
|
|
231
|
+
// When the TDD rail is disabled in config, the gate is inert — allow the commit.
|
|
232
|
+
if (!isTddEnabled(path.resolve(__dirname, "..", ".."))) {
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
166
236
|
console.log("🔍 TDD Check: Verifying test coverage for staged files...\n");
|
|
167
237
|
|
|
168
238
|
const stagedFiles = getStagedFiles();
|
|
@@ -236,8 +306,12 @@ async function main() {
|
|
|
236
306
|
}
|
|
237
307
|
}
|
|
238
308
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
309
|
+
module.exports = { isTddEnabled };
|
|
310
|
+
|
|
311
|
+
// Run with error handling (only as a script, not when required by tests).
|
|
312
|
+
if (require.main === module) {
|
|
313
|
+
main().catch((error) => {
|
|
314
|
+
console.error("Error in TDD check hook:", error.message);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const { execFileSync } = require('node:child_process');
|
|
35
|
+
const fs = require('node:fs');
|
|
35
36
|
const path = require('node:path');
|
|
36
37
|
|
|
37
38
|
// Conservative protected-path set, mirroring .forge/protected-paths.yaml categories:
|
|
@@ -52,6 +53,174 @@ const PROTECTED_PATTERNS = [
|
|
|
52
53
|
/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock[b]?)$/i,
|
|
53
54
|
];
|
|
54
55
|
|
|
56
|
+
// ── Config-honest enforcement ───────────────────────────────────────────────
|
|
57
|
+
// A DISABLED gate/rail in .forge/config.yaml must make its hook genuinely inert
|
|
58
|
+
// (issue eda6d866). These hooks are self-contained (target projects have
|
|
59
|
+
// .forge/hooks/*.js but NOT lib/), so we read + interpret the config here rather
|
|
60
|
+
// than through the resolver. The `yaml` package is a Forge dependency present in
|
|
61
|
+
// any project that ran `forge setup`; when it is somehow absent we degrade to a
|
|
62
|
+
// conservative raw-text scan. Unparseable/missing config FAILS TOWARD enforcement
|
|
63
|
+
// (default ON) so we never silently drop a gate the user did not disable.
|
|
64
|
+
|
|
65
|
+
/** Load `.forge/config.yaml` into an object, or `{ __raw }` for a text-scan fallback, or null. */
|
|
66
|
+
function loadConfigObject(projectRoot) {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = fs.readFileSync(path.join(projectRoot, '.forge', 'config.yaml'), 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return null; // no config file → caller defaults to enforcement ON
|
|
72
|
+
}
|
|
73
|
+
if (!raw || !raw.trim()) return {};
|
|
74
|
+
let YAML;
|
|
75
|
+
try {
|
|
76
|
+
YAML = require('yaml');
|
|
77
|
+
} catch {
|
|
78
|
+
// The yaml MODULE is genuinely unavailable → degrade to a conservative raw-text
|
|
79
|
+
// scan (the only case that keeps { __raw }). Parser presence and parse success are
|
|
80
|
+
// deliberately separated so a MALFORMED file never reaches the fuzzy scan below.
|
|
81
|
+
return { __raw: raw };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = YAML.parse(raw);
|
|
85
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
86
|
+
} catch {
|
|
87
|
+
// MALFORMED YAML (module present, parse threw) → FAIL TOWARD ENFORCEMENT. Return {}
|
|
88
|
+
// so resolveEnforcement defaults to TDD ON + built-in protected paths. We must NOT
|
|
89
|
+
// fall through to the raw-text scan: a broken file containing a `rail.tdd_intent:
|
|
90
|
+
// enabled: false` fragment could otherwise switch enforcement OFF (issue eda6d866).
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A primitive is "disabled" only when its `enabled` is explicitly boolean false. */
|
|
96
|
+
function isExplicitlyDisabled(node) {
|
|
97
|
+
return Boolean(node) && typeof node === 'object' && node.enabled === false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Overly-broad patterns the runtime graph rejects (lib/core/runtime-graph.js
|
|
101
|
+
// validateProtectedPaths). Kept in sync here because this hook is self-contained
|
|
102
|
+
// and cannot import from lib/.
|
|
103
|
+
const OVERLY_BROAD_PROTECTED_PATTERNS = ['*', '**', '**/*', '.', './', '/'];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a config-supplied protectedPaths list with the SAME rules the runtime
|
|
107
|
+
* graph enforces: every entry must be a non-empty string and not an overly-broad
|
|
108
|
+
* pattern. A list with ANY invalid entry is rejected WHOLESALE (returns null →
|
|
109
|
+
* caller falls back to the built-in protected set), so an invalid or overly-broad
|
|
110
|
+
* config can never become authoritative and WEAKEN protection — it fails toward
|
|
111
|
+
* enforcement, matching the rest of this hook's fail-safe stance (issue eda6d866).
|
|
112
|
+
* @returns {string[]|null} validated list (may be empty = deliberately inert), or
|
|
113
|
+
* null when the list is absent/invalid.
|
|
114
|
+
*/
|
|
115
|
+
function validateConfiguredProtectedPaths(list) {
|
|
116
|
+
if (!Array.isArray(list)) return null;
|
|
117
|
+
const validated = [];
|
|
118
|
+
for (const entry of list) {
|
|
119
|
+
if (typeof entry !== 'string' || entry.trim() === '') return null;
|
|
120
|
+
const pattern = entry.trim();
|
|
121
|
+
if (OVERLY_BROAD_PROTECTED_PATTERNS.includes(pattern)) return null;
|
|
122
|
+
validated.push(pattern);
|
|
123
|
+
}
|
|
124
|
+
return validated;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Scan raw YAML for a `<key>:` block whose immediate child is `enabled: false`. */
|
|
128
|
+
function rawKeyDisabled(raw, key) {
|
|
129
|
+
const lines = String(raw).split(/\r?\n/);
|
|
130
|
+
const keyRe = new RegExp(`^(\\s*)"?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"?\\s*:\\s*$`);
|
|
131
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
132
|
+
const m = lines[i].match(keyRe);
|
|
133
|
+
if (!m) continue;
|
|
134
|
+
const parentIndent = m[1].length;
|
|
135
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
136
|
+
if (!lines[j].trim()) continue;
|
|
137
|
+
const childIndent = lines[j].match(/^\s*/)[0].length;
|
|
138
|
+
if (childIndent <= parentIndent) break; // left the block
|
|
139
|
+
if (/^\s*enabled\s*:\s*false\s*$/.test(lines[j])) return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the enforcement state the installed hooks must honor.
|
|
147
|
+
* @returns {{ tddEnabled: boolean, protectedPaths: string[]|null }}
|
|
148
|
+
* tddEnabled — false only when rail.tdd_intent is explicitly disabled.
|
|
149
|
+
* protectedPaths — the configured list (may be []), or null when unset (→ built-in set).
|
|
150
|
+
*/
|
|
151
|
+
function resolveEnforcement(projectRoot) {
|
|
152
|
+
const config = loadConfigObject(projectRoot);
|
|
153
|
+
if (!config) return { tddEnabled: true, protectedPaths: null };
|
|
154
|
+
|
|
155
|
+
let tddDisabled;
|
|
156
|
+
let protectedPaths = null;
|
|
157
|
+
if (config.__raw) {
|
|
158
|
+
tddDisabled = rawKeyDisabled(config.__raw, 'rail.tdd_intent') || rawKeyDisabled(config.__raw, 'tdd_intent');
|
|
159
|
+
if (/^\s*protectedPaths\s*:\s*\[\s*\]\s*$/m.test(config.__raw)) protectedPaths = [];
|
|
160
|
+
} else {
|
|
161
|
+
// `forge gate disable` writes workflow.gates['rail.tdd_intent']; the `full`
|
|
162
|
+
// profile writes top-level rails.tdd_intent. Honor either shape.
|
|
163
|
+
const gates = config.workflow && config.workflow.gates;
|
|
164
|
+
const rails = config.rails;
|
|
165
|
+
tddDisabled = isExplicitlyDisabled(gates && gates['rail.tdd_intent'])
|
|
166
|
+
|| isExplicitlyDisabled(rails && rails.tdd_intent);
|
|
167
|
+
// Validate before overriding built-in protection: a bad/overly-broad list must
|
|
168
|
+
// fall back to the built-in set (null), never become authoritative (T2).
|
|
169
|
+
protectedPaths = validateConfiguredProtectedPaths(config.protectedPaths);
|
|
170
|
+
}
|
|
171
|
+
return { tddEnabled: !tddDisabled, protectedPaths };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The SINGLE resolved predicate each hook gates on — flag-agnostic on purpose.
|
|
176
|
+
* Whatever config flag ultimately governs an enforcement kind resolves inside
|
|
177
|
+
* resolveEnforcement(); callers ask only "is this active?". This is the one place
|
|
178
|
+
* the TDD off-switch is read, so a future flag change lands here with no rework.
|
|
179
|
+
* @param {'tdd'|'protected-path'} kind
|
|
180
|
+
*/
|
|
181
|
+
function isEnforcementActive(kind, projectRoot) {
|
|
182
|
+
const { tddEnabled, protectedPaths } = resolveEnforcement(projectRoot);
|
|
183
|
+
if (kind === 'tdd') return tddEnabled;
|
|
184
|
+
if (kind === 'protected-path') return protectedPaths === null || protectedPaths.length > 0;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Translate a config protectedPaths entry (a path or glob like `.github/workflows/**`)
|
|
189
|
+
// into an anchored matcher. `**` spans path separators, `*` stays within one segment.
|
|
190
|
+
function globToRegExp(pattern) {
|
|
191
|
+
const norm = normalize(pattern);
|
|
192
|
+
let re = '';
|
|
193
|
+
for (let i = 0; i < norm.length; i += 1) {
|
|
194
|
+
const c = norm[i];
|
|
195
|
+
if (c === '*') {
|
|
196
|
+
if (norm[i + 1] === '*') { re += '.*'; i += 1; if (norm[i + 1] === '/') i += 1; }
|
|
197
|
+
else re += '[^/]*';
|
|
198
|
+
} else if ('\\^$.|?+()[]{}'.includes(c)) {
|
|
199
|
+
re += `\\${c}`;
|
|
200
|
+
} else {
|
|
201
|
+
re += c;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return new RegExp(`(^|/)${re}(/|$)`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build the protected-path matcher from resolved config (config is the source of
|
|
209
|
+
* truth — the hardcoded PROTECTED_PATTERNS set is only the fallback when config
|
|
210
|
+
* omits protectedPaths entirely, so no gate is silently dropped):
|
|
211
|
+
* null → unset → built-in default set (fail toward enforcement / back-compat)
|
|
212
|
+
* [] → explicitly empty → nothing protected (inert)
|
|
213
|
+
* list → protect exactly those paths/globs
|
|
214
|
+
*/
|
|
215
|
+
function buildProtectedMatcher(protectedPaths) {
|
|
216
|
+
if (protectedPaths === null) {
|
|
217
|
+
return p => Boolean(p) && PROTECTED_PATTERNS.some(re => re.test(normalize(p)));
|
|
218
|
+
}
|
|
219
|
+
if (protectedPaths.length === 0) return () => false;
|
|
220
|
+
const regexes = protectedPaths.map(globToRegExp);
|
|
221
|
+
return p => Boolean(p) && regexes.some(re => re.test(normalize(p)));
|
|
222
|
+
}
|
|
223
|
+
|
|
55
224
|
/** Parse `--intent <id> --harness <id>` from an argv slice. */
|
|
56
225
|
function parseArgs(argv) {
|
|
57
226
|
const out = { intent: null, harness: null };
|
|
@@ -84,7 +253,7 @@ function normalize(p) {
|
|
|
84
253
|
return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
85
254
|
}
|
|
86
255
|
|
|
87
|
-
/** True when a path falls inside Forge's protected set. */
|
|
256
|
+
/** True when a path falls inside Forge's built-in protected set (the fallback matcher). */
|
|
88
257
|
function isProtectedPath(p) {
|
|
89
258
|
if (!p) return false;
|
|
90
259
|
const n = normalize(p);
|
|
@@ -105,13 +274,13 @@ const WRITE_INTENT_RE = /(^|[\s;|&(])(rm|mv|cp|tee|truncate|chmod|chown|ln|sed|p
|
|
|
105
274
|
* (rm/mv/sed/tee/redirection/...), then (2) token-scan (split on whitespace +
|
|
106
275
|
* shell operators, strip quotes) for a protected path. Never throws.
|
|
107
276
|
*/
|
|
108
|
-
function commandTouchesProtectedPath(command) {
|
|
277
|
+
function commandTouchesProtectedPath(command, isProtected = isProtectedPath) {
|
|
109
278
|
if (typeof command !== 'string' || !command) return false;
|
|
110
279
|
if (!WRITE_INTENT_RE.test(command)) return false;
|
|
111
280
|
const tokens = command.split(/[\s;|&<>()]+/);
|
|
112
281
|
for (const raw of tokens) {
|
|
113
282
|
const token = raw.replace(/^["']+|["']+$/g, '');
|
|
114
|
-
if (token && !token.startsWith('-') &&
|
|
283
|
+
if (token && !token.startsWith('-') && isProtected(token)) return true;
|
|
115
284
|
}
|
|
116
285
|
return false;
|
|
117
286
|
}
|
|
@@ -133,14 +302,22 @@ function runInstalledTddCheck() {
|
|
|
133
302
|
}
|
|
134
303
|
}
|
|
135
304
|
|
|
305
|
+
// Fully-ON default keeps back-compat: callers that pass no `enforcement` (and the
|
|
306
|
+
// existing test suite) get the original always-enforce behavior.
|
|
307
|
+
const ENFORCEMENT_ON = Object.freeze({ tddEnabled: true, protectedPaths: null });
|
|
308
|
+
|
|
136
309
|
/**
|
|
137
|
-
* Core enforcement decision. `runTddCheck` is injectable for deterministic tests
|
|
310
|
+
* Core enforcement decision. `runTddCheck` is injectable for deterministic tests;
|
|
311
|
+
* `enforcement` (from resolveEnforcement) makes a DISABLED gate/rail inert.
|
|
138
312
|
* @returns {{ decision: 'allow'|'deny', reason?: string }}
|
|
139
313
|
*/
|
|
140
|
-
function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
|
|
314
|
+
function decide({ intent, input, runTddCheck = runInstalledTddCheck, enforcement = ENFORCEMENT_ON }) {
|
|
141
315
|
if (intent === 'protected-path') {
|
|
316
|
+
// Config is the source of truth: matcher is built from the resolved
|
|
317
|
+
// protectedPaths list (empty → inert; unset → built-in fallback set).
|
|
318
|
+
const matchesProtected = buildProtectedMatcher(enforcement.protectedPaths);
|
|
142
319
|
const target = extractPath(input);
|
|
143
|
-
if (
|
|
320
|
+
if (matchesProtected(target)) {
|
|
144
321
|
return {
|
|
145
322
|
decision: 'deny',
|
|
146
323
|
reason: `Forge-protected path '${normalize(target)}' — edit it through the owning Forge CLI/skill, not a raw write.`,
|
|
@@ -151,7 +328,7 @@ function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
|
|
|
151
328
|
// so the deny-capable shell surface actually protects instead of no-oping.
|
|
152
329
|
if (!target) {
|
|
153
330
|
const command = extractCommand(input);
|
|
154
|
-
if (commandTouchesProtectedPath(command)) {
|
|
331
|
+
if (commandTouchesProtectedPath(command, matchesProtected)) {
|
|
155
332
|
return {
|
|
156
333
|
decision: 'deny',
|
|
157
334
|
reason: 'Shell command writes to a Forge-protected path — use the owning Forge CLI/skill instead.',
|
|
@@ -162,6 +339,8 @@ function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
|
|
|
162
339
|
}
|
|
163
340
|
|
|
164
341
|
if (intent === 'tdd-gate') {
|
|
342
|
+
// TDD rail disabled in config → inert: never run the check, never block.
|
|
343
|
+
if (!enforcement.tddEnabled) return { decision: 'allow' };
|
|
165
344
|
const command = extractCommand(input);
|
|
166
345
|
if (!isGitCommit(command)) return { decision: 'allow' };
|
|
167
346
|
const code = runTddCheck();
|
|
@@ -222,7 +401,10 @@ function readStdin() {
|
|
|
222
401
|
function main() {
|
|
223
402
|
const { intent, harness } = parseArgs(process.argv.slice(2));
|
|
224
403
|
const input = readStdin();
|
|
225
|
-
|
|
404
|
+
// Project root is two levels up from this installed hook (<root>/.forge/hooks/),
|
|
405
|
+
// so enforcement resolves against the project's config regardless of cwd.
|
|
406
|
+
const enforcement = resolveEnforcement(path.resolve(__dirname, '..', '..'));
|
|
407
|
+
const decision = decide({ intent, input, enforcement });
|
|
226
408
|
const output = formatOutput(harness || 'claude', decision);
|
|
227
409
|
if (output) process.stdout.write(output);
|
|
228
410
|
// Exit 0 always: the decision travels in the JSON body, not the exit code, so a
|
|
@@ -232,6 +414,10 @@ function main() {
|
|
|
232
414
|
|
|
233
415
|
module.exports = {
|
|
234
416
|
PROTECTED_PATTERNS,
|
|
417
|
+
resolveEnforcement,
|
|
418
|
+
isEnforcementActive,
|
|
419
|
+
globToRegExp,
|
|
420
|
+
buildProtectedMatcher,
|
|
235
421
|
parseArgs,
|
|
236
422
|
extractPath,
|
|
237
423
|
extractCommand,
|
package/AGENTS.md
CHANGED
|
@@ -150,6 +150,7 @@ This project uses the **Professional Git Workflow** with Lefthook for automated
|
|
|
150
150
|
- Blocks commits if source code modified without test files
|
|
151
151
|
- Offers guided recovery (add tests now, skip with tech debt tracking, emergency override)
|
|
152
152
|
- No AI decision required - automatic validation
|
|
153
|
+
- **Strong default, not a hard floor.** The TDD gate is the default-ON `rail.tdd_intent` rail; turn it off with `forge gate disable rail.tdd_intent` (the `minimal` adoption profile ships it off). The installed hooks read the resolved config at run time, so a disabled rail makes them genuinely inert — enforcement honestly follows your config.
|
|
153
154
|
|
|
154
155
|
**Pre-push hook validates tests:**
|
|
155
156
|
- Branch protection: blocks direct push to `main`/`master`
|
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.1.0-beta.3] - 2026-07-17
|
|
13
|
+
|
|
14
|
+
The **adoption-unblocking wave** — the changes beta testers hit on their very first real session. `forge ship`, the hook-enforcement verdict, and `forge worktree create` no longer dead-end a fresh or in-flight branch, and the PR shepherd now arms itself for any agent. Every change below was adversarially reviewed before merge.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`forge ship` / `forge review` no longer hard-fail on a fresh or in-flight branch (B1).** They previously threw `Stage ship requires authoritative workflow state` whenever no `/plan → /dev → /validate → /ship` history had been walked — blocking the three most common adoption paths: a fresh setup, an in-flight branch, and a manual commit. The stage gate now degrades to a loud stderr warning and seeds stage history in the kernel, so ship works incrementally and future gating gets real data. The rework/contradiction guard (e.g. `validate` started-but-not-done) stays **fail-closed**, and `FORGE_STAGE_GATE=strict` restores the legacy hard block. (#413)
|
|
19
|
+
- **`forge setup` / `forge init --profile minimal` report hook enforcement honestly.** A deliberately-disabled TDD gate no longer prints `✓ Git hook enforcement active` or exits `1` with a `TDD ENFORCEMENT IS NOT ACTIVE` banner — the human-facing verdict and exit code now honor the resolved config (the hook scripts already honored it at run time; this closes the reporting/exit half). A disabled gate reports as intentionally inert with the re-enable command; a corrupt config still fails toward enforcement. (#414, #399)
|
|
20
|
+
- **`forge worktree create` bases new branches on the default branch, not the current checkout (B2).** A worktree created while on a WIP branch previously forked from the current HEAD and silently inherited unrelated commits. New worktrees now fork from the repository default branch (`origin/<default>` when the remote ref exists, else the local default); a new `--base <ref>` flag overrides it (invalid refs error and create nothing), and `create` prints the base it used so the fork point is never silent. (#415)
|
|
21
|
+
- **`forge plan` no longer moves the shared checkout's HEAD.** Creating the feature branch during planning switched the shared working checkout, colliding with parallel work; it now creates the branch without switching HEAD. (#396)
|
|
22
|
+
- **CI and grounding hardening.** Closed a Windows `EBUSY` by closing the kernel driver after context-event I/O (#411); removed an invalid `pull_request_review_thread` Actions trigger from the PR monitor (#404); stopped the pr-monitor red-X and auto-regenerated the D20 kill-list (#394); and cleared five residual B1 stage-state/worktree-linkage rows (#393). The kernel-driver close/ownership invariant is now regression-tested (#412).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Agent-agnostic auto-shepherd PR monitor.** The PR watcher now arms itself on any open PR (`forge push` + `watch --adopt`), gated on the default-ON `rail.auto_shepherd` rail so **any** harness gets PR tracking without a per-agent monitor (#408, #407). It surfaces PR-monitor events into each turn via shepherd-events hooks (#409), lands an actionable `pr-verdict:*` label on the PR (#403), and takes Tier-2 **safe** auto-actions — update-branch when behind, re-run a flaky required check (#406).
|
|
27
|
+
- **Grounding read-first gate.** A P1 **fail-closed** `gate.read_first` blocks a claim about a file the agent has not read. (#410)
|
|
28
|
+
- **Unified command surface — noun verbs + aliases.** Declarative command-alias infrastructure (#401), `forge memory` shortcuts with a passthrough fix (#402), and a `forge pr` noun (`ship`/`preflight`/`shepherd`/`merge`) with gate docs (#405) — the CLI now groups by how people conventionally use it, aliases preserved.
|
|
29
|
+
- **Memory capture-on-exit + unified `forge memory`.** Memory commands are consolidated under `forge memory` with typed notes (#392), and a PreCompact/Stop hook plus `--session-summary` captures session memory on exit so nothing is lost to a compaction. (#397)
|
|
30
|
+
- **Global-plugin front door + lazy `.forge` home.** A global activation entry point that creates the `.forge` home lazily on first use. (#400)
|
|
31
|
+
- **Guided 0.0.10 → current upgrade.** `forge upgrade` walks the breaking Beads → Kernel upgrade from 0.0.10 and nudges the issue-path migration. (#398)
|
|
32
|
+
- **Consolidated opt-in Beads → Kernel migrator.** A single explicit-only migrator with an honest field-gap report. (#391)
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **TypeScript dev dependency bumped 5.4.5 → 7.0.2** (dev-deps group; no runtime change). (#371)
|
|
37
|
+
- Install docs now point at `forge-workflow@beta` so users land on the current prerelease. (#395)
|
|
38
|
+
- Bumped the GitHub Actions dependency group (3 updates). (#390)
|
|
39
|
+
|
|
12
40
|
## [0.1.0-beta.2] - 2026-07-15
|
|
13
41
|
|
|
14
42
|
The **beta-blocker hardening wave** — Forge's advertised loop now composes end to end, its quality gates fail closed, its control surfaces describe themselves honestly, and the runtime is Beads-free. Every change below was adversarially reviewed before merge. (v0.1.0-beta.1 was tagged but never reached npm — its publish token had expired; this release switches to OIDC Trusted Publishing and is the first npm beta.)
|
package/QUICKSTART.md
CHANGED
|
@@ -14,12 +14,16 @@ This guide gets Forge installed and visible to an AI coding agent without assumi
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
# Bun
|
|
17
|
-
bun add -D forge-workflow
|
|
17
|
+
bun add -D forge-workflow@beta
|
|
18
18
|
|
|
19
19
|
# npm
|
|
20
|
-
npm install --save-dev forge-workflow
|
|
20
|
+
npm install --save-dev forge-workflow@beta
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
> **Note:** the current release is a **prerelease** published under the `beta`
|
|
24
|
+
> dist-tag, so install with `@beta`. A bare `forge-workflow` resolves to the
|
|
25
|
+
> older `latest` (stable) version.
|
|
26
|
+
|
|
23
27
|
You can also run one-off commands with `bunx forge ...` (or `npx forge ...`).
|
|
24
28
|
|
|
25
29
|
Terms used below:
|
package/README.md
CHANGED
|
@@ -188,7 +188,9 @@ actually works, and update it as you grow.
|
|
|
188
188
|
|
|
189
189
|
```bash
|
|
190
190
|
# Add to your project
|
|
191
|
-
bun add -D forge-workflow
|
|
191
|
+
bun add -D forge-workflow@beta # or: npm install --save-dev forge-workflow@beta
|
|
192
|
+
# The current release is a prerelease under the `beta` dist-tag; a bare
|
|
193
|
+
# `forge-workflow` resolves to the older stable `latest`.
|
|
192
194
|
|
|
193
195
|
# Install for your agent(s) and configure the workflow
|
|
194
196
|
bunx forge setup --agents claude --yes
|
package/bin/forge.js
CHANGED
|
@@ -60,10 +60,21 @@ const {
|
|
|
60
60
|
} = require('../lib/docs-command');
|
|
61
61
|
const { resetSoft, resetHard, reinstall } = require('../lib/reset');
|
|
62
62
|
const { loadCommands, executeCommand } = require('../lib/commands/_registry');
|
|
63
|
+
const {
|
|
64
|
+
isAlias,
|
|
65
|
+
isHiddenAlias,
|
|
66
|
+
isVisibleAlias,
|
|
67
|
+
resolveAlias,
|
|
68
|
+
resolveDispatch,
|
|
69
|
+
maybeWarnDeprecation,
|
|
70
|
+
passthroughAliasNames,
|
|
71
|
+
visibleAliasNames,
|
|
72
|
+
} = require('../lib/commands/_aliases');
|
|
63
73
|
const { resolveCommandOpts } = require('../lib/commands/_resolve-command-opts');
|
|
64
74
|
const { getPackageRoot } = require('../lib/package-root');
|
|
65
75
|
const { enforceStageEntry } = require('../lib/workflow/enforce-stage');
|
|
66
76
|
const { normalizeStageId } = require('../lib/workflow/stages');
|
|
77
|
+
const { firstPositionalIndex } = require('../lib/global-flags');
|
|
67
78
|
|
|
68
79
|
// Load enhanced onboarding modules (static relative requires — bundleable)
|
|
69
80
|
const contextMerge = require('../lib/context-merge');
|
|
@@ -2315,16 +2326,12 @@ async function _interactiveSetup() {
|
|
|
2315
2326
|
}
|
|
2316
2327
|
|
|
2317
2328
|
// Parse CLI flags
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2320
|
-
//
|
|
2321
|
-
//
|
|
2322
|
-
//
|
|
2323
|
-
|
|
2324
|
-
'create', 'update', 'claim', 'close', 'show', 'list',
|
|
2325
|
-
'ready', 'blocked', 'stale', 'orphans', 'lint', 'claims', 'issues',
|
|
2326
|
-
];
|
|
2327
|
-
|
|
2329
|
+
// The curated back-compat alias allowlist (formerly the inline
|
|
2330
|
+
// ISSUE_ALIAS_COMMANDS array) now lives in lib/commands/_aliases.js as the single
|
|
2331
|
+
// declarative source of truth. `aliasNames()` returns the bare verbs that stay
|
|
2332
|
+
// registered + routable but are hidden from `forge --help` (kernel issue 450c6e34),
|
|
2333
|
+
// so `forge issue` reads as the single canonical issue surface. The canonical
|
|
2334
|
+
// `issue` is deliberately not an alias — it stays documented.
|
|
2328
2335
|
function parseFlags() {
|
|
2329
2336
|
const flags = {
|
|
2330
2337
|
quick: false,
|
|
@@ -2349,8 +2356,11 @@ function parseFlags() {
|
|
|
2349
2356
|
|
|
2350
2357
|
// Issue passthrough commands delegate all flags to bd.
|
|
2351
2358
|
// Skip global parsing so flags like --type, -p, --help reach the handler intact.
|
|
2352
|
-
// Canonical `issue` plus the
|
|
2353
|
-
|
|
2359
|
+
// Canonical `issue` plus the ISSUE-canonical back-compat aliases only. Non-issue
|
|
2360
|
+
// aliases (e.g. the memory shortcuts remember/recall/insights) are excluded so
|
|
2361
|
+
// their global flags still parse exactly as their standalone commands' did —
|
|
2362
|
+
// keeping bare `recall -p <dir>` / `recall --help` byte-identical.
|
|
2363
|
+
const issuePassthroughCommands = [...passthroughAliasNames(), 'issue'];
|
|
2354
2364
|
if (issuePassthroughCommands.includes(args[0])) {
|
|
2355
2365
|
return flags;
|
|
2356
2366
|
}
|
|
@@ -2610,15 +2620,30 @@ function showHelp() {
|
|
|
2610
2620
|
console.log(' Run `forge init --help` for all profile/classification/harness flags.');
|
|
2611
2621
|
console.log('');
|
|
2612
2622
|
|
|
2623
|
+
// Shortcuts block: VISIBLE back-compat aliases for a canonical `<noun> <sub>`
|
|
2624
|
+
// form (e.g. `remember` -> `forge memory add`). The bare verb keeps working and
|
|
2625
|
+
// is documented here; the noun form is canonical. Rendered as its own block ABOVE
|
|
2626
|
+
// "Additional commands" (and trimmed from that enumeration below) so the noun
|
|
2627
|
+
// surface reads clean.
|
|
2628
|
+
const shortcutNames = visibleAliasNames();
|
|
2629
|
+
if (shortcutNames.length > 0) {
|
|
2630
|
+
console.log('Shortcuts (bare aliases for canonical noun subcommands):');
|
|
2631
|
+
const shortcutWidth = Math.max(...shortcutNames.map(name => name.length));
|
|
2632
|
+
for (const name of shortcutNames) {
|
|
2633
|
+
console.log(` ${name.padEnd(shortcutWidth)} -> forge ${resolveAlias(name).canonical}`);
|
|
2634
|
+
}
|
|
2635
|
+
console.log('');
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2613
2638
|
// Append auto-discovered registry commands. Hidden issue aliases (the bare
|
|
2614
2639
|
// passthroughs + plural `issues`, plus any command self-declaring `hidden: true`)
|
|
2615
|
-
//
|
|
2616
|
-
//
|
|
2617
|
-
// solely to render help, so deleting from its Map only affects
|
|
2618
|
-
// command dispatch (main) uses a separate registry
|
|
2640
|
+
// AND the visible noun shortcuts rendered above are trimmed from this enumeration
|
|
2641
|
+
// so the canonical noun surface reads clean; both remain routable. This registry
|
|
2642
|
+
// instance is loaded solely to render help, so deleting from its Map only affects
|
|
2643
|
+
// the printed list — command dispatch (main) uses a separate registry.
|
|
2619
2644
|
const helpRegistry = loadCommands(path.join(__dirname, '..', 'lib', 'commands'));
|
|
2620
2645
|
for (const [name, cmd] of [...helpRegistry.commands]) {
|
|
2621
|
-
if (
|
|
2646
|
+
if (isHiddenAlias(name) || isVisibleAlias(name) || cmd.hidden === true) {
|
|
2622
2647
|
helpRegistry.commands.delete(name);
|
|
2623
2648
|
}
|
|
2624
2649
|
}
|
|
@@ -3945,7 +3970,7 @@ async function handleExternalServices(skipExternal, selectedAgents) {
|
|
|
3945
3970
|
}
|
|
3946
3971
|
|
|
3947
3972
|
async function main() {
|
|
3948
|
-
|
|
3973
|
+
let command = args[0];
|
|
3949
3974
|
const flags = parseFlags();
|
|
3950
3975
|
const suppressJsonIntrospectionOutput = ['options', 'explain'].includes(command) && args.includes('--json');
|
|
3951
3976
|
const suppressCommandJsonOutput = args.includes('--json');
|
|
@@ -4032,6 +4057,52 @@ async function main() {
|
|
|
4032
4057
|
}
|
|
4033
4058
|
}
|
|
4034
4059
|
|
|
4060
|
+
// Back-compat alias handling (lib/commands/_aliases.js, the single declarative
|
|
4061
|
+
// source of truth generalised from the former ISSUE_ALIAS_COMMANDS array). Emit
|
|
4062
|
+
// an opt-in deprecation hint (stderr only, gated on FORGE_DEPRECATION_WARNINGS +
|
|
4063
|
+
// the alias being marked deprecated — never on stdout, so `--json` stays clean),
|
|
4064
|
+
// then resolve any bare alias whose name is NOT itself a registered command to
|
|
4065
|
+
// its canonical `<noun> <sub>` handler. For P0 every alias is also a registered
|
|
4066
|
+
// command file and none are deprecated, so nothing warns and nothing is
|
|
4067
|
+
// rewritten — dispatch stays byte-identical. Registered commands skip the
|
|
4068
|
+
// rewrite via the first clause; this activates only when a later phase folds a
|
|
4069
|
+
// bare verb into a noun handler.
|
|
4070
|
+
maybeWarnDeprecation(command);
|
|
4071
|
+
let dispatchArgv = args;
|
|
4072
|
+
if (!registry.commands.has(command) && isAlias(command)) {
|
|
4073
|
+
const resolved = resolveDispatch(command, args, (name) => registry.commands.has(name));
|
|
4074
|
+
command = resolved.command;
|
|
4075
|
+
dispatchArgv = resolved.args;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
// A workflow stage reached through its canonical noun form (e.g. `pr ship`,
|
|
4079
|
+
// whose bare alias `ship` IS a stage) must dispatch through the SAME top-level
|
|
4080
|
+
// stage path as the bare verb — stage-entry enforcement, kernel stage-run
|
|
4081
|
+
// recording, and ensureForgeHome all key on the stage token, which sits at the
|
|
4082
|
+
// subcommand position under a noun. Rewrite `<noun> <stage> [args]` to the
|
|
4083
|
+
// top-level `<stage> [args]` so behavior is byte-identical to the bare stage
|
|
4084
|
+
// alias. Detected precisely via the declarative alias map: the subcommand is a
|
|
4085
|
+
// stage AND its bare alias's canonical is exactly `<command> <sub>` (so only a
|
|
4086
|
+
// genuine noun→stage form matches; non-stage pr/gate subcommands route via
|
|
4087
|
+
// their noun handler as usual). Only `pr ship` matches today.
|
|
4088
|
+
//
|
|
4089
|
+
// The stage token is located as the first POSITIONAL after the noun (scanning
|
|
4090
|
+
// past any global flags and their values), so `forge pr --path /tmp ship ...`
|
|
4091
|
+
// shares the same stage path as `forge pr ship ...` instead of falling through
|
|
4092
|
+
// to pr.handler and missing stage-entry enforcement. Intervening global flags
|
|
4093
|
+
// are preserved into the rewritten argv so the stage handler still sees them.
|
|
4094
|
+
const stageIdx = firstPositionalIndex(dispatchArgv, 1);
|
|
4095
|
+
const nounSub = stageIdx >= 0 ? dispatchArgv[stageIdx] : undefined;
|
|
4096
|
+
if (nounSub && normalizeStageId(nounSub) && registry.commands.has(nounSub)) {
|
|
4097
|
+
const bareAlias = resolveAlias(nounSub);
|
|
4098
|
+
if (bareAlias && bareAlias.canonical === `${command} ${nounSub}`) {
|
|
4099
|
+
command = nounSub;
|
|
4100
|
+
// Drop only the consumed stage token (the noun at index 0 is dropped by the
|
|
4101
|
+
// registry dispatch's own `.slice(1)`); keep the intervening global flags.
|
|
4102
|
+
dispatchArgv = [nounSub, ...dispatchArgv.slice(1, stageIdx), ...dispatchArgv.slice(stageIdx + 1)];
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4035
4106
|
// Registry command dispatch — auto-discovered commands take priority
|
|
4036
4107
|
if (registry.commands.has(command)) {
|
|
4037
4108
|
try {
|
|
@@ -4041,7 +4112,7 @@ async function main() {
|
|
|
4041
4112
|
// also assembles the driver + migrated broker (B1/B2).
|
|
4042
4113
|
const { commandOpts, args: dispatchArgs } = await resolveCommandOpts(
|
|
4043
4114
|
command,
|
|
4044
|
-
|
|
4115
|
+
dispatchArgv.slice(1),
|
|
4045
4116
|
{ env: process.env, projectRoot },
|
|
4046
4117
|
);
|
|
4047
4118
|
const result = await executeCommand(
|
package/docs/guides/SETUP.md
CHANGED
|
@@ -12,9 +12,12 @@ This guide covers supported Forge adoption paths. Use [Quickstart](../../QUICKST
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
bun add -D forge-workflow
|
|
15
|
+
bun add -D forge-workflow@beta
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
> The current release is a prerelease under the `beta` dist-tag — install with
|
|
19
|
+
> `@beta`. A bare `forge-workflow` resolves to the older stable `latest`.
|
|
20
|
+
|
|
18
21
|
The package exposes `forge`, `forge-workflow`, and `forge-preflight`.
|
|
19
22
|
|
|
20
23
|
`install.sh` is a thin bootstrapper. It installs or invokes `forge-workflow` and delegates setup to the package; it is not a separate implementation of setup behavior.
|
package/docs/guides/SUPPORT.md
CHANGED
|
@@ -108,6 +108,11 @@ Create isolated work:
|
|
|
108
108
|
forge worktree create <slug> --branch <branch-name>
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
The new branch is forked from the repository's default branch (`origin/<default>`
|
|
112
|
+
when present, else the local default), not the current checkout — so a worktree
|
|
113
|
+
made from a WIP branch does not inherit unrelated commits. Override with
|
|
114
|
+
`--base <ref>` to fork from a specific ref; `create` prints the base it used.
|
|
115
|
+
|
|
111
116
|
Remove it:
|
|
112
117
|
|
|
113
118
|
```bash
|