cleargate 0.14.0 → 0.15.1

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 (150) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/MANIFEST.json +72 -16
  3. package/dist/admin-api/index.cjs +0 -1
  4. package/dist/admin-api/index.js +1 -2
  5. package/dist/auth/factory.cjs +0 -1
  6. package/dist/auth/factory.js +2 -3
  7. package/dist/auth/require-token.cjs +0 -1
  8. package/dist/auth/require-token.js +1 -2
  9. package/dist/auth/token-store.cjs +0 -1
  10. package/dist/auth/token-store.js +1 -2
  11. package/dist/{bootstrap-root-QKSA5V75.js → bootstrap-root-2H5HVTCC.js} +1 -2
  12. package/dist/{chunk-PDE37WFQ.js → chunk-A7MSQUU7.js} +2 -3
  13. package/dist/{chunk-BTSZOEWC.js → chunk-P6KEDAK2.js} +0 -1
  14. package/dist/{chunk-E3X7IE5E.js → chunk-PY6FHGV5.js} +1 -2
  15. package/dist/{chunk-5DI2Z3C2.js → chunk-Y53ZZYYU.js} +1 -2
  16. package/dist/cli.cjs +1564 -1414
  17. package/dist/cli.js +1514 -1364
  18. package/dist/lib/ledger.cjs +0 -1
  19. package/dist/lib/ledger.js +1 -2
  20. package/dist/lib/lifecycle-reconcile.cjs +0 -1
  21. package/dist/lib/lifecycle-reconcile.js +2 -3
  22. package/dist/{whoami-EANGN46Z.js → whoami-JKQQPABQ.js} +3 -4
  23. package/package.json +4 -3
  24. package/templates/cleargate-planning/.claude/agents/architect-synth.md +2 -0
  25. package/templates/cleargate-planning/.claude/agents/architect.md +4 -2
  26. package/templates/cleargate-planning/.claude/agents/developer.md +4 -11
  27. package/templates/cleargate-planning/.claude/agents/qa.md +14 -6
  28. package/templates/cleargate-planning/.claude/hooks/pending-task-sentinel.sh +2 -2
  29. package/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +19 -1
  30. package/templates/cleargate-planning/.cleargate/config.example.yml +16 -0
  31. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.deferred-verify.red.node.test.ts +245 -0
  32. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +227 -0
  33. package/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +5 -4
  34. package/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +75 -2
  35. package/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +48 -0
  36. package/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +57 -1
  37. package/templates/cleargate-planning/.cleargate/scripts/provision_worktree_config.sh +155 -0
  38. package/templates/cleargate-planning/.cleargate/scripts/qa_red_lint.mjs +380 -0
  39. package/templates/cleargate-planning/.cleargate/scripts/run_script.sh +34 -1
  40. package/templates/cleargate-planning/.cleargate/scripts/test/cr077_eviction.red.sh +113 -0
  41. package/templates/cleargate-planning/.cleargate/scripts/test/cr078_init.test.sh +309 -0
  42. package/templates/cleargate-planning/.cleargate/scripts/test/cr079_provision.red.sh +262 -0
  43. package/templates/cleargate-planning/.cleargate/scripts/test/cr080_wrapper.test.sh +177 -0
  44. package/templates/cleargate-planning/.cleargate/scripts/test/cr081_qa_red_lint.red.sh +348 -0
  45. package/templates/cleargate-planning/.cleargate/sprint-runs/_off-sprint/.session-totals.json +1 -0
  46. package/templates/cleargate-planning/.cleargate/sprint-runs/_off-sprint/token-ledger.jsonl +222 -0
  47. package/templates/cleargate-planning/.cleargate/templates/sprint_context.md +17 -0
  48. package/templates/cleargate-planning/.cleargate/templates/story.md +1 -0
  49. package/templates/cleargate-planning/MANIFEST.json +72 -16
  50. package/dist/admin-api/index.cjs.map +0 -1
  51. package/dist/admin-api/index.js.map +0 -1
  52. package/dist/auth/factory.cjs.map +0 -1
  53. package/dist/auth/factory.js.map +0 -1
  54. package/dist/auth/require-token.cjs.map +0 -1
  55. package/dist/auth/require-token.js.map +0 -1
  56. package/dist/auth/token-store.cjs.map +0 -1
  57. package/dist/auth/token-store.js.map +0 -1
  58. package/dist/bootstrap-root-QKSA5V75.js.map +0 -1
  59. package/dist/chunk-5DI2Z3C2.js.map +0 -1
  60. package/dist/chunk-BTSZOEWC.js.map +0 -1
  61. package/dist/chunk-E3X7IE5E.js.map +0 -1
  62. package/dist/chunk-PDE37WFQ.js.map +0 -1
  63. package/dist/cli.cjs.map +0 -1
  64. package/dist/cli.js.map +0 -1
  65. package/dist/lib/ledger.cjs.map +0 -1
  66. package/dist/lib/ledger.js.map +0 -1
  67. package/dist/lib/lifecycle-reconcile.cjs.map +0 -1
  68. package/dist/lib/lifecycle-reconcile.js.map +0 -1
  69. package/dist/templates/cleargate-planning/.claude/agents/architect-reader.md +0 -61
  70. package/dist/templates/cleargate-planning/.claude/agents/architect-synth.md +0 -124
  71. package/dist/templates/cleargate-planning/.claude/agents/architect.md +0 -230
  72. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +0 -108
  73. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +0 -194
  74. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +0 -261
  75. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-query.md +0 -143
  76. package/dist/templates/cleargate-planning/.claude/agents/developer.md +0 -185
  77. package/dist/templates/cleargate-planning/.claude/agents/devops.md +0 -257
  78. package/dist/templates/cleargate-planning/.claude/agents/qa.md +0 -171
  79. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +0 -274
  80. package/dist/templates/cleargate-planning/.claude/hooks/pending-task-sentinel.sh +0 -209
  81. package/dist/templates/cleargate-planning/.claude/hooks/pre-commit-surface-gate.sh +0 -33
  82. package/dist/templates/cleargate-planning/.claude/hooks/pre-commit-test-ratchet.sh +0 -58
  83. package/dist/templates/cleargate-planning/.claude/hooks/pre-commit.sh +0 -19
  84. package/dist/templates/cleargate-planning/.claude/hooks/pre-edit-gate.sh +0 -162
  85. package/dist/templates/cleargate-planning/.claude/hooks/pre-tool-use-autonomy.sh +0 -58
  86. package/dist/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +0 -148
  87. package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +0 -75
  88. package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +0 -43
  89. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +0 -590
  90. package/dist/templates/cleargate-planning/.claude/settings.json +0 -68
  91. package/dist/templates/cleargate-planning/.claude/skills/flashcard/SKILL.md +0 -102
  92. package/dist/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +0 -742
  93. package/dist/templates/cleargate-planning/.cleargate/FLASHCARD.md +0 -7
  94. package/dist/templates/cleargate-planning/.cleargate/config.example.yml +0 -67
  95. package/dist/templates/cleargate-planning/.cleargate/config.yml +0 -18
  96. package/dist/templates/cleargate-planning/.cleargate/delivery/archive/.gitkeep +0 -0
  97. package/dist/templates/cleargate-planning/.cleargate/delivery/pending-sync/.gitkeep +0 -0
  98. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +0 -551
  99. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +0 -878
  100. package/dist/templates/cleargate-planning/.cleargate/knowledge/mid-sprint-triage-rubric.md +0 -160
  101. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +0 -213
  102. package/dist/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +0 -71
  103. package/dist/templates/cleargate-planning/.cleargate/scripts/_migrate-schema-v3.mjs +0 -120
  104. package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +0 -265
  105. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +0 -1012
  106. package/dist/templates/cleargate-planning/.cleargate/scripts/collision_surface.sh +0 -114
  107. package/dist/templates/cleargate-planning/.cleargate/scripts/constants.mjs +0 -62
  108. package/dist/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +0 -219
  109. package/dist/templates/cleargate-planning/.cleargate/scripts/file_surface_diff.sh +0 -320
  110. package/dist/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +0 -15
  111. package/dist/templates/cleargate-planning/.cleargate/scripts/init_gate_config.sh +0 -38
  112. package/dist/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +0 -240
  113. package/dist/templates/cleargate-planning/.cleargate/scripts/launch_wave.mjs +0 -341
  114. package/dist/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +0 -54
  115. package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +0 -206
  116. package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +0 -371
  117. package/dist/templates/cleargate-planning/.cleargate/scripts/prefill_report.mjs +0 -280
  118. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +0 -378
  119. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +0 -888
  120. package/dist/templates/cleargate-planning/.cleargate/scripts/run_script.sh +0 -209
  121. package/dist/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +0 -71
  122. package/dist/templates/cleargate-planning/.cleargate/scripts/state.schema.json +0 -127
  123. package/dist/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +0 -717
  124. package/dist/templates/cleargate-planning/.cleargate/scripts/surface-whitelist.txt +0 -27
  125. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_assert_story_files.sh +0 -261
  126. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_file_surface.sh +0 -210
  127. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +0 -190
  128. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +0 -482
  129. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_test_ratchet.sh +0 -327
  130. package/dist/templates/cleargate-planning/.cleargate/scripts/test_ratchet.mjs +0 -261
  131. package/dist/templates/cleargate-planning/.cleargate/scripts/update_state.mjs +0 -246
  132. package/dist/templates/cleargate-planning/.cleargate/scripts/validate_bounce_readiness.mjs +0 -111
  133. package/dist/templates/cleargate-planning/.cleargate/scripts/validate_state.mjs +0 -184
  134. package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +0 -172
  135. package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +0 -126
  136. package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +0 -130
  137. package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +0 -137
  138. package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +0 -166
  139. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +0 -111
  140. package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +0 -122
  141. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_context.md +0 -50
  142. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +0 -224
  143. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +0 -213
  144. package/dist/templates/cleargate-planning/CLAUDE.md +0 -66
  145. package/dist/templates/cleargate-planning/MANIFEST.json +0 -503
  146. package/dist/templates/synthesis/active-sprint.md +0 -30
  147. package/dist/templates/synthesis/open-gates.md +0 -38
  148. package/dist/templates/synthesis/product-state.md +0 -31
  149. package/dist/templates/synthesis/roadmap.md +0 -63
  150. package/dist/whoami-EANGN46Z.js.map +0 -1
@@ -1,114 +0,0 @@
1
- #!/usr/bin/env bash
2
- # collision_surface.sh — STORY-033-03 (EPIC-033)
3
- #
4
- # Standalone fork of parse_surface_paths() from file_surface_diff.sh:158-189.
5
- # FIXES the single-column bug: the original reads only cols[2]; this fork scans
6
- # ALL columns to detect file paths regardless of which column they appear in.
7
- #
8
- # Usage: collision_surface.sh <story-file>
9
- # Emits one file path per line, deduped, backticks stripped, comma-split cells split.
10
- # Exit 0 always.
11
- #
12
- # FAIL-SAFE CONTRACT (BUG-033): when ZERO paths are parseable from the §3.1 table
13
- # (no table, prose-only table, or only slash-free/extension-less tokens), this
14
- # script emits EMPTY stdout AND a `[collision_surface] WARN:` line on STDERR.
15
- # The wave-compatibility predicate (architect-synth) MUST treat an empty surface
16
- # as "cannot prove disjointness" → fail-safe-serialize, NEVER as
17
- # "empty ∩ empty = ∅ ⇒ disjoint". Empty stdout is a SERIALIZE signal, not a
18
- # parallelize signal. Over-serialization is the safe failure direction.
19
- #
20
- # Used by architect-synth to compute the five-clause wave-compatibility predicate.
21
- # Do NOT modify file_surface_diff.sh — this is a deliberate standalone fork.
22
-
23
- set -euo pipefail
24
-
25
- STORY_FILE="${1:-}"
26
-
27
- if [[ -z "${STORY_FILE}" ]]; then
28
- echo "[collision_surface] ERROR: No story file argument provided." >&2
29
- echo "[collision_surface] Usage: $0 <story-file>" >&2
30
- exit 1
31
- fi
32
-
33
- if [[ ! -f "${STORY_FILE}" ]]; then
34
- echo "[collision_surface] ERROR: Story file not found: ${STORY_FILE}" >&2
35
- exit 1
36
- fi
37
-
38
- # ---- Parse §3.1 file surface table (multi-column fix) -----------------------
39
- #
40
- # CHANGE vs file_surface_diff.sh:158-189:
41
- # Original: val=cols[2] — reads only the second column (the "Value" column).
42
- # Fork: iterate cols[1..n] — checks every column for path-like tokens.
43
- #
44
- # Non-path skip list (tightened to avoid false matches with col-1 labels):
45
- # "Yes", "No", "Yes/No", "N/A", and any prefix of "Yes/No —"
46
- #
47
- # Path-shape guard (looks_like_path): a token is a path IFF it contains "/" OR ends
48
- # in a known file extension. This catches both slash-bearing paths (a/b.ts) AND
49
- # bare filenames the prior slash-only guard missed (package.json, schema.sql), while
50
- # still rejecting label cells like "Primary File (new)" or prose like "Some details.".
51
- # BUG-033: the guard now matches the documented contract — the prior code was
52
- # slash-only despite the comment claiming "/ OR known extension".
53
- #
54
- # Over-serialization is the safe failure direction — a token we cannot classify as a
55
- # path is dropped, so the story trends toward an empty surface → fail-safe-serialize.
56
-
57
- parse_surface_paths() {
58
- local story_file="$1"
59
- awk '
60
- # A token is a path IFF it contains "/" OR ends in a known file extension.
61
- function looks_like_path(s) {
62
- return (s ~ /\//) || \
63
- (s ~ /\.(ts|tsx|cts|mts|js|jsx|cjs|mjs|json|jsonc|sh|bash|md|sql|svelte|vue|css|scss|sass|less|ya?ml|toml|ini|env|txt|prisma|html|htm|py|rs|go|rb|java|kt)$/)
64
- }
65
- /^### 3\.1/ { in_section=1; next }
66
- in_section && /^### / { in_section=0; next }
67
- in_section && /^\|/ {
68
- line=$0
69
- gsub(/^\||\|$/, "", line)
70
- n=split(line, cols, "|")
71
- # Scan ALL columns (fix: was only cols[2])
72
- for (c=1; c<=n; c++) {
73
- val=cols[c]
74
- # Trim whitespace
75
- gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
76
- # Strip backticks
77
- gsub(/`/, "", val)
78
- # Skip known non-path cells (exact and prefix matches)
79
- if (val == "Yes") continue
80
- if (val == "No") continue
81
- if (val == "Yes/No") continue
82
- if (val == "N/A") continue
83
- if (val == "Item") continue
84
- if (val == "Value") continue
85
- if (val == "") continue
86
- # Skip cells starting with "Yes/No —" (partial answers)
87
- if (substr(val, 1, 8) == "Yes/No -") continue
88
- # Handle multiple paths separated by ", "; guard each split token.
89
- npaths=split(val, paths, ", ")
90
- for (i=1; i<=npaths; i++) {
91
- p=paths[i]
92
- gsub(/^[[:space:]]+|[[:space:]]+$/, "", p)
93
- if (p != "" && looks_like_path(p)) print p
94
- }
95
- }
96
- }
97
- ' "${story_file}"
98
- }
99
-
100
- # Collect paths and deduplicate (portable bash 3.2 compat — no mapfile, no declare -A on macOS)
101
- # FLASHCARD #bash #macos: no mapfile on macOS bash 3.2 — use while-read loop
102
- # FLASHCARD #bash #macos: no associative arrays (declare -A) on macOS bash 3.2 — use awk for dedup
103
- SURFACE="$(parse_surface_paths "${STORY_FILE}" | awk '!seen[$0]++')"
104
-
105
- if [[ -z "${SURFACE}" ]]; then
106
- # BUG-033 fail-safe signal. ZERO parseable paths = the disjointness predicate CANNOT
107
- # prove this story is collision-free, so it must NOT be co-waved. stdout stays empty
108
- # (the caller reads "no proven surface"); the predicate fail-safe-serializes on empty.
109
- echo "[collision_surface] WARN: no parseable file surface in ${STORY_FILE} — downstream MUST fail-safe-serialize (empty surface is NOT 'disjoint')." >&2
110
- exit 0
111
- fi
112
-
113
- printf '%s\n' "${SURFACE}"
114
- exit 0
@@ -1,62 +0,0 @@
1
- /**
2
- * ClearGate Execution Phase — Constants
3
- *
4
- * state.json v3 Schema (STORY-070-01: execution_mode retired — single always-enforced behavior):
5
- *
6
- * {
7
- * "schema_version": 3, // integer, mandatory
8
- * "sprint_id": "S-NN", // string
9
- * "sprint_status": "Active", // string
10
- * "stories": {
11
- * "STORY-NNN-NN": {
12
- * "state": "Ready to Bounce", // one of VALID_STATES
13
- * "qa_bounces": 0, // integer 0..BOUNCE_CAP
14
- * "arch_bounces": 0, // integer 0..BOUNCE_CAP
15
- * "worktree": null, // string|null — path to worktree checkout
16
- * "updated_at": "<ISO-8601>", // string
17
- * "notes": "", // string
18
- * "lane": "standard", // default "standard"
19
- * "lane_assigned_by": "architect" | "human-override" | "migration-default",
20
- * "lane_demoted_at": "<ISO-8601>" | null,
21
- * "lane_demotion_reason": string | null
22
- * }
23
- * },
24
- * "last_action": "<string>", // human-readable last operation
25
- * "updated_at": "<ISO-8601>" // string
26
- * }
27
- *
28
- * Break-glass env var: CLEARGATE_ADVISORY=1 downgrades gate failures to warnings.
29
- */
30
-
31
- export const SCHEMA_VERSION = 3;
32
-
33
- export const BOUNCE_CAP = 3;
34
-
35
- export const VALID_STATES = [
36
- 'Ready to Bounce',
37
- 'Bouncing',
38
- 'QA Passed',
39
- 'Architect Passed',
40
- 'Sprint Review',
41
- 'Done',
42
- 'Escalated',
43
- 'Parking Lot',
44
- ];
45
-
46
- export const TERMINAL_STATES = ['Done', 'Escalated', 'Parking Lot'];
47
-
48
- /**
49
- * Canonical state-machine transitions table.
50
- * Key: current state. Value: array of allowed next states.
51
- * Terminal states have empty arrays (no transitions out).
52
- */
53
- export const STATE_TRANSITIONS = {
54
- 'Ready to Bounce': ['Bouncing', 'Parking Lot'],
55
- 'Bouncing': ['QA Passed', 'Ready to Bounce', 'Escalated', 'Parking Lot'],
56
- 'QA Passed': ['Architect Passed', 'Ready to Bounce', 'Escalated', 'Parking Lot'],
57
- 'Architect Passed': ['Sprint Review', 'Ready to Bounce', 'Escalated', 'Parking Lot'],
58
- 'Sprint Review': ['Done', 'Ready to Bounce', 'Escalated', 'Parking Lot'],
59
- 'Done': [],
60
- 'Escalated': [],
61
- 'Parking Lot': [],
62
- };
@@ -1,219 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * dedupe_frontmatter.mjs — BUG-025
4
- *
5
- * One-shot corpus dedupe pass: scan every .md file under
6
- * .cleargate/delivery/pending-sync/ and .cleargate/delivery/archive/.
7
- * For any file whose YAML frontmatter contains duplicate top-level keys,
8
- * keep the LAST occurrence of each key (closest to the body — this is what
9
- * the stamp hook writes most recently) and rewrite the file.
10
- *
11
- * Idempotent: re-running produces zero diff when no duplicates remain.
12
- *
13
- * Usage:
14
- * node .cleargate/scripts/dedupe_frontmatter.mjs [--dry-run] [<dir>]
15
- *
16
- * --dry-run Print which files would be rewritten without writing.
17
- * <dir> Walk only this directory instead of the canonical corpus dirs.
18
- * Used by integration tests targeting a tmpdir.
19
- */
20
-
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
23
- import * as url from 'node:url';
24
-
25
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
26
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
27
-
28
- const args = process.argv.slice(2);
29
- const DRY_RUN = args.includes('--dry-run');
30
- const DIR_ARG = args.find((a) => !a.startsWith('--'));
31
-
32
- const PENDING_SYNC = path.join(REPO_ROOT, '.cleargate', 'delivery', 'pending-sync');
33
- const ARCHIVE = path.join(REPO_ROOT, '.cleargate', 'delivery', 'archive');
34
-
35
- /**
36
- * Collect all .md files in a flat directory (non-recursive — delivery dirs are flat).
37
- */
38
- function collectMd(dir) {
39
- let entries;
40
- try {
41
- entries = fs.readdirSync(dir, { withFileTypes: true });
42
- } catch {
43
- return [];
44
- }
45
- return entries
46
- .filter((e) => e.isFile() && e.name.endsWith('.md'))
47
- .map((e) => path.join(dir, e.name));
48
- }
49
-
50
- /**
51
- * Parse a raw markdown string into:
52
- * fmLines — the lines between the opening and closing `---` delimiters
53
- * closeIdx — index of the closing `---` line (in the full `lines` array)
54
- * lines — all lines of the file
55
- *
56
- * Returns null if the file has no valid frontmatter.
57
- */
58
- function parseFmLines(raw) {
59
- const lines = raw.split('\n');
60
- if (lines[0] !== '---') return null;
61
- let closeIdx = -1;
62
- for (let i = 1; i < lines.length; i++) {
63
- if (lines[i] === '---') {
64
- closeIdx = i;
65
- break;
66
- }
67
- }
68
- if (closeIdx === -1) return null;
69
- return { lines, closeIdx, fmLines: lines.slice(1, closeIdx) };
70
- }
71
-
72
- /**
73
- * Given the frontmatter lines, detect duplicate top-level keys.
74
- * Returns a Map from key → array of line-indices (within fmLines) where it appears.
75
- * Only entries with ≥2 occurrences indicate duplicates.
76
- *
77
- * A "top-level key" is a line that starts with a non-space character followed by
78
- * `:<space>` or `:<end-of-line>`. Multi-line values (YAML scalars, blocks) that
79
- * contain `:` on continuation lines are NOT top-level keys (they start with space/tab).
80
- */
81
- function findDuplicateKeys(fmLines) {
82
- /** @type {Map<string, number[]>} */
83
- const keyMap = new Map();
84
- for (let i = 0; i < fmLines.length; i++) {
85
- const line = fmLines[i];
86
- // Top-level key: starts at column 0, has `:` after a word character sequence
87
- const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):/);
88
- if (m) {
89
- const key = m[1];
90
- if (!keyMap.has(key)) {
91
- keyMap.set(key, []);
92
- }
93
- keyMap.get(key).push(i);
94
- }
95
- }
96
- // Return only keys with duplicates
97
- /** @type {Map<string, number[]>} */
98
- const dupes = new Map();
99
- for (const [k, indices] of keyMap) {
100
- if (indices.length > 1) {
101
- dupes.set(k, indices);
102
- }
103
- }
104
- return dupes;
105
- }
106
-
107
- /**
108
- * Deduplicate frontmatter lines: for each duplicate key, keep the LAST occurrence
109
- * and discard all earlier ones (including their potential multi-line values).
110
- *
111
- * Multi-line value detection: lines that follow a key line and start with
112
- * whitespace (` ` or `\t`) belong to the preceding key's value.
113
- *
114
- * Returns the deduplicated fmLines array (may be the same reference if no changes).
115
- */
116
- function dedupeLines(fmLines, dupes) {
117
- if (dupes.size === 0) return fmLines;
118
-
119
- // Build a set of fmLine indices to DROP (all but the last occurrence of each dup key,
120
- // including their continuation lines).
121
- /** @type {Set<number>} */
122
- const dropSet = new Set();
123
-
124
- for (const [, indices] of dupes) {
125
- // Keep last occurrence; drop all earlier ones (+ their continuations)
126
- const toRemove = indices.slice(0, -1); // all but last
127
- for (const startIdx of toRemove) {
128
- dropSet.add(startIdx);
129
- // Mark continuation lines (indent-starting lines following a key line)
130
- let j = startIdx + 1;
131
- while (j < fmLines.length && /^[ \t]/.test(fmLines[j])) {
132
- dropSet.add(j);
133
- j++;
134
- }
135
- }
136
- }
137
-
138
- return fmLines.filter((_, i) => !dropSet.has(i));
139
- }
140
-
141
- /**
142
- * Atomic write: write to a .tmp file then rename over the target.
143
- */
144
- function writeAtomic(filePath, content) {
145
- const tmpPath = `${filePath}.tmp.${Date.now()}`;
146
- fs.writeFileSync(tmpPath, content, 'utf8');
147
- fs.renameSync(tmpPath, filePath);
148
- }
149
-
150
- // ── Main ──────────────────────────────────────────────────────────────────────
151
-
152
- const files = DIR_ARG
153
- ? collectMd(path.resolve(DIR_ARG))
154
- : [...collectMd(PENDING_SYNC), ...collectMd(ARCHIVE)];
155
-
156
- let rewritten = 0;
157
- let skipped = 0;
158
- let errors = 0;
159
-
160
- for (const filePath of files) {
161
- let raw;
162
- try {
163
- raw = fs.readFileSync(filePath, 'utf8');
164
- } catch (e) {
165
- console.error(`error reading ${filePath}: ${e.message}`);
166
- errors++;
167
- continue;
168
- }
169
-
170
- const parsed = parseFmLines(raw);
171
- if (!parsed) {
172
- skipped++;
173
- continue;
174
- }
175
-
176
- const { lines, closeIdx, fmLines } = parsed;
177
- const dupes = findDuplicateKeys(fmLines);
178
-
179
- if (dupes.size === 0) {
180
- skipped++;
181
- continue;
182
- }
183
-
184
- const relPath = path.relative(REPO_ROOT, filePath);
185
- const dupeSummary = Array.from(dupes.keys()).join(', ');
186
-
187
- if (DRY_RUN) {
188
- console.log(`would-rewrite: ${relPath} (duplicate keys: ${dupeSummary})`);
189
- rewritten++;
190
- continue;
191
- }
192
-
193
- // Build the new file content: deduplicated frontmatter + rest unchanged
194
- const cleanedFmLines = dedupeLines(fmLines, dupes);
195
- const newLines = [
196
- lines[0], // opening ---
197
- ...cleanedFmLines,
198
- lines[closeIdx], // closing ---
199
- ...lines.slice(closeIdx + 1),
200
- ];
201
- const newContent = newLines.join('\n');
202
-
203
- // Verify the result is actually different (guard against no-op edge cases)
204
- if (newContent === raw) {
205
- skipped++;
206
- continue;
207
- }
208
-
209
- writeAtomic(filePath, newContent);
210
- console.log(`rewritten: ${relPath} (removed duplicate keys: ${dupeSummary})`);
211
- rewritten++;
212
- }
213
-
214
- console.log(
215
- `\nDedupe complete: ${rewritten} ${DRY_RUN ? 'would-rewrite' : 'rewritten'}, ${skipped} skipped, ${errors} errors.`,
216
- );
217
- if (errors > 0) {
218
- process.exit(1);
219
- }