voidforge-build 23.19.0 → 23.21.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.
Files changed (64) hide show
  1. package/dist/.claude/agents/celebrimbor-forge-artist.md +1 -0
  2. package/dist/.claude/agents/ducem-token-economics.md +1 -0
  3. package/dist/.claude/agents/galadriel-frontend.md +1 -0
  4. package/dist/.claude/agents/romanoff-integrations.md +4 -0
  5. package/dist/.claude/agents/silver-surfer-herald.md +19 -4
  6. package/dist/.claude/commands/architect.md +4 -3
  7. package/dist/.claude/commands/assemble.md +12 -0
  8. package/dist/.claude/commands/assess.md +1 -0
  9. package/dist/.claude/commands/build.md +8 -0
  10. package/dist/.claude/commands/contextmeter.md +56 -0
  11. package/dist/.claude/commands/debrief.md +10 -0
  12. package/dist/.claude/commands/engage.md +5 -0
  13. package/dist/.claude/commands/git.md +19 -3
  14. package/dist/.claude/commands/imagine.md +1 -1
  15. package/dist/.claude/commands/seal.md +81 -0
  16. package/dist/.claude/commands/ux.md +13 -0
  17. package/dist/.claude/workflows/gauntlet.workflow.js +13 -1
  18. package/dist/CHANGELOG.md +63 -0
  19. package/dist/CLAUDE.md +10 -1
  20. package/dist/HOLOCRON.md +16 -2
  21. package/dist/VERSION.md +3 -1
  22. package/dist/docs/methods/AI_INTELLIGENCE.md +3 -0
  23. package/dist/docs/methods/ASSEMBLER.md +12 -0
  24. package/dist/docs/methods/BUILD_PROTOCOL.md +15 -0
  25. package/dist/docs/methods/CAMPAIGN.md +11 -0
  26. package/dist/docs/methods/DEVOPS_ENGINEER.md +66 -0
  27. package/dist/docs/methods/FIELD_MEDIC.md +1 -0
  28. package/dist/docs/methods/FORGE_ARTIST.md +3 -4
  29. package/dist/docs/methods/GAUNTLET.md +6 -0
  30. package/dist/docs/methods/MUSTER.md +2 -0
  31. package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +18 -0
  32. package/dist/docs/methods/QA_ENGINEER.md +21 -1
  33. package/dist/docs/methods/RELEASE_MANAGER.md +38 -0
  34. package/dist/docs/methods/SECURITY_AUDITOR.md +11 -1
  35. package/dist/docs/methods/SUB_AGENTS.md +33 -0
  36. package/dist/docs/methods/SYSTEMS_ARCHITECT.md +15 -0
  37. package/dist/docs/methods/TESTING.md +2 -0
  38. package/dist/docs/methods/TROUBLESHOOTING.md +2 -2
  39. package/dist/docs/methods/WORKFLOWS.md +14 -0
  40. package/dist/docs/patterns/ai-prompt-safety.ts +85 -0
  41. package/dist/docs/patterns/data-pipeline.ts +59 -1
  42. package/dist/docs/patterns/egress-sandbox.sh +43 -0
  43. package/dist/docs/patterns/exclusion-set-invariant.md +62 -0
  44. package/dist/docs/patterns/multi-tenant-property-test.ts +64 -0
  45. package/dist/docs/patterns/nginx-vhost.conf +156 -0
  46. package/dist/docs/patterns/oauth-token-lifecycle.ts +21 -0
  47. package/dist/docs/patterns/post-deploy-probe.sh +115 -0
  48. package/dist/docs/patterns/rls-test-fixture.py +140 -0
  49. package/dist/docs/patterns/structural-sql-sentinel.py +134 -0
  50. package/dist/scripts/statusline/README.md +38 -0
  51. package/dist/scripts/statusline/context-awareness-hook.sh +53 -0
  52. package/dist/scripts/statusline/settings-snippet.json +17 -0
  53. package/dist/scripts/statusline/voidforge-statusline.sh +91 -0
  54. package/dist/scripts/voidforge.js +69 -6
  55. package/dist/wizard/lib/claude-md-strategy.d.ts +87 -0
  56. package/dist/wizard/lib/claude-md-strategy.js +198 -0
  57. package/dist/wizard/lib/marker.d.ts +48 -1
  58. package/dist/wizard/lib/marker.js +58 -2
  59. package/dist/wizard/lib/patterns/oauth-token-lifecycle.d.ts +14 -0
  60. package/dist/wizard/lib/patterns/oauth-token-lifecycle.js +21 -0
  61. package/dist/wizard/lib/project-init.js +59 -0
  62. package/dist/wizard/lib/updater.d.ts +19 -0
  63. package/dist/wizard/lib/updater.js +84 -33
  64. package/package.json +2 -2
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # context-awareness-hook.sh — UserPromptSubmit hook that injects context-budget
3
+ # awareness INTO Claude's own context as the window fills.
4
+ #
5
+ # The status-line meter is for the human; this hook is for the model. Claude
6
+ # cannot see its own remaining context directly, so each turn (once usage crosses
7
+ # a threshold) this prints a JSON object whose `hookSpecificOutput.additionalContext`
8
+ # Claude receives — "you have ~X% left, checkpoint soon." Below the threshold it is
9
+ # silent, so it adds zero noise until it matters.
10
+ #
11
+ # Cadence: Claude Code has no time/turn-interval hooks — UserPromptSubmit (once per
12
+ # user turn) is the finest cadence available, which is exactly when fresh awareness
13
+ # is useful. Threshold-gated so it behaves like a periodic warning that only speaks
14
+ # near the limit.
15
+ #
16
+ # Requires jq; without it, no-op (exit 0). A hook must never break the turn.
17
+ #
18
+ # Env knobs:
19
+ # VOIDFORGE_CONTEXT_WINDOW denominator (default 200000; auto-bumps to 1000000 when usage exceeds 200k)
20
+ # VOIDFORGE_CONTEXT_WARN_PCT start warning at this % used (default 80)
21
+ # VOIDFORGE_CONTEXT_CRIT_PCT escalate to "checkpoint NOW" at this % (default 92)
22
+ set -uo pipefail
23
+
24
+ input="$(cat 2>/dev/null || true)"
25
+ command -v jq >/dev/null 2>&1 || exit 0
26
+
27
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
28
+ [ -n "$transcript" ] && [ -f "$transcript" ] || exit 0
29
+
30
+ usage="$(tail -n 400 "$transcript" | jq -c 'select(.message.usage != null) | .message.usage' 2>/dev/null | tail -1)"
31
+ [ -n "$usage" ] || exit 0
32
+ used="$(printf '%s' "$usage" | jq -r '((.input_tokens//0)+(.cache_read_input_tokens//0)+(.cache_creation_input_tokens//0))' 2>/dev/null)"
33
+ used="${used%%.*}"
34
+ [ -n "${used:-}" ] || exit 0
35
+
36
+ if [ "$used" -gt 200000 ] 2>/dev/null; then window=1000000; else window="${VOIDFORGE_CONTEXT_WINDOW:-200000}"; fi
37
+ [ "${window:-0}" -gt 0 ] 2>/dev/null || exit 0
38
+ pct=$(( used * 100 / window ))
39
+
40
+ warn="${VOIDFORGE_CONTEXT_WARN_PCT:-80}"
41
+ crit="${VOIDFORGE_CONTEXT_CRIT_PCT:-92}"
42
+ [ "$pct" -lt "$warn" ] && exit 0
43
+
44
+ rem_k=$(( (window - used) / 1000 ))
45
+
46
+ if [ "$pct" -ge "$crit" ]; then
47
+ msg="⚠️ CONTEXT CRITICAL: ~${pct}% of the ${window}-token window is used (~${rem_k}k left). Compaction is imminent — checkpoint NOW: run /vault (or /seal) to preserve session state before the context is summarized, and prefer finishing the current sub-task over starting new work."
48
+ else
49
+ msg="Context monitor: ~${pct}% of the ${window}-token window is used (~${rem_k}k left). You are approaching the limit — wrap up open loops and consider /vault or /seal to checkpoint before compaction."
50
+ fi
51
+
52
+ jq -cn --arg m "$msg" '{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$m}}'
53
+ exit 0
@@ -0,0 +1,17 @@
1
+ {
2
+ "statusLine": {
3
+ "type": "command",
4
+ "command": "bash scripts/statusline/voidforge-statusline.sh",
5
+ "padding": 0
6
+ },
7
+ "hooks": {
8
+ "UserPromptSubmit": [
9
+ {
10
+ "matcher": "",
11
+ "hooks": [
12
+ { "type": "command", "command": "bash scripts/statusline/context-awareness-hook.sh" }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # voidforge-statusline.sh — Context-usage meter for the Claude Code status line.
3
+ #
4
+ # Reads the status-line JSON on stdin and prints ONE line:
5
+ # <model> ⟦████████░░⟧ 78% ctx · 44k left
6
+ # The meter is colored green → yellow → red as the context window fills.
7
+ #
8
+ # Source of truth: the native `.context_window` object Claude Code pipes to the
9
+ # status line (`used_percentage`, `context_window_size`). When that field is
10
+ # absent (older Claude Code), it falls back to deriving usage from the most
11
+ # recent assistant `message.usage` in `.transcript_path`.
12
+ #
13
+ # Requires jq. Without jq it prints a minimal line and exits 0 — a status line
14
+ # must NEVER hard-fail (that would blank the bar).
15
+ #
16
+ # Env knobs (shared with the awareness hook so colors and warnings stay in lockstep):
17
+ # VOIDFORGE_CONTEXT_WINDOW denominator when the size field is absent (default 200000)
18
+ # VOIDFORGE_CONTEXT_WARN_PCT meter turns yellow at this % used (default 80)
19
+ # VOIDFORGE_CONTEXT_CRIT_PCT meter turns red at this % used (default 92)
20
+ set -uo pipefail
21
+
22
+ input="$(cat 2>/dev/null || true)"
23
+
24
+ if ! command -v jq >/dev/null 2>&1; then
25
+ printf 'VoidForge · ctx meter needs jq (brew install jq)\n'
26
+ exit 0
27
+ fi
28
+
29
+ j() { printf '%s' "$input" | jq -r "$1" 2>/dev/null; }
30
+
31
+ model="$(j '.model.display_name // .model.id // "Claude"')"
32
+ pct="$(j '.context_window.used_percentage // empty')"
33
+ window="$(j '.context_window.context_window_size // empty')"
34
+
35
+ # Fallback: derive from the transcript when the native field is absent.
36
+ if [ -z "$pct" ]; then
37
+ transcript="$(j '.transcript_path // empty')"
38
+ if [ -n "$transcript" ] && [ -f "$transcript" ]; then
39
+ usage="$(tail -n 400 "$transcript" | jq -c 'select(.message.usage != null) | .message.usage' 2>/dev/null | tail -1)"
40
+ if [ -n "$usage" ]; then
41
+ used="$(printf '%s' "$usage" | jq -r '((.input_tokens//0)+(.cache_read_input_tokens//0)+(.cache_creation_input_tokens//0))' 2>/dev/null)"
42
+ used="${used%%.*}"
43
+ if [ -z "$window" ]; then
44
+ if [ "${used:-0}" -gt 200000 ] 2>/dev/null; then window=1000000; else window="${VOIDFORGE_CONTEXT_WINDOW:-200000}"; fi
45
+ fi
46
+ if [ -n "${used:-}" ] && [ "${window:-0}" -gt 0 ] 2>/dev/null; then
47
+ pct=$(( used * 100 / window ))
48
+ fi
49
+ fi
50
+ fi
51
+ fi
52
+
53
+ # Coerce to integer; bail to model-only if we still have nothing.
54
+ pct="${pct%%.*}"
55
+ if [ -z "$pct" ]; then
56
+ printf '%s\n' "$model"
57
+ exit 0
58
+ fi
59
+ [ -z "$window" ] && window="${VOIDFORGE_CONTEXT_WINDOW:-200000}"
60
+ window="${window%%.*}"
61
+
62
+ [ "$pct" -lt 0 ] 2>/dev/null && pct=0
63
+ [ "$pct" -gt 100 ] 2>/dev/null && pct=100
64
+
65
+ remaining=$(( window - window * pct / 100 ))
66
+ if [ "$remaining" -ge 1000 ]; then rem_h="$(( remaining / 1000 ))k"; else rem_h="${remaining}"; fi
67
+
68
+ # Color band — defaults align with the awareness-hook thresholds (warn 80 → yellow,
69
+ # crit 92 → red) so the meter turns red exactly when the hook goes critical. Both
70
+ # honor the same env vars, so retuning one retunes the other.
71
+ yellow_at="${VOIDFORGE_CONTEXT_WARN_PCT:-80}"
72
+ red_at="${VOIDFORGE_CONTEXT_CRIT_PCT:-92}"
73
+ if [ "$pct" -ge "$red_at" ]; then color=$'\033[31m' # red — checkpoint now
74
+ elif [ "$pct" -ge "$yellow_at" ]; then color=$'\033[33m' # yellow — getting full
75
+ else color=$'\033[32m' # green — healthy
76
+ fi
77
+ reset=$'\033[0m'
78
+ dim=$'\033[2m'
79
+
80
+ # 10-cell meter, rounded.
81
+ filled=$(( (pct + 5) / 10 ))
82
+ [ "$filled" -gt 10 ] && filled=10
83
+ [ "$filled" -lt 0 ] && filled=0
84
+ bar=""
85
+ i=0
86
+ while [ "$i" -lt 10 ]; do
87
+ if [ "$i" -lt "$filled" ]; then bar="${bar}█"; else bar="${bar}░"; fi
88
+ i=$(( i + 1 ))
89
+ done
90
+
91
+ printf '%s %s⟦%s⟧ %d%%%s %sctx · %s left%s\n' "$model" "$color" "$bar" "$pct" "$reset" "$dim" "$rem_h" "$reset"
@@ -345,6 +345,21 @@ async function cmdMigrateTreasury() {
345
345
  console.log(' Per-project logs start fresh (genesis hash). Global data preserved in archive.');
346
346
  console.log(' To rollback: move archive back to ~/.voidforge/treasury/\n');
347
347
  }
348
+ function showUpdateHelp() {
349
+ console.log('voidforge update — update project methodology (Bombadil)\n');
350
+ console.log('Usage: npx voidforge update [options]\n');
351
+ console.log('Options:');
352
+ console.log(' --self Update the wizard/CLI itself instead of the project');
353
+ console.log(' --extensions Update all installed extensions across registered projects');
354
+ console.log(' --no-self-update Skip the automatic CLI self-upgrade check');
355
+ console.log(' --help, -h Show this help (does NOT run the update)');
356
+ console.log('');
357
+ console.log('CLAUDE.md is updated per the `.voidforge` marker `claudeMd` policy:');
358
+ console.log(' preserve (default) Never overwrite in place — write CLAUDE.md.upstream + warn');
359
+ console.log(' merge Replace only the VOIDFORGE methodology fences, keep project sections');
360
+ console.log(' skip Never touch CLAUDE.md');
361
+ console.log('');
362
+ }
348
363
  function showHelp() {
349
364
  console.log('VoidForge — From nothing, everything.\n');
350
365
  console.log('Usage: npx voidforge <command> [options]\n');
@@ -464,7 +479,16 @@ async function main() {
464
479
  break;
465
480
  }
466
481
  case 'update': {
467
- if (args.includes('--self')) {
482
+ const { resolveUpdateMode } = await import('../wizard/lib/updater.js');
483
+ const updateMode = resolveUpdateMode(args);
484
+ // Help guard (issue #368): `update --help` / `-h` must PRINT usage and
485
+ // exit — never execute the (potentially destructive) update. Help wins
486
+ // over every action flag, checked before any work happens.
487
+ if (updateMode === 'help') {
488
+ showUpdateHelp();
489
+ break;
490
+ }
491
+ if (updateMode === 'self') {
468
492
  const { selfUpdate } = await import('../wizard/lib/updater.js');
469
493
  const result = selfUpdate();
470
494
  console.log(result.message);
@@ -510,7 +534,7 @@ async function main() {
510
534
  // npm view failed — offline or registry issue. Continue with local version.
511
535
  }
512
536
  }
513
- if (args.includes('--extensions')) {
537
+ if (updateMode === 'extensions') {
514
538
  const { readRegistry } = await import('../wizard/lib/project-registry.js');
515
539
  const { readMarker: readMkr } = await import('../wizard/lib/marker.js');
516
540
  const { getExtension } = await import('../wizard/lib/extensions.js');
@@ -537,11 +561,42 @@ async function main() {
537
561
  break;
538
562
  }
539
563
  // Methodology update
540
- const { findProjectRoot: findProjRoot } = await import('../wizard/lib/marker.js');
541
- const projRoot = findProjRoot();
564
+ const { findProjectRoot: findProjRoot, detectLegacyConsumer, readVersionFile, createMarker: makeMarker, writeMarker: saveMarker, } = await import('../wizard/lib/marker.js');
565
+ let projRoot = findProjRoot();
542
566
  if (!projRoot) {
543
- console.error('Not a VoidForge project run `npx voidforge init` first.');
544
- process.exit(1);
567
+ // Issue #369: a project that consumed methodology via git (pre-marker)
568
+ // has no `.voidforge` but IS a real consumer. Offer to create the
569
+ // marker instead of sending the user to `init` (which scaffolds).
570
+ const legacy = detectLegacyConsumer();
571
+ if (legacy) {
572
+ const version = readVersionFile(legacy.dir);
573
+ console.log('\n No .voidforge marker found, but this looks like a legacy');
574
+ console.log(' VoidForge methodology consumer:');
575
+ console.log(` dir: ${legacy.dir}`);
576
+ console.log(` tier: ${legacy.inferredTier} (inferred)`);
577
+ console.log(` version: ${version} (from VERSION.md)`);
578
+ console.log('');
579
+ let createIt = true;
580
+ if (process.stdin.isTTY) {
581
+ const answer = (await prompt(' Create the .voidforge marker now? [Y/n]: ')).toLowerCase();
582
+ createIt = answer === '' || answer === 'y' || answer === 'yes';
583
+ }
584
+ else {
585
+ console.log(' Non-interactive: creating the marker automatically.');
586
+ }
587
+ if (!createIt) {
588
+ console.log('\n Aborted — no marker created. Update skipped.\n');
589
+ break;
590
+ }
591
+ const newMarker = makeMarker(version, legacy.inferredTier);
592
+ await saveMarker(legacy.dir, newMarker);
593
+ console.log(` Created .voidforge marker (${newMarker.id}).\n`);
594
+ projRoot = legacy.dir;
595
+ }
596
+ else {
597
+ console.error('Not a VoidForge project — run `npx voidforge init` first.');
598
+ process.exit(1);
599
+ }
545
600
  }
546
601
  const { diffMethodology, applyUpdate } = await import('../wizard/lib/updater.js');
547
602
  const plan = await diffMethodology(projRoot);
@@ -566,6 +621,14 @@ async function main() {
566
621
  console.log(` - ${f} (kept locally)`);
567
622
  }
568
623
  console.log(` Unchanged: ${plan.unchanged} files\n`);
624
+ // CLAUDE.md non-destructive handling (issue #368) — surface the policy,
625
+ // any dropped-section warning, and the side-file path before applying.
626
+ if (plan.claudeMd && plan.claudeMd.warnings.length > 0) {
627
+ console.log(' CLAUDE.md:');
628
+ for (const w of plan.claudeMd.warnings)
629
+ console.log(` ${w}`);
630
+ console.log('');
631
+ }
569
632
  const result = await applyUpdate(projRoot);
570
633
  console.log(` Updated to v${result.newVersion}. ${plan.added.length + plan.modified.length} files changed.\n`);
571
634
  break;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CLAUDE.md update strategy — the single mechanism the updater uses to decide
3
+ * how an `update` may touch a project's CLAUDE.md (issue #368).
4
+ *
5
+ * CLAUDE.md is the file Claude Code loads every session; it carries the
6
+ * project's operational knowledge. The old updater preserved only the first ~10
7
+ * lines and overwrote the rest, silently discarding every project-specific
8
+ * section. That is the same bug class as #331 (silent destruction of user
9
+ * content). This module makes the update NON-DESTRUCTIVE by default and gives
10
+ * projects a precise, opt-in lossless merge via sentinel fences.
11
+ *
12
+ * Three strategies (from the `.voidforge` marker's `claudeMd` field):
13
+ * - 'preserve' (default): never overwrite in place. If upstream differs, the
14
+ * new methodology is written to a side file (`CLAUDE.md.upstream`) and the
15
+ * operator is warned. The original CLAUDE.md is left untouched.
16
+ * - 'merge': replace ONLY the content between the sentinel fences
17
+ * `<!-- VOIDFORGE:BEGIN methodology -->` / `<!-- VOIDFORGE:END methodology -->`,
18
+ * leaving everything outside them verbatim. Falls back to 'preserve'
19
+ * (side-file + warning) when the fences are absent in either document —
20
+ * there is no lossless in-place merge without an explicit fenced block.
21
+ * - 'skip': do not read or write CLAUDE.md at all.
22
+ *
23
+ * In every strategy, section-loss detection runs and surfaces a warning so an
24
+ * update can never silently drop project sections.
25
+ */
26
+ import type { ClaudeMdStrategy } from './marker.js';
27
+ export declare const FENCE_BEGIN = "<!-- VOIDFORGE:BEGIN methodology -->";
28
+ export declare const FENCE_END = "<!-- VOIDFORGE:END methodology -->";
29
+ /** Side file written when an in-place merge would be destructive. */
30
+ export declare const UPSTREAM_SUFFIX = ".upstream";
31
+ export type ClaudeMdAction = 'unchanged' | 'skip' | 'overwrite' | 'merge-fenced' | 'side-file';
32
+ export interface ClaudeMdMergeResult {
33
+ action: ClaudeMdAction;
34
+ /**
35
+ * New content to write to CLAUDE.md itself, or null if CLAUDE.md must NOT be
36
+ * touched (skip / unchanged / side-file paths).
37
+ */
38
+ claudeMdContent: string | null;
39
+ /**
40
+ * Content to write to the side file, or null if no side file is needed.
41
+ * When set, the caller writes it to `<CLAUDE.md path>.upstream`.
42
+ */
43
+ sideFileContent: string | null;
44
+ /** Project headings present locally but absent upstream — would be dropped by a naive overwrite. */
45
+ droppedSections: string[];
46
+ /** Human-readable warnings to surface to the operator. */
47
+ warnings: string[];
48
+ }
49
+ /**
50
+ * Extract top-level-ish markdown headings (#, ##) used as section identities.
51
+ * Code fences are skipped so a `#` inside a ```bash block is not a heading.
52
+ * Normalized (trimmed, leading hashes stripped) for stable comparison.
53
+ */
54
+ export declare function extractSections(content: string): string[];
55
+ /**
56
+ * Sections present in `current` but missing from `incoming` — i.e. content that
57
+ * a naive whole-file overwrite would silently destroy.
58
+ */
59
+ export declare function findDroppedSections(current: string, incoming: string): string[];
60
+ /**
61
+ * Normalize away the project-identity region so two CLAUDE.md files that differ
62
+ * ONLY in their `## Project` block (name/one-liner/domain/repo, or the
63
+ * `[PROJECT_NAME]` placeholders) compare equal.
64
+ *
65
+ * This is why a freshly-`init`ed project — whose `## Project` block has the real
66
+ * name injected while the upstream template still carries `[PROJECT_NAME]` —
67
+ * does not register as a spurious "change" on the very next `update`. It is a
68
+ * comparison-only transform; we never write the normalized form.
69
+ */
70
+ export declare function stripIdentity(content: string): string;
71
+ export declare function hasFences(content: string): boolean;
72
+ /**
73
+ * Replace the fenced methodology block in `current` with the fenced block from
74
+ * `upstream`. Everything outside the fences in `current` is preserved verbatim.
75
+ * Returns null if either document lacks a well-formed fence pair.
76
+ */
77
+ export declare function mergeFenced(current: string, upstream: string): string | null;
78
+ /**
79
+ * Decide how to update CLAUDE.md given the current project content, the incoming
80
+ * upstream content, and the configured strategy. Pure function — performs no
81
+ * I/O. The caller performs the writes described by the returned result.
82
+ *
83
+ * @param current Existing project CLAUDE.md (null/undefined if the file is absent).
84
+ * @param upstream Incoming methodology CLAUDE.md.
85
+ * @param strategy Marker `claudeMd` field (defaults applied by the caller).
86
+ */
87
+ export declare function planClaudeMdUpdate(current: string | null | undefined, upstream: string, strategy: ClaudeMdStrategy): ClaudeMdMergeResult;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * CLAUDE.md update strategy — the single mechanism the updater uses to decide
3
+ * how an `update` may touch a project's CLAUDE.md (issue #368).
4
+ *
5
+ * CLAUDE.md is the file Claude Code loads every session; it carries the
6
+ * project's operational knowledge. The old updater preserved only the first ~10
7
+ * lines and overwrote the rest, silently discarding every project-specific
8
+ * section. That is the same bug class as #331 (silent destruction of user
9
+ * content). This module makes the update NON-DESTRUCTIVE by default and gives
10
+ * projects a precise, opt-in lossless merge via sentinel fences.
11
+ *
12
+ * Three strategies (from the `.voidforge` marker's `claudeMd` field):
13
+ * - 'preserve' (default): never overwrite in place. If upstream differs, the
14
+ * new methodology is written to a side file (`CLAUDE.md.upstream`) and the
15
+ * operator is warned. The original CLAUDE.md is left untouched.
16
+ * - 'merge': replace ONLY the content between the sentinel fences
17
+ * `<!-- VOIDFORGE:BEGIN methodology -->` / `<!-- VOIDFORGE:END methodology -->`,
18
+ * leaving everything outside them verbatim. Falls back to 'preserve'
19
+ * (side-file + warning) when the fences are absent in either document —
20
+ * there is no lossless in-place merge without an explicit fenced block.
21
+ * - 'skip': do not read or write CLAUDE.md at all.
22
+ *
23
+ * In every strategy, section-loss detection runs and surfaces a warning so an
24
+ * update can never silently drop project sections.
25
+ */
26
+ // ── Constants ────────────────────────────────────────────
27
+ export const FENCE_BEGIN = '<!-- VOIDFORGE:BEGIN methodology -->';
28
+ export const FENCE_END = '<!-- VOIDFORGE:END methodology -->';
29
+ /** Side file written when an in-place merge would be destructive. */
30
+ export const UPSTREAM_SUFFIX = '.upstream';
31
+ // ── Heading extraction ───────────────────────────────────
32
+ /**
33
+ * Extract top-level-ish markdown headings (#, ##) used as section identities.
34
+ * Code fences are skipped so a `#` inside a ```bash block is not a heading.
35
+ * Normalized (trimmed, leading hashes stripped) for stable comparison.
36
+ */
37
+ export function extractSections(content) {
38
+ const sections = [];
39
+ let inFence = false;
40
+ for (const rawLine of content.split('\n')) {
41
+ const line = rawLine.trimEnd();
42
+ const fenceToggle = /^\s*(```|~~~)/.test(line);
43
+ if (fenceToggle) {
44
+ inFence = !inFence;
45
+ continue;
46
+ }
47
+ if (inFence)
48
+ continue;
49
+ const m = line.match(/^(#{1,2})\s+(.+?)\s*$/);
50
+ if (m)
51
+ sections.push(m[2].trim());
52
+ }
53
+ return sections;
54
+ }
55
+ /**
56
+ * Sections present in `current` but missing from `incoming` — i.e. content that
57
+ * a naive whole-file overwrite would silently destroy.
58
+ */
59
+ export function findDroppedSections(current, incoming) {
60
+ const incomingSet = new Set(extractSections(incoming).map((s) => s.toLowerCase()));
61
+ const seen = new Set();
62
+ const dropped = [];
63
+ for (const s of extractSections(current)) {
64
+ const key = s.toLowerCase();
65
+ if (!incomingSet.has(key) && !seen.has(key)) {
66
+ seen.add(key);
67
+ dropped.push(s);
68
+ }
69
+ }
70
+ return dropped;
71
+ }
72
+ // ── Fences ───────────────────────────────────────────────
73
+ // ── Identity normalization ───────────────────────────────
74
+ /**
75
+ * Normalize away the project-identity region so two CLAUDE.md files that differ
76
+ * ONLY in their `## Project` block (name/one-liner/domain/repo, or the
77
+ * `[PROJECT_NAME]` placeholders) compare equal.
78
+ *
79
+ * This is why a freshly-`init`ed project — whose `## Project` block has the real
80
+ * name injected while the upstream template still carries `[PROJECT_NAME]` —
81
+ * does not register as a spurious "change" on the very next `update`. It is a
82
+ * comparison-only transform; we never write the normalized form.
83
+ */
84
+ export function stripIdentity(content) {
85
+ let out = content;
86
+ // Drop the monorepo template comment fences around the Project block.
87
+ out = out.replace(/<!--\s*REMOVE-FOR-NPM-PUBLISH:[\s\S]*?-->\n?/g, '');
88
+ out = out.replace(/<!--\s*END-REMOVE-FOR-NPM-PUBLISH\s*-->\n?/g, '');
89
+ // Drop a leading `## Project` block: from the `## Project` heading up to (but
90
+ // not including) the next `## ` heading.
91
+ out = out.replace(/^##\s+Project[ \t]*\n[\s\S]*?(?=^##\s)/m, '');
92
+ return out.trim();
93
+ }
94
+ export function hasFences(content) {
95
+ const begin = content.indexOf(FENCE_BEGIN);
96
+ const end = content.indexOf(FENCE_END);
97
+ return begin !== -1 && end !== -1 && end > begin;
98
+ }
99
+ /**
100
+ * Replace the fenced methodology block in `current` with the fenced block from
101
+ * `upstream`. Everything outside the fences in `current` is preserved verbatim.
102
+ * Returns null if either document lacks a well-formed fence pair.
103
+ */
104
+ export function mergeFenced(current, upstream) {
105
+ if (!hasFences(current) || !hasFences(upstream))
106
+ return null;
107
+ const upBegin = upstream.indexOf(FENCE_BEGIN);
108
+ const upEnd = upstream.indexOf(FENCE_END) + FENCE_END.length;
109
+ const upstreamBlock = upstream.slice(upBegin, upEnd);
110
+ const curBegin = current.indexOf(FENCE_BEGIN);
111
+ const curEnd = current.indexOf(FENCE_END) + FENCE_END.length;
112
+ return current.slice(0, curBegin) + upstreamBlock + current.slice(curEnd);
113
+ }
114
+ // ── Core decision ────────────────────────────────────────
115
+ /**
116
+ * Decide how to update CLAUDE.md given the current project content, the incoming
117
+ * upstream content, and the configured strategy. Pure function — performs no
118
+ * I/O. The caller performs the writes described by the returned result.
119
+ *
120
+ * @param current Existing project CLAUDE.md (null/undefined if the file is absent).
121
+ * @param upstream Incoming methodology CLAUDE.md.
122
+ * @param strategy Marker `claudeMd` field (defaults applied by the caller).
123
+ */
124
+ export function planClaudeMdUpdate(current, upstream, strategy) {
125
+ // New project (no existing CLAUDE.md): just write upstream. Nothing to lose.
126
+ if (current == null) {
127
+ return {
128
+ action: 'overwrite',
129
+ claudeMdContent: upstream,
130
+ sideFileContent: null,
131
+ droppedSections: [],
132
+ warnings: [],
133
+ };
134
+ }
135
+ // Identical already — never write. Identity-normalized so a fresh project
136
+ // (real name injected vs upstream `[PROJECT_NAME]` placeholder) is not treated
137
+ // as a spurious change on its first update.
138
+ if (current === upstream || stripIdentity(current) === stripIdentity(upstream)) {
139
+ return {
140
+ action: 'unchanged',
141
+ claudeMdContent: null,
142
+ sideFileContent: null,
143
+ droppedSections: [],
144
+ warnings: [],
145
+ };
146
+ }
147
+ if (strategy === 'skip') {
148
+ return {
149
+ action: 'skip',
150
+ claudeMdContent: null,
151
+ sideFileContent: null,
152
+ droppedSections: [],
153
+ warnings: ['CLAUDE.md left untouched (claudeMd: "skip"). Upstream changes were NOT applied.'],
154
+ };
155
+ }
156
+ const dropped = findDroppedSections(current, upstream);
157
+ if (strategy === 'merge') {
158
+ const merged = mergeFenced(current, upstream);
159
+ if (merged !== null) {
160
+ // Fenced merge is lossless for everything outside the fences. Re-check loss
161
+ // against the merged result, not the raw upstream, so project sections
162
+ // preserved by the fence are NOT reported as dropped.
163
+ const residualLoss = findDroppedSections(current, merged);
164
+ const warnings = merged === current
165
+ ? ['CLAUDE.md fenced methodology block already current — no change.']
166
+ : ['CLAUDE.md updated within VOIDFORGE methodology fences; project sections preserved.'];
167
+ if (residualLoss.length > 0) {
168
+ warnings.push(`WARNING: fenced merge still drops ${residualLoss.length} section(s): ${residualLoss.join(', ')}.`);
169
+ }
170
+ return {
171
+ action: merged === current ? 'unchanged' : 'merge-fenced',
172
+ claudeMdContent: merged === current ? null : merged,
173
+ sideFileContent: null,
174
+ droppedSections: residualLoss,
175
+ warnings,
176
+ };
177
+ }
178
+ // 'merge' requested but no fences — fall through to safe side-file behavior.
179
+ }
180
+ // 'preserve' (default), OR 'merge' without fences: never overwrite in place.
181
+ // Write upstream to the side file and warn; leave the original intact.
182
+ const warnings = [
183
+ `CLAUDE.md was NOT overwritten. Incoming methodology written to CLAUDE.md${UPSTREAM_SUFFIX} — review and merge deliberately.`,
184
+ ];
185
+ if (strategy === 'merge') {
186
+ warnings.unshift(`claudeMd: "merge" requested but no ${FENCE_BEGIN} / ${FENCE_END} fences found in CLAUDE.md — falling back to safe side-file.`);
187
+ }
188
+ if (dropped.length > 0) {
189
+ warnings.push(`An in-place overwrite would have dropped ${dropped.length} project section(s): ${dropped.join(', ')}.`);
190
+ }
191
+ return {
192
+ action: 'side-file',
193
+ claudeMdContent: null,
194
+ sideFileContent: upstream,
195
+ droppedSections: dropped,
196
+ warnings,
197
+ };
198
+ }
@@ -4,17 +4,40 @@
4
4
  * Every VoidForge project has a `.voidforge` JSON file at root.
5
5
  * The CLI walks up from cwd to find it, determining the project root.
6
6
  */
7
+ /**
8
+ * How `update` is allowed to touch the project's CLAUDE.md (issue #368):
9
+ * - 'preserve' (default, safest): never overwrite in place. If upstream
10
+ * differs, write it to `CLAUDE.md.upstream` and warn — the operator merges
11
+ * deliberately. CLAUDE.md is the file Claude Code loads every session and
12
+ * carries project-specific operational knowledge; clobbering it is the same
13
+ * bug class as #331 (silent destruction of user content).
14
+ * - 'merge': update only the content between the sentinel fences
15
+ * `<!-- VOIDFORGE:BEGIN methodology -->` / `<!-- VOIDFORGE:END methodology -->`,
16
+ * leaving every project section outside the fences verbatim. Falls back to
17
+ * 'preserve' (side-file) when fences are absent — there is no lossless
18
+ * in-place merge without them.
19
+ * - 'skip': the updater never reads or writes CLAUDE.md at all.
20
+ */
21
+ export type ClaudeMdStrategy = 'preserve' | 'merge' | 'skip';
7
22
  export interface VoidForgeMarker {
8
23
  id: string;
9
24
  version: string;
10
25
  created: string;
11
26
  tier: 'full' | 'methodology';
12
27
  extensions: string[];
28
+ /**
29
+ * Optional CLAUDE.md update policy (issue #368). Absent on legacy markers;
30
+ * callers MUST default to 'preserve' when undefined so the safe-by-default
31
+ * behavior applies to projects created before this field existed.
32
+ */
33
+ claudeMd?: ClaudeMdStrategy;
13
34
  }
35
+ /** Safe default when a marker omits `claudeMd`. Never silently clobber. */
36
+ export declare const DEFAULT_CLAUDE_MD_STRATEGY: ClaudeMdStrategy;
14
37
  export declare const MARKER_FILE = ".voidforge";
15
38
  export declare function readMarker(dir: string): Promise<VoidForgeMarker | null>;
16
39
  export declare function writeMarker(dir: string, marker: VoidForgeMarker): Promise<void>;
17
- export declare function createMarker(version: string, tier?: VoidForgeMarker['tier'], extensions?: string[]): VoidForgeMarker;
40
+ export declare function createMarker(version: string, tier?: VoidForgeMarker['tier'], extensions?: string[], claudeMd?: ClaudeMdStrategy): VoidForgeMarker;
18
41
  /**
19
42
  * Walk up from `startDir` to find the nearest `.voidforge` marker.
20
43
  * Returns the directory containing the marker, or null if none found.
@@ -31,5 +54,29 @@ export declare function findProjectRoot(startDir?: string): string | null;
31
54
  * Like findProjectRoot but throws with a user-friendly message.
32
55
  */
33
56
  export declare function requireProjectRoot(startDir?: string): string;
57
+ export interface LegacyConsumer {
58
+ dir: string;
59
+ /** Tier inferred from the project shape: 'full' if a wizard/ dir is present. */
60
+ inferredTier: VoidForgeMarker['tier'];
61
+ }
62
+ /**
63
+ * Detect a legacy methodology consumer that predates the `.voidforge` marker.
64
+ *
65
+ * Such projects originally consumed methodology via git (before the marker
66
+ * convention) and so `update` hard-errors them toward `init` — which is the
67
+ * wrong remedy on an existing project (issue #369). The signature of a real
68
+ * consumer is the methodology footprint: `VERSION.md` + `.claude/commands/` +
69
+ * `docs/methods/` all present. When that holds and NO marker exists, the CLI
70
+ * should OFFER to create the marker rather than send the user to `init`.
71
+ *
72
+ * Tier is inferred from `wizard/` presence (full-tier projects embed/embedded
73
+ * the wizard), mirroring the workaround in the field report.
74
+ *
75
+ * Returns null when the dir already has a marker (not legacy) or does not look
76
+ * like a methodology consumer (genuinely not a VoidForge project).
77
+ */
78
+ export declare function detectLegacyConsumer(dir?: string): LegacyConsumer | null;
79
+ /** Read the methodology version from a project's VERSION.md, or 'unknown'. */
80
+ export declare function readVersionFile(dir: string): string;
34
81
  export declare function getGlobalDir(): string;
35
82
  export declare function getVaultPath(): string;