forge-orkes 0.80.3 → 0.81.0
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/bin/create-forge.js +154 -20
- package/package.json +1 -1
- package/template/.claude/hooks/forge-size-gate.sh +26 -0
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +28 -0
- package/template/.claude/settings.json +1 -1
- package/template/.forge/gitignore +11 -5
- package/template/.forge/migrations/0.80.0-context-md-sharding.md +12 -8
package/bin/create-forge.js
CHANGED
|
@@ -295,19 +295,25 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
* Guarantee CLAUDE.md carries the single `@.forge/FORGE.md` import line
|
|
299
|
-
* any legacy embedded forge section out in one pass.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
298
|
+
* Guarantee CLAUDE.md carries the single `@.forge/FORGE.md` import line WITHIN ITS
|
|
299
|
+
* FIRST 3 LINES, migrating any legacy embedded forge section out in one pass. Claude
|
|
300
|
+
* Code truncates memory files at 40k chars from the TAIL — an import line buried below
|
|
301
|
+
* that cliff silently unloads the entire framework, so the import must never live in
|
|
302
|
+
* the tail. The framework prose itself lives in .forge/FORGE.md (synced separately) —
|
|
303
|
+
* this function never writes prose into CLAUDE.md and never touches user content
|
|
304
|
+
* outside the legacy forge section / the one line it relocates.
|
|
302
305
|
*
|
|
303
306
|
* no CLAUDE.md → write the stub (comment + import) → 'created'
|
|
304
307
|
* marker section present → replace section in place with the import line → 'migrated'
|
|
305
308
|
* legacy `# Forge` header (no markers) → strip header-to-EOF, append import line → 'migrated'
|
|
306
|
-
* import line
|
|
307
|
-
*
|
|
309
|
+
* import line within the first 3 lines → untouched, no rewrite (no mtime churn) → 'unchanged'
|
|
310
|
+
* import line present but lower → remove that line, insert as line 1 + blank line,
|
|
311
|
+
* every other byte preserved in order → 'moved'
|
|
312
|
+
* no import line anywhere → prepend import line + blank line, original
|
|
313
|
+
* content follows intact → 'appended'
|
|
308
314
|
*
|
|
309
|
-
* Newline cleanup is confined to the seam of the replaced region only — a
|
|
310
|
-
* collapse would mutate user content, which upgrades must never do.
|
|
315
|
+
* Newline cleanup is confined to the seam of the replaced/relocated region only — a
|
|
316
|
+
* global collapse would mutate user content, which upgrades must never do.
|
|
311
317
|
*/
|
|
312
318
|
function ensureClaudeMdImport() {
|
|
313
319
|
const destPath = path.join(targetDir, 'CLAUDE.md');
|
|
@@ -344,21 +350,90 @@ function ensureClaudeMdImport() {
|
|
|
344
350
|
return 'migrated';
|
|
345
351
|
}
|
|
346
352
|
|
|
347
|
-
// Import line
|
|
348
|
-
|
|
353
|
+
// Import line present somewhere (tolerating trailing whitespace/CRLF) — find its
|
|
354
|
+
// exact line so position and byte-preservation are decided on real line boundaries,
|
|
355
|
+
// not the regex's own (looser) match span.
|
|
356
|
+
if (FORGE_IMPORT_RE.test(existing)) {
|
|
357
|
+
const lines = existing.split('\n');
|
|
358
|
+
const idx = lines.findIndex((l) => l.trimEnd() === FORGE_IMPORT_LINE);
|
|
359
|
+
|
|
360
|
+
// Already within the first 3 lines — leave the file byte-for-byte untouched.
|
|
361
|
+
if (idx === -1 || idx <= 2) return 'unchanged';
|
|
362
|
+
|
|
363
|
+
// Remove the import's own line.
|
|
364
|
+
lines.splice(idx, 1);
|
|
365
|
+
// If that removal left two adjacent blank lines where there weren't three
|
|
366
|
+
// before, collapse them back to one — the only non-import byte we ever drop.
|
|
367
|
+
if (idx > 0 && idx < lines.length && lines[idx - 1].trim() === '' && lines[idx].trim() === '') {
|
|
368
|
+
lines.splice(idx, 1);
|
|
369
|
+
}
|
|
370
|
+
const merged = FORGE_IMPORT_LINE + '\n\n' + lines.join('\n');
|
|
371
|
+
fs.writeFileSync(destPath, merged);
|
|
372
|
+
return 'moved';
|
|
373
|
+
}
|
|
349
374
|
|
|
350
|
-
// User-authored CLAUDE.md with no forge content and no import line —
|
|
351
|
-
|
|
352
|
-
|
|
375
|
+
// User-authored CLAUDE.md with no forge content and no import line anywhere —
|
|
376
|
+
// prepend the import (plus a separating blank line) so it is never buried below
|
|
377
|
+
// the truncation cliff. Original content follows intact, byte-for-byte.
|
|
378
|
+
const merged = FORGE_IMPORT_LINE + '\n\n' + existing;
|
|
353
379
|
fs.writeFileSync(destPath, merged);
|
|
354
380
|
return 'appended';
|
|
355
381
|
}
|
|
356
382
|
|
|
383
|
+
// --- Hook identity helpers (per-hook dedupe key: event + matcher + script basename) ---
|
|
384
|
+
|
|
385
|
+
// Normalize a hook group's matcher for key comparisons — trim, and treat an
|
|
386
|
+
// absent/empty matcher as the same value so an unset matcher never desyncs
|
|
387
|
+
// from "".
|
|
388
|
+
function normalizeMatcher(matcher) {
|
|
389
|
+
return typeof matcher === 'string' ? matcher.trim() : '';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Basename of the first path-looking token in a command string (quotes
|
|
393
|
+
// stripped) — "bash /path/to/x.sh" and "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh"
|
|
394
|
+
// both resolve to "x.sh", so the same framework script hosted at a different
|
|
395
|
+
// path, or invoked via an interpreter prefix, is recognized as the same hook.
|
|
396
|
+
// No path-looking token (e.g. a bare `echo` one-liner) → the whole trimmed
|
|
397
|
+
// command is its own identity.
|
|
398
|
+
function hookScriptBasename(command) {
|
|
399
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
400
|
+
for (const raw of tokens) {
|
|
401
|
+
let tok = raw;
|
|
402
|
+
if ((tok.startsWith('"') && tok.endsWith('"')) || (tok.startsWith("'") && tok.endsWith("'"))) {
|
|
403
|
+
tok = tok.slice(1, -1);
|
|
404
|
+
}
|
|
405
|
+
if (tok.includes('/')) return path.basename(tok);
|
|
406
|
+
}
|
|
407
|
+
return command.trim();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Identity of a single hook entry within a group. Prompt-backed hooks key off
|
|
411
|
+
// the prompt text; anything else (neither command nor prompt) falls back to a
|
|
412
|
+
// full-entry identity so it never collides with something it isn't.
|
|
413
|
+
function hookEntryIdentity(hookEntry) {
|
|
414
|
+
if (typeof hookEntry.command === 'string') return 'cmd:' + hookScriptBasename(hookEntry.command);
|
|
415
|
+
if (typeof hookEntry.prompt === 'string') return 'prompt:' + hookEntry.prompt.trim();
|
|
416
|
+
return 'raw:' + JSON.stringify(hookEntry);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Full dedupe key for one (event, matcher, hook-entry) registration. JSON-encoded
|
|
420
|
+
// as a tuple so no delimiter choice can be ambiguated by the values themselves.
|
|
421
|
+
function hookKey(event, matcher, hookEntry) {
|
|
422
|
+
return JSON.stringify([event, normalizeMatcher(matcher), hookEntryIdentity(hookEntry)]);
|
|
423
|
+
}
|
|
424
|
+
|
|
357
425
|
/**
|
|
358
426
|
* Additively install the template's framework-managed hooks into an existing
|
|
359
|
-
* settings.json (ADR-017). Never removes a user's hooks
|
|
360
|
-
*
|
|
361
|
-
*
|
|
427
|
+
* settings.json (ADR-017). Never removes a user's hooks. Dedupes PER HOOK
|
|
428
|
+
* ENTRY, keyed by (event, matcher, script basename) — not whole-group
|
|
429
|
+
* equality — because consumer repos legitimately reshape groups (combine two
|
|
430
|
+
* matcher="Bash" groups into one, host the same script at a project-local
|
|
431
|
+
* path, etc.) without that being a meaningfully different hook. A hook whose
|
|
432
|
+
* key already exists anywhere under that event is skipped; a genuinely new
|
|
433
|
+
* hook is appended into an existing dest group sharing its matcher, or into a
|
|
434
|
+
* newly created group when no such group exists yet. See cleanupDuplicateHooks()
|
|
435
|
+
* (called from upgradeSettings()) for the paired destination-side cleanup pass
|
|
436
|
+
* that collapses duplicates already baked into a project's settings.json.
|
|
362
437
|
*/
|
|
363
438
|
function mergeManagedHooks(destSettings, srcSettings) {
|
|
364
439
|
if (!srcSettings.hooks) return;
|
|
@@ -366,12 +441,62 @@ function mergeManagedHooks(destSettings, srcSettings) {
|
|
|
366
441
|
for (const event of Object.keys(srcSettings.hooks)) {
|
|
367
442
|
const srcGroups = srcSettings.hooks[event] || [];
|
|
368
443
|
const destGroups = destSettings.hooks[event] || (destSettings.hooks[event] = []);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
444
|
+
|
|
445
|
+
const existingKeys = new Set();
|
|
446
|
+
const groupsByMatcher = new Map();
|
|
447
|
+
for (const group of destGroups) {
|
|
448
|
+
const nm = normalizeMatcher(group.matcher);
|
|
449
|
+
if (!groupsByMatcher.has(nm)) groupsByMatcher.set(nm, group);
|
|
450
|
+
for (const hookEntry of group.hooks || []) {
|
|
451
|
+
existingKeys.add(hookKey(event, group.matcher, hookEntry));
|
|
373
452
|
}
|
|
374
453
|
}
|
|
454
|
+
|
|
455
|
+
for (const srcGroup of srcGroups) {
|
|
456
|
+
for (const hookEntry of srcGroup.hooks || []) {
|
|
457
|
+
const key = hookKey(event, srcGroup.matcher, hookEntry);
|
|
458
|
+
if (existingKeys.has(key)) continue; // already installed, whatever group it sits in
|
|
459
|
+
|
|
460
|
+
const nm = normalizeMatcher(srcGroup.matcher);
|
|
461
|
+
let destGroup = groupsByMatcher.get(nm);
|
|
462
|
+
if (!destGroup) {
|
|
463
|
+
destGroup = { matcher: srcGroup.matcher, hooks: [] };
|
|
464
|
+
destGroups.push(destGroup);
|
|
465
|
+
groupsByMatcher.set(nm, destGroup);
|
|
466
|
+
}
|
|
467
|
+
destGroup.hooks.push(hookEntry);
|
|
468
|
+
existingKeys.add(key);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Collapse duplicate hook registrations already baked into a project's
|
|
476
|
+
* settings.json — the same (event, matcher, script basename) appearing in
|
|
477
|
+
* more than one group, or more than once in the same group — keeping the
|
|
478
|
+
* first occurrence and dropping any group the collapse empties. Runs on
|
|
479
|
+
* every upgrade, independent of what mergeManagedHooks() just added, so
|
|
480
|
+
* repos that accumulated near-duplicate groups under the old whole-group
|
|
481
|
+
* equality merge converge without operator action. Idempotent by
|
|
482
|
+
* construction: a settings.json with no duplicates is left byte-identical.
|
|
483
|
+
*/
|
|
484
|
+
function cleanupDuplicateHooks(destSettings) {
|
|
485
|
+
if (!destSettings.hooks) return;
|
|
486
|
+
for (const event of Object.keys(destSettings.hooks)) {
|
|
487
|
+
const groups = destSettings.hooks[event];
|
|
488
|
+
if (!Array.isArray(groups)) continue;
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
for (const group of groups) {
|
|
491
|
+
if (!Array.isArray(group.hooks)) continue;
|
|
492
|
+
group.hooks = group.hooks.filter((hookEntry) => {
|
|
493
|
+
const key = hookKey(event, group.matcher, hookEntry);
|
|
494
|
+
if (seen.has(key)) return false;
|
|
495
|
+
seen.add(key);
|
|
496
|
+
return true;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
destSettings.hooks[event] = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
|
|
375
500
|
}
|
|
376
501
|
}
|
|
377
502
|
|
|
@@ -475,6 +600,9 @@ function upgradeSettings() {
|
|
|
475
600
|
migrateActiveSkillHook(destSettings);
|
|
476
601
|
// Additively install framework-managed safety guardrails (ADR-017)
|
|
477
602
|
mergeManagedHooks(destSettings, srcSettings);
|
|
603
|
+
// Converge any duplicate hook registrations already baked into the
|
|
604
|
+
// destination (pre-existing, or left by an older whole-group merge).
|
|
605
|
+
cleanupDuplicateHooks(destSettings);
|
|
478
606
|
mergeManagedDeny(destSettings, srcSettings);
|
|
479
607
|
mergeManagedAllow(destSettings, srcSettings);
|
|
480
608
|
|
|
@@ -535,6 +663,9 @@ async function install() {
|
|
|
535
663
|
case 'appended':
|
|
536
664
|
console.log(' Added @.forge/FORGE.md import line to existing CLAUDE.md');
|
|
537
665
|
break;
|
|
666
|
+
case 'moved':
|
|
667
|
+
console.log(' Moved @.forge/FORGE.md import line to the top of CLAUDE.md');
|
|
668
|
+
break;
|
|
538
669
|
case 'unchanged':
|
|
539
670
|
console.log(' CLAUDE.md import line already present');
|
|
540
671
|
break;
|
|
@@ -959,6 +1090,9 @@ async function upgrade() {
|
|
|
959
1090
|
} else if (claudeStatus === 'appended') {
|
|
960
1091
|
results.updated.push('CLAUDE.md');
|
|
961
1092
|
say(' CLAUDE.md: @.forge/FORGE.md import line restored');
|
|
1093
|
+
} else if (claudeStatus === 'moved') {
|
|
1094
|
+
results.updated.push('CLAUDE.md');
|
|
1095
|
+
say(' CLAUDE.md: @.forge/FORGE.md import line moved to the top (was below the truncation-safe zone)');
|
|
962
1096
|
} else if (claudeStatus === 'unchanged') {
|
|
963
1097
|
results.unchanged.push('CLAUDE.md');
|
|
964
1098
|
} else if (claudeStatus === 'created') {
|
package/package.json
CHANGED
|
@@ -42,6 +42,16 @@
|
|
|
42
42
|
# A declared-gate artifact that does not exist in this repo (e.g. no
|
|
43
43
|
# prototypes/ dir yet) is skipped silently — not an error, not a violation.
|
|
44
44
|
#
|
|
45
|
+
# Special-cased alongside the tiered skill-file class: the CONSUMER's
|
|
46
|
+
# repo-root CLAUDE.md, advisory at 30 KB (m-56). This is NOT a FORGE.md Size
|
|
47
|
+
# Gates table row — that table lists forge-owned artifacts, and CLAUDE.md
|
|
48
|
+
# belongs entirely to the consumer (the import-to-top fix keeps the
|
|
49
|
+
# @.forge/FORGE.md import out of Claude Code's 40k-char tail-truncation
|
|
50
|
+
# zone; this check is the companion advisory for when the consumer's own
|
|
51
|
+
# prose grows past a size where relocating it into path-scoped
|
|
52
|
+
# .claude/rules/ files, ADR-018, is worth considering). Absent CLAUDE.md or
|
|
53
|
+
# under 30 KB → silent, same contract as every other check.
|
|
54
|
+
#
|
|
45
55
|
# Contract (SessionStart, mirrors forge-state-rollup.sh — ADR-032):
|
|
46
56
|
# - Exit 0 ALWAYS. SessionStart is non-blocking; this hook must never abort
|
|
47
57
|
# a session.
|
|
@@ -164,6 +174,22 @@ for f in "$root"/.claude/skills/*/SKILL.md; do
|
|
|
164
174
|
check_gate "$f" "$skill_kb"
|
|
165
175
|
done
|
|
166
176
|
|
|
177
|
+
# --- consumer CLAUDE.md advisory (special-cased — NOT a Size Gates table row) -
|
|
178
|
+
# CLAUDE.md is consumer-owned, not forge-owned, so it never joins the declared
|
|
179
|
+
# table above (m-56). Threshold is 30 KB BINARY (30720 B — Claude Code's own
|
|
180
|
+
# memory-file semantics use binary units), unlike the decimal-KB gates above.
|
|
181
|
+
# Missing file or under threshold → silent, same contract as every other check.
|
|
182
|
+
|
|
183
|
+
CLAUDE_MD_GATE_BYTES=30720
|
|
184
|
+
|
|
185
|
+
if [ -f "$root/CLAUDE.md" ]; then
|
|
186
|
+
claude_bytes="$(wc -c < "$root/CLAUDE.md" 2>/dev/null | tr -d ' ')"
|
|
187
|
+
if [ -n "$claude_bytes" ] && [ "$claude_bytes" -gt "$CLAUDE_MD_GATE_BYTES" ]; then
|
|
188
|
+
VIOLATIONS="${VIOLATIONS}${VIOLATIONS:+, }CLAUDE.md (${claude_bytes}B, over the 30KB advisory threshold — relocate prose into path-scoped .claude/rules/, ADR-018)"
|
|
189
|
+
VIOLATION_COUNT=$((VIOLATION_COUNT + 1))
|
|
190
|
+
fi
|
|
191
|
+
fi
|
|
192
|
+
|
|
167
193
|
# --- summary (stdout → injected into boot context) ---------------------------
|
|
168
194
|
# Silent when clean. One terse line when not.
|
|
169
195
|
|
|
@@ -114,6 +114,34 @@ check_has "missing tier-list → default 20KB tier applied even to 'forge'" "$ou
|
|
|
114
114
|
rm -rf "$REPO/.claude/skills"
|
|
115
115
|
printf 'forge\nchief-of-staff\n' > "$REPO/.claude/hooks/skill-gate-tiers.txt"
|
|
116
116
|
|
|
117
|
+
# ===========================================================================
|
|
118
|
+
# Case F1 — consumer CLAUDE.md over the 30KB (30720B) advisory threshold: one
|
|
119
|
+
# advisory line naming CLAUDE.md, its byte size, the 30KB threshold, and the
|
|
120
|
+
# .claude/rules/ relocation pointer. Special-cased, NOT a Size Gates table row.
|
|
121
|
+
# ===========================================================================
|
|
122
|
+
pad_to "$REPO/CLAUDE.md" 31000
|
|
123
|
+
out_f1="$("$GATE" --project "$REPO" 2>&1)"
|
|
124
|
+
check_has "over-30KB CLAUDE.md named" "$out_f1" "CLAUDE.md"
|
|
125
|
+
check_has "over-30KB CLAUDE.md shows actual bytes" "$out_f1" "31000B"
|
|
126
|
+
check_has "over-30KB CLAUDE.md names the 30KB threshold" "$out_f1" "30KB"
|
|
127
|
+
check_has "over-30KB CLAUDE.md points at .claude/rules/ relocation" "$out_f1" ".claude/rules"
|
|
128
|
+
rm -f "$REPO/CLAUDE.md"
|
|
129
|
+
|
|
130
|
+
# ===========================================================================
|
|
131
|
+
# Case F2 — consumer CLAUDE.md under the 30KB threshold → never named.
|
|
132
|
+
# ===========================================================================
|
|
133
|
+
pad_to "$REPO/CLAUDE.md" 5000
|
|
134
|
+
out_f2b="$("$GATE" --project "$REPO" 2>&1)"
|
|
135
|
+
check_absent "under-30KB CLAUDE.md never named" "$out_f2b" "CLAUDE.md"
|
|
136
|
+
rm -f "$REPO/CLAUDE.md"
|
|
137
|
+
|
|
138
|
+
# ===========================================================================
|
|
139
|
+
# Case F3 — no CLAUDE.md at all → no error, clean run, exit 0.
|
|
140
|
+
# ===========================================================================
|
|
141
|
+
out_f3="$("$GATE" --project "$REPO" 2>&1)"; rc_f3=$?
|
|
142
|
+
check "absent CLAUDE.md → exit 0" "$rc_f3" "0"
|
|
143
|
+
check_absent "absent CLAUDE.md never named" "$out_f3" "CLAUDE.md"
|
|
144
|
+
|
|
117
145
|
# ===========================================================================
|
|
118
146
|
# Case F — non-Forge repo / non-repo dir: silent no-op, exit 0.
|
|
119
147
|
# ===========================================================================
|
|
@@ -34,13 +34,19 @@ dev-source
|
|
|
34
34
|
# Orchestration locks
|
|
35
35
|
*.lock
|
|
36
36
|
|
|
37
|
-
# Render-on-read derived registries (0.53.0
|
|
38
|
-
# from their sources (state/index.yml ←
|
|
39
|
-
# ← streams/*.yml + active milestones
|
|
40
|
-
#
|
|
41
|
-
#
|
|
37
|
+
# Render-on-read derived registries (0.53.0; context.md joined in 0.80.0/ADR-033).
|
|
38
|
+
# Regenerated at every `forge` boot from their sources (state/index.yml ←
|
|
39
|
+
# state/milestone-*.yml; streams/active.yml ← streams/*.yml + active milestones;
|
|
40
|
+
# context.md ← context/*.md joined against state/index.yml). Local cache only —
|
|
41
|
+
# never committed, so a committed projection can never drift stale from its
|
|
42
|
+
# source. Without these entries a fresh install would re-commit them, and an
|
|
43
|
+
# upgrade would silently re-track context.md after a project migrated off the
|
|
44
|
+
# monolithic file (issue #46 — this line must ship canonically, not as a
|
|
45
|
+
# per-project manual edit, since this file is overwritten wholesale on every
|
|
46
|
+
# upgrade).
|
|
42
47
|
state/index.yml
|
|
43
48
|
streams/active.yml
|
|
49
|
+
context.md
|
|
44
50
|
|
|
45
51
|
# Notion board id-binding cache (ADR-020 derived class) — forge-id → page-id, rebuilt
|
|
46
52
|
# by query-self-heal; never committed (a committed copy would leak private Notion page IDs).
|
|
@@ -123,19 +123,23 @@ What is **not** repeatable is the untrack step below — do that once.
|
|
|
123
123
|
git rm --cached .forge/context.md
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
If your project uses the shipped `.forge/.gitignore` default (no custom
|
|
127
|
+
gitignore layout), a `create-forge` install/upgrade at 0.80.4+ already lists
|
|
128
|
+
`context.md` next to `state/index.yml` and `streams/active.yml` in that file —
|
|
129
|
+
nothing to add by hand, and it's safe against a future upgrade overwriting it
|
|
130
|
+
(issue #46: before 0.80.4, `.forge/.gitignore` was wholesale re-synced from
|
|
131
|
+
the canonical shipped file on every upgrade, silently reverting a manual
|
|
132
|
+
addition here — the fix moved the line into the canonical source itself).
|
|
133
|
+
|
|
134
|
+
If your project customized gitignore layout (e.g. this repo's own root
|
|
135
|
+
`.gitignore`, not `.forge/.gitignore`), add `.forge/context.md` there by
|
|
136
|
+
hand, next to the other two derived-registry entries, matching their
|
|
137
|
+
existing comment block rather than starting a new one:
|
|
131
138
|
|
|
132
139
|
```bash
|
|
133
140
|
grep -rn "index.yml" .gitignore .forge/.gitignore 2>/dev/null
|
|
134
141
|
```
|
|
135
142
|
|
|
136
|
-
Add the line next to the other two derived-registry entries, matching their
|
|
137
|
-
existing comment block rather than starting a new one.
|
|
138
|
-
|
|
139
143
|
### 3. Commit the migration output
|
|
140
144
|
|
|
141
145
|
```bash
|