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.
@@ -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, migrating
299
- * any legacy embedded forge section out in one pass. The framework prose itself lives
300
- * in .forge/FORGE.md (synced separately)this function never writes prose into
301
- * CLAUDE.md and never touches user content outside the legacy forge section.
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 already present → 'unchanged'
307
- * none of the above append comment + import line at EOF 'appended'
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 global
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 already present (tolerating trailing whitespace/CRLF) — nothing to do.
348
- if (FORGE_IMPORT_RE.test(existing)) return 'unchanged';
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 — append it.
351
- const merged =
352
- existing.trimEnd() + '\n\n' + CLAUDE_STUB;
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; dedups by deep-equality
360
- * so re-running upgrade is idempotent. New groups (the safety guardrails) get
361
- * appended; groups the project already has (identical) are skipped.
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
- for (const group of srcGroups) {
370
- const groupJson = JSON.stringify(group);
371
- if (!destGroups.some((d) => JSON.stringify(d) === groupJson)) {
372
- destGroups.push(group);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.80.3",
3
+ "version": "0.81.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -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
  # ===========================================================================
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.80.3",
3
+ "version": "0.81.0",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -34,13 +34,19 @@ dev-source
34
34
  # Orchestration locks
35
35
  *.lock
36
36
 
37
- # Render-on-read derived registries (0.53.0). Regenerated at every `forge` boot
38
- # from their sources (state/index.yml ← state/milestone-*.yml; streams/active.yml
39
- # ← streams/*.yml + active milestones). Local cache only — never committed, so a
40
- # committed projection can never drift stale from its source. Without these
41
- # entries a fresh install would re-commit them.
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
- Add `.forge/context.md` to whichever gitignore file already lists
127
- `.forge/state/index.yml` and `.forge/streams/active.yml` grep for
128
- `index.yml` to find the right file (in this repo it's the root
129
- `.gitignore`, under the "Render-on-read derived registries" comment block;
130
- your project's may differ if it customized gitignore layout):
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