@bigknoxy/hashpilot 4.6.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,466 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # shellcheck disable=SC2034
5
+ BOLD='\033[1m'
6
+ DIM='\033[2m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[0;33m'
9
+ RED='\033[0;31m'
10
+ NC='\033[0m'
11
+
12
+ log() { printf "${GREEN}[hashpilot]${NC} %s\n" "$1"; }
13
+ warn() { printf "${YELLOW}[hashpilot]${NC} %s\n" "$1"; }
14
+ err() { printf "${RED}[hashpilot]${NC} %s\n" "$1"; }
15
+ detail() { printf "${DIM} →${NC} %s\n" "$1"; }
16
+
17
+ # ── Detect source directory ──────────────────────────────────────────────
18
+ REMOTE_MODE=false
19
+ SOURCE_DIR=""
20
+
21
+ # Try to resolve from script location (local clone mode)
22
+ if SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null)"; then
23
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd 2>/dev/null || echo "")"
24
+ if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/package.json" ]; then
25
+ SOURCE_DIR="$REPO_ROOT"
26
+ fi
27
+ fi
28
+
29
+ # No local source — download release tarball from GitHub (curl-pipe / remote mode)
30
+ if [ -z "$SOURCE_DIR" ]; then
31
+ REMOTE_MODE=true
32
+ CLONE_DIR=$(mktemp -d)
33
+
34
+ # Determine version to download (latest release)
35
+ log "Fetching latest release info from GitHub..."
36
+ RELEASE_INFO=$(curl -fsSL "https://api.github.com/repos/bigknoxy/HashPilot/releases/latest" 2>/dev/null || echo "")
37
+ TAG_NAME=$(echo "$RELEASE_INFO" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)
38
+ if [ -n "$TAG_NAME" ]; then
39
+ TARBALL_URL="https://github.com/bigknoxy/HashPilot/archive/refs/tags/${TAG_NAME}.tar.gz"
40
+ log "Downloading HashPilot ${TAG_NAME} from GitHub..."
41
+ else
42
+ # Fallback to main branch if no release
43
+ TARBALL_URL="https://github.com/bigknoxy/HashPilot/archive/refs/heads/main.tar.gz"
44
+ log "Downloading HashPilot from main branch..."
45
+ fi
46
+
47
+ curl -fsSL "$TARBALL_URL" | tar -xz -C "$CLONE_DIR" --strip-components=1 2>&1 | while IFS= read -r line; do detail "$line"; done
48
+ SOURCE_DIR="$CLONE_DIR"
49
+ detail "Extracted to $CLONE_DIR"
50
+ fi
51
+
52
+ # Read the version from package.json so the installer can never drift from the
53
+ # released version. Falls back to "unknown" rather than a stale literal. Read
54
+ # only now that SOURCE_DIR is resolved, so this works in both local-clone mode
55
+ # and remote (curl-pipe) mode, where $0 doesn't point at the source tree.
56
+ HASHPILOT_VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$SOURCE_DIR/package.json" 2>/dev/null | head -1)"
57
+ HASHPILOT_VERSION="${HASHPILOT_VERSION:-unknown}"
58
+
59
+ # ── Parse arguments ──────────────────────────────────────────────────────
60
+ TARGET_DIR="${HOME}/.agentic-tools"
61
+ KEEP_TELEMETRY=false
62
+ FORCE=false
63
+
64
+ while [ $# -gt 0 ]; do
65
+ case "$1" in
66
+ --source) SOURCE_DIR="$2"; shift 2 ;;
67
+ --target) TARGET_DIR="$2"; shift 2 ;;
68
+ --keep-telemetry) KEEP_TELEMETRY=true; shift ;;
69
+ --force|-f) FORCE=true; shift ;;
70
+ --help|-h)
71
+ echo "HashPilot Installer v${HASHPILOT_VERSION}"
72
+ echo "Usage: $0 [options]"
73
+ echo " --source <dir> Source directory (default: repo root)."
74
+ echo " If omitted and no local source found,"
75
+ echo " auto-downloads release tarball from GitHub."
76
+ echo " --target <dir> Install target (default: ~/.agentic-tools)"
77
+ echo " --keep-telemetry Preserve existing telemetry on reinstall"
78
+ echo ' --force, -f Overwrite existing install without any prompt (including the non-interactive existing-install notice)'
79
+ echo " --help, -h Show this help"
80
+ echo ""
81
+ echo "One-liner: curl -fsSL https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/install.sh | bash"
82
+ exit 0
83
+ ;;
84
+ *) err "Unknown option: $1"; exit 1 ;;
85
+ esac
86
+ done
87
+
88
+ # ── Prerequisites ────────────────────────────────────────────────────────
89
+ log "Checking prerequisites..."
90
+
91
+ # Auto-install bun if not present
92
+ if ! command -v bun &>/dev/null; then
93
+ warn "bun not found. Installing bun..."
94
+ curl -fsSL https://bun.sh/install | bash
95
+ # Source the new PATH
96
+ export BUN_INSTALL="${HOME}/.bun"
97
+ export PATH="${BUN_INSTALL}/bin:${PATH}"
98
+ fi
99
+
100
+ BUN_VER=$(bun --version 2>/dev/null || echo "0")
101
+ detail "bun ${BUN_VER}"
102
+
103
+ if ! command -v bash &>/dev/null; then
104
+ err "bash is required"
105
+ exit 1
106
+ fi
107
+
108
+ if [ "$REMOTE_MODE" = "true" ] && ! command -v curl &>/dev/null; then
109
+ err "curl is required to download HashPilot"
110
+ exit 1
111
+ fi
112
+
113
+ if [ "$REMOTE_MODE" = "true" ] && ! command -v tar &>/dev/null; then
114
+ err "tar is required to extract HashPilot"
115
+ exit 1
116
+ fi
117
+
118
+ # Check source
119
+ if [ ! -f "$SOURCE_DIR/package.json" ]; then
120
+ err "Source directory '$SOURCE_DIR' does not contain package.json"
121
+ err "Run from the hashpilot repo root or use --source <path>"
122
+ exit 1
123
+ fi
124
+
125
+ # ── Detect existing install ──────────────────────────────────────────────
126
+ MANIFEST="$TARGET_DIR/manifest.json"
127
+ if [ -f "$MANIFEST" ]; then
128
+ if [ "$FORCE" != "true" ]; then
129
+ # Check if we're in an interactive terminal
130
+ if [ -t 0 ]; then
131
+ warn "Existing HashPilot installation detected at $TARGET_DIR"
132
+ echo -n " Overwrite? [y/N] "
133
+ read -r CONFIRM
134
+ if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
135
+ log "Install cancelled."
136
+ exit 0
137
+ fi
138
+ else
139
+ # Non-interactive (piped) - proceed with upgrade by default
140
+ # (user piped the script, so they clearly want to install/upgrade)
141
+ warn "Existing HashPilot installation detected at $TARGET_DIR; upgrading in non-interactive mode"
142
+ fi
143
+ fi
144
+ log "Upgrading existing installation..."
145
+ else
146
+ log "Fresh installation..."
147
+ fi
148
+
149
+ # ── Install Core ────────────────────────────────────────────────────────
150
+ log "Installing HashPilot Core..."
151
+ mkdir -p "$TARGET_DIR"
152
+
153
+ # If core already exists, remove node_modules first to avoid stale deps
154
+ if [ -d "$TARGET_DIR/structured-editing" ]; then
155
+ rm -rf "$TARGET_DIR/structured-editing/node_modules"
156
+ # Preserve telemetry if requested
157
+ if [ "$KEEP_TELEMETRY" == "true" ] && [ -f "$TARGET_DIR/logs/telemetry.jsonl" ]; then
158
+ # The log can contain source diffs. A fixed path under a world-writable
159
+ # /tmp is readable by any local user and is a symlink-attack target, so
160
+ # back up beside the data itself, in a 0700 directory with an
161
+ # unpredictable name (#50).
162
+ TELEMETRY_BACKUP_DIR="$(mktemp -d "$TARGET_DIR/.telemetry-backup.XXXXXX")"
163
+ chmod 700 "$TELEMETRY_BACKUP_DIR"
164
+ cp "$TARGET_DIR/logs/telemetry.jsonl" "$TELEMETRY_BACKUP_DIR/"
165
+ chmod 600 "$TELEMETRY_BACKUP_DIR/telemetry.jsonl"
166
+ detail "Backed up telemetry to $TELEMETRY_BACKUP_DIR/"
167
+ fi
168
+ fi
169
+
170
+ # Copy core (exclude node_modules, .git)
171
+ rsync -a --delete \
172
+ --exclude='node_modules' \
173
+ --exclude='.git' \
174
+ --exclude='logs' \
175
+ "$SOURCE_DIR/" "$TARGET_DIR/structured-editing/" 2>/dev/null || \
176
+ cp -r "$SOURCE_DIR"/* "$TARGET_DIR/structured-editing/" 2>/dev/null || {
177
+ # Fallback: manual copy
178
+ mkdir -p "$TARGET_DIR/structured-editing"
179
+ for item in "$SOURCE_DIR"/*; do
180
+ [ "$(basename "$item")" == "node_modules" ] && continue
181
+ [ "$(basename "$item")" == ".git" ] && continue
182
+ cp -r "$item" "$TARGET_DIR/structured-editing/"
183
+ done
184
+ }
185
+ detail "Core source copied to $TARGET_DIR/structured-editing"
186
+
187
+ # ── Install dependencies ────────────────────────────────────────────────
188
+ log "Installing dependencies..."
189
+ cd "$TARGET_DIR/structured-editing"
190
+ bun install --frozen-lockfile 2>&1 | while IFS= read -r line; do detail "$line"; done
191
+ cd "$OLDPWD"
192
+ detail "Dependencies installed"
193
+
194
+ # ── Create CLI launcher ──────────────────────────────────────────────────
195
+ log "Creating CLI launcher..."
196
+ mkdir -p "$TARGET_DIR/bin"
197
+ # Remove any existing entry before writing. A development install
198
+ # (`bun run install-cli`) leaves this path as a symlink into the checkout, and
199
+ # `>` follows a symlink — so writing straight to it overwrites the repo's own
200
+ # src/cli-node.cjs instead of replacing the launcher.
201
+ rm -f "$TARGET_DIR/bin/hashpilot"
202
+ cat > "$TARGET_DIR/bin/hashpilot" << 'LAUNCHER'
203
+ #!/bin/bash
204
+ exec bun run "$HOME/.agentic-tools/structured-editing/src/cli.ts" "$@"
205
+ LAUNCHER
206
+ chmod +x "$TARGET_DIR/bin/hashpilot"
207
+ detail "Launcher created at $TARGET_DIR/bin/hashpilot"
208
+
209
+ # Remove stale symlink from the old binary name (pre-3.1 installs).
210
+ if [ -L "$TARGET_DIR/bin/structured-edit" ]; then
211
+ rm -f "$TARGET_DIR/bin/structured-edit"
212
+ detail "Removed stale symlink: $TARGET_DIR/bin/structured-edit"
213
+ fi
214
+
215
+ # ── Configure PATH ───────────────────────────────────────────────────────
216
+ log "Adding PATH entry..."
217
+
218
+ detect_rc() {
219
+ if [ -n "${HASHPILOT_SHELL_RC:-}" ]; then
220
+ echo "$HASHPILOT_SHELL_RC"
221
+ return
222
+ fi
223
+ # Prefer the rc file for the shell the user actually runs. Picking the first
224
+ # existing file instead puts the PATH line in ~/.bashrc on a macOS zsh box,
225
+ # where no interactive shell ever reads it.
226
+ case "${SHELL:-}" in
227
+ */zsh) echo "${HOME}/.zshrc"; return ;;
228
+ */bash) [ -f "${HOME}/.bashrc" ] && { echo "${HOME}/.bashrc"; return; } ;;
229
+ esac
230
+ for f in "${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do
231
+ if [ -f "$f" ]; then
232
+ echo "$f"
233
+ return
234
+ fi
235
+ done
236
+ # Default
237
+ echo "${HOME}/.bashrc"
238
+ }
239
+
240
+ RC_FILE=$(detect_rc)
241
+ PATH_MARKER_START="# >>> hashpilot path >>>"
242
+ PATH_MARKER_END="# <<< hashpilot path <<<"
243
+ PATH_LINE="export PATH=\"\$HOME/.agentic-tools/bin:\$PATH\""
244
+
245
+ if [ -f "$RC_FILE" ]; then
246
+ if grep -q "$PATH_MARKER_START" "$RC_FILE" 2>/dev/null; then
247
+ detail "PATH entry already exists in $RC_FILE (skipping)"
248
+ else
249
+ {
250
+ echo ""
251
+ echo "$PATH_MARKER_START"
252
+ echo "$PATH_LINE"
253
+ echo "$PATH_MARKER_END"
254
+ } >> "$RC_FILE"
255
+ detail "Added PATH entry to $RC_FILE"
256
+ fi
257
+ else
258
+ detail "Creating $RC_FILE with PATH entry"
259
+ {
260
+ echo "# Generated by HashPilot installer"
261
+ echo "$PATH_MARKER_START"
262
+ echo "$PATH_LINE"
263
+ echo "$PATH_MARKER_END"
264
+ } > "$RC_FILE"
265
+ fi
266
+
267
+ # ── Install templates (OpenCode, Pi, Claude) ─────────────────────────────
268
+ TEMPLATES="$TARGET_DIR/structured-editing/templates"
269
+
270
+ install_template() {
271
+ local src="$1"
272
+ local dst="$2"
273
+ local label="$3"
274
+ mkdir -p "$(dirname "$dst")"
275
+ if [ -f "$src" ]; then
276
+ cp "$src" "$dst"
277
+ detail "Installed ${label}: ${dst}"
278
+ else
279
+ warn "${label} template not found at ${src} (skipping)"
280
+ fi
281
+ }
282
+
283
+ log "Installing adapter integrations..."
284
+
285
+ # OpenCode
286
+ install_template "$TEMPLATES/opencode-skill.md" \
287
+ "${HOME}/.config/opencode/skills/hashpilot/SKILL.md" "OpenCode skill"
288
+ install_template "$TEMPLATES/opencode-agent.md" \
289
+ "${HOME}/.config/opencode/agent/hashpilot.md" "OpenCode agent"
290
+
291
+ # Pi
292
+ install_template "$TEMPLATES/pi-extension.ts" \
293
+ "${HOME}/.pi/agent/extensions/hashpilot.ts" "Pi extension"
294
+ install_template "$TEMPLATES/pi-skill.md" \
295
+ "${HOME}/.pi/agent/skills/hashpilot/SKILL.md" "Pi skill"
296
+
297
+ # Claude
298
+ CLAUDE_MARKER="HashPilot Claude — Structured Editing Integration"
299
+ CLAUDE_FILE="${HOME}/.claude/CLAUDE.md"
300
+ if [ -f "$TEMPLATES/claude-section.md" ]; then
301
+ mkdir -p "$(dirname "$CLAUDE_FILE")"
302
+ if [ -f "$CLAUDE_FILE" ] && grep -q "$CLAUDE_MARKER" "$CLAUDE_FILE" 2>/dev/null; then
303
+ detail "Claude integration already present in $CLAUDE_FILE (skipping)"
304
+ else
305
+ {
306
+ echo ""
307
+ cat "$TEMPLATES/claude-section.md"
308
+ } >> "$CLAUDE_FILE"
309
+ detail "Appended Claude integration to $CLAUDE_FILE"
310
+ fi
311
+ else
312
+ warn "Claude section template not found (skipping)"
313
+ fi
314
+
315
+ # ── Bootstrap config ────────────────────────────────────────────────────
316
+ log "Bootstrapping config..."
317
+ CONFIG_DIR="${HOME}/.config/hashpilot"
318
+ CONFIG_FILE="${CONFIG_DIR}/config.json"
319
+ if [ -f "$CONFIG_FILE" ]; then
320
+ detail "Config already exists at $CONFIG_FILE (preserving)"
321
+ else
322
+ mkdir -p "$CONFIG_DIR"
323
+ cat > "$CONFIG_FILE" << 'CONFIG'
324
+ {
325
+ "telemetry": {
326
+ "enabled": true
327
+ },
328
+ "provenance": {
329
+ "maxContextLength": 500
330
+ }
331
+ }
332
+ CONFIG
333
+ detail "Created default config at $CONFIG_FILE"
334
+ fi
335
+
336
+ # ── Restore telemetry ───────────────────────────────────────────────────
337
+ if [ "$KEEP_TELEMETRY" == "true" ] && [ -n "${TELEMETRY_BACKUP_DIR:-}" ] && [ -f "$TELEMETRY_BACKUP_DIR/telemetry.jsonl" ]; then
338
+ mkdir -p "$TARGET_DIR/logs"
339
+ cp "$TELEMETRY_BACKUP_DIR/telemetry.jsonl" "$TARGET_DIR/logs/"
340
+ detail "Restored telemetry from backup"
341
+ rm -rf "$TELEMETRY_BACKUP_DIR"
342
+ fi
343
+
344
+ # ── Write manifest ───────────────────────────────────────────────────────
345
+ log "Writing manifest..."
346
+ MANIFEST_FILE="$TARGET_DIR/manifest.json"
347
+
348
+ # Detect shell rc path entries
349
+ RC_ENTRIES="[]"
350
+ if [ -f "$RC_FILE" ]; then
351
+ RC_ENTRIES=$(cat <<MANIFEST_RC
352
+ [
353
+ {
354
+ "file": "$RC_FILE",
355
+ "marker_start": "$PATH_MARKER_START",
356
+ "marker_end": "$PATH_MARKER_END"
357
+ }
358
+ ]
359
+ MANIFEST_RC
360
+ )
361
+ fi
362
+
363
+ cat > "$MANIFEST_FILE" << MANIFEST
364
+ {
365
+ "version": "1",
366
+ "hashpilotVersion": "${HASHPILOT_VERSION}",
367
+ "installedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
368
+ "sourceType": "$([ "$REMOTE_MODE" == "true" ] && echo "remote" || echo "clone")",
369
+ "hashpilotDir": "${TARGET_DIR}",
370
+ "components": {
371
+ "core": {
372
+ "source": "${TARGET_DIR}/structured-editing"
373
+ },
374
+ "bin": [
375
+ "${TARGET_DIR}/bin/hashpilot"
376
+ ],
377
+ "config": [
378
+ "${CONFIG_FILE}"
379
+ ],
380
+ "claude": {
381
+ "modified": [
382
+ "${CLAUDE_FILE}"
383
+ ]
384
+ },
385
+ "opencode": [
386
+ "${HOME}/.config/opencode/skills/hashpilot/SKILL.md",
387
+ "${HOME}/.config/opencode/agent/hashpilot.md"
388
+ ],
389
+ "pi": [
390
+ "${HOME}/.pi/agent/extensions/hashpilot.ts",
391
+ "${HOME}/.pi/agent/skills/hashpilot/SKILL.md"
392
+ ],
393
+ "telemetry": {
394
+ "dir": "${TARGET_DIR}/logs"
395
+ },
396
+ "pathEntries": ${RC_ENTRIES}
397
+ }
398
+ }
399
+ MANIFEST
400
+ detail "Manifest written to $MANIFEST_FILE"
401
+
402
+ # ── Cleanup ────────────────────────────────────────────────────────────────
403
+ if [ "$REMOTE_MODE" = "true" ] && [ -n "${CLONE_DIR:-}" ]; then
404
+ rm -rf "$CLONE_DIR"
405
+ detail "Cleaned up temporary source"
406
+ fi
407
+
408
+ # ── Verify ───────────────────────────────────────────────────────────────
409
+ log "Verifying installation..."
410
+ if [ -f "$TARGET_DIR/bin/hashpilot" ]; then
411
+ detail "CLI launcher: OK"
412
+ else
413
+ err "CLI launcher missing!"
414
+ exit 1
415
+ fi
416
+
417
+ if [ -d "$TARGET_DIR/structured-editing/node_modules" ]; then
418
+ detail "Dependencies: OK"
419
+ else
420
+ err "Dependencies not installed!"
421
+ exit 1
422
+ fi
423
+
424
+ # Quick smoke test
425
+ if command -v hashpilot &>/dev/null || [ -x "$TARGET_DIR/bin/hashpilot" ]; then
426
+ VER=$("$TARGET_DIR/bin/hashpilot" --version 2>/dev/null || echo "unknown")
427
+ detail "CLI version: ${VER}"
428
+ fi
429
+
430
+ # Final gate: doctor exits 2 on a broken install, 1 on warnings, 0 when healthy.
431
+ # Telling the user "installed successfully" and letting them discover the
432
+ # breakage on their first edit is the worse outcome (#46).
433
+ if [ -x "$TARGET_DIR/bin/hashpilot" ]; then
434
+ # Two traps, both hit on the first real install:
435
+ # 1. `set -e` aborts the whole script when a command substitution exits
436
+ # non-zero, so a plain assignment turned "doctor found something" into
437
+ # a silent exit 2 with no message. Capture the code with `|| ...`.
438
+ # 2. The PATH entry was just written to the shell rc, which this process
439
+ # never sourced, so `bin-on-path` fails at exactly the moment it cannot
440
+ # yet succeed. Export the real PATH for the check.
441
+ DOCTOR_CODE=0
442
+ DOCTOR_OUT=$(PATH="$TARGET_DIR/bin:$PATH" "$TARGET_DIR/bin/hashpilot" --format text doctor 2>&1) || DOCTOR_CODE=$?
443
+ if [ "$DOCTOR_CODE" -ge 2 ]; then
444
+ err "Installation is not healthy:"
445
+ echo "$DOCTOR_OUT"
446
+ exit 1
447
+ elif [ "$DOCTOR_CODE" -eq 1 ]; then
448
+ detail "Doctor: healthy with warnings (run 'hashpilot doctor' for detail)"
449
+ else
450
+ detail "Doctor: healthy"
451
+ fi
452
+ fi
453
+
454
+ echo ""
455
+ printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
456
+ printf "${GREEN} HashPilot v${HASHPILOT_VERSION} installed successfully${NC}\n"
457
+ printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
458
+ echo ""
459
+ echo " Core: $TARGET_DIR/structured-editing"
460
+ echo " CLI: hashpilot"
461
+ echo " Config: ${CONFIG_FILE}"
462
+ echo " Manifest: $MANIFEST_FILE"
463
+ echo ""
464
+ echo " Run 'hashpilot doctor' to verify the installation."
465
+ echo " Restart your shell or run: source $RC_FILE"
466
+ echo ""
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Structural lint for ROADMAP.md.
4
+ *
5
+ * The roadmap is hand-edited every time an issue is filed or closed, and it has
6
+ * already shipped two defects of exactly this shape: an issue row duplicated into
7
+ * two sprints, and a row inserted out of score order. Both are mechanical and both
8
+ * are cheap to detect, so they are detected here instead of in review.
9
+ *
10
+ * bun run scripts/roadmap-lint.ts # lint ROADMAP.md
11
+ * bun run scripts/roadmap-lint.ts <file>... # lint specific files
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ export interface LintIssue {
17
+ line: number;
18
+ rule: string;
19
+ message: string;
20
+ }
21
+
22
+ export interface Row {
23
+ line: number;
24
+ issue: number;
25
+ item: string;
26
+ score: number;
27
+ priority: string;
28
+ table: string;
29
+ }
30
+
31
+ const PRIORITIES = new Set(["P0", "P1", "P2", "P3"]);
32
+ /** Accepted spellings of the priority column header, lowercased. */
33
+ const PRIORITY_HEADERS = new Set(["pri", "priority"]);
34
+ /** `| [#12](../../issues/12) | …` — display number and link target must agree. */
35
+ const ISSUE_CELL = /^\[#(\d+)\]\((?:\.\.\/)*(?:\.\.\/)?issues\/(\d+)\)$/;
36
+
37
+ function cells(line: string): string[] {
38
+ return line
39
+ .trim()
40
+ .replace(/^\|/, "")
41
+ .replace(/\|$/, "")
42
+ .split("|")
43
+ .map((c) => c.trim());
44
+ }
45
+
46
+ const isDivider = (line: string) => /^\|[\s:|-]+\|$/.test(line.trim());
47
+ const isRow = (line: string) => line.trim().startsWith("|");
48
+
49
+ /**
50
+ * Parses every scored issue table. A table qualifies when its header carries both
51
+ * a `#` column and a `Score` column, which excludes prose tables like the
52
+ * "Existing work already in the repo" listing.
53
+ */
54
+ export function parseTables(text: string): { rows: Row[]; issues: LintIssue[] } {
55
+ const lines = text.split("\n");
56
+ const rows: Row[] = [];
57
+ const issues: LintIssue[] = [];
58
+ let table: string | null = null;
59
+ let heading = "(top of file)";
60
+ let columns: string[] = [];
61
+
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i]!;
64
+ if (line.startsWith("#")) {
65
+ heading = line.replace(/^#+\s*/, "").trim();
66
+ table = null;
67
+ continue;
68
+ }
69
+ if (!isRow(line)) {
70
+ table = null;
71
+ continue;
72
+ }
73
+ if (isDivider(line)) continue;
74
+
75
+ const c = cells(line);
76
+ // Header row: opens a table if it is a scored issue table.
77
+ if (c[0] === "#" && isDivider(lines[i + 1] ?? "")) {
78
+ const scored = c.some((h) => h.toLowerCase() === "score");
79
+ table = scored ? heading : null;
80
+ columns = c;
81
+ // Every per-row rule below is keyed off a header name, so a renamed or
82
+ // dropped header turns its rule into a silent no-op — the table still
83
+ // lints clean while nothing about it is actually checked. Require the
84
+ // headers the rules depend on.
85
+ if (scored && !columns.some((h) => PRIORITY_HEADERS.has(h.toLowerCase()))) {
86
+ issues.push({
87
+ line: i + 1,
88
+ rule: "missing-column",
89
+ message: `scored table '${heading}' has no priority column (expected one of: ${[...PRIORITY_HEADERS].join(", ")}) — priority validation would be skipped`,
90
+ });
91
+ }
92
+ continue;
93
+ }
94
+ if (table === null) continue;
95
+
96
+ const link = ISSUE_CELL.exec(c[0] ?? "");
97
+ if (!link) {
98
+ issues.push({
99
+ line: i + 1,
100
+ rule: "issue-link",
101
+ message: `first cell is not an issue link: ${c[0]}`,
102
+ });
103
+ continue;
104
+ }
105
+ if (link[1] !== link[2]) {
106
+ issues.push({
107
+ line: i + 1,
108
+ rule: "issue-link",
109
+ message: `link text #${link[1]} points at issue ${link[2]}`,
110
+ });
111
+ }
112
+ if (c.length !== columns.length) {
113
+ issues.push({
114
+ line: i + 1,
115
+ rule: "column-count",
116
+ message: `row has ${c.length} cells, header has ${columns.length}`,
117
+ });
118
+ }
119
+
120
+ const scoreIdx = columns.findIndex((h) => h.toLowerCase() === "score");
121
+ const priIdx = columns.findIndex((h) => PRIORITY_HEADERS.has(h.toLowerCase()));
122
+ const raw = c[scoreIdx] ?? "";
123
+ const score = Number(raw);
124
+ if (!/^\d+$/.test(raw)) {
125
+ issues.push({ line: i + 1, rule: "score", message: `score is not an integer: ${raw}` });
126
+ }
127
+ const priority = c[priIdx] ?? "";
128
+ if (priIdx !== -1 && !PRIORITIES.has(priority)) {
129
+ issues.push({ line: i + 1, rule: "priority", message: `unknown priority: ${priority}` });
130
+ }
131
+
132
+ rows.push({
133
+ line: i + 1,
134
+ issue: Number(link[1]),
135
+ item: c[1] ?? "",
136
+ score: Number.isNaN(score) ? -1 : score,
137
+ priority,
138
+ table,
139
+ });
140
+ }
141
+ return { rows, issues };
142
+ }
143
+
144
+ export function lintRoadmap(text: string): LintIssue[] {
145
+ const { rows, issues } = parseTables(text);
146
+
147
+ // An issue belongs to exactly one table. A duplicate means a row was copied
148
+ // during a re-prioritization and the original never removed.
149
+ const seen = new Map<number, Row>();
150
+ for (const row of rows) {
151
+ const prior = seen.get(row.issue);
152
+ if (prior) {
153
+ issues.push({
154
+ line: row.line,
155
+ rule: "duplicate-issue",
156
+ message: `#${row.issue} already listed at line ${prior.line} (${prior.table})`,
157
+ });
158
+ continue;
159
+ }
160
+ seen.set(row.issue, row);
161
+ }
162
+
163
+ // Score orders work within a table, so a table that is not descending is
164
+ // telling a reader the wrong thing about what to pick up next.
165
+ const byTable = new Map<string, Row[]>();
166
+ for (const row of rows) {
167
+ if (!byTable.has(row.table)) byTable.set(row.table, []);
168
+ byTable.get(row.table)!.push(row);
169
+ }
170
+ for (const [table, group] of byTable) {
171
+ for (let i = 1; i < group.length; i++) {
172
+ const prev = group[i - 1]!;
173
+ const curr = group[i]!;
174
+ if (curr.score > prev.score) {
175
+ issues.push({
176
+ line: curr.line,
177
+ rule: "score-order",
178
+ message: `${table}: #${curr.issue} (${curr.score}) is listed after #${prev.issue} (${prev.score}) — tables sort by descending score`,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ return issues.sort((a, b) => a.line - b.line);
185
+ }
186
+
187
+ if (import.meta.main) {
188
+ const files = process.argv.slice(2).filter((a) => !a.startsWith("-"));
189
+ const targets = files.length ? files : [join(import.meta.dir, "..", "ROADMAP.md")];
190
+ let failed = false;
191
+ for (const file of targets) {
192
+ const found = lintRoadmap(readFileSync(file, "utf8"));
193
+ for (const issue of found) {
194
+ failed = true;
195
+ console.error(`${file}:${issue.line} [${issue.rule}] ${issue.message}`);
196
+ }
197
+ if (!found.length) console.log(`✓ ${file}`);
198
+ }
199
+ process.exit(failed ? 1 : 0);
200
+ }