@event4u/agent-config 8.3.0 → 8.4.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 (140) hide show
  1. package/.claude-plugin/marketplace.json +4 -426
  2. package/.claude-plugin/skills/install-agent-config/SKILL.md +32 -0
  3. package/CHANGELOG.md +25 -0
  4. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  5. package/dist/cli/registry.js +1 -0
  6. package/dist/cli/registry.js.map +1 -1
  7. package/dist/discovery/deprecation-report.md +1 -1
  8. package/dist/discovery/discovery-manifest.json +1 -1
  9. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  10. package/dist/discovery/discovery-manifest.summary.md +1 -1
  11. package/dist/discovery/orphan-report.md +1 -1
  12. package/dist/discovery/packs.json +1 -1
  13. package/dist/discovery/trust-report.md +1 -1
  14. package/dist/discovery/workspaces.json +1 -1
  15. package/dist/mcp/registry-manifest.json +1 -1
  16. package/docs/contracts/surface-matrix.md +103 -0
  17. package/package.json +4 -1
  18. package/src/agent-src/README.md +64 -0
  19. package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
  20. package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
  21. package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
  22. package/src/agent-src/templates/AGENTS.md +34 -0
  23. package/src/agent-src/templates/scripts/README.md +195 -0
  24. package/src/agent-src/templates/scripts/check_memory.ts +662 -0
  25. package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
  26. package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  27. package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
  28. package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
  29. package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
  30. package/src/agent-src/templates/scripts/memory_report.ts +868 -0
  31. package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
  32. package/src/agent-src/templates/scripts/memory_status.ts +215 -0
  33. package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
  34. package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  35. package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
  36. package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
  37. package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
  38. package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
  39. package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
  40. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
  41. package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
  42. package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
  43. package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
  44. package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
  45. package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
  46. package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
  47. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
  48. package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
  49. package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
  50. package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
  51. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
  52. package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
  53. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
  54. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
  55. package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
  56. package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
  57. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
  58. package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
  61. package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
  62. package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
  63. package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
  64. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
  67. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
  68. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
  73. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
  74. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
  75. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
  76. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
  77. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
  78. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
  79. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
  80. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
  81. package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
  82. package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
  83. package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
  84. package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
  85. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
  86. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
  87. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
  88. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
  89. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
  90. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
  91. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
  92. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
  93. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
  94. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
  95. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
  96. package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
  97. package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
  98. package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
  99. package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
  100. package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
  101. package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
  102. package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
  103. package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
  104. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
  105. package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
  106. package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
  107. package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
  108. package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
  109. package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
  110. package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
  111. package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
  112. package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
  113. package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
  114. package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
  115. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
  116. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
  117. package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
  118. package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
  119. package/src/config/surface-matrix.yml +213 -0
  120. package/src/install/atomic.ts +111 -0
  121. package/src/install/conflict.ts +226 -0
  122. package/src/install/detect.ts +280 -0
  123. package/src/install/paths.ts +90 -0
  124. package/src/install/plan.ts +205 -0
  125. package/src/install/preflight.ts +210 -0
  126. package/src/install/selectedTools.ts +52 -0
  127. package/src/install/toolDetection.ts +120 -0
  128. package/src/install/txlog.ts +161 -0
  129. package/src/install/types.ts +175 -0
  130. package/src/install/wizard-plan.ts +216 -0
  131. package/src/scripts/_cli/cmd_converge.ts +399 -0
  132. package/src/scripts/_cli/cmd_doctor.ts +94 -0
  133. package/src/scripts/_cli/cmd_upgrade.ts +40 -16
  134. package/src/scripts/_dispatch.bash +13 -0
  135. package/src/scripts/condense.ts +67 -36
  136. package/src/scripts/hook_manifest.yaml +16 -7
  137. package/src/scripts/lint_marketplace.ts +54 -56
  138. package/src/scripts/lint_surface_matrix.ts +200 -0
  139. package/src/scripts/prepack-check.mjs +108 -5
  140. package/src/scripts/surface_probe_hook.ts +231 -0
@@ -0,0 +1,1087 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Generate `agents/roadmaps-progress.md` — aggregated progress across open roadmaps.
4
+ *
5
+ * TypeScript twin of `src/agent-src/scripts/update_roadmap_progress.py`
6
+ * (ADR-200, Python→TypeScript migration). The `.augment`-projected copy of this
7
+ * script is what CI runs as `--check`, so byte-parity is load-bearing: the
8
+ * rendered dashboard markdown, the `--check` stale comparison, the stderr
9
+ * warnings, and the exit codes all mirror the Python original EXACTLY. The
10
+ * public surface (`collect`, `parse_roadmap`, `RoadmapStats`-shaped objects with
11
+ * `.rel` / `.open_` / `.deferred` / `total_active`, …) is preserved so
12
+ * `archive_completed_roadmaps` can consume it the same way Python does. snake_case
13
+ * is kept on the public surface.
14
+ *
15
+ * Scans every roadmap under `agents/roadmaps/` (excluding `archive/`, `skipped/`,
16
+ * `template.md`, `README.md`, `open-questions*.md`), counts checkbox states per
17
+ * phase, and writes a dashboard at `agents/roadmaps-progress.md` (outside the
18
+ * `roadmaps/` folder to keep it clean) with:
19
+ *
20
+ * - Overall progress (open-roadmap count, steps done, %)
21
+ * - A summary table of every open roadmap
22
+ * - Per-roadmap phase breakdown
23
+ *
24
+ * Checkbox states:
25
+ * [x] done [ ] open [~] deferred [-] cancelled
26
+ *
27
+ * Percentage = done / (done + open). Deferred and cancelled do not count towards
28
+ * "open" (they are explicit decisions).
29
+ *
30
+ * `[~]` deferred items carry plans the user intends to revisit later. They
31
+ * block silent auto-archive per `roadmap-progress-sync` Iron Law 3.
32
+ *
33
+ * Invocation (from project root):
34
+ * node node_modules/.bin/tsx .augment/scripts/update_roadmap_progress.ts # rewrite
35
+ * node node_modules/.bin/tsx .augment/scripts/update_roadmap_progress.ts --check # CI: exit 1 if stale
36
+ *
37
+ * --- Parity notes (ADR-200) ---
38
+ *
39
+ * - Python `round()` is banker's rounding (round-half-to-even). Percentages and
40
+ * the progress bar use it, so `_pyRound` replicates it — a naïve `Math.round`
41
+ * would drift on exact-half values (e.g. 12.5 → 12, not 13).
42
+ * - The Python `re` patterns (MULTILINE / DOTALL) are translated 1:1; `\s` in
43
+ * the checkbox/phase classes maps to Python's `\s` (`[ \t\n\r\f\v]`), and the
44
+ * Unicode em-dash `—` is preserved in the phase separator class.
45
+ * - `sorted(roadmap_root.rglob("*.md"))` → component-wise sort over a recursive
46
+ * `.md` walk (only files; symlinked dirs are followed like pathlib).
47
+ * - `collect_bundles` does `import yaml` lazily and returns `[]` on ImportError
48
+ * or a malformed registry → `createRequire('yaml')` in a try/catch, same
49
+ * graceful-degradation contract. PyYAML 1.1 semantics via the `yaml` package.
50
+ * - JSON is not emitted; the only structured output is the markdown dashboard
51
+ * built with `"\n".join(lines) + "\n"`.
52
+ * - `Path.read_text(encoding="utf-8")` → `fs.readFileSync(p, "utf-8")`.
53
+ * - `process.exitCode` is set; `process.exit()` is never called. argparse
54
+ * usage errors throw `ArgparseExit(2)`; `-h`/`--help` throws `ArgparseExit(0)`.
55
+ */
56
+
57
+ import { createRequire } from 'node:module';
58
+ import { spawnSync } from 'node:child_process';
59
+ import * as fs from 'node:fs';
60
+ import * as path from 'node:path';
61
+ import { fileURLToPath, pathToFileURL } from 'node:url';
62
+ import type * as YamlModule from 'yaml';
63
+
64
+ const _HERE = fileURLToPath(import.meta.url);
65
+ const _require = createRequire(import.meta.url);
66
+
67
+ // CHECKBOX_RE = re.compile(r"^\s*[-*]\s+\[([ xX~\-])\]\s", re.MULTILINE)
68
+ const CHECKBOX_RE = /^[ \t\n\r\f\v]*[-*][ \t\n\r\f\v]+\[([ xX~\-])\][ \t\n\r\f\v]/gm;
69
+ // PHASE_RE (MULTILINE) — H2/H3 "Phase <id>" with optional separator + name.
70
+ const PHASE_RE =
71
+ /^(#{2,3})[ \t\n\r\f\v]+Phase[ \t\n\r\f\v]+(\d+[a-z]?|[IVX]+|[A-Z](?:\d+)?)(?:[\s:—\-]+([\s\S]*?))?[ \t\f\v\r]*$/gm;
72
+ // TITLE_RE = re.compile(r"^#\s+(?:Roadmap:\s*)?(.+?)\s*$", re.MULTILINE)
73
+ const TITLE_RE = /^#[ \t\n\r\f\v]+(?:Roadmap:[ \t\n\r\f\v]*)?(.+?)[ \t\f\v\r]*$/m;
74
+ const EXCLUDE_NAMES: ReadonlySet<string> = new Set([
75
+ 'template.md',
76
+ 'README.md',
77
+ 'progress.md',
78
+ 'roadmaps-progress.md',
79
+ ]);
80
+ const EXCLUDE_PREFIXES: readonly string[] = ['open-questions'];
81
+ const EXCLUDE_DIRS: ReadonlySet<string> = new Set(['archive', 'skipped', 'stubs', 'later']);
82
+
83
+ // FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\s*\n", re.DOTALL)
84
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---[ \t\n\r\f\v]*\n/;
85
+ const DRAFT_VALUES: ReadonlySet<string> = new Set(['draft']);
86
+
87
+ const MERGE_GATED_RE = /merge-gated/i;
88
+ // PR_NUM_RE = re.compile(r"pr\s*[=#:]?\s*#?\s*(\d+)", re.IGNORECASE)
89
+ const PR_NUM_RE = /pr[ \t\n\r\f\v]*[=#:]?[ \t\n\r\f\v]*#?[ \t\n\r\f\v]*(\d+)/gi;
90
+
91
+ /** Python `round()` — round-half-to-even (banker's rounding). */
92
+ function _pyRound(x: number): number {
93
+ const floor = Math.floor(x);
94
+ const diff = x - floor;
95
+ if (diff < 0.5) return floor;
96
+ if (diff > 0.5) return floor + 1;
97
+ // Exact half → round to even.
98
+ return floor % 2 === 0 ? floor : floor + 1;
99
+ }
100
+
101
+ class PhaseStats {
102
+ id: string;
103
+ name: string;
104
+ done: number;
105
+ open_: number;
106
+ deferred: number;
107
+ cancelled: number;
108
+ merge_gated: number;
109
+ merge_gated_prs: number[];
110
+
111
+ constructor(
112
+ id: string,
113
+ name: string,
114
+ done = 0,
115
+ open_ = 0,
116
+ deferred = 0,
117
+ cancelled = 0,
118
+ merge_gated = 0,
119
+ merge_gated_prs: number[] = [],
120
+ ) {
121
+ this.id = id;
122
+ this.name = name;
123
+ this.done = done;
124
+ this.open_ = open_;
125
+ this.deferred = deferred;
126
+ this.cancelled = cancelled;
127
+ this.merge_gated = merge_gated;
128
+ this.merge_gated_prs = merge_gated_prs;
129
+ }
130
+
131
+ get total_active(): number {
132
+ return this.done + this.open_;
133
+ }
134
+
135
+ get total_all(): number {
136
+ return this.done + this.open_ + this.deferred + this.cancelled;
137
+ }
138
+
139
+ get percent(): number {
140
+ return this.total_active ? _pyRound((this.done * 100) / this.total_active) : 0;
141
+ }
142
+
143
+ get state(): string {
144
+ if (this.total_active === 0 && (this.deferred || this.cancelled)) {
145
+ return '⏭️ skipped';
146
+ }
147
+ if (this.total_active === 0) {
148
+ return '⬜ empty';
149
+ }
150
+ if (this.done === 0) {
151
+ return '⬜ not started';
152
+ }
153
+ if (this.open_ === 0) {
154
+ return '✅ done';
155
+ }
156
+ return '🟡 in progress';
157
+ }
158
+ }
159
+
160
+ class RoadmapStats {
161
+ path: string;
162
+ rel: string;
163
+ title: string;
164
+ phases: PhaseStats[];
165
+ blockers: Blocker[];
166
+
167
+ constructor(
168
+ p: string,
169
+ rel: string,
170
+ title: string,
171
+ phases: PhaseStats[] = [],
172
+ blockers: Blocker[] = [],
173
+ ) {
174
+ this.path = p;
175
+ this.rel = rel;
176
+ this.title = title;
177
+ this.phases = phases;
178
+ this.blockers = blockers;
179
+ }
180
+
181
+ get open_blockers(): Blocker[] {
182
+ return this.blockers.filter((b) => b.status !== 'resolved');
183
+ }
184
+
185
+ get resolved_blockers(): Blocker[] {
186
+ return this.blockers.filter((b) => b.status === 'resolved');
187
+ }
188
+
189
+ get done(): number {
190
+ return this.phases.reduce((s, p) => s + p.done, 0);
191
+ }
192
+
193
+ get open_(): number {
194
+ return this.phases.reduce((s, p) => s + p.open_, 0);
195
+ }
196
+
197
+ get deferred(): number {
198
+ return this.phases.reduce((s, p) => s + p.deferred, 0);
199
+ }
200
+
201
+ get cancelled(): number {
202
+ return this.phases.reduce((s, p) => s + p.cancelled, 0);
203
+ }
204
+
205
+ get merge_gated(): number {
206
+ return this.phases.reduce((s, p) => s + p.merge_gated, 0);
207
+ }
208
+
209
+ get merge_gated_prs(): number[] {
210
+ const seen: number[] = [];
211
+ for (const p of this.phases) {
212
+ for (const n of p.merge_gated_prs) {
213
+ if (!seen.includes(n)) {
214
+ seen.push(n);
215
+ }
216
+ }
217
+ }
218
+ return seen;
219
+ }
220
+
221
+ get total_active(): number {
222
+ return this.done + this.open_;
223
+ }
224
+
225
+ get total_all(): number {
226
+ return this.done + this.open_ + this.deferred + this.cancelled;
227
+ }
228
+
229
+ get percent(): number {
230
+ return this.total_active ? _pyRound((this.done * 100) / this.total_active) : 0;
231
+ }
232
+ }
233
+
234
+ /** `str.strip()` over Python whitespace (space/tab/nl/cr/ff/vt). */
235
+ function _strip(s: string): string {
236
+ return s.replace(/^[ \t\n\r\f\v]+/, '').replace(/[ \t\n\r\f\v]+$/, '');
237
+ }
238
+
239
+ /** `str.splitlines()` — split on Python's line boundaries (no trailing empty). */
240
+ function _splitlines(s: string): string[] {
241
+ if (s === '') return [];
242
+ // Python splitlines splits on \n, \r, \r\n (and a few more, but markdown
243
+ // here is plain \n / \r\n). Mirror that set; drop the trailing empty.
244
+ const parts = s.split(/\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/);
245
+ if (parts.length && parts[parts.length - 1] === '') {
246
+ parts.pop();
247
+ }
248
+ return parts;
249
+ }
250
+
251
+ function parse_frontmatter(text: string): Record<string, string> {
252
+ const m = FRONTMATTER_RE.exec(text);
253
+ if (!m) {
254
+ return {};
255
+ }
256
+ const fm: Record<string, string> = {};
257
+ for (const line of _splitlines(m[1] as string)) {
258
+ const stripped = _strip(line);
259
+ if (!stripped || stripped.startsWith('#') || !line.includes(':')) {
260
+ continue;
261
+ }
262
+ // line.partition(":") — split on first colon.
263
+ const idx = line.indexOf(':');
264
+ const key = line.slice(0, idx);
265
+ const value = line.slice(idx + 1);
266
+ fm[_strip(key)] = _stripQuotes(_strip(value));
267
+ }
268
+ return fm;
269
+ }
270
+
271
+ /** value.strip().strip('"').strip("'") — Python's chained one-char strips. */
272
+ function _stripQuotes(s: string): string {
273
+ let out = s.replace(/^"+/, '').replace(/"+$/, '');
274
+ out = out.replace(/^'+/, '').replace(/'+$/, '');
275
+ return out;
276
+ }
277
+
278
+ function is_draft(fm: Record<string, string>): boolean {
279
+ return DRAFT_VALUES.has((fm['status'] ?? '').toLowerCase());
280
+ }
281
+
282
+ function is_roadmap_candidate(p: string): boolean {
283
+ const name = path.basename(p);
284
+ if (EXCLUDE_NAMES.has(name)) {
285
+ return false;
286
+ }
287
+ if (EXCLUDE_PREFIXES.some((pre) => name.startsWith(pre))) {
288
+ return false;
289
+ }
290
+ // path.parts — every component of the path; exclude if any is an excluded dir.
291
+ const parts = p.split(path.sep).filter((s) => s !== '');
292
+ if (parts.some((part) => EXCLUDE_DIRS.has(part))) {
293
+ return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ type CheckboxCounts = [number, number, number, number, number, number[]];
299
+
300
+ function count_checkboxes(text: string): CheckboxCounts {
301
+ let done = 0;
302
+ let open_ = 0;
303
+ let deferred = 0;
304
+ let cancelled = 0;
305
+ let merge_gated = 0;
306
+ const prs: number[] = [];
307
+ const matches: Array<{ start: number; group1: string }> = [];
308
+ CHECKBOX_RE.lastIndex = 0;
309
+ let m: RegExpExecArray | null;
310
+ while ((m = CHECKBOX_RE.exec(text)) !== null) {
311
+ matches.push({ start: m.index, group1: m[1] as string });
312
+ if (m.index === CHECKBOX_RE.lastIndex) {
313
+ CHECKBOX_RE.lastIndex++;
314
+ }
315
+ }
316
+ for (let i = 0; i < matches.length; i++) {
317
+ const c = (matches[i] as { start: number; group1: string }).group1.toLowerCase();
318
+ if (c === 'x') {
319
+ done += 1;
320
+ } else if (c === ' ') {
321
+ open_ += 1;
322
+ const span_end =
323
+ i + 1 < matches.length ? (matches[i + 1] as { start: number }).start : text.length;
324
+ const span = text.slice((matches[i] as { start: number }).start, span_end);
325
+ if (MERGE_GATED_RE.test(span)) {
326
+ merge_gated += 1;
327
+ PR_NUM_RE.lastIndex = 0;
328
+ let pm: RegExpExecArray | null;
329
+ while ((pm = PR_NUM_RE.exec(span)) !== null) {
330
+ prs.push(parseInt(pm[1] as string, 10));
331
+ }
332
+ }
333
+ } else if (c === '~') {
334
+ deferred += 1;
335
+ } else if (c === '-') {
336
+ cancelled += 1;
337
+ }
338
+ }
339
+ return [done, open_, deferred, cancelled, merge_gated, prs];
340
+ }
341
+
342
+ interface Blocker {
343
+ id: string;
344
+ status: string;
345
+ owner: string;
346
+ blocks: string;
347
+ todo: string[];
348
+ resolvedWhen: string;
349
+ }
350
+
351
+ // Strip fenced code blocks before blocker detection — a roadmap that shows the
352
+ // `## Blockers` shape as a documentation example (fenced, indented or not)
353
+ // must not be mistaken for a live blocker on that roadmap.
354
+ const FENCED_CODE_RE = /^[ \t]*```[^\n]*\n[\s\S]*?^[ \t]*```[ \t]*$/gm;
355
+ const BLOCKERS_SECTION_RE = /^##[ \t]+Blockers[ \t]*$/im;
356
+ const BLOCKER_HEADING_RE = /^###[ \t]+blocker:[ \t]*(.+?)[ \t]*$/gim;
357
+ const NEXT_H2_RE = /^##[ \t]+\S/m;
358
+ const LEGACY_BLOCKED_UNTIL_RE = /^>[ \t]*Blocked until:?[ \t]*(.+)$/im;
359
+
360
+ function _stripFencedCode(text: string): string {
361
+ return text.replace(FENCED_CODE_RE, (m) => '\n'.repeat(_splitlines(m).length));
362
+ }
363
+
364
+ const BLOCKER_FIELD_RE = /^-[ \t]*\*\*(Status|Owner|Blocks|Resolved when|What to do):\*\*/i;
365
+
366
+ /** Strip an inline `<!-- comment -->` and trim. */
367
+ function _stripComment(s: string): string {
368
+ return _strip(s.replace(/<!--[\s\S]*?-->/g, ''));
369
+ }
370
+
371
+ /**
372
+ * `- **Label:** value` → trimmed `value`, joined with any wrapped
373
+ * continuation lines (non-bulleted text under the same field, e.g. a long
374
+ * "Blocks" sentence spanning two lines) up to the next `- **Field:**`
375
+ * marker, blank line, or heading.
376
+ */
377
+ function _blockerField(slice: string, label: string): string | null {
378
+ const lines = _splitlines(slice);
379
+ const re = new RegExp(`^-[ \\t]*\\*\\*${label}:\\*\\*[ \\t]*(.*)$`, 'i');
380
+ const startIdx = lines.findIndex((l) => re.test(_strip(l)));
381
+ if (startIdx === -1) {
382
+ return null;
383
+ }
384
+ const m = re.exec(_strip(lines[startIdx] as string));
385
+ const parts: string[] = [];
386
+ const first = _stripComment((m?.[1] as string) ?? '');
387
+ if (first) {
388
+ parts.push(first);
389
+ }
390
+ for (let i = startIdx + 1; i < lines.length; i++) {
391
+ const trimmed = _strip(lines[i] as string);
392
+ if (trimmed === '' || BLOCKER_FIELD_RE.test(trimmed) || trimmed.startsWith('#')) {
393
+ break;
394
+ }
395
+ parts.push(_stripComment(trimmed));
396
+ }
397
+ return parts.length ? parts.join(' ') : null;
398
+ }
399
+
400
+ /** Lines under `- **What to do:**` up to the next `- **Field:**` marker. */
401
+ function _blockerTodo(slice: string): string[] {
402
+ const lines = _splitlines(slice);
403
+ const startIdx = lines.findIndex((l) => /^-[ \t]*\*\*What to do:\*\*/i.test(_strip(l)));
404
+ if (startIdx === -1) {
405
+ return [];
406
+ }
407
+ const out: string[] = [];
408
+ for (let i = startIdx + 1; i < lines.length; i++) {
409
+ const trimmed = _strip(lines[i] as string);
410
+ if (BLOCKER_FIELD_RE.test(trimmed) || trimmed.startsWith('#')) {
411
+ break;
412
+ }
413
+ if (trimmed !== '') {
414
+ out.push(trimmed);
415
+ }
416
+ }
417
+ return out;
418
+ }
419
+
420
+ function parse_blockers(raw_text: string): Blocker[] {
421
+ const text = _stripFencedCode(raw_text);
422
+ const blockers: Blocker[] = [];
423
+ const sectionMatch = BLOCKERS_SECTION_RE.exec(text);
424
+ if (sectionMatch) {
425
+ const sectionStart = sectionMatch.index + sectionMatch[0].length;
426
+ const rest = text.slice(sectionStart);
427
+ const h2 = NEXT_H2_RE.exec(rest);
428
+ const sectionEnd = h2 ? sectionStart + h2.index : text.length;
429
+ const section = text.slice(sectionStart, sectionEnd);
430
+ BLOCKER_HEADING_RE.lastIndex = 0;
431
+ const heads: Array<{ start: number; end: number; id: string }> = [];
432
+ let hm: RegExpExecArray | null;
433
+ while ((hm = BLOCKER_HEADING_RE.exec(section)) !== null) {
434
+ heads.push({
435
+ start: hm.index,
436
+ end: hm.index + hm[0].length,
437
+ id: _strip(hm[1] as string),
438
+ });
439
+ if (hm.index === BLOCKER_HEADING_RE.lastIndex) {
440
+ BLOCKER_HEADING_RE.lastIndex++;
441
+ }
442
+ }
443
+ for (let i = 0; i < heads.length; i++) {
444
+ const cur = heads[i] as { start: number; end: number; id: string };
445
+ const bodyEnd =
446
+ i + 1 < heads.length ? (heads[i + 1] as { start: number }).start : section.length;
447
+ const body = section.slice(cur.end, bodyEnd);
448
+ blockers.push({
449
+ id: cur.id,
450
+ status: (_blockerField(body, 'Status') ?? 'open').toLowerCase(),
451
+ owner: _blockerField(body, 'Owner') ?? 'user',
452
+ blocks: _blockerField(body, 'Blocks') ?? '(unspecified)',
453
+ todo: _blockerTodo(body),
454
+ resolvedWhen: _blockerField(body, 'Resolved when') ?? '(unspecified)',
455
+ });
456
+ }
457
+ }
458
+ const legacyMatch = LEGACY_BLOCKED_UNTIL_RE.exec(text);
459
+ if (legacyMatch) {
460
+ blockers.push({
461
+ id: 'legacy',
462
+ status: 'open',
463
+ owner: 'user',
464
+ blocks: 'entire roadmap',
465
+ todo: [_strip(legacyMatch[1] as string)],
466
+ resolvedWhen: 'condition described above clears',
467
+ });
468
+ }
469
+ return blockers;
470
+ }
471
+
472
+ function parse_roadmap(p: string, roadmap_root: string): RoadmapStats | null {
473
+ const text = fs.readFileSync(p, { encoding: 'utf-8' });
474
+ const phase_matches: Array<{ start: number; end: number; g2: string; g3: string | undefined }> =
475
+ [];
476
+ PHASE_RE.lastIndex = 0;
477
+ let pm: RegExpExecArray | null;
478
+ while ((pm = PHASE_RE.exec(text)) !== null) {
479
+ phase_matches.push({
480
+ start: pm.index,
481
+ end: pm.index + pm[0].length,
482
+ g2: pm[2] as string,
483
+ g3: pm[3],
484
+ });
485
+ if (pm.index === PHASE_RE.lastIndex) {
486
+ PHASE_RE.lastIndex++;
487
+ }
488
+ }
489
+ if (phase_matches.length === 0) {
490
+ return null; // not a roadmap — no ## Phase headings
491
+ }
492
+ const title_match = TITLE_RE.exec(text);
493
+ const title = title_match ? _strip(title_match[1] as string) : _stem(p);
494
+ const rel = _relPosix(roadmap_root, p);
495
+ const stats = new RoadmapStats(p, rel, title, [], parse_blockers(text));
496
+ for (let i = 0; i < phase_matches.length; i++) {
497
+ const cur = phase_matches[i] as { start: number; end: number; g2: string; g3: string | undefined };
498
+ const start = cur.end;
499
+ const end = i + 1 < phase_matches.length ? (phase_matches[i + 1] as { start: number }).start : text.length;
500
+ const [d, o, df, c, mg, prs] = count_checkboxes(text.slice(start, end));
501
+ const phase_id = cur.g2;
502
+ const name = _strip(cur.g3 ?? '') || `Phase ${phase_id}`;
503
+ stats.phases.push(new PhaseStats(phase_id, name, d, o, df, c, mg, prs));
504
+ }
505
+ return stats;
506
+ }
507
+
508
+ /** Path.stem — filename without final suffix. */
509
+ function _stem(p: string): string {
510
+ const base = path.basename(p);
511
+ const dot = base.lastIndexOf('.');
512
+ return dot > 0 ? base.slice(0, dot) : base;
513
+ }
514
+
515
+ /** str(path.relative_to(root)) — POSIX-separated relative path. */
516
+ function _relPosix(root: string, p: string): string {
517
+ return path.relative(root, p).split(path.sep).join('/');
518
+ }
519
+
520
+ /** Deterministic anchor slug for a roadmap's blocker section — explicit
521
+ * anchors (rather than GitHub's auto heading-slug) survive `.md` / brackets. */
522
+ function _blockerAnchor(rel: string): string {
523
+ const stem = rel.endsWith('.md') ? rel.slice(0, -3) : rel;
524
+ return (
525
+ 'blockers-' +
526
+ stem
527
+ .toLowerCase()
528
+ .replace(/[^a-z0-9]+/g, '-')
529
+ .replace(/^-+|-+$/g, '')
530
+ );
531
+ }
532
+
533
+ function bar(pct: number, width = 10): string {
534
+ const filled = _pyRound((pct * width) / 100);
535
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
536
+ }
537
+
538
+ /** sorted(roadmap_root.rglob("*.md")) — recursive, component-wise sort, files. */
539
+ function _rglobMdSorted(root: string): string[] {
540
+ const out: string[] = [];
541
+ const walk = (dir: string): void => {
542
+ let entries: fs.Dirent[];
543
+ try {
544
+ entries = fs.readdirSync(dir, { withFileTypes: true });
545
+ } catch {
546
+ return;
547
+ }
548
+ for (const ent of entries) {
549
+ const full = path.join(dir, ent.name);
550
+ if (ent.isDirectory()) {
551
+ walk(full);
552
+ } else if (ent.isSymbolicLink() && _isDir(full)) {
553
+ walk(full);
554
+ } else if (ent.name.endsWith('.md')) {
555
+ out.push(full);
556
+ }
557
+ }
558
+ };
559
+ walk(root);
560
+ out.sort();
561
+ return out;
562
+ }
563
+
564
+ function _isDir(p: string): boolean {
565
+ try {
566
+ return fs.statSync(p).isDirectory();
567
+ } catch {
568
+ return false;
569
+ }
570
+ }
571
+
572
+ function _isFile(p: string): boolean {
573
+ try {
574
+ return fs.statSync(p).isFile();
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
580
+ function collect(roadmap_root: string): RoadmapStats[] {
581
+ const results: RoadmapStats[] = [];
582
+ for (const p of _rglobMdSorted(roadmap_root)) {
583
+ if (!_isFile(p) || !is_roadmap_candidate(p)) {
584
+ continue;
585
+ }
586
+ const text = fs.readFileSync(p, { encoding: 'utf-8' });
587
+ if (is_draft(parse_frontmatter(text))) {
588
+ continue;
589
+ }
590
+ const stats = parse_roadmap(p, roadmap_root);
591
+ if (stats) {
592
+ results.push(stats);
593
+ }
594
+ }
595
+ return results;
596
+ }
597
+
598
+ function unarchived_complete(roadmaps: RoadmapStats[]): RoadmapStats[] {
599
+ return roadmaps.filter((r) => r.total_active > 0 && r.open_ === 0 && r.deferred === 0);
600
+ }
601
+
602
+ function merge_gated_pending(roadmaps: RoadmapStats[]): RoadmapStats[] {
603
+ return roadmaps.filter((r) => r.open_ > 0 && r.merge_gated === r.open_);
604
+ }
605
+
606
+ function pending_iron_law_3(roadmaps: RoadmapStats[]): RoadmapStats[] {
607
+ return roadmaps.filter((r) => r.total_active > 0 && r.open_ === 0 && r.deferred > 0);
608
+ }
609
+
610
+ interface Bundle {
611
+ slug: string;
612
+ tickets: number;
613
+ status: string;
614
+ roadmap: string;
615
+ }
616
+
617
+ function collect_bundles(repo_root: string): Bundle[] {
618
+ const reg = path.join(repo_root, 'agents', 'tickets', '_registry.yml');
619
+ if (!fs.existsSync(reg)) {
620
+ return [];
621
+ }
622
+ let YAML: typeof YamlModule;
623
+ try {
624
+ YAML = _require('yaml') as typeof YamlModule;
625
+ } catch {
626
+ return [];
627
+ }
628
+ let data: unknown;
629
+ try {
630
+ // yaml.safe_load(...) or {} — PyYAML 1.1 semantics; graceful on malformed.
631
+ data = YAML.parse(fs.readFileSync(reg, { encoding: 'utf-8' }), { version: '1.1' }) ?? {};
632
+ } catch {
633
+ return [];
634
+ }
635
+ const out: Bundle[] = [];
636
+ const bundles =
637
+ data && typeof data === 'object' && !Array.isArray(data)
638
+ ? ((data as Record<string, unknown>)['bundles'] ?? null)
639
+ : null;
640
+ const bundleMap =
641
+ bundles && typeof bundles === 'object' && !Array.isArray(bundles)
642
+ ? (bundles as Record<string, unknown>)
643
+ : {};
644
+ for (const slug of Object.keys(bundleMap).sort()) {
645
+ const metaRaw = bundleMap[slug];
646
+ const meta =
647
+ metaRaw && typeof metaRaw === 'object' && !Array.isArray(metaRaw)
648
+ ? (metaRaw as Record<string, unknown>)
649
+ : {};
650
+ const bdir = path.join(repo_root, 'agents', 'tickets', slug);
651
+ let n = 0;
652
+ if (_isDir(bdir)) {
653
+ try {
654
+ n = fs.readdirSync(bdir).filter((f) => f.startsWith('T-') && f.endsWith('.md')).length;
655
+ } catch {
656
+ n = 0;
657
+ }
658
+ }
659
+ out.push({
660
+ slug,
661
+ tickets: n,
662
+ status: (meta['status'] as string) ?? '?',
663
+ roadmap: (meta['source_roadmap'] as string) ?? '',
664
+ });
665
+ }
666
+ return out;
667
+ }
668
+
669
+ function render(roadmaps: RoadmapStats[], bundles: Bundle[] | null = null): string {
670
+ const total_done = roadmaps.reduce((s, r) => s + r.done, 0);
671
+ const total_active = roadmaps.reduce((s, r) => s + r.total_active, 0);
672
+ const overall_pct = total_active ? _pyRound((total_done * 100) / total_active) : 0;
673
+ const pending = pending_iron_law_3(roadmaps);
674
+ const gated = merge_gated_pending(roadmaps);
675
+ const total_open_blockers = roadmaps.reduce((s, r) => s + r.open_blockers.length, 0);
676
+ const lines: string[] = [];
677
+ lines.push('# Roadmap Progress\n');
678
+ const header_meta =
679
+ `> ${roadmaps.length} open roadmap` +
680
+ `${roadmaps.length !== 1 ? 's' : ''}` +
681
+ ' · [roadmaps/](roadmaps/) · [archive/](roadmaps/archive/) · ' +
682
+ '[skipped/](roadmaps/skipped/) · [later/](roadmaps/later/)' +
683
+ (total_open_blockers > 0
684
+ ? ` · **${total_open_blockers}** open blocker${total_open_blockers !== 1 ? 's' : ''}`
685
+ : '') +
686
+ '\n';
687
+ lines.push(
688
+ // Honest provenance (road-to-roadmap-archival-robustness, gap C): name a
689
+ // regen path that exists in EVERY install, not a hardcoded
690
+ // `.augment/scripts/...` that a script-less / non-Augment consumer does
691
+ // not have. `task roadmap-progress` covers Taskfile projects; the bare
692
+ // script name covers the rest.
693
+ '> Auto-generated — do not edit. Regenerate with `task roadmap-progress` ' +
694
+ 'or by running the `update_roadmap_progress` script for your install; ' +
695
+ 'rewritten on every roadmap create / execute / completion change ' +
696
+ '(timestamp lives in git history).\n>\n' +
697
+ header_meta,
698
+ );
699
+ lines.push('## Overall\n');
700
+ lines.push(`**${total_done} / ${total_active} steps done · ${overall_pct}%**\n`);
701
+ lines.push('```text\n' + bar(overall_pct, 40) + ` ${overall_pct}%\n` + '```\n');
702
+ if (pending.length) {
703
+ lines.push('## ⚠️ Iron Law 3 — unresolved deferred items\n');
704
+ lines.push(
705
+ 'These roadmaps have `count_open == 0` but carry `[~]` deferred ' +
706
+ 'items. Per `roadmap-progress-sync` Iron Law 3 they do NOT ' +
707
+ 'auto-archive — the user must resolve the deferrals first ' +
708
+ '(spawn follow-up, restore, or cancel). See ' +
709
+ '[`roadmap-management § 4b`](../packages/core/.agent-src.uncondensed/skills/roadmap-management/SKILL.md).\n',
710
+ );
711
+ lines.push('| Roadmap | Done | Deferred | Cancelled |');
712
+ lines.push('|---|---:|---:|---:|');
713
+ for (const r of pending) {
714
+ lines.push(
715
+ `| [${r.rel}](roadmaps/${r.rel}) | ${r.done} | ` + `${r.deferred} | ${r.cancelled} |`,
716
+ );
717
+ }
718
+ lines.push('');
719
+ }
720
+ if (gated.length) {
721
+ lines.push('## ⏳ Merge-gated — pending post-merge archival\n');
722
+ lines.push(
723
+ 'Every open item in these roadmaps is `merge-gated`: held open ' +
724
+ 'on purpose while a closing PR is in flight, so inbound ' +
725
+ 'references keep resolving until the file archives. **The moment ' +
726
+ 'the gating PR merges**, flip the merge-gated box → `[x]`, ' +
727
+ '`git mv` the roadmap to `archive/`, migrate inbound refs, and ' +
728
+ 'regenerate this dashboard — all in the same response (per ' +
729
+ '`roadmap-progress-sync` Iron Law 1). Do NOT leave it lingering ' +
730
+ 'at < 100%.\n',
731
+ );
732
+ lines.push('| Roadmap | Done | Merge-gated open | Gating PR |');
733
+ lines.push('|---|---:|---:|---|');
734
+ for (const r of gated) {
735
+ const prs = r.merge_gated_prs.map((n) => `#${n}`).join(', ') || '—';
736
+ lines.push(
737
+ `| [${r.rel}](roadmaps/${r.rel}) | ${r.done} | ` + `${r.merge_gated} | ${prs} |`,
738
+ );
739
+ }
740
+ lines.push('');
741
+ }
742
+ const unarchived = unarchived_complete(roadmaps);
743
+ if (unarchived.length) {
744
+ // Non-CI backstop (road-to-roadmap-archival-robustness, gap C): a
745
+ // persistent dashboard banner makes "completed but unarchived" visible
746
+ // without the CI `--check` gate — the consumer sees it on every regen.
747
+ lines.push('## ✅ Completed — pending archival\n');
748
+ lines.push(
749
+ 'These roadmaps are **complete** (`count_open == 0`, ' +
750
+ '`count_deferred == 0`) but still sit in the active tree. They ' +
751
+ 'should be in `agents/roadmaps/archive/`. Run the archival sweep ' +
752
+ '`archive_completed_roadmaps --all` (untracked-safe), or follow the ' +
753
+ 'manual fallback documented in the `roadmap-management` skill, then ' +
754
+ 'regenerate this dashboard.\n',
755
+ );
756
+ lines.push('| Roadmap | Done | Total |');
757
+ lines.push('|---|---:|---:|');
758
+ for (const r of unarchived) {
759
+ lines.push(`| [${r.rel}](roadmaps/${r.rel}) | ${r.done} | ${r.total_active} |`);
760
+ }
761
+ lines.push('');
762
+ }
763
+ if (roadmaps.length === 0) {
764
+ lines.push('_No open roadmaps._\n');
765
+ return lines.join('\n') + '\n';
766
+ }
767
+ lines.push('## Open roadmaps\n');
768
+ lines.push(
769
+ '| # | Roadmap | Phases | Steps | Open | Done | Deferred | Cancelled | Blocker | Progress |',
770
+ );
771
+ lines.push('|---|---|---:|---:|---:|---:|---:|---:|---:|---|');
772
+ roadmaps.forEach((r, idx) => {
773
+ const i = idx + 1;
774
+ const openBlockers = r.open_blockers.length;
775
+ const blockerCell =
776
+ openBlockers > 0 ? `[${openBlockers}](#${_blockerAnchor(r.rel)})` : '0';
777
+ lines.push(
778
+ `| ${i} | [${r.rel}](roadmaps/${r.rel}) | ${r.phases.length} | ${r.total_all} | ` +
779
+ `${r.open_} | ${r.done} | ${r.deferred} | ${r.cancelled} | ${blockerCell} | ` +
780
+ `${bar(r.percent)} ${r.percent}% |`,
781
+ );
782
+ });
783
+ lines.push('');
784
+ lines.push('---\n');
785
+ lines.push('## Per-roadmap phase breakdown\n');
786
+ for (const r of roadmaps) {
787
+ lines.push(`### [${r.rel}](roadmaps/${r.rel})\n`);
788
+ lines.push(`**${r.title}** — ${r.done} / ${r.total_active} done (${r.percent}%)\n`);
789
+ lines.push('| # | Phase | State | Open | Done | Deferred | Cancelled | % |');
790
+ lines.push('|---|---|---|---:|---:|---:|---:|---:|');
791
+ for (const p of r.phases) {
792
+ lines.push(
793
+ `| ${p.id} | ${p.name} | ${p.state} | ${p.open_} | ${p.done} | ` +
794
+ `${p.deferred} | ${p.cancelled} | ${p.percent}% |`,
795
+ );
796
+ }
797
+ lines.push('');
798
+ const openBlockers = r.open_blockers;
799
+ if (openBlockers.length) {
800
+ lines.push(`<a id="${_blockerAnchor(r.rel)}"></a>`);
801
+ lines.push('**Blockers**\n');
802
+ for (const b of openBlockers) {
803
+ lines.push(`- **${b.id}** (owner: ${b.owner}) — blocks ${b.blocks}`);
804
+ lines.push(' - **What to do:**');
805
+ for (const step of b.todo) {
806
+ lines.push(` ${step}`);
807
+ }
808
+ lines.push(` - **Resolved when:** ${b.resolvedWhen}`);
809
+ }
810
+ const resolvedCount = r.resolved_blockers.length;
811
+ if (resolvedCount > 0) {
812
+ lines.push(
813
+ `\n_${resolvedCount} blocker${resolvedCount !== 1 ? 's' : ''} resolved._`,
814
+ );
815
+ }
816
+ lines.push('');
817
+ }
818
+ }
819
+ if (bundles && bundles.length) {
820
+ lines.push('---\n');
821
+ lines.push('## Ticket bundles\n');
822
+ lines.push(
823
+ 'Materialised ticket bundles under [`agents/tickets/`](tickets/) ' +
824
+ '(via `/roadmap:materialize`), counted from ' +
825
+ '`agents/tickets/_registry.yml`.\n',
826
+ );
827
+ lines.push('| Bundle | Tickets | Status | Source roadmap |');
828
+ lines.push('|---|---:|---|---|');
829
+ for (const b of bundles) {
830
+ lines.push(
831
+ `| ${b.slug} | ${b.tickets} | ${b.status} | ` + `${b.roadmap} |`,
832
+ );
833
+ }
834
+ lines.push('');
835
+ }
836
+ return lines.join('\n') + '\n';
837
+ }
838
+
839
+ // --- argparse parity ---------------------------------------------------------
840
+
841
+ class ArgparseExit extends Error {
842
+ code: number;
843
+ constructor(code: number) {
844
+ super(`ArgparseExit(${code})`);
845
+ this.name = 'ArgparseExit';
846
+ this.code = code;
847
+ }
848
+ }
849
+
850
+ const _PROG = 'update_roadmap_progress.py';
851
+
852
+ function _usage(): string {
853
+ return `usage: ${_PROG} [-h] [--check] [--repo-root REPO_ROOT]\n`;
854
+ }
855
+
856
+ interface Args {
857
+ check: boolean;
858
+ repo_root: string;
859
+ }
860
+
861
+ function _parseArgs(argv: readonly string[]): Args {
862
+ let check = false;
863
+ let repo_root = process.cwd();
864
+ const emitError = (msg: string): never => {
865
+ process.stderr.write(_usage());
866
+ process.stderr.write(`${_PROG}: error: ${msg}\n`);
867
+ throw new ArgparseExit(2);
868
+ };
869
+ let i = 0;
870
+ while (i < argv.length) {
871
+ const tok = argv[i] as string;
872
+ if (tok === '-h' || tok === '--help') {
873
+ process.stdout.write(_usage());
874
+ throw new ArgparseExit(0);
875
+ } else if (tok === '--check') {
876
+ check = true;
877
+ i += 1;
878
+ } else if (tok === '--repo-root') {
879
+ const val = argv[i + 1];
880
+ if (val === undefined) {
881
+ emitError('argument --repo-root: expected one argument');
882
+ }
883
+ repo_root = val as string;
884
+ i += 2;
885
+ } else if (tok.startsWith('--repo-root=')) {
886
+ repo_root = tok.slice('--repo-root='.length);
887
+ i += 1;
888
+ } else {
889
+ emitError(`unrecognized arguments: ${tok}`);
890
+ }
891
+ }
892
+ return { check, repo_root };
893
+ }
894
+
895
+ /**
896
+ * When the default cwd carries no `agents/roadmaps/`, fall back to the git
897
+ * toplevel — hook and IDE invocations often run from a subdirectory, and a
898
+ * cwd-only resolution silently skipped the dashboard there. An explicit
899
+ * `--repo-root` always wins; a cwd that has the directory is used as-is
900
+ * (monorepo sub-project support).
901
+ */
902
+ function _fallback_git_toplevel(repo_root: string): string {
903
+ try {
904
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], {
905
+ cwd: repo_root,
906
+ encoding: 'utf-8',
907
+ timeout: 10_000,
908
+ });
909
+ const top = (r.stdout || '').trim();
910
+ if (r.status === 0 && top !== '' && _isDir(path.join(top, 'agents', 'roadmaps'))) {
911
+ return top;
912
+ }
913
+ } catch {
914
+ /* not a git repo / git missing — keep the cwd default */
915
+ }
916
+ return repo_root;
917
+ }
918
+
919
+ function main(argv?: readonly string[]): number {
920
+ const args = _parseArgs(argv ?? process.argv.slice(2));
921
+ let repo_root = args.repo_root;
922
+ if (repo_root === process.cwd() && !_isDir(path.join(repo_root, 'agents', 'roadmaps'))) {
923
+ repo_root = _fallback_git_toplevel(repo_root);
924
+ }
925
+ const roadmap_root = path.join(repo_root, 'agents', 'roadmaps');
926
+ const target = path.join(repo_root, 'agents', 'roadmaps-progress.md');
927
+ if (!_isDir(roadmap_root)) {
928
+ if (args.check) {
929
+ return 0;
930
+ }
931
+ process.stdout.write(`ℹ️ No roadmaps directory at ${roadmap_root} — nothing to do.\n`);
932
+ return 0;
933
+ }
934
+ const roadmaps = collect(roadmap_root);
935
+ const new_text = render(roadmaps, collect_bundles(repo_root));
936
+ const current = fs.existsSync(target) ? fs.readFileSync(target, { encoding: 'utf-8' }) : '';
937
+ const complete = unarchived_complete(roadmaps);
938
+ const pending = pending_iron_law_3(roadmaps);
939
+ const gated = merge_gated_pending(roadmaps);
940
+
941
+ const _relToRepo = (p: string): string => _relPosix(repo_root, p);
942
+
943
+ const _warn_merge_gated = (): void => {
944
+ process.stderr.write(
945
+ '⏳ Merge-gated roadmaps (every open item gated on a PR) — ' +
946
+ 'flip + archive the moment the gating PR merges ' +
947
+ '(`roadmap-progress-sync` Iron Law 1):\n',
948
+ );
949
+ for (const r of gated) {
950
+ const prs = r.merge_gated_prs.map((n) => `#${n}`).join(', ') || 'PR unknown';
951
+ process.stderr.write(
952
+ ` - ${r.rel} (${r.done}/${r.total_active} done · ` +
953
+ `${r.merge_gated} merge-gated · ${prs})\n`,
954
+ );
955
+ }
956
+ };
957
+
958
+ if (args.check) {
959
+ const stale = current !== new_text;
960
+ if (stale) {
961
+ process.stderr.write(
962
+ `❌ ${_relToRepo(target)} is stale. ` +
963
+ 'Run `node node_modules/.bin/tsx .augment/scripts/update_roadmap_progress.ts` ' +
964
+ 'to regenerate (or `task roadmap-progress` in Taskfile ' +
965
+ 'projects).\n',
966
+ );
967
+ }
968
+ if (complete.length) {
969
+ process.stderr.write(
970
+ '❌ Completed roadmaps are still in `agents/roadmaps/` — ' +
971
+ 'move them to `agents/roadmaps/archive/` (per the ' +
972
+ '`roadmap-progress-sync` rule):\n',
973
+ );
974
+ for (const r of complete) {
975
+ process.stderr.write(` - ${r.rel} (${r.done}/${r.total_active} done)\n`);
976
+ }
977
+ }
978
+ if (pending.length) {
979
+ process.stderr.write(
980
+ '❌ Iron Law 3 — roadmaps with unresolved `[~]` deferred ' +
981
+ 'items must NOT auto-archive. Resolve via `roadmap-management § 4b` ' +
982
+ '(spawn follow-up, restore, or cancel):\n',
983
+ );
984
+ for (const r of pending) {
985
+ process.stderr.write(
986
+ ` - ${r.rel} (${r.done}/${r.total_active} done · ` +
987
+ `${r.deferred} deferred)\n`,
988
+ );
989
+ }
990
+ }
991
+ if (gated.length) {
992
+ _warn_merge_gated();
993
+ }
994
+ if (stale || complete.length || pending.length) {
995
+ return 1;
996
+ }
997
+ process.stdout.write(`✅ ${_relToRepo(target)} is up to date.\n`);
998
+ return 0;
999
+ }
1000
+ fs.writeFileSync(target, new_text, { encoding: 'utf-8' });
1001
+ process.stdout.write(
1002
+ `✅ Wrote ${_relToRepo(target)} · ` +
1003
+ `${roadmaps.length} roadmap(s) · ` +
1004
+ `${roadmaps.reduce((s, r) => s + r.done, 0)}/${roadmaps.reduce((s, r) => s + r.total_active, 0)} steps done.\n`,
1005
+ );
1006
+ if (complete.length) {
1007
+ process.stderr.write(
1008
+ '⚠️ Completed roadmaps not yet archived — move to ' +
1009
+ '`agents/roadmaps/archive/`:\n',
1010
+ );
1011
+ for (const r of complete) {
1012
+ process.stderr.write(` - ${r.rel}\n`);
1013
+ }
1014
+ }
1015
+ if (pending.length) {
1016
+ process.stderr.write(
1017
+ '⚠️ Iron Law 3 — roadmaps with unresolved `[~]` deferred items. ' +
1018
+ 'Surface them and ask the user (`roadmap-management § 4b`) ' +
1019
+ 'before any archive:\n',
1020
+ );
1021
+ for (const r of pending) {
1022
+ process.stderr.write(` - ${r.rel} (${r.deferred} deferred)\n`);
1023
+ }
1024
+ }
1025
+ if (gated.length) {
1026
+ _warn_merge_gated();
1027
+ }
1028
+ return 0;
1029
+ }
1030
+
1031
+ function _isCliEntry(): boolean {
1032
+ if (process.argv[1] === undefined) {
1033
+ return false;
1034
+ }
1035
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
1036
+ if (import.meta.url === argvUrl) {
1037
+ return true;
1038
+ }
1039
+ // A symlinked invocation (e.g. via `.augment/scripts` → `dist/agent-src/scripts`,
1040
+ // or macOS /var → /private/var temp dirs) makes the raw URLs differ:
1041
+ // import.meta.url is the resolved real path while argv[1] keeps the symlink
1042
+ // path. Compare realpaths so the entry guard still fires (without this the
1043
+ // dashboard regen silently no-ops when run through the symlink).
1044
+ try {
1045
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
1046
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
1047
+ return here === argv;
1048
+ } catch {
1049
+ return false;
1050
+ }
1051
+ }
1052
+ if (_isCliEntry() || process.argv[1] === _HERE) {
1053
+ try {
1054
+ process.exitCode = main();
1055
+ } catch (exc) {
1056
+ if (exc instanceof ArgparseExit) {
1057
+ process.exitCode = exc.code;
1058
+ } else {
1059
+ throw exc;
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ export {
1065
+ PhaseStats,
1066
+ RoadmapStats,
1067
+ CHECKBOX_RE,
1068
+ PHASE_RE,
1069
+ TITLE_RE,
1070
+ FRONTMATTER_RE,
1071
+ parse_frontmatter,
1072
+ is_draft,
1073
+ is_roadmap_candidate,
1074
+ count_checkboxes,
1075
+ parse_blockers,
1076
+ parse_roadmap,
1077
+ bar,
1078
+ collect,
1079
+ unarchived_complete,
1080
+ merge_gated_pending,
1081
+ pending_iron_law_3,
1082
+ collect_bundles,
1083
+ render,
1084
+ main,
1085
+ _pyRound,
1086
+ };
1087
+ export type { Bundle, Blocker };