@friedbotstudio/create-baseline 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 +202 -0
- package/README.md +222 -0
- package/bin/cli.js +247 -0
- package/obj/template/.claude/agents/swarm-worker.md +52 -0
- package/obj/template/.claude/bin/LICENSE +201 -0
- package/obj/template/.claude/bin/NOTICE +48 -0
- package/obj/template/.claude/commands/approve-spec.md +29 -0
- package/obj/template/.claude/commands/approve-swarm.md +27 -0
- package/obj/template/.claude/commands/grant-commit.md +19 -0
- package/obj/template/.claude/commands/init-project.md +191 -0
- package/obj/template/.claude/hooks/artifact_template_guard.sh +141 -0
- package/obj/template/.claude/hooks/consent_gate_grant.sh +89 -0
- package/obj/template/.claude/hooks/destructive_cmd_guard.sh +42 -0
- package/obj/template/.claude/hooks/env_guard.sh +36 -0
- package/obj/template/.claude/hooks/git_commit_guard.sh +93 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +121 -0
- package/obj/template/.claude/hooks/lib/__pycache__/resume_writer.cpython-314.pyc +0 -0
- package/obj/template/.claude/hooks/lib/common.sh +328 -0
- package/obj/template/.claude/hooks/lib/resume_writer.py +341 -0
- package/obj/template/.claude/hooks/lint_runner.sh +55 -0
- package/obj/template/.claude/hooks/memory_pre_compact.sh +36 -0
- package/obj/template/.claude/hooks/memory_session_start.sh +244 -0
- package/obj/template/.claude/hooks/memory_stop.sh +173 -0
- package/obj/template/.claude/hooks/plantuml_syntax_guard.sh +161 -0
- package/obj/template/.claude/hooks/process_lifecycle_guard.sh +89 -0
- package/obj/template/.claude/hooks/setup_guard.sh +50 -0
- package/obj/template/.claude/hooks/spec_approval_guard.sh +81 -0
- package/obj/template/.claude/hooks/spec_design_calls_guard.sh +183 -0
- package/obj/template/.claude/hooks/spec_diagram_presence_guard.sh +141 -0
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +39 -0
- package/obj/template/.claude/hooks/swarm_boundary_guard.sh +136 -0
- package/obj/template/.claude/hooks/tdd_order_guard.sh +176 -0
- package/obj/template/.claude/hooks/test_runner.sh +75 -0
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +12 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +285 -0
- package/obj/template/.claude/hooks/track_guard.sh +127 -0
- package/obj/template/.claude/hooks/verify_pass_guard.sh +88 -0
- package/obj/template/.claude/memory/README.md +108 -0
- package/obj/template/.claude/memory/_pending.md +15 -0
- package/obj/template/.claude/memory/_resume.md +12 -0
- package/obj/template/.claude/memory/conventions.md +26 -0
- package/obj/template/.claude/memory/decisions.md +29 -0
- package/obj/template/.claude/memory/landmarks.md +26 -0
- package/obj/template/.claude/memory/landmines.md +27 -0
- package/obj/template/.claude/memory/libraries.md +27 -0
- package/obj/template/.claude/memory/pending-questions.md +28 -0
- package/obj/template/.claude/project.json +221 -0
- package/obj/template/.claude/settings.json +110 -0
- package/obj/template/.claude/skills/archive/SKILL.md +48 -0
- package/obj/template/.claude/skills/archive/archive.sh +145 -0
- package/obj/template/.claude/skills/audit-baseline/SKILL.md +80 -0
- package/obj/template/.claude/skills/audit-baseline/audit.sh +919 -0
- package/obj/template/.claude/skills/brd/SKILL.md +44 -0
- package/obj/template/.claude/skills/brd/template.md +83 -0
- package/obj/template/.claude/skills/chore/SKILL.md +99 -0
- package/obj/template/.claude/skills/claude-automation-recommender/LICENSE +202 -0
- package/obj/template/.claude/skills/claude-automation-recommender/NOTICE +69 -0
- package/obj/template/.claude/skills/claude-automation-recommender/SKILL.md +358 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/hooks-patterns.md +226 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/mcp-servers.md +263 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/plugins-reference.md +98 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/skills-reference.md +408 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/subagent-templates.md +181 -0
- package/obj/template/.claude/skills/code-structure/SKILL.md +204 -0
- package/obj/template/.claude/skills/commit/SKILL.md +21 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +252 -0
- package/obj/template/.claude/skills/copywriting/evals/evals.json +111 -0
- package/obj/template/.claude/skills/copywriting/references/ai-writing-detection.md +200 -0
- package/obj/template/.claude/skills/copywriting/references/copy-frameworks.md +344 -0
- package/obj/template/.claude/skills/copywriting/references/natural-transitions.md +272 -0
- package/obj/template/.claude/skills/design-ui/SKILL.md +175 -0
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +89 -0
- package/obj/template/.claude/skills/design-ui/references/intent-table.md +64 -0
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +121 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +125 -0
- package/obj/template/.claude/skills/document/SKILL.md +66 -0
- package/obj/template/.claude/skills/documentation/SKILL.md +50 -0
- package/obj/template/.claude/skills/harness/SKILL.md +169 -0
- package/obj/template/.claude/skills/humanizer/SKILL.md +489 -0
- package/obj/template/.claude/skills/humanizer/references/ai-writing-detection.md +208 -0
- package/obj/template/.claude/skills/impeccable/PROJECT_NOTES.md +22 -0
- package/obj/template/.claude/skills/impeccable/SKILL.md +153 -0
- package/obj/template/.claude/skills/impeccable/agents/openai.yaml +4 -0
- package/obj/template/.claude/skills/impeccable/reference/adapt.md +190 -0
- package/obj/template/.claude/skills/impeccable/reference/animate.md +173 -0
- package/obj/template/.claude/skills/impeccable/reference/audit.md +134 -0
- package/obj/template/.claude/skills/impeccable/reference/bolder.md +113 -0
- package/obj/template/.claude/skills/impeccable/reference/brand.md +104 -0
- package/obj/template/.claude/skills/impeccable/reference/clarify.md +174 -0
- package/obj/template/.claude/skills/impeccable/reference/cognitive-load.md +106 -0
- package/obj/template/.claude/skills/impeccable/reference/color-and-contrast.md +105 -0
- package/obj/template/.claude/skills/impeccable/reference/colorize.md +154 -0
- package/obj/template/.claude/skills/impeccable/reference/craft.md +138 -0
- package/obj/template/.claude/skills/impeccable/reference/critique.md +213 -0
- package/obj/template/.claude/skills/impeccable/reference/delight.md +302 -0
- package/obj/template/.claude/skills/impeccable/reference/distill.md +111 -0
- package/obj/template/.claude/skills/impeccable/reference/document.md +427 -0
- package/obj/template/.claude/skills/impeccable/reference/extract.md +70 -0
- package/obj/template/.claude/skills/impeccable/reference/harden.md +347 -0
- package/obj/template/.claude/skills/impeccable/reference/heuristics-scoring.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/interaction-design.md +195 -0
- package/obj/template/.claude/skills/impeccable/reference/layout.md +141 -0
- package/obj/template/.claude/skills/impeccable/reference/live.md +513 -0
- package/obj/template/.claude/skills/impeccable/reference/motion-design.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/onboard.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/optimize.md +258 -0
- package/obj/template/.claude/skills/impeccable/reference/overdrive.md +130 -0
- package/obj/template/.claude/skills/impeccable/reference/personas.md +178 -0
- package/obj/template/.claude/skills/impeccable/reference/polish.md +232 -0
- package/obj/template/.claude/skills/impeccable/reference/product.md +62 -0
- package/obj/template/.claude/skills/impeccable/reference/quieter.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/responsive-design.md +114 -0
- package/obj/template/.claude/skills/impeccable/reference/shape.md +136 -0
- package/obj/template/.claude/skills/impeccable/reference/spatial-design.md +100 -0
- package/obj/template/.claude/skills/impeccable/reference/teach.md +137 -0
- package/obj/template/.claude/skills/impeccable/reference/typeset.md +124 -0
- package/obj/template/.claude/skills/impeccable/reference/typography.md +159 -0
- package/obj/template/.claude/skills/impeccable/reference/ux-writing.md +107 -0
- package/obj/template/.claude/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
- package/obj/template/.claude/skills/impeccable/scripts/command-metadata.json +94 -0
- package/obj/template/.claude/skills/impeccable/scripts/design-parser.mjs +820 -0
- package/obj/template/.claude/skills/impeccable/scripts/detect-csp.mjs +198 -0
- package/obj/template/.claude/skills/impeccable/scripts/is-generated.mjs +69 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-accept.mjs +465 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-browser.js +4684 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-inject.mjs +436 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-poll.mjs +187 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-server.mjs +679 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-wrap.mjs +395 -0
- package/obj/template/.claude/skills/impeccable/scripts/live.mjs +247 -0
- package/obj/template/.claude/skills/impeccable/scripts/load-context.mjs +93 -0
- package/obj/template/.claude/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
- package/obj/template/.claude/skills/impeccable/scripts/pin.mjs +214 -0
- package/obj/template/.claude/skills/implement/SKILL.md +83 -0
- package/obj/template/.claude/skills/intake/SKILL.md +46 -0
- package/obj/template/.claude/skills/intake/template.md +61 -0
- package/obj/template/.claude/skills/integrate/SKILL.md +62 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +172 -0
- package/obj/template/.claude/skills/memory-flush/sweep.py +286 -0
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +327 -0
- package/obj/template/.claude/skills/prose/SKILL.md +119 -0
- package/obj/template/.claude/skills/rca/SKILL.md +42 -0
- package/obj/template/.claude/skills/rca/template.md +83 -0
- package/obj/template/.claude/skills/research/SKILL.md +75 -0
- package/obj/template/.claude/skills/scenario/SKILL.md +64 -0
- package/obj/template/.claude/skills/scout/SKILL.md +72 -0
- package/obj/template/.claude/skills/security/SKILL.md +75 -0
- package/obj/template/.claude/skills/simplify/SKILL.md +67 -0
- package/obj/template/.claude/skills/spec/SKILL.md +69 -0
- package/obj/template/.claude/skills/spec/template.md +274 -0
- package/obj/template/.claude/skills/spec-diagram-review/SKILL.md +81 -0
- package/obj/template/.claude/skills/spec-lint/SKILL.md +55 -0
- package/obj/template/.claude/skills/spec-lint/lint.sh +218 -0
- package/obj/template/.claude/skills/spec-render/SKILL.md +45 -0
- package/obj/template/.claude/skills/spec-render/render.sh +109 -0
- package/obj/template/.claude/skills/spec-traceability-review/SKILL.md +72 -0
- package/obj/template/.claude/skills/swarm-dispatch/SKILL.md +212 -0
- package/obj/template/.claude/skills/swarm-dispatch/swarm_merge.sh +154 -0
- package/obj/template/.claude/skills/swarm-plan/SKILL.md +90 -0
- package/obj/template/.claude/skills/swarm-plan/validate.sh +181 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +100 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +569 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context-README.md +53 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context.md +246 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-example.md +175 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-template.md +152 -0
- package/obj/template/.claude/skills/triage/SKILL.md +55 -0
- package/obj/template/.claude/skills/verify/SKILL.md +74 -0
- package/obj/template/.mcp.json +24 -0
- package/obj/template/CLAUDE.md +327 -0
- package/obj/template/docs/init/seed.md +585 -0
- package/obj/template/manifest.json +214 -0
- package/package.json +48 -0
- package/src/.mcp.template.json +24 -0
- package/src/.npmrc.template +2 -0
- package/src/CLAUDE.template.md +327 -0
- package/src/agents/swarm-worker.template.md +51 -0
- package/src/cli/conflict.js +31 -0
- package/src/cli/doctor.js +152 -0
- package/src/cli/install.js +93 -0
- package/src/cli/io.js +27 -0
- package/src/cli/manifest.js +38 -0
- package/src/cli/mcp.js +54 -0
- package/src/cli/merge.js +107 -0
- package/src/cli/plantuml.js +121 -0
- package/src/cli/util.js +10 -0
- package/src/memory/_pending.template.md +15 -0
- package/src/memory/_resume.template.md +12 -0
- package/src/memory/conventions.template.md +26 -0
- package/src/memory/decisions.template.md +29 -0
- package/src/memory/landmarks.template.md +26 -0
- package/src/memory/landmines.template.md +27 -0
- package/src/memory/libraries.template.md +27 -0
- package/src/memory/pending-questions.template.md +28 -0
- package/src/project.template.json +221 -0
- package/src/seed.template.md +585 -0
- package/src/settings.template.json +110 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared helpers for baseline Claude Code hook scripts.
|
|
3
|
+
# Sourced by every hook in .claude/hooks/.
|
|
4
|
+
#
|
|
5
|
+
# Contract:
|
|
6
|
+
# - Hooks receive a JSON payload on stdin (the Claude Code hook event).
|
|
7
|
+
# - Hooks emit JSON to stdout for structured decisions, or exit non-zero with
|
|
8
|
+
# a stderr message to block/warn.
|
|
9
|
+
# - All hooks must be resilient to a missing/invalid project.json.
|
|
10
|
+
#
|
|
11
|
+
# Dependencies: bash >= 4, python3 (JSON parsing — POSIX-portable enough for
|
|
12
|
+
# macOS + modern Linux). No jq requirement.
|
|
13
|
+
|
|
14
|
+
set -u
|
|
15
|
+
|
|
16
|
+
# Defensive PATH: hooks may be invoked with a minimal environment. Ensure the
|
|
17
|
+
# standard utilities (dirname, cat, date, etc.) and python3 are findable.
|
|
18
|
+
export PATH="${PATH:-}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/usr/sbin:/sbin"
|
|
19
|
+
if [ -z "${PATH%%:*}" ]; then
|
|
20
|
+
PATH="${PATH#:}"
|
|
21
|
+
export PATH
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
CLAUDE_PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
25
|
+
CLAUDE_DOTDIR="$CLAUDE_PROJECT_ROOT/.claude"
|
|
26
|
+
PROJECT_JSON="$CLAUDE_DOTDIR/project.json"
|
|
27
|
+
STATE_DIR="$CLAUDE_DOTDIR/state"
|
|
28
|
+
LOG_DIR="$STATE_DIR/logs"
|
|
29
|
+
mkdir -p "$STATE_DIR" "$LOG_DIR" 2>/dev/null || true
|
|
30
|
+
|
|
31
|
+
# Read the raw hook JSON payload from stdin into HOOK_PAYLOAD.
|
|
32
|
+
read_payload() {
|
|
33
|
+
HOOK_PAYLOAD="$(cat)"
|
|
34
|
+
export HOOK_PAYLOAD
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Extract a field from the hook payload using a jsonpath-ish dotted path.
|
|
38
|
+
# Usage: payload_get '.tool_input.command'
|
|
39
|
+
# Requires read_payload to have been called first (so HOOK_PAYLOAD is set).
|
|
40
|
+
payload_get() {
|
|
41
|
+
local path="$1"
|
|
42
|
+
python3 - "$path" <<'PY'
|
|
43
|
+
import json, os, sys
|
|
44
|
+
path = sys.argv[1]
|
|
45
|
+
raw = os.environ.get("HOOK_PAYLOAD", "")
|
|
46
|
+
if not raw:
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(raw)
|
|
50
|
+
except Exception:
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
cur = data
|
|
53
|
+
for part in path.strip('.').split('.'):
|
|
54
|
+
if part == '':
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(cur, dict):
|
|
57
|
+
cur = cur.get(part)
|
|
58
|
+
else:
|
|
59
|
+
cur = None
|
|
60
|
+
break
|
|
61
|
+
if cur is None:
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
if isinstance(cur, (dict, list)):
|
|
64
|
+
print(json.dumps(cur))
|
|
65
|
+
else:
|
|
66
|
+
print(cur)
|
|
67
|
+
PY
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Read a field from .claude/project.json at a dotted path.
|
|
71
|
+
# Prints empty string if project.json or key is missing.
|
|
72
|
+
project_get() {
|
|
73
|
+
local path="$1"
|
|
74
|
+
[ -f "$PROJECT_JSON" ] || { echo ""; return 0; }
|
|
75
|
+
python3 - "$path" "$PROJECT_JSON" <<'PY'
|
|
76
|
+
import json, sys
|
|
77
|
+
path, pj = sys.argv[1], sys.argv[2]
|
|
78
|
+
try:
|
|
79
|
+
with open(pj) as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
except Exception:
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
cur = data
|
|
84
|
+
for part in path.strip('.').split('.'):
|
|
85
|
+
if part == '':
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(cur, dict):
|
|
88
|
+
cur = cur.get(part)
|
|
89
|
+
else:
|
|
90
|
+
cur = None
|
|
91
|
+
break
|
|
92
|
+
if cur is None:
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
if isinstance(cur, (dict, list)):
|
|
95
|
+
print(json.dumps(cur))
|
|
96
|
+
else:
|
|
97
|
+
print(cur)
|
|
98
|
+
PY
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Emit a structured block decision (PreToolUse).
|
|
102
|
+
# Usage: emit_block "reason text"
|
|
103
|
+
emit_block() {
|
|
104
|
+
local reason="$1"
|
|
105
|
+
python3 - "$reason" <<'PY'
|
|
106
|
+
import json, sys
|
|
107
|
+
print(json.dumps({
|
|
108
|
+
"hookSpecificOutput": {
|
|
109
|
+
"hookEventName": "PreToolUse",
|
|
110
|
+
"permissionDecision": "deny",
|
|
111
|
+
"permissionDecisionReason": sys.argv[1],
|
|
112
|
+
}
|
|
113
|
+
}))
|
|
114
|
+
PY
|
|
115
|
+
exit 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Emit a structured ask decision (PreToolUse).
|
|
119
|
+
emit_ask() {
|
|
120
|
+
local reason="$1"
|
|
121
|
+
python3 - "$reason" <<'PY'
|
|
122
|
+
import json, sys
|
|
123
|
+
print(json.dumps({
|
|
124
|
+
"hookSpecificOutput": {
|
|
125
|
+
"hookEventName": "PreToolUse",
|
|
126
|
+
"permissionDecision": "ask",
|
|
127
|
+
"permissionDecisionReason": sys.argv[1],
|
|
128
|
+
}
|
|
129
|
+
}))
|
|
130
|
+
PY
|
|
131
|
+
exit 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Emit an allow (no-op) decision. Equivalent to exit 0 with no output, but
|
|
135
|
+
# explicit for clarity.
|
|
136
|
+
emit_allow() {
|
|
137
|
+
exit 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Emit an informational message (PostToolUse or advisory PreToolUse). Does not
|
|
141
|
+
# block. Printed to stderr so it surfaces in the transcript without polluting
|
|
142
|
+
# stdout (which Claude Code interprets as structured output).
|
|
143
|
+
emit_info() {
|
|
144
|
+
printf '%s\n' "$1" >&2
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Append a line to a hook-specific log for debugging. Never fails the hook.
|
|
148
|
+
log_line() {
|
|
149
|
+
local hook="$1" msg="$2"
|
|
150
|
+
printf '%s %s\n' "$(date -u +%FT%TZ)" "$msg" >>"$LOG_DIR/$hook.log" 2>/dev/null || true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# True if the given path matches any glob in a JSON array (from project_get).
|
|
154
|
+
# Usage: path_matches_globs "src/foo.py" "$(project_get .tdd.source_globs)"
|
|
155
|
+
path_matches_globs() {
|
|
156
|
+
local path="$1" globs_json="$2"
|
|
157
|
+
[ -z "$globs_json" ] && return 1
|
|
158
|
+
python3 - "$path" "$globs_json" <<'PY'
|
|
159
|
+
import json, sys, fnmatch
|
|
160
|
+
path, globs_json = sys.argv[1], sys.argv[2]
|
|
161
|
+
try:
|
|
162
|
+
globs = json.loads(globs_json)
|
|
163
|
+
except Exception:
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
if not isinstance(globs, list):
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
for g in globs:
|
|
168
|
+
if fnmatch.fnmatchcase(path, g) or fnmatch.fnmatchcase(path, g.rstrip('/**') + '/*'):
|
|
169
|
+
sys.exit(0)
|
|
170
|
+
# also handle simple ** recursion
|
|
171
|
+
if '**' in g:
|
|
172
|
+
# translate ** to match any depth
|
|
173
|
+
import re
|
|
174
|
+
pat = re.escape(g).replace(r'\*\*', '.*').replace(r'\*', '[^/]*').replace(r'\?', '.')
|
|
175
|
+
if re.fullmatch(pat, path):
|
|
176
|
+
sys.exit(0)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
PY
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# True if stdin command matches any pattern in a JSON-array-of-regex.
|
|
182
|
+
# Usage: cmd_matches_any "$cmd" "$(project_get .destructive.hard_block_patterns)"
|
|
183
|
+
cmd_matches_any() {
|
|
184
|
+
local cmd="$1" patterns_json="$2"
|
|
185
|
+
[ -z "$patterns_json" ] && return 1
|
|
186
|
+
python3 - "$cmd" "$patterns_json" <<'PY'
|
|
187
|
+
import json, sys, re
|
|
188
|
+
cmd, patterns_json = sys.argv[1], sys.argv[2]
|
|
189
|
+
try:
|
|
190
|
+
patterns = json.loads(patterns_json)
|
|
191
|
+
except Exception:
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
for p in patterns:
|
|
194
|
+
try:
|
|
195
|
+
if re.search(p, cmd):
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
except re.error:
|
|
198
|
+
continue
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
PY
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Canonicalize a path and make it relative to CLAUDE_PROJECT_ROOT.
|
|
204
|
+
# Lexical only — collapses ./ and ../ via os.path.normpath, does NOT resolve
|
|
205
|
+
# symlinks. This is deliberate: symlink resolution would let a path like
|
|
206
|
+
# .claude/state/spec_approvals/foo.approval (whose final component is a
|
|
207
|
+
# symlink to /tmp/x) silently redirect, defeating the case-pattern checks.
|
|
208
|
+
# Symlink-swap defense is a separate hardening (see seed.md §16 follow-ups).
|
|
209
|
+
#
|
|
210
|
+
# Returns the project-relative canonical path on stdout, or an absolute
|
|
211
|
+
# canonical path if the input escapes the project root. Empty if input is
|
|
212
|
+
# empty or equals the project root.
|
|
213
|
+
canonical_rel() {
|
|
214
|
+
python3 - "$1" "$CLAUDE_PROJECT_ROOT" <<'PY'
|
|
215
|
+
import os, sys
|
|
216
|
+
file_path = sys.argv[1]
|
|
217
|
+
root = sys.argv[2]
|
|
218
|
+
if not file_path:
|
|
219
|
+
sys.exit(0)
|
|
220
|
+
norm = os.path.normpath(os.path.abspath(file_path))
|
|
221
|
+
norm_root = os.path.normpath(os.path.abspath(root))
|
|
222
|
+
sep = os.sep
|
|
223
|
+
if norm == norm_root:
|
|
224
|
+
print("")
|
|
225
|
+
elif norm.startswith(norm_root + sep):
|
|
226
|
+
print(norm[len(norm_root) + 1:])
|
|
227
|
+
else:
|
|
228
|
+
print(norm)
|
|
229
|
+
PY
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Consent-gate marker file paths — written ONLY by consent_gate_grant.sh
|
|
233
|
+
# (UserPromptSubmit) when the user invokes the corresponding slash command,
|
|
234
|
+
# read by the gate guards (PreToolUse) before allowing approval-token writes.
|
|
235
|
+
# Hooks reference these constants instead of literal paths so a rename is a
|
|
236
|
+
# one-line change. Relative variants are for case-pattern matching against
|
|
237
|
+
# tool_input.file_path after CLAUDE_PROJECT_ROOT stripping.
|
|
238
|
+
CONSENT_MARKER_SPEC="$STATE_DIR/.spec_approval_grant"
|
|
239
|
+
CONSENT_MARKER_SWARM="$STATE_DIR/.swarm_approval_grant"
|
|
240
|
+
CONSENT_MARKER_COMMIT="$STATE_DIR/.commit_consent_grant"
|
|
241
|
+
CONSENT_MARKER_SPEC_REL=".claude/state/.spec_approval_grant"
|
|
242
|
+
CONSENT_MARKER_SWARM_REL=".claude/state/.swarm_approval_grant"
|
|
243
|
+
CONSENT_MARKER_COMMIT_REL=".claude/state/.commit_consent_grant"
|
|
244
|
+
|
|
245
|
+
# Reduce any user-typed approval argument (bare slug, filename, or path) to a
|
|
246
|
+
# bare slug. Called from BOTH ends of the consent handshake so the marker and
|
|
247
|
+
# the expected-slug check produce the same shape:
|
|
248
|
+
# docs/specs/foo.md -> foo
|
|
249
|
+
# foo.md -> foo
|
|
250
|
+
# foo -> foo
|
|
251
|
+
# approval-slug-canonicalization -> approval-slug-canonicalization
|
|
252
|
+
# Also used by the approval guards: feed in basename(file, .approval) to
|
|
253
|
+
# canonicalize legacy `<slug>.md.approval` and current `<slug>.approval` to
|
|
254
|
+
# the same bare slug, so both filename shapes validate.
|
|
255
|
+
canonical_slug() {
|
|
256
|
+
local s="${1##*/}"
|
|
257
|
+
printf '%s' "${s%.md}"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Block Claude from writing a consent-marker file via Write/Edit/MultiEdit.
|
|
261
|
+
# The marker's unforgeability is what makes the consent gate structural —
|
|
262
|
+
# only consent_gate_grant (UserPromptSubmit, outside Claude's tool boundary)
|
|
263
|
+
# may produce it.
|
|
264
|
+
#
|
|
265
|
+
# Args: $1 rel_path $2 marker_rel_path $3 gate_label $4 user_command_hint
|
|
266
|
+
# Calls emit_block (which exits) on match; returns 0 otherwise.
|
|
267
|
+
block_marker_self_write() {
|
|
268
|
+
local rel="$1" marker_rel="$2" gate_label="$3" cmd_hint="$4"
|
|
269
|
+
local hook_log="${gate_label// /_}"
|
|
270
|
+
hook_log="${hook_log,,}"
|
|
271
|
+
if [ "$rel" = "$marker_rel" ]; then
|
|
272
|
+
log_line "$hook_log" "BLOCKED direct write to consent marker: $rel"
|
|
273
|
+
emit_block "$gate_label: '$rel' is a consent marker written by the consent_gate_grant UserPromptSubmit hook in response to \`$cmd_hint\`. Claude is not permitted to create or edit this marker — its unforgeability is what makes the gate structurally enforced."
|
|
274
|
+
fi
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Validate a consent marker (freshness + optional slug match) and consume it.
|
|
278
|
+
# emit_blocks (and exits) on any failure; returns 0 on success after deleting
|
|
279
|
+
# the marker. TTL comes from .consent.gate_marker_ttl_seconds (default 60).
|
|
280
|
+
#
|
|
281
|
+
# Args: $1 marker_path $2 gate_label $3 user_command_hint [$4 expected_slug]
|
|
282
|
+
#
|
|
283
|
+
# Marker shape:
|
|
284
|
+
# - With slug ($4 non-empty): line 1 = slug, line 2 = epoch.
|
|
285
|
+
# - Epoch-only ($4 empty): line 1 = epoch.
|
|
286
|
+
validate_consent_marker() {
|
|
287
|
+
local marker="$1" gate_label="$2" cmd_hint="$3" expected_slug="${4:-}"
|
|
288
|
+
local hook_log ttl marker_slug marker_epoch now age
|
|
289
|
+
hook_log="${gate_label// /_}"
|
|
290
|
+
hook_log="${hook_log,,}"
|
|
291
|
+
|
|
292
|
+
ttl="$(project_get .consent.gate_marker_ttl_seconds)"
|
|
293
|
+
[ -z "$ttl" ] && ttl=120
|
|
294
|
+
|
|
295
|
+
if [ ! -f "$marker" ]; then
|
|
296
|
+
log_line "$hook_log" "BLOCKED no marker: $marker"
|
|
297
|
+
emit_block "$gate_label: requires a fresh consent marker at $marker. The marker is produced by the consent_gate_grant hook when the user runs \`$cmd_hint\` — Claude cannot create it."
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
if [ -n "$expected_slug" ]; then
|
|
301
|
+
{ read -r marker_slug; read -r marker_epoch; } < "$marker" 2>/dev/null
|
|
302
|
+
else
|
|
303
|
+
read -r marker_epoch < "$marker" 2>/dev/null
|
|
304
|
+
marker_slug=""
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
if ! [[ "$marker_epoch" =~ ^[0-9]+$ ]]; then
|
|
308
|
+
log_line "$hook_log" "BLOCKED malformed marker: $marker"
|
|
309
|
+
emit_block "$gate_label: marker at $marker is malformed. Ask the user to re-run \`$cmd_hint\`."
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
now="$(date +%s)"
|
|
313
|
+
age=$(( now - marker_epoch ))
|
|
314
|
+
if [ "$age" -gt "$ttl" ]; then
|
|
315
|
+
log_line "$hook_log" "BLOCKED marker expired age=${age}s ttl=${ttl}s"
|
|
316
|
+
rm -f "$marker"
|
|
317
|
+
emit_block "$gate_label: consent marker expired (${age}s old, TTL ${ttl}s). Ask the user to re-run \`$cmd_hint\`."
|
|
318
|
+
fi
|
|
319
|
+
|
|
320
|
+
if [ -n "$expected_slug" ] && [ "$marker_slug" != "$expected_slug" ]; then
|
|
321
|
+
log_line "$hook_log" "BLOCKED slug mismatch marker=$marker_slug expected=$expected_slug"
|
|
322
|
+
emit_block "$gate_label: marker slug ($marker_slug) does not match expected ($expected_slug). Ask the user to re-run \`$cmd_hint\` with the correct argument."
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
log_line "$hook_log" "ALLOWED marker=$marker age=${age}s slug=${marker_slug:-N/A}"
|
|
326
|
+
rm -f "$marker"
|
|
327
|
+
return 0
|
|
328
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Continuity snapshot writer.
|
|
3
|
+
|
|
4
|
+
Walks a Claude Code transcript JSONL plus state files and writes a
|
|
5
|
+
single-snapshot `.claude/memory/_resume.md`. Shared by:
|
|
6
|
+
|
|
7
|
+
- memory_pre_compact.sh (PreCompact event — capture before compaction)
|
|
8
|
+
- memory_stop.sh (Stop event — refresh every turn-end)
|
|
9
|
+
|
|
10
|
+
The snapshot answers "where were we / what's next?" so a session that
|
|
11
|
+
gets compacted, /clear'd, or resumed in a new shell can pick up without
|
|
12
|
+
the user re-explaining context.
|
|
13
|
+
|
|
14
|
+
Heuristic only: scrapes the transcript for recent user prompts, recent
|
|
15
|
+
file writes, and the most recent Skill invocation, then merges with
|
|
16
|
+
.claude/state/workflow.json if present.
|
|
17
|
+
|
|
18
|
+
Invoked as:
|
|
19
|
+
python3 resume_writer.py <transcript_path> <project_dir> <trigger>
|
|
20
|
+
|
|
21
|
+
trigger is one of: pre-compact, stop, harness — recorded in frontmatter.
|
|
22
|
+
Exits 0 on success or any failure (this is best-effort; it must never
|
|
23
|
+
break the hook that called it).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Iterable
|
|
35
|
+
|
|
36
|
+
# How many most-recent items of each kind to retain in the snapshot.
|
|
37
|
+
MAX_USER_PROMPTS = 3
|
|
38
|
+
MAX_FILES = 12
|
|
39
|
+
MAX_SKILLS = 5
|
|
40
|
+
MAX_BASH = 5
|
|
41
|
+
USER_PROMPT_CHARS = 400 # truncate per-prompt; the snapshot is bounded
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _utc_now_iso() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_json(path: Path) -> dict | list | None:
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _iter_transcript_events(transcript: Path) -> Iterable[dict]:
|
|
56
|
+
if not transcript.is_file():
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
with transcript.open("r", encoding="utf-8", errors="replace") as f:
|
|
60
|
+
for line in f:
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
yield json.loads(line)
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
except Exception:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_text_blocks(content) -> list[str]:
|
|
73
|
+
"""A message's `content` is either a string or list of typed blocks."""
|
|
74
|
+
out: list[str] = []
|
|
75
|
+
if isinstance(content, str):
|
|
76
|
+
if content.strip():
|
|
77
|
+
out.append(content.strip())
|
|
78
|
+
return out
|
|
79
|
+
if not isinstance(content, list):
|
|
80
|
+
return out
|
|
81
|
+
for block in content:
|
|
82
|
+
if not isinstance(block, dict):
|
|
83
|
+
continue
|
|
84
|
+
if block.get("type") == "text":
|
|
85
|
+
t = block.get("text", "")
|
|
86
|
+
if isinstance(t, str) and t.strip():
|
|
87
|
+
out.append(t.strip())
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _walk(transcript: Path) -> dict:
|
|
92
|
+
"""Single pass over the transcript collecting everything we need."""
|
|
93
|
+
user_prompts: list[str] = []
|
|
94
|
+
file_writes: list[tuple[str, str]] = [] # (path, tool)
|
|
95
|
+
skill_calls: list[str] = []
|
|
96
|
+
bash_cmds: list[str] = []
|
|
97
|
+
last_assistant_text: str = ""
|
|
98
|
+
|
|
99
|
+
for ev in _iter_transcript_events(transcript):
|
|
100
|
+
msg = ev.get("message") if isinstance(ev, dict) else None
|
|
101
|
+
if not isinstance(msg, dict):
|
|
102
|
+
# Some transcripts put role/content at top level.
|
|
103
|
+
msg = ev if isinstance(ev, dict) else {}
|
|
104
|
+
role = msg.get("role") or ev.get("role") if isinstance(ev, dict) else None
|
|
105
|
+
content = msg.get("content")
|
|
106
|
+
|
|
107
|
+
if role == "user":
|
|
108
|
+
for t in _extract_text_blocks(content):
|
|
109
|
+
# Skip system-reminder noise and hook injections.
|
|
110
|
+
if t.startswith("<system-reminder>") or "<command-name>" in t[:64]:
|
|
111
|
+
continue
|
|
112
|
+
if t.startswith("<local-command-"):
|
|
113
|
+
continue
|
|
114
|
+
user_prompts.append(t)
|
|
115
|
+
elif role == "assistant":
|
|
116
|
+
text_blocks = _extract_text_blocks(content)
|
|
117
|
+
if text_blocks:
|
|
118
|
+
last_assistant_text = text_blocks[-1]
|
|
119
|
+
if isinstance(content, list):
|
|
120
|
+
for block in content:
|
|
121
|
+
if not isinstance(block, dict):
|
|
122
|
+
continue
|
|
123
|
+
if block.get("type") != "tool_use":
|
|
124
|
+
continue
|
|
125
|
+
name = block.get("name", "")
|
|
126
|
+
inp = block.get("input") or {}
|
|
127
|
+
if name in ("Edit", "Write", "MultiEdit"):
|
|
128
|
+
fp = inp.get("file_path") or ""
|
|
129
|
+
if fp:
|
|
130
|
+
file_writes.append((fp, name))
|
|
131
|
+
elif name == "Skill":
|
|
132
|
+
sk = inp.get("skill") or ""
|
|
133
|
+
if sk:
|
|
134
|
+
skill_calls.append(sk)
|
|
135
|
+
elif name == "Bash":
|
|
136
|
+
cmd = inp.get("command") or ""
|
|
137
|
+
if cmd:
|
|
138
|
+
bash_cmds.append(cmd.strip().splitlines()[0][:160])
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"user_prompts": user_prompts,
|
|
142
|
+
"file_writes": file_writes,
|
|
143
|
+
"skill_calls": skill_calls,
|
|
144
|
+
"bash_cmds": bash_cmds,
|
|
145
|
+
"last_assistant_text": last_assistant_text,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_workflow(project_dir: Path) -> dict:
|
|
150
|
+
"""Return whatever .claude/state/workflow.json holds, or {}."""
|
|
151
|
+
p = project_dir / ".claude" / "state" / "workflow.json"
|
|
152
|
+
data = _read_json(p)
|
|
153
|
+
return data if isinstance(data, dict) else {}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _last_harness_log_line(project_dir: Path, slug: str) -> str:
|
|
157
|
+
log = project_dir / ".claude" / "state" / "harness" / f"{slug}.log"
|
|
158
|
+
if not log.is_file():
|
|
159
|
+
return ""
|
|
160
|
+
try:
|
|
161
|
+
lines = [ln for ln in log.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
|
|
162
|
+
return lines[-1] if lines else ""
|
|
163
|
+
except Exception:
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _rel(path: str, project_dir: Path) -> str:
|
|
168
|
+
"""Make project-relative if possible."""
|
|
169
|
+
try:
|
|
170
|
+
p = Path(path)
|
|
171
|
+
if p.is_absolute():
|
|
172
|
+
return str(p.relative_to(project_dir))
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
return path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _dedup_keep_order(items: Iterable[str]) -> list[str]:
|
|
179
|
+
seen: set[str] = set()
|
|
180
|
+
out: list[str] = []
|
|
181
|
+
for it in items:
|
|
182
|
+
if it in seen:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(it)
|
|
185
|
+
out.append(it)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def compose_snapshot(transcript: Path, project_dir: Path, trigger: str) -> str:
|
|
190
|
+
walk = _walk(transcript)
|
|
191
|
+
workflow = _read_workflow(project_dir)
|
|
192
|
+
|
|
193
|
+
slug = workflow.get("slug") or "(none)"
|
|
194
|
+
entry_phase = workflow.get("entry_phase") or "(unknown)"
|
|
195
|
+
completed = workflow.get("completed") or []
|
|
196
|
+
exceptions = workflow.get("exceptions") or []
|
|
197
|
+
phases = workflow.get("phases") or []
|
|
198
|
+
|
|
199
|
+
# Next phase = first phase not in completed, after entry_phase.
|
|
200
|
+
next_phase = "(unknown)"
|
|
201
|
+
if phases and isinstance(completed, list):
|
|
202
|
+
try:
|
|
203
|
+
start = phases.index(entry_phase) if entry_phase in phases else 0
|
|
204
|
+
except ValueError:
|
|
205
|
+
start = 0
|
|
206
|
+
for ph in phases[start:]:
|
|
207
|
+
if ph in exceptions:
|
|
208
|
+
continue
|
|
209
|
+
if ph not in completed:
|
|
210
|
+
next_phase = ph
|
|
211
|
+
break
|
|
212
|
+
else:
|
|
213
|
+
next_phase = "(workflow complete)"
|
|
214
|
+
|
|
215
|
+
last_completed = completed[-1] if completed else "(none)"
|
|
216
|
+
|
|
217
|
+
# File writes — most recent unique paths, project-relative.
|
|
218
|
+
files_recent = []
|
|
219
|
+
for fp, _tool in reversed(walk["file_writes"]):
|
|
220
|
+
rel = _rel(fp, project_dir)
|
|
221
|
+
if rel.startswith(".claude/state/") or rel.startswith(".claude/memory/_pending"):
|
|
222
|
+
continue
|
|
223
|
+
if rel not in files_recent:
|
|
224
|
+
files_recent.append(rel)
|
|
225
|
+
if len(files_recent) >= MAX_FILES:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
# User prompts — last K, most-recent first.
|
|
229
|
+
prompts_recent = walk["user_prompts"][-MAX_USER_PROMPTS:]
|
|
230
|
+
prompts_recent = list(reversed(prompts_recent))
|
|
231
|
+
|
|
232
|
+
# Skill calls — last K, dedup keep-order, most-recent first.
|
|
233
|
+
skills_recent = list(reversed(_dedup_keep_order(walk["skill_calls"][-MAX_SKILLS * 3:])))[:MAX_SKILLS]
|
|
234
|
+
|
|
235
|
+
# Bash — last K (chatty, so keep small).
|
|
236
|
+
bash_recent = walk["bash_cmds"][-MAX_BASH:]
|
|
237
|
+
bash_recent = list(reversed(bash_recent))
|
|
238
|
+
|
|
239
|
+
last_log = _last_harness_log_line(project_dir, slug) if slug != "(none)" else ""
|
|
240
|
+
|
|
241
|
+
# Continue-with hint.
|
|
242
|
+
if slug == "(none)":
|
|
243
|
+
hint = "No active workflow. Run `/triage \"<request>\"` to start one, or `/harness` if you have a concrete request."
|
|
244
|
+
elif next_phase == "(workflow complete)":
|
|
245
|
+
hint = f"Workflow `{slug}` is complete. Run `/grant-commit` then `/harness` to commit."
|
|
246
|
+
else:
|
|
247
|
+
hint = f"Run `/harness` to resume `{slug}` at phase `{next_phase}`."
|
|
248
|
+
|
|
249
|
+
# Compose markdown.
|
|
250
|
+
lines: list[str] = []
|
|
251
|
+
lines.append("---")
|
|
252
|
+
lines.append("name: resume")
|
|
253
|
+
lines.append("type: continuity")
|
|
254
|
+
lines.append(f"last-updated: {_utc_now_iso()}")
|
|
255
|
+
lines.append(f"trigger: {trigger}")
|
|
256
|
+
lines.append("---")
|
|
257
|
+
lines.append("")
|
|
258
|
+
lines.append("# Resume snapshot")
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append("## Active workflow")
|
|
261
|
+
lines.append(f"- Slug: `{slug}`")
|
|
262
|
+
lines.append(f"- Entry phase: `{entry_phase}`")
|
|
263
|
+
lines.append(f"- Last completed phase: `{last_completed}`")
|
|
264
|
+
lines.append(f"- Next phase due: `{next_phase}`")
|
|
265
|
+
if exceptions:
|
|
266
|
+
lines.append(f"- Exceptions: {', '.join(f'`{e}`' for e in exceptions)}")
|
|
267
|
+
if last_log:
|
|
268
|
+
lines.append(f"- Last harness log: `{last_log}`")
|
|
269
|
+
lines.append("")
|
|
270
|
+
|
|
271
|
+
lines.append("## In-flight files (most recent writes this session)")
|
|
272
|
+
if files_recent:
|
|
273
|
+
for fp in files_recent:
|
|
274
|
+
lines.append(f"- `{fp}`")
|
|
275
|
+
else:
|
|
276
|
+
lines.append("- (none captured)")
|
|
277
|
+
lines.append("")
|
|
278
|
+
|
|
279
|
+
lines.append("## Recent skill invocations")
|
|
280
|
+
if skills_recent:
|
|
281
|
+
for sk in skills_recent:
|
|
282
|
+
lines.append(f"- `/{sk}`")
|
|
283
|
+
else:
|
|
284
|
+
lines.append("- (none captured)")
|
|
285
|
+
lines.append("")
|
|
286
|
+
|
|
287
|
+
if bash_recent:
|
|
288
|
+
lines.append("## Recent shell commands")
|
|
289
|
+
for cmd in bash_recent:
|
|
290
|
+
lines.append(f"- `{cmd}`")
|
|
291
|
+
lines.append("")
|
|
292
|
+
|
|
293
|
+
lines.append("## Recent user requests (most recent first)")
|
|
294
|
+
if prompts_recent:
|
|
295
|
+
for p in prompts_recent:
|
|
296
|
+
text = p.replace("\r", " ")
|
|
297
|
+
if len(text) > USER_PROMPT_CHARS:
|
|
298
|
+
text = text[:USER_PROMPT_CHARS].rstrip() + "…"
|
|
299
|
+
block = "\n".join(f"> {ln}" for ln in text.splitlines())
|
|
300
|
+
lines.append(block)
|
|
301
|
+
lines.append("")
|
|
302
|
+
else:
|
|
303
|
+
lines.append("- (none captured)")
|
|
304
|
+
lines.append("")
|
|
305
|
+
|
|
306
|
+
lines.append("## Continue with")
|
|
307
|
+
lines.append(hint)
|
|
308
|
+
lines.append("")
|
|
309
|
+
|
|
310
|
+
return "\n".join(lines)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def write_snapshot(transcript: Path, project_dir: Path, trigger: str) -> Path | None:
|
|
314
|
+
mem_dir = project_dir / ".claude" / "memory"
|
|
315
|
+
if not mem_dir.is_dir():
|
|
316
|
+
return None
|
|
317
|
+
body = compose_snapshot(transcript, project_dir, trigger)
|
|
318
|
+
out = mem_dir / "_resume.md"
|
|
319
|
+
try:
|
|
320
|
+
out.write_text(body, encoding="utf-8")
|
|
321
|
+
return out
|
|
322
|
+
except Exception:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main(argv: list[str]) -> int:
|
|
327
|
+
if len(argv) < 4:
|
|
328
|
+
sys.stderr.write("usage: resume_writer.py <transcript> <project_dir> <trigger>\n")
|
|
329
|
+
return 0 # never fail the caller
|
|
330
|
+
try:
|
|
331
|
+
transcript = Path(argv[1])
|
|
332
|
+
project_dir = Path(argv[2])
|
|
333
|
+
trigger = argv[3]
|
|
334
|
+
write_snapshot(transcript, project_dir, trigger)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
sys.stderr.write(f"resume_writer: {e}\n")
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
sys.exit(main(sys.argv))
|