doco-cli 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 +68 -0
- package/dist/index.js +5100 -0
- package/dist/schema.sql +362 -0
- package/package.json +44 -0
- package/templates/agent-bootstrap/.claude/bootstrap-fetch.sh +274 -0
- package/templates/agent-bootstrap/.claude/post-tool-use-check.sh +144 -0
- package/templates/agent-bootstrap/.claude/settings.json +56 -0
- package/templates/agent-bootstrap/.claude/stop-check.sh +206 -0
- package/templates/agent-bootstrap/.claude/user-prompt-fetch.sh +172 -0
- package/templates/agent-bootstrap/.env.example +21 -0
- package/templates/agent-bootstrap/AGENTS.md +233 -0
- package/templates/agent-bootstrap/CLAUDE.md +1 -0
- package/templates/glossary.yaml +52 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PostToolUse hook: after Edit/Write tool calls, cross-reference the
|
|
3
|
+
# touched file against the prompt's pre-fetched /search.json hits. If a
|
|
4
|
+
# Decision with vector_score > 0.45 names this file's basename or
|
|
5
|
+
# relative path inside its body, inject an additionalContext block
|
|
6
|
+
# nudging the agent to PATCH that Decision rather than skip-and-rationalize.
|
|
7
|
+
#
|
|
8
|
+
# Wired from `.claude/settings.json`. Hook input (JSON on stdin) carries:
|
|
9
|
+
# .hook_event_name = "PostToolUse"
|
|
10
|
+
# .tool_name = "Edit" | "Write" | "MultiEdit" | "Bash" | ...
|
|
11
|
+
# .tool_input = { file_path: "...", ... } for Edit/Write
|
|
12
|
+
# .tool_response = { content, isError }
|
|
13
|
+
#
|
|
14
|
+
# Hook output is the JSON envelope:
|
|
15
|
+
# { "hookSpecificOutput": { "hookEventName": "PostToolUse",
|
|
16
|
+
# "additionalContext": "<nudge text>" } }
|
|
17
|
+
#
|
|
18
|
+
# Soft nudge — never blocks the tool call. Silent exit 0 on every
|
|
19
|
+
# failure path (no hits cached, no jq, no Decision body matches, etc).
|
|
20
|
+
#
|
|
21
|
+
# Per ADR-141 (decision_01KRK9P1FPF5ZAPJ4DXAKPXYFJ), item 8: catch
|
|
22
|
+
# context-scrolled drift the moment an Edit lands, while the relevant
|
|
23
|
+
# governing Decision is still surfaceable.
|
|
24
|
+
|
|
25
|
+
set -u
|
|
26
|
+
|
|
27
|
+
# 1. Read hook input.
|
|
28
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
29
|
+
if [ -z "$INPUT" ] || ! command -v jq >/dev/null 2>&1; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || true)
|
|
34
|
+
case "$TOOL_NAME" in
|
|
35
|
+
Edit|Write|MultiEdit|NotebookEdit) ;;
|
|
36
|
+
*) exit 0 ;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
|
|
40
|
+
[ -z "$FILE_PATH" ] && exit 0
|
|
41
|
+
|
|
42
|
+
# 2. Load .env so we have $PWD-ish context and DOCO_TOKEN. DOCO_ID
|
|
43
|
+
# lives in AGENTS.md (committed, non-secret) and is used to build
|
|
44
|
+
# the nudge URL — fall back to grepping AGENTS.md when it's not in
|
|
45
|
+
# env. Legacy repos with DOCO_ID still in .env keep working: env wins.
|
|
46
|
+
if [ -f "$PWD/.env" ]; then
|
|
47
|
+
set -a
|
|
48
|
+
# shellcheck disable=SC1091
|
|
49
|
+
source "$PWD/.env"
|
|
50
|
+
set +a
|
|
51
|
+
fi
|
|
52
|
+
DOCO_BASE_URL="https://doco.to"
|
|
53
|
+
DOCO_ID="${DOCO_ID:-}"
|
|
54
|
+
if [ -z "$DOCO_ID" ]; then
|
|
55
|
+
for f in "$PWD/AGENTS.md" "$PWD/CLAUDE.md"; do
|
|
56
|
+
if [ -f "$f" ]; then
|
|
57
|
+
DOCO_ID=$(grep -oE 'doco_[A-Za-z0-9]+' "$f" | head -1)
|
|
58
|
+
[ -n "$DOCO_ID" ] && break
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# 3. Locate the pre-fetched hits file written by user-prompt-fetch.sh.
|
|
64
|
+
HITS_KEY=$(printf '%s' "$PWD" | shasum 2>/dev/null | awk '{print $1}' || printf 'default')
|
|
65
|
+
HITS_FILE="${TMPDIR:-/tmp}/doco-last-hits-${HITS_KEY}.json"
|
|
66
|
+
[ -f "$HITS_FILE" ] || exit 0
|
|
67
|
+
[ -s "$HITS_FILE" ] || exit 0
|
|
68
|
+
|
|
69
|
+
# 4. Compute substrings to search Decision bodies for.
|
|
70
|
+
# Conservative match: basename + repo-relative path (slashes stripped of
|
|
71
|
+
# leading $PWD). Decisions cite files in many shapes — basename catches
|
|
72
|
+
# the most, relative path narrows for ambiguous basenames like "index.ts".
|
|
73
|
+
BASENAME=$(basename -- "$FILE_PATH")
|
|
74
|
+
RELPATH="$FILE_PATH"
|
|
75
|
+
case "$FILE_PATH" in
|
|
76
|
+
"$PWD"/*) RELPATH="${FILE_PATH#$PWD/}" ;;
|
|
77
|
+
esac
|
|
78
|
+
|
|
79
|
+
# 5. Find Decisions with vector_score > 0.45 whose body mentions either
|
|
80
|
+
# substring. We need the body, which /search.json hits DON'T carry —
|
|
81
|
+
# they expose summary + file_path. Match against summary first; if no
|
|
82
|
+
# summary hit, optionally fall back to reading the file_path (it's
|
|
83
|
+
# local: docos/<owner>/<doco>/decisions/<id>.md).
|
|
84
|
+
MATCH_JSON=$(jq -c --arg b "$BASENAME" --arg r "$RELPATH" '
|
|
85
|
+
[ .hits[]?
|
|
86
|
+
| select(.node_type == "decision")
|
|
87
|
+
| select((.vector_score // 0) > 0.45)
|
|
88
|
+
| select(
|
|
89
|
+
((.summary // "") | contains($b))
|
|
90
|
+
or ((.summary // "") | contains($r))
|
|
91
|
+
or ((.file_path // "") | contains("/decisions/"))
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
' "$HITS_FILE" 2>/dev/null || printf '[]')
|
|
95
|
+
|
|
96
|
+
# Stage 2: for high-score Decisions whose summary didn't mention the path,
|
|
97
|
+
# grep their on-disk body. Keep this bounded — top 5 by vector_score only.
|
|
98
|
+
DEEP_MATCH=$(jq -c --arg b "$BASENAME" --arg r "$RELPATH" '
|
|
99
|
+
[ .hits[]?
|
|
100
|
+
| select(.node_type == "decision")
|
|
101
|
+
| select((.vector_score // 0) > 0.45)
|
|
102
|
+
| { id, summary, file_path, vector_score, slug: (.slug // .seq_id // .id) }
|
|
103
|
+
] | sort_by(-.vector_score) | .[0:5]
|
|
104
|
+
' "$HITS_FILE" 2>/dev/null || printf '[]')
|
|
105
|
+
|
|
106
|
+
FINAL_MATCHES="[]"
|
|
107
|
+
if [ "$DEEP_MATCH" != "[]" ] && [ -n "$DEEP_MATCH" ]; then
|
|
108
|
+
# Read each candidate's file_path, grep for basename/relpath, keep matches.
|
|
109
|
+
COUNT=$(printf '%s' "$DEEP_MATCH" | jq 'length' 2>/dev/null || echo 0)
|
|
110
|
+
i=0
|
|
111
|
+
ACC="[]"
|
|
112
|
+
while [ "$i" -lt "$COUNT" ]; do
|
|
113
|
+
CAND=$(printf '%s' "$DEEP_MATCH" | jq -c ".[$i]" 2>/dev/null)
|
|
114
|
+
CFP=$(printf '%s' "$CAND" | jq -r '.file_path // ""' 2>/dev/null)
|
|
115
|
+
if [ -n "$CFP" ] && [ -f "$CFP" ]; then
|
|
116
|
+
if grep -qF -- "$BASENAME" "$CFP" 2>/dev/null \
|
|
117
|
+
|| grep -qF -- "$RELPATH" "$CFP" 2>/dev/null; then
|
|
118
|
+
ACC=$(printf '%s' "$ACC" | jq -c --argjson c "$CAND" '. + [$c]' 2>/dev/null || printf '%s' "$ACC")
|
|
119
|
+
fi
|
|
120
|
+
fi
|
|
121
|
+
i=$((i+1))
|
|
122
|
+
done
|
|
123
|
+
FINAL_MATCHES="$ACC"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
MATCH_COUNT=$(printf '%s' "$FINAL_MATCHES" | jq 'length' 2>/dev/null || echo 0)
|
|
127
|
+
[ "$MATCH_COUNT" = "0" ] && exit 0
|
|
128
|
+
|
|
129
|
+
# 6. Build the nudge text. List up to 3 matches with URLs.
|
|
130
|
+
NUDGE_BODY=$(printf '%s' "$FINAL_MATCHES" | jq -r --arg host "$DOCO_BASE_URL" --arg doco_id "$DOCO_ID" --arg fp "$RELPATH" '
|
|
131
|
+
.[0:3] | map(
|
|
132
|
+
"- [" + (.slug // .id) + "](" + ($host) + "/by-id/" + ($doco_id) + "/decision/" + .id + ") (vector_score " + ((.vector_score // 0) | tostring) + "): " +
|
|
133
|
+
(if (.summary | length) > 200 then (.summary[:197] + "...") else .summary end)
|
|
134
|
+
) | join("\n")
|
|
135
|
+
' 2>/dev/null)
|
|
136
|
+
|
|
137
|
+
[ -z "$NUDGE_BODY" ] && exit 0
|
|
138
|
+
|
|
139
|
+
NUDGE=$(printf '🔮 Doco PostToolUse — file just edited (%s) is referenced in an existing Decision\n\nThis edit touched **%s**. The following Decision(s) from this prompt'\''s search hits cite this path in their body (vector_score > 0.45):\n\n%s\n\n**Consider PATCHing one of these Decisions** instead of opening a sibling. Per ADR-141: if a high-vector_score hit already governs the change, PATCH it (`doco patch decision <id> --append-body "..."`) rather than skipping the capture or creating a near-duplicate. Two overlapping nodes are strictly worse than one stale one.' \
|
|
140
|
+
"$RELPATH" "$RELPATH" "$NUDGE_BODY")
|
|
141
|
+
|
|
142
|
+
# 7. Emit the JSON envelope.
|
|
143
|
+
jq -nc --arg c "$NUDGE" \
|
|
144
|
+
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://claude.com/schemas/settings.json",
|
|
3
|
+
"permissions": {
|
|
4
|
+
"allow": [
|
|
5
|
+
"Bash(doco:*)"
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"hooks": {
|
|
9
|
+
"SessionStart": [
|
|
10
|
+
{
|
|
11
|
+
"matcher": "startup|resume|clear|compact",
|
|
12
|
+
"hooks": [
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "bash .claude/bootstrap-fetch.sh",
|
|
16
|
+
"timeout": 10
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"UserPromptSubmit": [
|
|
22
|
+
{
|
|
23
|
+
"hooks": [
|
|
24
|
+
{
|
|
25
|
+
"type": "command",
|
|
26
|
+
"command": "bash .claude/user-prompt-fetch.sh",
|
|
27
|
+
"timeout": 8
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"PostToolUse": [
|
|
33
|
+
{
|
|
34
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
35
|
+
"hooks": [
|
|
36
|
+
{
|
|
37
|
+
"type": "command",
|
|
38
|
+
"command": "bash .claude/post-tool-use-check.sh",
|
|
39
|
+
"timeout": 6
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"Stop": [
|
|
45
|
+
{
|
|
46
|
+
"hooks": [
|
|
47
|
+
{
|
|
48
|
+
"type": "command",
|
|
49
|
+
"command": "bash .claude/stop-check.sh",
|
|
50
|
+
"timeout": 6
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stop hook: fires when the assistant finishes a turn. Parses the
|
|
3
|
+
# session transcript to count Edit/Write tool calls, Doco write commands,
|
|
4
|
+
# and user-facing Doco operation footers. It injects a final nudge for
|
|
5
|
+
# either of the two common drift cases:
|
|
6
|
+
#
|
|
7
|
+
# - edits happened but no Doco capture/patch/write was attempted;
|
|
8
|
+
# - Doco writes returned footer_lines, but the agent never pasted every
|
|
9
|
+
# footer line into user-facing text.
|
|
10
|
+
#
|
|
11
|
+
# Wired from `.claude/settings.json`. Hook input (JSON on stdin) carries:
|
|
12
|
+
# .hook_event_name = "Stop"
|
|
13
|
+
# .transcript_path = "/Users/.../.claude/projects/.../<session-id>.jsonl"
|
|
14
|
+
# .session_id, .cwd, .permission_mode, .effort
|
|
15
|
+
#
|
|
16
|
+
# Hook output is the JSON envelope:
|
|
17
|
+
# { "hookSpecificOutput": { "hookEventName": "Stop",
|
|
18
|
+
# "additionalContext": "<nudge text>" } }
|
|
19
|
+
#
|
|
20
|
+
# Per ADR-141 (decision_01KRK9P1FPF5ZAPJ4DXAKPXYFJ), item 9: catch the
|
|
21
|
+
# agent who edited code, drifted from canonical, and was about to
|
|
22
|
+
# declare done without any capture.
|
|
23
|
+
#
|
|
24
|
+
# Soft nudge — never blocks the response. The agent already finished;
|
|
25
|
+
# this just prepends one more reminder before the user sees the reply.
|
|
26
|
+
# Silent exit 0 on every failure path.
|
|
27
|
+
|
|
28
|
+
set -u
|
|
29
|
+
|
|
30
|
+
# 1. Read hook input.
|
|
31
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
32
|
+
if [ -z "$INPUT" ] || ! command -v jq >/dev/null 2>&1; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || true)
|
|
37
|
+
[ -z "$TRANSCRIPT" ] && exit 0
|
|
38
|
+
[ -f "$TRANSCRIPT" ] || exit 0
|
|
39
|
+
|
|
40
|
+
# Loop-guard: when the Stop hook fires and emits additionalContext, the
|
|
41
|
+
# agent may choose to continue (e.g. to capture or paste footers). If our last nudge is
|
|
42
|
+
# still the latest user-message-shaped entry in the transcript, we've
|
|
43
|
+
# already nudged this stretch — bail out to avoid double-nudging.
|
|
44
|
+
if grep -q "Doco Stop nudge —" "$TRANSCRIPT" 2>/dev/null; then
|
|
45
|
+
# Already nudged. Only nudge again if a new Edit/Write/Bash came AFTER the
|
|
46
|
+
# nudge line. Cheap check: look at the last 100 lines of the transcript.
|
|
47
|
+
LAST_CHUNK=$(tail -c 200000 "$TRANSCRIPT" 2>/dev/null || true)
|
|
48
|
+
NUDGE_LINE=$(printf '%s\n' "$LAST_CHUNK" | grep -n "Doco Stop nudge —" | tail -1 | cut -d: -f1)
|
|
49
|
+
if [ -n "$NUDGE_LINE" ]; then
|
|
50
|
+
AFTER_NUDGE=$(printf '%s\n' "$LAST_CHUNK" | tail -n "+$NUDGE_LINE")
|
|
51
|
+
if ! printf '%s' "$AFTER_NUDGE" | grep -qE '"name":"(Edit|Write|MultiEdit|NotebookEdit|Bash)"'; then
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# 2. Parse the current turn's tool-use events. "Current turn" = since
|
|
58
|
+
# the LAST user message in the transcript. Walk back from EOF until
|
|
59
|
+
# we hit a user role; everything after is this turn.
|
|
60
|
+
#
|
|
61
|
+
# Transcript is JSONL — one event per line. Assistant tool calls
|
|
62
|
+
# appear as type=assistant with message.content[].type=tool_use
|
|
63
|
+
# objects carrying .name and .input.
|
|
64
|
+
#
|
|
65
|
+
# We count Edit/Write/MultiEdit/NotebookEdit calls in tool_name, Doco
|
|
66
|
+
# write commands in Bash tool calls, footer lines printed by tools,
|
|
67
|
+
# and footer lines pasted into assistant text. The critical check is
|
|
68
|
+
# tool footer_lines > assistant footer_lines: the write succeeded, but
|
|
69
|
+
# the client never got the per-operation update.
|
|
70
|
+
|
|
71
|
+
# Use python3 — it's preinstalled on macOS / most Linux and avoids the
|
|
72
|
+
# multi-line jq+awk gymnastics. Falls back silently if python3 missing.
|
|
73
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
COUNTS=$(python3 - "$TRANSCRIPT" <<'PYEOF' 2>/dev/null || true
|
|
78
|
+
import json, sys, re
|
|
79
|
+
|
|
80
|
+
path = sys.argv[1]
|
|
81
|
+
edits = 0
|
|
82
|
+
doco_writes = 0
|
|
83
|
+
assistant_footer_lines = 0
|
|
84
|
+
tool_footer_lines = 0
|
|
85
|
+
|
|
86
|
+
FOOTER_RE = re.compile(
|
|
87
|
+
r'\[(?:🔮|✅) Doco\]\s*(?:✍️|📝|🧹|➕|➖|🔁|🏷️|🗑️)\s+'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def text_from(value):
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
return value
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
parts = []
|
|
95
|
+
for item in value:
|
|
96
|
+
if isinstance(item, str):
|
|
97
|
+
parts.append(item)
|
|
98
|
+
elif isinstance(item, dict):
|
|
99
|
+
parts.append(text_from(item.get('text') or item.get('content') or ''))
|
|
100
|
+
return '\n'.join(parts)
|
|
101
|
+
return ''
|
|
102
|
+
|
|
103
|
+
def count_footer_lines(text):
|
|
104
|
+
return len(FOOTER_RE.findall(text or ''))
|
|
105
|
+
|
|
106
|
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
107
|
+
lines = f.readlines()
|
|
108
|
+
|
|
109
|
+
# Find index of the most recent user-message event. Skip queue-operation
|
|
110
|
+
# entries (which are just transcript bookkeeping).
|
|
111
|
+
last_user_idx = -1
|
|
112
|
+
for i, line in enumerate(lines):
|
|
113
|
+
try:
|
|
114
|
+
d = json.loads(line)
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
if d.get('type') == 'user' and d.get('message', {}).get('role') == 'user':
|
|
118
|
+
# Skip tool_result-shaped user messages (those are assistant's
|
|
119
|
+
# tool outputs being fed back, not a fresh user prompt).
|
|
120
|
+
content = d.get('message', {}).get('content')
|
|
121
|
+
if isinstance(content, str):
|
|
122
|
+
last_user_idx = i
|
|
123
|
+
elif isinstance(content, list):
|
|
124
|
+
has_text = any(
|
|
125
|
+
isinstance(c, dict) and c.get('type') == 'text'
|
|
126
|
+
for c in content
|
|
127
|
+
)
|
|
128
|
+
has_only_tool_results = all(
|
|
129
|
+
isinstance(c, dict) and c.get('type') == 'tool_result'
|
|
130
|
+
for c in content
|
|
131
|
+
) if content else False
|
|
132
|
+
if has_text or not has_only_tool_results:
|
|
133
|
+
last_user_idx = i
|
|
134
|
+
|
|
135
|
+
# Scan events AFTER the last user message.
|
|
136
|
+
for line in lines[last_user_idx + 1:]:
|
|
137
|
+
try:
|
|
138
|
+
d = json.loads(line)
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
content = d.get('message', {}).get('content', [])
|
|
142
|
+
if isinstance(content, str):
|
|
143
|
+
if d.get('type') == 'assistant':
|
|
144
|
+
assistant_footer_lines += count_footer_lines(content)
|
|
145
|
+
continue
|
|
146
|
+
if not isinstance(content, list):
|
|
147
|
+
continue
|
|
148
|
+
for c in content:
|
|
149
|
+
if not isinstance(c, dict):
|
|
150
|
+
continue
|
|
151
|
+
if d.get('type') == 'assistant' and c.get('type') == 'tool_use':
|
|
152
|
+
name = c.get('name', '')
|
|
153
|
+
inp = c.get('input', {}) or {}
|
|
154
|
+
if name in ('Edit', 'Write', 'MultiEdit', 'NotebookEdit'):
|
|
155
|
+
edits += 1
|
|
156
|
+
elif name == 'Bash':
|
|
157
|
+
cmd = inp.get('command', '') or ''
|
|
158
|
+
# Match CLI write surfaces and raw HTTP writes to capture
|
|
159
|
+
# endpoints. `doco scope add-rule` writes Rule nodes.
|
|
160
|
+
if re.search(r'\bdoco\s+(capture|patch|supersede)\b', cmd):
|
|
161
|
+
doco_writes += 1
|
|
162
|
+
elif re.search(r'\bdoco\s+scope\s+add-rule\b', cmd):
|
|
163
|
+
doco_writes += 1
|
|
164
|
+
elif re.search(r'curl[^|;&]*-X\s*(POST|PATCH|DELETE)[^|;&]*/api/[a-z]+(?:/\S*)?\.json', cmd, re.IGNORECASE):
|
|
165
|
+
doco_writes += 1
|
|
166
|
+
elif re.search(r'curl[^|;&]*/api/[a-z]+(?:/\S*)?\.json[^|;&]*-X\s*(POST|PATCH|DELETE)', cmd, re.IGNORECASE):
|
|
167
|
+
doco_writes += 1
|
|
168
|
+
elif d.get('type') == 'assistant' and c.get('type') == 'text':
|
|
169
|
+
assistant_footer_lines += count_footer_lines(c.get('text', '') or '')
|
|
170
|
+
elif d.get('type') == 'user' and c.get('type') == 'tool_result':
|
|
171
|
+
tool_footer_lines += count_footer_lines(text_from(c.get('content', '')))
|
|
172
|
+
|
|
173
|
+
print(f"{edits} {doco_writes} {assistant_footer_lines} {tool_footer_lines}")
|
|
174
|
+
PYEOF
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
EDITS=$(printf '%s' "$COUNTS" | awk '{print $1}')
|
|
178
|
+
DOCO_WRITES=$(printf '%s' "$COUNTS" | awk '{print $2}')
|
|
179
|
+
ASSISTANT_FOOTERS=$(printf '%s' "$COUNTS" | awk '{print $3}')
|
|
180
|
+
TOOL_FOOTERS=$(printf '%s' "$COUNTS" | awk '{print $4}')
|
|
181
|
+
EDITS="${EDITS:-0}"
|
|
182
|
+
DOCO_WRITES="${DOCO_WRITES:-0}"
|
|
183
|
+
ASSISTANT_FOOTERS="${ASSISTANT_FOOTERS:-0}"
|
|
184
|
+
TOOL_FOOTERS="${TOOL_FOOTERS:-0}"
|
|
185
|
+
|
|
186
|
+
# 3a. Doco wrote nodes, but the assistant didn't paste every returned
|
|
187
|
+
# footer line into user-facing text.
|
|
188
|
+
if [ "$TOOL_FOOTERS" -gt "$ASSISTANT_FOOTERS" ] 2>/dev/null; then
|
|
189
|
+
NUDGE=$(printf '🔮 Doco Stop nudge — Doco write footer_lines not shown to user\n\nThis turn'\''s Doco write tool output contained %s footer line(s), but assistant text emitted %s. The closing tally is not a substitute for per-operation updates.\n\nBefore declaring done, paste every returned `footer_lines` entry verbatim, one per line, above the final tally. If multiple nodes were added or updated, the user should see one Doco operation line for each returned footer line.' \
|
|
190
|
+
"$TOOL_FOOTERS" "$ASSISTANT_FOOTERS")
|
|
191
|
+
|
|
192
|
+
jq -nc --arg c "$NUDGE" \
|
|
193
|
+
'{hookSpecificOutput: {hookEventName: "Stop", additionalContext: $c}}'
|
|
194
|
+
exit 0
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
# 3b. Existing ADR-141 nudge: edits happened, but no Doco write signal.
|
|
198
|
+
if [ "$EDITS" = "0" ] || [ "$DOCO_WRITES" != "0" ] || [ "$ASSISTANT_FOOTERS" != "0" ]; then
|
|
199
|
+
exit 0
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
NUDGE=$(printf '🔮 Doco Stop nudge — turn had edits but no captures\n\nThis turn made %s Edit/Write tool call(s) but no `doco capture` / `doco patch` invocation and no footer-line was emitted. Before declaring done:\n\n1. **Name the existing node you'\''re relying on.** If a Decision, Rule, or Action already covers what you changed, the capture obligation is satisfied — but say *which* node. "CLI can'\''t capture Actions" or "too small for a Decision" are not naming a node.\n2. **If no node fits**, capture one now (`doco capture decision …`, `doco capture action …`, or `doco patch <type> <id> --append-body …` per ADR-141). One short Decision beats a months-from-now archaeology dig through `git log`.\n3. The Stop hook reminded you. Suppress this nudge by either capturing or by stating the node-name you'\''re relying on in your final summary.' \
|
|
203
|
+
"$EDITS")
|
|
204
|
+
|
|
205
|
+
jq -nc --arg c "$NUDGE" \
|
|
206
|
+
'{hookSpecificOutput: {hookEventName: "Stop", additionalContext: $c}}'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# UserPromptSubmit hook: keep the Doco protocol fresh in the agent's
|
|
3
|
+
# context on every turn, AND pre-fetch the /search.json result for the
|
|
4
|
+
# user's prompt so the top-of-reply query indicator is pre-built.
|
|
5
|
+
#
|
|
6
|
+
# Wired from `.claude/settings.json`. Hook input (JSON on stdin) carries
|
|
7
|
+
# `.prompt`. Hook output is the JSON envelope Claude Code's hook runner
|
|
8
|
+
# expects: { hookSpecificOutput: { hookEventName: "UserPromptSubmit",
|
|
9
|
+
# additionalContext: "<text>" } }.
|
|
10
|
+
#
|
|
11
|
+
# Why this exists: SessionStart loads the 24KB canonical_instructions
|
|
12
|
+
# ONCE per session. As the context scrolls, the agent drifts — forgets
|
|
13
|
+
# to render the query indicator, forgets footer lines, forgets capture
|
|
14
|
+
# triggers. UserPromptSubmit fires on every message, so we re-push a
|
|
15
|
+
# tight protocol checklist AND a fresh search result. Cost: ~1-4KB of
|
|
16
|
+
# additionalContext per turn; benefit: structural compliance.
|
|
17
|
+
#
|
|
18
|
+
# Env vars consumed (sourced from $PWD/.env if not in shell):
|
|
19
|
+
# DOCO_TOKEN optional bearer token (gates per-Doco context)
|
|
20
|
+
# DOCO_ID "doco_..." — which Doco to query
|
|
21
|
+
#
|
|
22
|
+
# Per the `claude-md-strong-bootstrap-and-session-start-hook` Decision +
|
|
23
|
+
# the `user-prompt-submit-hook-keeps-protocol-fresh` Decision (this
|
|
24
|
+
# commit's iteration).
|
|
25
|
+
|
|
26
|
+
set -u
|
|
27
|
+
|
|
28
|
+
# 1. Read hook input from stdin.
|
|
29
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
30
|
+
PROMPT=""
|
|
31
|
+
if [ -n "$INPUT" ] && command -v jq >/dev/null 2>&1; then
|
|
32
|
+
PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || true)
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# 2. Load .env if present. The production host is fixed at doco.to; env
|
|
36
|
+
# only carries the DOCO_TOKEN secret. DOCO_ID lives in AGENTS.md
|
|
37
|
+
# (committed, non-secret) — read it from there if it's not in env.
|
|
38
|
+
# Legacy repos with DOCO_ID still in .env keep working: env wins.
|
|
39
|
+
if [ -f "$PWD/.env" ]; then
|
|
40
|
+
set -a
|
|
41
|
+
# shellcheck disable=SC1091
|
|
42
|
+
source "$PWD/.env"
|
|
43
|
+
set +a
|
|
44
|
+
fi
|
|
45
|
+
DOCO_BASE_URL="https://doco.to"
|
|
46
|
+
DOCO_ID="${DOCO_ID:-}"
|
|
47
|
+
if [ -z "$DOCO_ID" ]; then
|
|
48
|
+
for f in "$PWD/AGENTS.md" "$PWD/CLAUDE.md"; do
|
|
49
|
+
if [ -f "$f" ]; then
|
|
50
|
+
DOCO_ID=$(grep -oE 'doco_[A-Za-z0-9]+' "$f" | head -1)
|
|
51
|
+
[ -n "$DOCO_ID" ] && break
|
|
52
|
+
fi
|
|
53
|
+
done
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# 3. Build the protocol reminder — short, deterministic, every turn.
|
|
57
|
+
# Kept under 800 chars so the per-turn token cost stays bounded.
|
|
58
|
+
read -r -d '' PROTOCOL_REMINDER <<'EOF' || true
|
|
59
|
+
## Doco protocol — apply this in your reply
|
|
60
|
+
|
|
61
|
+
1. TOP OF THE TURN'S FIRST TEXT OUTPUT (once per turn, NOT on intermediate progress updates between tool calls): paste the pre-fetched Doco block FIRST.
|
|
62
|
+
|
|
63
|
+
If the pre-fetched block says "[🔮 Doco] Not connected yet: <reason>", paste that exact line instead of any connected indicator. Do not render query/count/tally/footer lines while disconnected. Ask the project owner to authorize with `doco login --host https://doco.to`.
|
|
64
|
+
|
|
65
|
+
If connected and queried, render the two-line query indicator:
|
|
66
|
+
[🔮 Doco] <querying-verb>
|
|
67
|
+
[🔮 Doco] <N> relevant nodes found (<X.X>s)
|
|
68
|
+
If you didn't query (greeting, one-word ack), emit nothing at the top — no "Not querying" placeholder, no fallback line. Absence is the signal.
|
|
69
|
+
The pre-fetched connected block below has the verbs already picked at random plus real <N>/<X.Xs> — paste it verbatim.
|
|
70
|
+
|
|
71
|
+
2. AFTER EVERY WRITE (POST/PATCH/DELETE on /api/*.json), only when connected: render every returned footer_lines entry verbatim, one per line, before the closing tally. Running `doco capture` / `doco patch` is not enough; the user-facing reply must contain the operation lines. Shape:
|
|
72
|
+
[🔮 Doco] <op-icon> <Type> <verb>: [<summary>](<url>) — <icon> <scope1>, <icon> <scope2>
|
|
73
|
+
The summary is the markdown link to the entity. Never show the raw ULID. Scope tail omitted when no scopes. Last line in a batch carries (X.Xs) timing AFTER the scope tail — already there.
|
|
74
|
+
|
|
75
|
+
3. BEFORE DECLARING DONE: scan capture triggers. Scope names are BARE (no scope_ prefix) and match templates: user-flow changed → `user-flows` Decision. Bug fixed → `bugs` Decision + `bugs` Rule (`born_from: <decision_id>`). Framework touched (CLI / hooks / canonical) → add `framework`. ADR-shaped → `adrs`. POST to /api/decisions.json etc. via the host's capture endpoints. **If instinct says skip, name the existing node you're relying on — "CLI can't capture X" or "too small for a Decision" aren't naming a node. If a high-vector_score hit already governs the change, PATCH it instead of skipping.**
|
|
76
|
+
|
|
77
|
+
4. CLOSING LINE OF THE TURN (once per turn, on the LAST text output only — NOT on intermediate progress updates between tool calls; even when 0 writes):
|
|
78
|
+
[🔮 Doco] <doco_id>: **<N>** node(s) added/updated
|
|
79
|
+
<N> = count of distinct entities you added/updated this turn (PATCH-3-fields-of-1-Decision = 1, not 3). The number MUST be wrapped in markdown bold (`**N**`). Singular when N == 1, plural otherwise (0 is plural). A "turn" is one user prompt → your complete answer, even when threaded through many tool calls; the tally bookends the turn, not each chunk.
|
|
80
|
+
|
|
81
|
+
The full canonical_instructions was loaded at session start. Re-fetch via `doco bootstrap` if you've lost track and are connected.
|
|
82
|
+
EOF
|
|
83
|
+
|
|
84
|
+
# 4. Pre-fetch /search.json for the user's prompt so the agent doesn't
|
|
85
|
+
# have to. If anything needed for access is missing, pre-build the
|
|
86
|
+
# disconnected indicator so the agent never presents as connected.
|
|
87
|
+
QUERY_BLOCK=""
|
|
88
|
+
DISCONNECTED_REASON=""
|
|
89
|
+
if [ -z "${DOCO_ID:-}" ]; then
|
|
90
|
+
DISCONNECTED_REASON="missing DOCO_ID"
|
|
91
|
+
elif [ -z "${DOCO_TOKEN:-}" ]; then
|
|
92
|
+
DISCONNECTED_REASON="missing DOCO_TOKEN; ask the project owner to authorize with doco login --host https://doco.to"
|
|
93
|
+
elif ! command -v curl >/dev/null 2>&1; then
|
|
94
|
+
DISCONNECTED_REASON="curl is not installed"
|
|
95
|
+
elif ! command -v jq >/dev/null 2>&1; then
|
|
96
|
+
DISCONNECTED_REASON="jq is not installed"
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
if [ -n "$DISCONNECTED_REASON" ]; then
|
|
100
|
+
QUERY_BLOCK=$(printf '\n\n## Doco connection for THIS prompt — paste as your top-of-reply indicator\n\n[🔮 Doco] Not connected yet: %s\n\nDo not render any other Doco indicator, footer, or tally lines until the connection is fixed.\n' "$DISCONNECTED_REASON")
|
|
101
|
+
elif [ -n "$PROMPT" ]; then
|
|
102
|
+
ENC=$(printf '%s' "$PROMPT" | jq -sRr @uri 2>/dev/null || true)
|
|
103
|
+
TMP_RESP="${TMPDIR:-/tmp}/doco-search-$$.json"
|
|
104
|
+
HTTP_STATUS=$(curl -sS --max-time 5 -w '%{http_code}' -o "$TMP_RESP" \
|
|
105
|
+
-H "Authorization: Bearer ${DOCO_TOKEN}" \
|
|
106
|
+
"${DOCO_BASE_URL}/by-id/${DOCO_ID}/search.json?q=${ENC}&limit=10" 2>/dev/null || true)
|
|
107
|
+
RESP=$(cat "$TMP_RESP" 2>/dev/null || true)
|
|
108
|
+
rm -f "$TMP_RESP" 2>/dev/null || true
|
|
109
|
+
if [ "$HTTP_STATUS" = "200" ] && [ -n "$RESP" ]; then
|
|
110
|
+
COUNT=$(printf '%s' "$RESP" | jq -r '.count // 0' 2>/dev/null || echo 0)
|
|
111
|
+
MS=$(printf '%s' "$RESP" | jq -r '.duration_ms // 0' 2>/dev/null || echo 0)
|
|
112
|
+
SECS=$(awk -v ms="$MS" 'BEGIN { printf "%.1f", ms/1000 }')
|
|
113
|
+
HITS=$(printf '%s' "$RESP" | jq -r '
|
|
114
|
+
.hits[]? |
|
|
115
|
+
"- " + .node_type + " [" + (.slug // .seq_id // .id) + "]: " +
|
|
116
|
+
(if (.summary | length) > 120 then (.summary[:117] + "...") else .summary end)
|
|
117
|
+
' 2>/dev/null | head -10)
|
|
118
|
+
# Random verbs — pick one at random. The variety is the point;
|
|
119
|
+
# the structured fields (slug, count, timing) stay identical.
|
|
120
|
+
LOADING_VERBS=("Connected to" "Tuned into" "Listening to" "Wired up to" "Synced with" "Plugged into" "Online with" "Reading" "Hooked into" "Eyes on" "Riding shotgun on" "Pinned to" "Threaded into" "Locked onto" "Channel open:" "Live on" "Mind-melded with" "Pulled up" "Holding the file on")
|
|
121
|
+
QUERYING_VERBS=("Querying..." "Looking it up..." "Asking around..." "Reading the room..." "Sniffing for hits..." "Flipping through notes..." "Scanning the graph..." "Searching the lore..." "Peering into the orb..." "Combing the archive..." "Hunting for prior art..." "Pinging the memory..." "Cross-referencing..." "Checking what's known..." "Tracing the trail..." "Diving in..." "Polling the Doco..." "Skimming the index..." "Asking the oracle..." "Searching...")
|
|
122
|
+
LOADING_VERB="${LOADING_VERBS[$RANDOM % ${#LOADING_VERBS[@]}]}"
|
|
123
|
+
QUERYING_VERB="${QUERYING_VERBS[$RANDOM % ${#QUERYING_VERBS[@]}]}"
|
|
124
|
+
QUERY_BLOCK=$(printf '\n\n## Pre-fetched query for THIS prompt — paste as your top-of-reply indicator\n\n[🔮 Doco] %s\n[🔮 Doco] %s %s. %s relevant nodes found (%ss)\n\nTop hits:\n%s\n' \
|
|
125
|
+
"$QUERYING_VERB" "$LOADING_VERB" "$DOCO_ID" "$COUNT" "$SECS" "$HITS")
|
|
126
|
+
# Persist the full hits JSON for cross-hook reads (PostToolUse path-match).
|
|
127
|
+
# Namespace by $PWD hash so concurrent worktrees don't stomp each other.
|
|
128
|
+
# The PostToolUse hook reads this on every Edit/Write tool call.
|
|
129
|
+
HITS_KEY=$(printf '%s' "$PWD" | shasum 2>/dev/null | awk '{print $1}' || printf 'default')
|
|
130
|
+
HITS_FILE="${TMPDIR:-/tmp}/doco-last-hits-${HITS_KEY}.json"
|
|
131
|
+
printf '%s' "$RESP" > "$HITS_FILE" 2>/dev/null || true
|
|
132
|
+
else
|
|
133
|
+
# For 403 (no_access) and 404 (not_found) the host returns rich
|
|
134
|
+
# recovery guidance in the response body (see
|
|
135
|
+
# packages/web/app/lib/missing-doco-guidance.server.ts). Prefer
|
|
136
|
+
# that body verbatim — it tells the user how to recover instead
|
|
137
|
+
# of just naming the failure. Fall back to a short reason string
|
|
138
|
+
# when the body is missing (host down, unauthorized token, etc.).
|
|
139
|
+
REASON=""
|
|
140
|
+
case "$HTTP_STATUS" in
|
|
141
|
+
000) REASON="doco.to unreachable" ;;
|
|
142
|
+
401) REASON="DOCO_TOKEN is missing or expired; ask the project owner to authorize with doco login --host https://doco.to" ;;
|
|
143
|
+
*) REASON="search failed with HTTP ${HTTP_STATUS}" ;;
|
|
144
|
+
esac
|
|
145
|
+
GUIDANCE=""
|
|
146
|
+
if [ -n "$RESP" ] && { [ "$HTTP_STATUS" = "403" ] || [ "$HTTP_STATUS" = "404" ]; }; then
|
|
147
|
+
GUIDANCE="$RESP"
|
|
148
|
+
fi
|
|
149
|
+
if [ -n "$GUIDANCE" ]; then
|
|
150
|
+
# First line of the body is the title — use it for the indicator
|
|
151
|
+
# so the [🔮 Doco] Not connected yet: line stays single-logical-line.
|
|
152
|
+
# Show the rest below as the recovery section.
|
|
153
|
+
FIRST_LINE=$(printf '%s' "$GUIDANCE" | head -n 1)
|
|
154
|
+
REST=$(printf '%s' "$GUIDANCE" | tail -n +2)
|
|
155
|
+
QUERY_BLOCK=$(printf '\n\n## Doco connection for THIS prompt — paste as your top-of-reply indicator\n\n[🔮 Doco] Not connected yet: %s\n\nThen show the project owner this recovery guidance from the host (do not render any other Doco indicator, footer, or tally lines until the connection is fixed):\n\n%s\n' \
|
|
156
|
+
"$FIRST_LINE" "$REST")
|
|
157
|
+
else
|
|
158
|
+
QUERY_BLOCK=$(printf '\n\n## Doco connection for THIS prompt — paste as your top-of-reply indicator\n\n[🔮 Doco] Not connected yet: %s\n\nDo not render any other Doco indicator, footer, or tally lines until the connection is fixed.\n' "$REASON")
|
|
159
|
+
fi
|
|
160
|
+
fi
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
# 5. Emit the JSON envelope. additionalContext = reminder + pre-fetch.
|
|
164
|
+
FULL="${PROTOCOL_REMINDER}${QUERY_BLOCK}"
|
|
165
|
+
if command -v jq >/dev/null 2>&1; then
|
|
166
|
+
jq -nc --arg c "$FULL" \
|
|
167
|
+
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
|
168
|
+
else
|
|
169
|
+
# jq missing — degrade. Output a minimal envelope referring the agent
|
|
170
|
+
# to the manual reminder.
|
|
171
|
+
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"Doco protocol reminder unavailable (jq missing). Render the query indicator at top + footer_lines after writes manually; see CANONICAL_INSTRUCTIONS."}}\n'
|
|
172
|
+
fi
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Doco agent-bootstrap env. Copy to .env and fill in. .env is gitignored
|
|
2
|
+
# by `doco init`; never commit secrets.
|
|
3
|
+
#
|
|
4
|
+
# Easiest path: don't fill this in by hand — run
|
|
5
|
+
# doco login --host https://doco.to --create <slug>
|
|
6
|
+
# from the repo root. The CLI opens a browser tab where the project
|
|
7
|
+
# owner approves the session and (optionally) creates a new Doco, then
|
|
8
|
+
# the CLI writes DOCO_TOKEN below to ./.env automatically and stamps
|
|
9
|
+
# DOCO_ID into the AGENTS.md header. See the "Where DOCO_ID and
|
|
10
|
+
# DOCO_TOKEN live" section of AGENTS.md for the full flow.
|
|
11
|
+
#
|
|
12
|
+
# Read by .claude/bootstrap-fetch.sh (SessionStart hook) and
|
|
13
|
+
# .claude/user-prompt-fetch.sh (UserPromptSubmit hook). DOCO_ID is NOT
|
|
14
|
+
# here — it lives in AGENTS.md (committed, non-secret coordinator); the
|
|
15
|
+
# hooks fall back to reading it from there.
|
|
16
|
+
|
|
17
|
+
# Bearer token for write capture (POST/PATCH on /api/*.json) + per-Doco
|
|
18
|
+
# context on the bootstrap response. Minted by `doco login` (recommended)
|
|
19
|
+
# or via /by-id/<doco_id>/settings → "Issue token". Treat as
|
|
20
|
+
# secret.
|
|
21
|
+
DOCO_TOKEN=
|