@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.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- 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
|
+
}
|