@lemoncode/lemony 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRIVACY.md +147 -0
- package/README.md +189 -0
- package/catalog/VERSION +1 -0
- package/catalog/agents/README.md +29 -0
- package/catalog/agents/architect.md +81 -0
- package/catalog/agents/fit-assessment.md +94 -0
- package/catalog/agents/implementer.md +67 -0
- package/catalog/agents/orchestrator.md +627 -0
- package/catalog/agents/reviewer.md +124 -0
- package/catalog/agents/spec-author.md +69 -0
- package/catalog/agents/ui-designer.md +25 -0
- package/catalog/commands/add-capability.md +69 -0
- package/catalog/commands/bypass.md +40 -0
- package/catalog/commands/define.md +24 -0
- package/catalog/commands/hotfix.md +47 -0
- package/catalog/commands/pause.md +52 -0
- package/catalog/commands/resume.md +56 -0
- package/catalog/commands/spinoff.md +59 -0
- package/catalog/commands/triage.md +24 -0
- package/catalog/harness.config.schema.json +116 -0
- package/catalog/hooks/README.md +56 -0
- package/catalog/hooks/init.sh +281 -0
- package/catalog/hooks/lib/lemony.sh +41 -0
- package/catalog/hooks/lib/playbook-scan.sh +394 -0
- package/catalog/hooks/lib/transcript-grep.sh +56 -0
- package/catalog/hooks/require-playbook.sh +97 -0
- package/catalog/hooks/session-close.sh +232 -0
- package/catalog/hooks/suggest-playbook.sh +72 -0
- package/catalog/playbook-format.md +198 -0
- package/catalog/schemas/README.md +13 -0
- package/catalog/schemas/tier2-events-history.md +104 -0
- package/catalog/schemas/tier2-events.md +286 -0
- package/catalog/skills/README.md +62 -0
- package/catalog/skills/bootstrap-architecture/SKILL.md +78 -0
- package/catalog/skills/code-explorer/SKILL.md +76 -0
- package/catalog/skills/grill-with-docs/ADR-FORMAT.md +49 -0
- package/catalog/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
- package/catalog/skills/grill-with-docs/SKILL.md +270 -0
- package/catalog/skills/grill-with-docs/reference.md +236 -0
- package/catalog/skills/mutation-testing/SKILL.md +84 -0
- package/catalog/skills/note-side-finding/SKILL.md +89 -0
- package/catalog/skills/playbook-iterate/SKILL.md +78 -0
- package/catalog/skills/prd-to-spec/SKILL.md +181 -0
- package/catalog/skills/raise-discovery/SKILL.md +112 -0
- package/catalog/skills/resolve-discovery/SKILL.md +123 -0
- package/catalog/skills/review-pr/SKILL.md +106 -0
- package/catalog/skills/review-pr/reference.md +105 -0
- package/catalog/skills/security-review/SKILL.md +90 -0
- package/catalog/skills/senior-review/SKILL.md +99 -0
- package/catalog/skills/silent-failure-hunter/SKILL.md +76 -0
- package/catalog/skills/spec-compliance-check/SKILL.md +74 -0
- package/catalog/skills/spec-to-issue/SKILL.md +88 -0
- package/catalog/skills/task-closeout/SKILL.md +229 -0
- package/catalog/skills/tdd/SKILL.md +171 -0
- package/catalog/skills/test-gap-report/SKILL.md +71 -0
- package/catalog/skills/triage-issue/SKILL.md +102 -0
- package/catalog/skills/update-architecture/SKILL.md +69 -0
- package/catalog/skills/verify/SKILL.md +90 -0
- package/catalog/skills/write-adr/SKILL.md +77 -0
- package/catalog/templates/README.md +32 -0
- package/catalog/templates/claude-code/.claude/settings.json.tpl +34 -0
- package/catalog/templates/claude-code/agents.md.tpl +109 -0
- package/catalog/templates/claude-code/docs/playbooks/README.md.tpl +96 -0
- package/catalog/templates/claude-code/harness.config.yml.tpl +59 -0
- package/catalog/templates/claude-code/state/history.md.tpl +6 -0
- package/dist/cli.mjs +5691 -0
- package/package.json +80 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"vendor_version": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+(-(alpha|beta|rc)\\.\\d+)?$"
|
|
8
|
+
},
|
|
9
|
+
"target": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"enum": [
|
|
12
|
+
"claude-code"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"task_storage": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"type": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"enum": [
|
|
21
|
+
"github"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"repo": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"pattern": "^[^\\s/]+\\/[^\\s/]+$"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"required": [
|
|
30
|
+
"type",
|
|
31
|
+
"repo"
|
|
32
|
+
],
|
|
33
|
+
"additionalProperties": false
|
|
34
|
+
},
|
|
35
|
+
"agents": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"enum": [
|
|
40
|
+
"orchestrator",
|
|
41
|
+
"spec-author",
|
|
42
|
+
"implementer",
|
|
43
|
+
"reviewer",
|
|
44
|
+
"architect",
|
|
45
|
+
"ui-designer"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"paths": {
|
|
50
|
+
"default": {},
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"state": {
|
|
54
|
+
"default": ".claude/state",
|
|
55
|
+
"type": "string"
|
|
56
|
+
},
|
|
57
|
+
"skills": {
|
|
58
|
+
"default": ".claude/skills",
|
|
59
|
+
"type": "string"
|
|
60
|
+
},
|
|
61
|
+
"agents": {
|
|
62
|
+
"default": ".claude/agents",
|
|
63
|
+
"type": "string"
|
|
64
|
+
},
|
|
65
|
+
"playbooks": {
|
|
66
|
+
"default": "docs/playbooks",
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
69
|
+
"playbooks_global": {
|
|
70
|
+
"default": "~/.claude/playbooks",
|
|
71
|
+
"type": "string"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": false
|
|
75
|
+
},
|
|
76
|
+
"rollback": {
|
|
77
|
+
"default": {},
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"keep_snapshots": {
|
|
81
|
+
"default": 3,
|
|
82
|
+
"anyOf": [
|
|
83
|
+
{
|
|
84
|
+
"type": "integer",
|
|
85
|
+
"exclusiveMinimum": 0,
|
|
86
|
+
"maximum": 9007199254740991
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"type": "string",
|
|
90
|
+
"const": "unlimited"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"additionalProperties": false
|
|
96
|
+
},
|
|
97
|
+
"telemetry": {
|
|
98
|
+
"default": {},
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {
|
|
101
|
+
"enabled": {
|
|
102
|
+
"default": true,
|
|
103
|
+
"type": "boolean"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"additionalProperties": false
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"required": [
|
|
110
|
+
"vendor_version",
|
|
111
|
+
"target",
|
|
112
|
+
"task_storage",
|
|
113
|
+
"agents"
|
|
114
|
+
],
|
|
115
|
+
"additionalProperties": false
|
|
116
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# hooks/ — lifecycle & enforcement hooks
|
|
2
|
+
|
|
3
|
+
Shell hooks the installer wires into the client's `.claude/settings.json` and
|
|
4
|
+
copies executable under `.claude/hooks/`. Helpers shared between hooks live in
|
|
5
|
+
`catalog/hooks/lib/` and are copied alongside.
|
|
6
|
+
|
|
7
|
+
## Hooks
|
|
8
|
+
|
|
9
|
+
| Hook | Trigger | Purpose |
|
|
10
|
+
| --------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `init.sh` | `SessionStart` | Orient the session (read-only state summary). Blocks on hard errors (config invalid, state corruption, no git user, no `gh` auth), warns otherwise (version drift, peer hook missing, branch behind, residual markers). <1s p99. Decision #54. |
|
|
12
|
+
| `session-close.sh` | `SessionEnd` + `/pause` | Emit `session_closed` event with duration math, write `current-<user>.md` / `sessions/<user>/{ts}.md`. No auto-commit (prints `git status --porcelain`). Decision #52. |
|
|
13
|
+
| `require-playbook.sh` | `PreToolUse` (Write\|Edit) | Block writes that match a playbook's `applies_to` glob list until that playbook has been read in this conversation. Decision #11. See `catalog/playbook-format.md`. |
|
|
14
|
+
| `suggest-playbook.sh` | `UserPromptSubmit` | Non-blocking nudge — emit a `<system-reminder>` when a prompt matches a playbook's `keywords` regex list and that playbook hasn't been read yet. Decision #11. |
|
|
15
|
+
|
|
16
|
+
## Shared lib
|
|
17
|
+
|
|
18
|
+
`lib/transcript-grep.sh` — find evidence in the JSONL transcript (parent +
|
|
19
|
+
`<transcript>/subagents/agent-*.jsonl`) that Claude actually called the `Read`
|
|
20
|
+
tool with a given path. Required on day 1 by both `require-playbook.sh` and
|
|
21
|
+
`suggest-playbook.sh` to avoid a misfire whereby sub-agent reads (Implementer,
|
|
22
|
+
Reviewer always run as sub-agents) would not satisfy the parent's gate.
|
|
23
|
+
|
|
24
|
+
`lib/playbook-scan.sh` — discover playbooks across the two layers
|
|
25
|
+
(`docs/playbooks/<topic>.md` local; `~/.claude/playbooks/<topic>.md` global; local
|
|
26
|
+
shadows global) and expose `playbook_scan_for_path` + `playbook_scan_for_prompt`.
|
|
27
|
+
Reads frontmatter via a single `awk` pass (no `yq` — decision #29) that
|
|
28
|
+
supports the documented format subset — see `catalog/playbook-format.md`.
|
|
29
|
+
|
|
30
|
+
## Fail-open philosophy
|
|
31
|
+
|
|
32
|
+
The three enforcement hooks (`session-close.sh`, `require-playbook.sh`,
|
|
33
|
+
`suggest-playbook.sh`) fail **open**: when their dependencies (`jq`, the
|
|
34
|
+
transcript file) are missing or malformed, they exit 0 — possibly with a
|
|
35
|
+
stderr warning — rather than blocking the agent. They are an enforcement
|
|
36
|
+
layer, not the gate of last resort.
|
|
37
|
+
|
|
38
|
+
`init.sh` is the only hook that **blocks** (exit 2) — and only on hard errors
|
|
39
|
+
the session cannot proceed past (missing/invalid config, state corruption,
|
|
40
|
+
missing git user, missing `gh` auth). That's by design (decision #54): the
|
|
41
|
+
session-start orient is the right place to fail loud, because everything
|
|
42
|
+
downstream depends on those preconditions.
|
|
43
|
+
|
|
44
|
+
## Bash 3.2 compatibility
|
|
45
|
+
|
|
46
|
+
macOS still ships `/bin/bash` 3.2 by default. The hooks avoid `declare -A`,
|
|
47
|
+
`globstar` (`**`), and other bash 4-only features so they run end-to-end on a
|
|
48
|
+
stock macOS install. `init.sh`'s doctor still recommends `brew install bash` —
|
|
49
|
+
the suggestion is forward-looking, not a hard dependency for these hooks.
|
|
50
|
+
|
|
51
|
+
## Local testing
|
|
52
|
+
|
|
53
|
+
Each hook is exercised by a Vitest spec that spawns `bash <hook>` against a
|
|
54
|
+
tmpdir fixture: `src/install/init-hook.spec.ts`,
|
|
55
|
+
`src/install/session-close-hook.spec.ts`, `src/install/require-playbook-hook.spec.ts`,
|
|
56
|
+
`src/install/suggest-playbook-hook.spec.ts`. Run with `node --run test`.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Lemony — Claude Code SessionStart hook (decision #54).
|
|
3
|
+
#
|
|
4
|
+
# Read-only orient run on every session entry filtered by source: orient on
|
|
5
|
+
# {startup, resume, clear}; skip {compact} silently to keep long sessions free
|
|
6
|
+
# of noise.
|
|
7
|
+
#
|
|
8
|
+
# Outputs four blocks (exit 2, stderr — model sees it) and four warnings
|
|
9
|
+
# (stdout — folded into session context). Plus a doctor check for bash version.
|
|
10
|
+
# Target: <1s p99.
|
|
11
|
+
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
# Read stdin only when Claude Code is piping a payload; if a developer runs
|
|
15
|
+
# this hook interactively (no pipe), default to an empty payload so the script
|
|
16
|
+
# doesn't hang on `cat` waiting for input.
|
|
17
|
+
if [ -t 0 ]; then
|
|
18
|
+
INPUT=""
|
|
19
|
+
else
|
|
20
|
+
INPUT="$(cat)"
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Extract `source` field from the JSON Claude Code pipes in. Bash-only
|
|
24
|
+
# (no jq dep) — minimal stdin parse keeps the hook startup cheap.
|
|
25
|
+
SOURCE="$(printf '%s' "$INPUT" \
|
|
26
|
+
| grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
27
|
+
| head -1 \
|
|
28
|
+
| sed 's/.*"\([^"]*\)"$/\1/')"
|
|
29
|
+
if [ "$SOURCE" = "compact" ]; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
REPO_ROOT="$(pwd)"
|
|
34
|
+
CONFIG="$REPO_ROOT/harness.config.yml"
|
|
35
|
+
|
|
36
|
+
ERRORS=()
|
|
37
|
+
WARNINGS=()
|
|
38
|
+
|
|
39
|
+
# ── Block 1 ─ harness.config.yml present and parseable ─────────────────────
|
|
40
|
+
# Pure-bash validation (no yq — #41): presence of the four keys the harness
|
|
41
|
+
# needs to operate, checked top-level plus block-scoped under `task_storage`
|
|
42
|
+
# in one awk pass, then extraction of the two values the orient output uses.
|
|
43
|
+
# Deep schema validation (formats, enums, did-you-mean typos) lives in
|
|
44
|
+
# `lemony doctor`; init.sh stays fail-closed yet node-free, so the boot
|
|
45
|
+
# path never depends on the CLI being installed. `jq` is the sole hard dep;
|
|
46
|
+
# awk/grep/git (preinstalled) cover the rest.
|
|
47
|
+
CONFIG_VERSION=""
|
|
48
|
+
CONFIG_REPO=""
|
|
49
|
+
if [ ! -f "$CONFIG" ]; then
|
|
50
|
+
ERRORS+=("harness.config.yml not found at $REPO_ROOT. Run \`lemony install\`.")
|
|
51
|
+
elif ! awk '
|
|
52
|
+
{ sub(/\r$/, "") }
|
|
53
|
+
/^[^[:space:]#]/ {
|
|
54
|
+
in_ts = ($1 == "task_storage:") ? 1 : 0
|
|
55
|
+
if ($1 == "vendor_version:") have_vv = 1
|
|
56
|
+
else if ($1 == "target:") have_tg = 1
|
|
57
|
+
else if ($1 == "task_storage:") have_ts = 1
|
|
58
|
+
}
|
|
59
|
+
in_ts && /^[[:space:]]+type:/ { have_tt = 1 }
|
|
60
|
+
in_ts && /^[[:space:]]+repo:/ { have_tr = 1 }
|
|
61
|
+
END {
|
|
62
|
+
exit !(have_vv && have_tg && have_ts && have_tt && have_tr)
|
|
63
|
+
}
|
|
64
|
+
' "$CONFIG"; then
|
|
65
|
+
ERRORS+=("harness.config.yml is missing required keys (vendor_version, target, task_storage.type, task_storage.repo).")
|
|
66
|
+
else
|
|
67
|
+
CONFIG_VERSION="$(awk '{ sub(/\r$/, "") } /^vendor_version:/ { sub(/^vendor_version:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$CONFIG")"
|
|
68
|
+
CONFIG_REPO="$(awk '
|
|
69
|
+
{ sub(/\r$/, "") }
|
|
70
|
+
/^[^[:space:]#]/ { in_ts = ($1 == "task_storage:") ? 1 : 0 }
|
|
71
|
+
in_ts && /^[[:space:]]*repo:/ { sub(/^[[:space:]]*repo:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }
|
|
72
|
+
' "$CONFIG")"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# ── Block 2 ─ state corruption (critical dirs missing) ─────────────────────
|
|
76
|
+
for dir in .claude/state .claude/skills .claude/agents; do
|
|
77
|
+
if [ ! -d "$REPO_ROOT/$dir" ]; then
|
|
78
|
+
ERRORS+=("Harness directory missing: $dir (state corruption — re-run \`lemony install\`).")
|
|
79
|
+
fi
|
|
80
|
+
done
|
|
81
|
+
|
|
82
|
+
# ── Block 3 ─ git config user.email present ────────────────────────────────
|
|
83
|
+
GIT_USER_EMAIL="$(git config user.email 2>/dev/null || true)"
|
|
84
|
+
if [ -z "$GIT_USER_EMAIL" ]; then
|
|
85
|
+
ERRORS+=("git config user.email is empty. Run: git config --global user.email 'you@example.com'.")
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# ── Block 4 ─ gh CLI installed and authenticated ───────────────────────────
|
|
89
|
+
# Uses `gh auth token` (local-only) instead of `gh auth status` (HTTPS round
|
|
90
|
+
# trip) to keep the orient inside the <1s p99 budget.
|
|
91
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
92
|
+
ERRORS+=("\`gh\` CLI not found in PATH. Install: https://cli.github.com")
|
|
93
|
+
elif ! gh auth token >/dev/null 2>&1; then
|
|
94
|
+
ERRORS+=("\`gh auth token\` returned no token. Run \`gh auth login\`.")
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# ── Warning 1 ─ vendor version drift (config vs installed CLI) ─────────────
|
|
98
|
+
# Resolve the CLI for the version read: the project-local devDependency bin first
|
|
99
|
+
# (it survives an fnm Node switch, #113), then a global install. No npx fallback —
|
|
100
|
+
# the orient hook stays inside its <1s p99 budget, so a non-resolvable CLI just
|
|
101
|
+
# skips the drift check rather than paying a network round trip every session.
|
|
102
|
+
LF_BIN=""
|
|
103
|
+
if [ -x "$REPO_ROOT/node_modules/.bin/lemony" ]; then
|
|
104
|
+
LF_BIN="$REPO_ROOT/node_modules/.bin/lemony"
|
|
105
|
+
elif command -v lemony >/dev/null 2>&1; then
|
|
106
|
+
LF_BIN="lemony"
|
|
107
|
+
fi
|
|
108
|
+
if [ -n "$CONFIG_VERSION" ] && [ -n "$LF_BIN" ]; then
|
|
109
|
+
INSTALLED_VERSION="$("$LF_BIN" --version 2>/dev/null | head -1)"
|
|
110
|
+
if [ -n "$INSTALLED_VERSION" ] && [ "$CONFIG_VERSION" != "$INSTALLED_VERSION" ]; then
|
|
111
|
+
# Direction-neutral by design (#172): the hook stays offline + node-free, so it
|
|
112
|
+
# can't order the two versions reliably (no portable semver compare in bash —
|
|
113
|
+
# BSD `sort` lacks `-V`). It flags the drift with a remedy safe in BOTH
|
|
114
|
+
# directions; `lemony doctor` resolves the precise direction (it has a
|
|
115
|
+
# real comparator) and the exact remediation.
|
|
116
|
+
WARNINGS+=("vendor version drift: harness.config.yml pins $CONFIG_VERSION, installed CLI is $INSTALLED_VERSION. If your CLI is newer, run \`lemony update\`; if it is older, upgrade your CLI (\`npm install\`) and do NOT run \`update\` (it would downgrade the repo). \`lemony doctor\` tells you which.")
|
|
117
|
+
fi
|
|
118
|
+
elif [ -n "$CONFIG_VERSION" ]; then
|
|
119
|
+
# #180: the repo pins a version but no CLI resolves (a teammate never ran
|
|
120
|
+
# `npm install`, no global). This used to be a silent skip — so a stale CLI could
|
|
121
|
+
# drift with no signal at all. Emit a visible line instead, so the check's absence
|
|
122
|
+
# isn't mistaken for "no drift". Still offline (#129): no registry lookup, just a
|
|
123
|
+
# heads-up to install the project-local CLI.
|
|
124
|
+
WARNINGS+=("could not verify CLI version: harness.config.yml pins $CONFIG_VERSION but no \`lemony\` CLI resolved. Run \`npm install\` to get the project-local one (then re-check with \`lemony doctor\`). Version-drift check skipped.")
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# ── Warning 2 ─ peer hook missing or non-executable ────────────────────────
|
|
128
|
+
PEER_HOOK="$REPO_ROOT/.claude/hooks/session-close.sh"
|
|
129
|
+
if [ ! -x "$PEER_HOOK" ]; then
|
|
130
|
+
WARNINGS+=("$PEER_HOOK is missing or not executable — \`/pause\` and SessionEnd telemetry will fail. Re-run \`lemony install\` (or chmod +x).")
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# ── Warning 3 ─ local branch behind origin (no fetch — uses existing refs) ─
|
|
134
|
+
DEFAULT_BRANCH="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')"
|
|
135
|
+
if [ -n "$DEFAULT_BRANCH" ]; then
|
|
136
|
+
BEHIND="$(git rev-list --count "HEAD..origin/$DEFAULT_BRANCH" 2>/dev/null || echo 0)"
|
|
137
|
+
if [ "${BEHIND:-0}" -gt 0 ]; then
|
|
138
|
+
WARNINGS+=("local branch is $BEHIND commit(s) behind origin/$DEFAULT_BRANCH. Consider \`git pull --rebase\` before starting.")
|
|
139
|
+
fi
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# ── Warning 4 ─ residual harness branches (parked tasks) ───────────────────
|
|
143
|
+
STALE_BRANCHES="$(git for-each-ref --format='%(refname:short)' refs/heads/harness/ 2>/dev/null | wc -l | tr -d ' ')"
|
|
144
|
+
if [ "${STALE_BRANCHES:-0}" -gt 0 ]; then
|
|
145
|
+
CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
|
146
|
+
case "$CURRENT_BRANCH" in
|
|
147
|
+
harness/*) : ;;
|
|
148
|
+
*) WARNINGS+=("$STALE_BRANCHES parked harness branch(es) present. Resume with \`continue <id>\` or close them with task-closeout.") ;;
|
|
149
|
+
esac
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
# ── Doctor (informational; never blocking) ─────────────────────────────────
|
|
153
|
+
BASH_MAJOR="${BASH_VERSION%%.*}"
|
|
154
|
+
if [ "${BASH_MAJOR:-0}" -lt 4 ]; then
|
|
155
|
+
# The shipped hooks (lifecycle + playbook enforcement) are explicitly bash
|
|
156
|
+
# 3.2 compatible. The warning is forward-looking: a future hook MAY require
|
|
157
|
+
# bash 4 features (`shopt -s globstar`, associative arrays). On stock macOS
|
|
158
|
+
# `/bin/bash` is 3.2 and current hooks run cleanly there.
|
|
159
|
+
WARNINGS+=("bash $BASH_VERSION detected; current hooks run on 3.2 but future ones may want 4+. \`brew install bash\` to be future-proof.")
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
# ── Lazy-init current-<user>.md (per-dev pointer, gitignored) ──────────────
|
|
163
|
+
# If the blocks didn't fire and we know the user, ensure the per-dev pointer
|
|
164
|
+
# file exists with a fresh `session_start_ts` so `session-close.sh` can compute
|
|
165
|
+
# `session_active_h` accurately. The refresh is a pure-awk in-place rewrite of
|
|
166
|
+
# the frontmatter scalar (#41: no yq).
|
|
167
|
+
if [ "${#ERRORS[@]}" -eq 0 ] && [ -n "$GIT_USER_EMAIL" ]; then
|
|
168
|
+
NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
169
|
+
# `%%@*` strips from the FIRST `@` (parity with status.ts's `email.split('@')[0]`),
|
|
170
|
+
# not `%@*` which strips from the last — they diverge only on a malformed multi-`@`
|
|
171
|
+
# address, but the pointer filename must match what status.ts reads back.
|
|
172
|
+
USER_SLUG="${GIT_USER_EMAIL%%@*}"
|
|
173
|
+
# The slug becomes a filename — drop anything path-significant so a crafted
|
|
174
|
+
# user.email (`../../x@y`) can't make CURRENT_PATH escape `.claude/state/`.
|
|
175
|
+
# Mirrors the readPointer guard in status.ts; skips the write (status returns
|
|
176
|
+
# empty), since the boot can otherwise proceed.
|
|
177
|
+
case "$USER_SLUG" in
|
|
178
|
+
'' | */* | *'\'* | *..*) USER_SLUG="" ;;
|
|
179
|
+
esac
|
|
180
|
+
CURRENT_PATH="$REPO_ROOT/.claude/state/current-$USER_SLUG.md"
|
|
181
|
+
if [ -z "$USER_SLUG" ]; then
|
|
182
|
+
: # path-unsafe slug — pointer write skipped above
|
|
183
|
+
elif [ ! -f "$CURRENT_PATH" ]; then
|
|
184
|
+
mkdir -p "$REPO_ROOT/.claude/state"
|
|
185
|
+
cat > "$CURRENT_PATH" <<EOF
|
|
186
|
+
---
|
|
187
|
+
active_task: null
|
|
188
|
+
branch: $(git symbolic-ref --quiet --short HEAD 2>/dev/null || echo "(unknown)")
|
|
189
|
+
session_start_ts: $NOW_ISO
|
|
190
|
+
last_close_ts: ""
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
# Current session — $GIT_USER_EMAIL
|
|
194
|
+
|
|
195
|
+
Per-dev pointer (gitignored). The lifecycle hooks read \`session_start_ts\`
|
|
196
|
+
to compute \`session_active_h\` and reset it on each SessionStart that orients.
|
|
197
|
+
|
|
198
|
+
## Resume hint
|
|
199
|
+
|
|
200
|
+
_(One paragraph — what to pick up next. Updated by \`/pause\`.)_
|
|
201
|
+
EOF
|
|
202
|
+
else
|
|
203
|
+
# Refresh session_start_ts on every orient so close-time math is accurate.
|
|
204
|
+
# awk in-place rewrite of the frontmatter scalar — without this,
|
|
205
|
+
# `session_start_ts` would stay frozen at the first orient and
|
|
206
|
+
# `session_active_h` would be cumulative across every clear/resume.
|
|
207
|
+
# Write through a mktemp file, not a predictable `$CURRENT_PATH.tmp`: the
|
|
208
|
+
# latter is a known path an attacker could pre-plant as a symlink for the
|
|
209
|
+
# redirect to follow. `mktemp` refuses to reuse an existing path, and `mv`
|
|
210
|
+
# over CURRENT_PATH itself safely replaces a symlink with a regular file.
|
|
211
|
+
if tmp="$(mktemp "$REPO_ROOT/.claude/state/.ptr.XXXXXX")"; then
|
|
212
|
+
awk -v ts="$NOW_ISO" '
|
|
213
|
+
/^---/ {
|
|
214
|
+
block++
|
|
215
|
+
if (block == 2 && !seen) print "session_start_ts: " ts
|
|
216
|
+
print
|
|
217
|
+
next
|
|
218
|
+
}
|
|
219
|
+
block == 1 && /^session_start_ts:/ { print "session_start_ts: " ts; seen = 1; next }
|
|
220
|
+
{ print }
|
|
221
|
+
' "$CURRENT_PATH" > "$tmp" && mv "$tmp" "$CURRENT_PATH" || rm -f "$tmp"
|
|
222
|
+
fi
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
# ── Telemetry opt-out disclosure (#232, D11/D13) ───────────────────────────
|
|
227
|
+
# Anonymous telemetry is on by default, so the first session — and any session
|
|
228
|
+
# after the effective consent changes — must surface the opt-out. The sentinel +
|
|
229
|
+
# fingerprint logic lives in TS (one resolver, no bash duplication of the env ›
|
|
230
|
+
# local › config precedence + .strict() fail-safe — #232 G); init.sh just shells
|
|
231
|
+
# out when a CLI resolves ($LF_BIN from the version-drift check) and skips
|
|
232
|
+
# otherwise. A CLI-less teammate is not left uninformed: `install` already showed
|
|
233
|
+
# the notice once. Healthy-path only, and never fails the boot — the command
|
|
234
|
+
# self-suppresses when telemetry is OFF and `|| true` swallows any non-zero.
|
|
235
|
+
if [ "${#ERRORS[@]}" -eq 0 ] && [ -n "$LF_BIN" ]; then
|
|
236
|
+
"$LF_BIN" telemetry notice 2>/dev/null || true
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
# ── Telemetry catch-up send (#228, Phase 4) ────────────────────────────────
|
|
240
|
+
# SessionEnd's send (#225) is the timely path; this is the safety net for a
|
|
241
|
+
# session that closed uncleanly (crash / SIGKILL / no SessionEnd fired) and so
|
|
242
|
+
# never shipped its tail. Flush the unsent tail of events.jsonl now, on entry.
|
|
243
|
+
# Detached in a subshell (`( … & )`) with output redirected so SessionStart never
|
|
244
|
+
# waits on the network; `nohup` lets it outlive a SIGHUP if the orient's process
|
|
245
|
+
# group tears down before the send completes — same fire-and-forget shape as
|
|
246
|
+
# session-close.sh (the CLI carries its own hard per-request timeout, D4, and
|
|
247
|
+
# never throws). The byte-offset cursor dedups: a clean prior close already
|
|
248
|
+
# advanced it, so this finds nothing to send. A failure (offline) leaves the
|
|
249
|
+
# cursor untouched and the next hook retries the same bytes — the offline
|
|
250
|
+
# invariant. No-op when no endpoint is configured. Runs AFTER the disclosure so a
|
|
251
|
+
# first-ever session surfaces the opt-out first (there is no backlog on a first
|
|
252
|
+
# session anyway, and the engine re-checks consent before any byte leaves the box).
|
|
253
|
+
if [ "${#ERRORS[@]}" -eq 0 ] && [ -n "$LF_BIN" ]; then
|
|
254
|
+
( nohup "$LF_BIN" telemetry send >/dev/null 2>&1 & )
|
|
255
|
+
fi
|
|
256
|
+
|
|
257
|
+
# ── Output ─────────────────────────────────────────────────────────────────
|
|
258
|
+
if [ "${#WARNINGS[@]}" -gt 0 ]; then
|
|
259
|
+
echo "Lemony — orient ($(date -u +%Y-%m-%dT%H:%M:%SZ))"
|
|
260
|
+
if [ -n "$CONFIG_REPO" ]; then
|
|
261
|
+
echo " project: $CONFIG_REPO user: ${GIT_USER_EMAIL:-(unset)}"
|
|
262
|
+
fi
|
|
263
|
+
for w in "${WARNINGS[@]}"; do
|
|
264
|
+
echo " ⚠ $w"
|
|
265
|
+
done
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
if [ "${#ERRORS[@]}" -gt 0 ]; then
|
|
269
|
+
{
|
|
270
|
+
echo "Lemony — blocking issues:"
|
|
271
|
+
for e in "${ERRORS[@]}"; do
|
|
272
|
+
echo " ✖ $e"
|
|
273
|
+
done
|
|
274
|
+
# Self-identify so a maintainer reading the log can locate the source
|
|
275
|
+
# without a grep — same treatment as the playbook hooks' block messages.
|
|
276
|
+
echo "(Hook: ${BASH_SOURCE[0]})"
|
|
277
|
+
} >&2
|
|
278
|
+
exit 2
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
exit 0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Lemony — CLI launcher (issues #107 / #113).
|
|
3
|
+
#
|
|
4
|
+
# The harness's hooks, agents and commands invoke the telemetry CLI (`lemony
|
|
5
|
+
# emit …`). Calling the bare `lemony` binary is fragile: an `npx …` install
|
|
6
|
+
# leaves nothing on PATH (#107), and a Node version manager (fnm/nvm) drops the
|
|
7
|
+
# global bin when the active version changes (#113), so `command -v lemony`
|
|
8
|
+
# silently fails and telemetry is skipped.
|
|
9
|
+
#
|
|
10
|
+
# This launcher resolves the CLI deterministically, preferring the project-local
|
|
11
|
+
# devDependency bin (which survives PATH / Node-manager churn because it lives in
|
|
12
|
+
# the repo's own `node_modules`), then a global install:
|
|
13
|
+
#
|
|
14
|
+
# 1. <repo-root>/node_modules/.bin/lemony — the devDependency (canonical)
|
|
15
|
+
# 2. a `lemony` on PATH — a global install
|
|
16
|
+
#
|
|
17
|
+
# Deliberately NO `npx` fallback: the package is private on GitHub Packages, so an
|
|
18
|
+
# `npx @lemoncode/lemony` in a hook (no `GITHUB_TOKEN` in the env) can't auth and
|
|
19
|
+
# would 401 or, worse, hang the SessionEnd hook on a network stall with no timeout —
|
|
20
|
+
# the exact reason init.sh avoids npx too. When neither resolves, fail fast with exit
|
|
21
|
+
# 127 and a one-line hint; callers (session-close, …) fail open on that.
|
|
22
|
+
#
|
|
23
|
+
# Call it with the CLI args, e.g. `.claude/hooks/lib/lemony.sh emit …`. It
|
|
24
|
+
# `exec`s the resolved CLI, so its exit code and stderr are the CLI's own — callers
|
|
25
|
+
# keep their existing fail-open / forward-the-error handling. Not a lifecycle hook
|
|
26
|
+
# (no stdin envelope, no exit-2 block) — a thin dispatcher.
|
|
27
|
+
set -u
|
|
28
|
+
|
|
29
|
+
# Repo root from the launcher's own location (.claude/hooks/lib/ → up three), so
|
|
30
|
+
# resolution is independent of the caller's cwd and of any git state.
|
|
31
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
32
|
+
|
|
33
|
+
LOCAL_BIN="$ROOT/node_modules/.bin/lemony"
|
|
34
|
+
if [ -x "$LOCAL_BIN" ]; then
|
|
35
|
+
exec "$LOCAL_BIN" "$@"
|
|
36
|
+
elif command -v lemony >/dev/null 2>&1; then
|
|
37
|
+
exec lemony "$@"
|
|
38
|
+
else
|
|
39
|
+
echo "lemony: CLI not found — add the devDependency (\`npm i -D @lemoncode/lemony\`) or install it globally. Skipping." >&2
|
|
40
|
+
exit 127
|
|
41
|
+
fi
|