@cairnkeep/cli 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +45 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +201 -0
  4. package/README.md +149 -0
  5. package/SECURITY.md +37 -0
  6. package/bin/cairn +73 -0
  7. package/claude/agents/code-reviewer.md +174 -0
  8. package/claude/agents/security-investigator.md +112 -0
  9. package/claude/agents/security-target-selector.md +90 -0
  10. package/claude/agents/security-validator.md +97 -0
  11. package/claude/agents/wiki-ingester.md +108 -0
  12. package/claude/agents/wiki-lint-auditor.md +94 -0
  13. package/claude/agents/wiki-query-analyst.md +84 -0
  14. package/claude/commands/context-explore.md +40 -0
  15. package/claude/commands/graphify.md +150 -0
  16. package/claude/commands/memory-review.md +85 -0
  17. package/claude/commands/memory-sync.md +71 -0
  18. package/claude/commands/recall.md +32 -0
  19. package/claude/commands/remember.md +43 -0
  20. package/claude/commands/repo-review.md +191 -0
  21. package/claude/commands/security-audit.md +174 -0
  22. package/claude/commands/wiki-ingest.md +112 -0
  23. package/claude/commands/wiki-lint.md +86 -0
  24. package/claude/commands/wiki-query.md +92 -0
  25. package/claude/hooks/context-explore-pretask.sh +93 -0
  26. package/claude/hooks/memory-capture.sh +59 -0
  27. package/claude/hooks/memory-recall.sh +84 -0
  28. package/claude/hooks/memory-wakeup.sh +45 -0
  29. package/docs/building-an-overlay.md +117 -0
  30. package/docs/domain-knowledge.md +86 -0
  31. package/docs/git-providers.md +36 -0
  32. package/docs/operating.md +366 -0
  33. package/mcp-memory-server/dist/embeddings.js +122 -0
  34. package/mcp-memory-server/dist/explore-cache.js +97 -0
  35. package/mcp-memory-server/dist/index.js +1256 -0
  36. package/mcp-memory-server/package.json +36 -0
  37. package/opencode/agents/code-reviewer.md +173 -0
  38. package/opencode/agents/security-investigator.md +111 -0
  39. package/opencode/agents/security-target-selector.md +89 -0
  40. package/opencode/agents/security-validator.md +96 -0
  41. package/opencode/agents/wiki-ingester.md +107 -0
  42. package/opencode/agents/wiki-lint-auditor.md +93 -0
  43. package/opencode/agents/wiki-query-analyst.md +83 -0
  44. package/opencode/command/code-review.md +199 -0
  45. package/opencode/command/context-explore.md +42 -0
  46. package/opencode/command/graphify.md +153 -0
  47. package/opencode/command/memory-review.md +44 -0
  48. package/opencode/command/memory-sync.md +94 -0
  49. package/opencode/command/recall.md +38 -0
  50. package/opencode/command/remember.md +46 -0
  51. package/opencode/command/security-audit.md +80 -0
  52. package/opencode/command/wiki-ingest.md +55 -0
  53. package/opencode/command/wiki-lint.md +51 -0
  54. package/opencode/command/wiki-query.md +50 -0
  55. package/opencode/plugins/memory-capture.ts +188 -0
  56. package/opencode/plugins/memory-recall.ts +132 -0
  57. package/opencode/plugins/memory-wakeup.ts +102 -0
  58. package/opencode/templates/security-finding-register.yaml.template +51 -0
  59. package/opencode/templates/security-issue.md.template +43 -0
  60. package/opencode/templates/security-policy.md.template +29 -0
  61. package/opencode/templates/security-report.md.template +43 -0
  62. package/opencode/templates/wiki-contradictions.md.template +31 -0
  63. package/opencode/templates/wiki-index.md.template +40 -0
  64. package/opencode/templates/wiki-lint-report.md.template +38 -0
  65. package/opencode/templates/wiki-log.md.template +11 -0
  66. package/opencode/templates/wiki-policy.md.template +47 -0
  67. package/opencode/templates/wiki-query-answer.md.template +26 -0
  68. package/opencode/templates/wiki-source-summary.md.template +33 -0
  69. package/opencode/workflows/memory-review-workflow.md +66 -0
  70. package/opencode/workflows/security-audit-workflow.md +197 -0
  71. package/opencode/workflows/wiki-ingest-workflow.md +149 -0
  72. package/opencode/workflows/wiki-lint-workflow.md +128 -0
  73. package/opencode/workflows/wiki-query-workflow.md +122 -0
  74. package/package.json +56 -0
  75. package/scripts/bootstrap.sh +99 -0
  76. package/scripts/doctor.sh +91 -0
  77. package/scripts/install-audit-timer.sh +68 -0
  78. package/scripts/lib/assert-tool-event.mjs +50 -0
  79. package/scripts/memory-store.sh +71 -0
  80. package/scripts/memory-wiki-audit.sh +156 -0
  81. package/scripts/sync-claude-assets.sh +182 -0
  82. package/scripts/sync-opencode-explore-assets.sh +187 -0
  83. package/scripts/sync-opencode-graphify-assets.sh +152 -0
  84. package/scripts/sync-opencode-memory-assets.sh +160 -0
  85. package/scripts/sync-opencode-plugin-assets.sh +163 -0
  86. package/scripts/sync-opencode-security-assets.sh +195 -0
  87. package/scripts/sync-opencode-wiki-assets.sh +250 -0
  88. package/scripts/test-audit-timer.sh +29 -0
  89. package/scripts/test-bootstrap-untracked.sh +41 -0
  90. package/scripts/test-cli-dispatch.sh +21 -0
  91. package/scripts/test-doctor.sh +32 -0
  92. package/scripts/test-launcher-hooks.sh +66 -0
  93. package/scripts/test-memory-store.sh +44 -0
  94. package/scripts/test-remember-recall-assertions.sh +70 -0
  95. package/scripts/transcript-to-text.mjs +53 -0
  96. package/scripts/verify-docs-parity.sh +112 -0
  97. package/scripts/verify-explore-maturation.sh +288 -0
  98. package/scripts/verify-fastcontext-reliability.sh +624 -0
  99. package/scripts/verify-no-private-references.sh +145 -0
  100. package/scripts/verify-opencode-live-parity.sh +864 -0
  101. package/scripts/verify-routing-seam.sh +169 -0
  102. package/scripts/verify-token-savings-ab.sh +557 -0
  103. package/templates/PROJECT-INSTRUCTIONS.md.template +42 -0
  104. package/templates/alignment-gap-register.yaml.template +56 -0
  105. package/templates/alignment-policy.md.template +28 -0
  106. package/templates/alignment-pr-template.md.template +18 -0
  107. package/templates/alignment-report.md.template +56 -0
  108. package/templates/env.example.template +25 -0
  109. package/templates/graph-policy.md.template +37 -0
  110. package/templates/graphs-gitignore.template +3 -0
  111. package/templates/memory-wiki-audit.service.template +6 -0
  112. package/templates/memory-wiki-audit.timer.template +9 -0
  113. package/templates/memory.json.template +4 -0
  114. package/templates/planning-config.json.template +6 -0
  115. package/templates/project-brief.md.template +40 -0
  116. package/templates/security-finding-register.yaml.template +51 -0
  117. package/templates/security-issue.md.template +43 -0
  118. package/templates/security-policy.md.template +29 -0
  119. package/templates/security-report.md.template +43 -0
  120. package/templates/start-claude.sh.template +46 -0
  121. package/templates/start-opencode.sh.template +41 -0
  122. package/templates/structured-change-canvas.md.template +63 -0
  123. package/templates/wiki-contradictions.md.template +31 -0
  124. package/templates/wiki-index.md.template +40 -0
  125. package/templates/wiki-lint-report.md.template +38 -0
  126. package/templates/wiki-log.md.template +11 -0
  127. package/templates/wiki-policy.md.template +47 -0
  128. package/templates/wiki-query-answer.md.template +26 -0
  129. package/templates/wiki-source-summary.md.template +33 -0
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # FastContext raw-endpoint reliability probe (CTX-06 / Phase 6 D-01/D-02/D-03).
5
+ #
6
+ # Mirrors scripts/verify-opencode-live-parity.sh's shape (staged, env-driven,
7
+ # loopback-safe, generous timeouts, single exit-code pass/fail). Unlike that
8
+ # harness, this probe never touches opencode or the cairn-memory MCP at all —
9
+ # it drives the raw llama-server `/v1/chat/completions` endpoint directly with
10
+ # FastContext's own read/glob/grep tool schemas, isolating the variable under
11
+ # test (model + chat-template + --jinja + quant) from any execution loop.
12
+ #
13
+ # Every value that could identify the operator's real infra (the endpoint URL,
14
+ # any Authorization header / API key) is read from the ambient shell at
15
+ # runtime and is never hardcoded, echoed, or written to the evidence log
16
+ # (DEC-no-private-references). Only a loopback default is committed.
17
+ #
18
+ # --self-test is the offline, no-network backstop: it exercises the record-
19
+ # and-check logic (gate #1 /props recording, gate #2 per-turn assertion, and
20
+ # the refined-D-05 verdict scoring) against canned fixture JSON so the gate
21
+ # discriminates PASS from FAIL before any live endpoint is ever reached.
22
+
23
+ usage() {
24
+ cat <<'EOF'
25
+ Usage: verify-fastcontext-reliability.sh --self-test
26
+ verify-fastcontext-reliability.sh --props-only
27
+ verify-fastcontext-reliability.sh --full
28
+ verify-fastcontext-reliability.sh -h|--help
29
+
30
+ Probes a llama-server-hosted FastContext GGUF for tool-call reliability
31
+ (CTX-06). The go/no-go verdict is anchored to the empirical per-turn gate
32
+ (finish_reason == "tool_calls" on every turn of a multi-prompt, multi-turn
33
+ matrix); the /props chat_template_tool_use field is recorded as evidence
34
+ alongside the verdict, not used as an automatic blocker (refined D-05).
35
+
36
+ Options:
37
+ --self-test
38
+ Offline: exercises the /props recording, the strict per-turn tool-call
39
+ assertion, and the refined-D-05 verdict logic against canned fixture
40
+ JSON. No network access, no live model required. This is the automated
41
+ Nyquist backstop for the operator-gated live probe below.
42
+ --props-only
43
+ Live: fetches GET /props from FASTCONTEXT_PROBE_URL and records
44
+ chat_template_tool_use / chat_template_caps / build_info / the raw
45
+ chat_template verbatim to the evidence log (gate #1 only, no chat
46
+ round-trips).
47
+ --full
48
+ Live: gate #1 (/props) plus the full >=5-prompt x >=3-turn tool-call
49
+ matrix (gate #2), then computes and records the go/no-go verdict.
50
+ Exits 0 only on a GO verdict.
51
+ -h, --help
52
+ Show this help text.
53
+
54
+ Environment:
55
+ FASTCONTEXT_PROBE_URL OpenAI-compatible base URL for the deployed
56
+ llama-server. Defaults to a loopback address;
57
+ the operator supplies the real endpoint at
58
+ runtime. Never echoed to stdout or the evidence
59
+ log — only a presence indicator is logged.
60
+ FASTCONTEXT_MODEL_ALIAS Model alias sent in each chat-completion request.
61
+ Defaults to the FastContext RL alias documented
62
+ in 06-RESEARCH.md; override for a different quant.
63
+ FASTCONTEXT_EVIDENCE_LOG Path to the raw evidence log. Defaults to a
64
+ *.log file under this phase's directory, which
65
+ the repo .gitignore already excludes from commits.
66
+ EOF
67
+ }
68
+
69
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
70
+
71
+ FASTCONTEXT_PROBE_URL_DEFAULT="http://127.0.0.1:8081/v1"
72
+ FASTCONTEXT_PROBE_URL="${FASTCONTEXT_PROBE_URL:-$FASTCONTEXT_PROBE_URL_DEFAULT}"
73
+ FASTCONTEXT_MODEL_ALIAS="${FASTCONTEXT_MODEL_ALIAS:-fastcontext-4b-rl}"
74
+ FASTCONTEXT_EVIDENCE_LOG="${FASTCONTEXT_EVIDENCE_LOG:-$ROOT_DIR/.planning/phases/06-fastcontext-reliability-spike/06-EVIDENCE.log}"
75
+
76
+ # System prompt copied verbatim from 06-RESEARCH.md finding #2's curl example
77
+ # (itself copied from token-miser/src/explore/client.rs) - inventing an
78
+ # approximate prompt would confound a no-go with a schema/prompt-mismatch
79
+ # artifact (RESEARCH #3 / Pitfall 5).
80
+ SYSTEM_PROMPT="You are a repository exploration agent. Locate the code relevant to the user's task using the read, glob, and grep tools. Do not attempt to solve the task."
81
+
82
+ # Tool schemas copied verbatim from 06-RESEARCH.md finding #2/#3 (the
83
+ # token-miser client.rs read/glob/grep shape) - field names, descriptions,
84
+ # and required lists are not to be re-derived approximately.
85
+ TOOL_SCHEMAS_JSON=$(cat <<'EOF'
86
+ [
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "read",
91
+ "description": "Read a file's contents with line numbers. Use offset/limit to read a span.",
92
+ "parameters": {
93
+ "type": "object",
94
+ "properties": {
95
+ "path": {"type": "string", "description": "Path relative to the repo root."},
96
+ "offset": {"type": "integer", "description": "1-based first line to read."},
97
+ "limit": {"type": "integer", "description": "Number of lines to read."}
98
+ },
99
+ "required": ["path"]
100
+ }
101
+ }
102
+ },
103
+ {
104
+ "type": "function",
105
+ "function": {
106
+ "name": "glob",
107
+ "description": "List files matching a glob pattern (gitignore-aware).",
108
+ "parameters": {
109
+ "type": "object",
110
+ "properties": {
111
+ "pattern": {"type": "string"},
112
+ "base": {"type": "string", "description": "Optional subdirectory to search under."}
113
+ },
114
+ "required": ["pattern"]
115
+ }
116
+ }
117
+ },
118
+ {
119
+ "type": "function",
120
+ "function": {
121
+ "name": "grep",
122
+ "description": "Regex search across the repo (gitignore-aware). Returns path:line:content.",
123
+ "parameters": {
124
+ "type": "object",
125
+ "properties": {
126
+ "regex": {"type": "string"},
127
+ "path": {"type": "string", "description": "Optional single file to search."},
128
+ "glob": {"type": "string", "description": "Optional glob to limit which files are searched."}
129
+ },
130
+ "required": ["regex"]
131
+ }
132
+ }
133
+ }
134
+ ]
135
+ EOF
136
+ )
137
+
138
+ # >=5 distinct exploration prompts targeting cairnkeep's own repo (the same
139
+ # corpus Phase 9's A/B will measure), per CONTEXT.md Claude's Discretion.
140
+ # Plausible exploration asks only - the probe never executes them; every
141
+ # turn is answered with a static stubbed tool result regardless of what the
142
+ # model actually asked for (Pattern 1).
143
+ EXPLORATION_PROMPTS=(
144
+ "Where is scope path containment implemented in this repo?"
145
+ "Where is the AgentFS project scope resolved in this repo?"
146
+ "Where is the git-provider abstraction configured in this repo?"
147
+ "Where does the OpenCode memory-wakeup plugin live in this repo?"
148
+ "Where do the asset-sync scripts render the infra-root placeholder value?"
149
+ )
150
+
151
+ # Globals populated by inspect_props()/record_props_evidence() and by
152
+ # run_turn_matrix()/compute_verdict() (Tasks 2-3). Declared here with safe
153
+ # defaults because `set -u` treats an unset reference as an error.
154
+ GATE1_STATUS=""
155
+ PROPS_RAW=""
156
+ MATRIX_TOTAL=0
157
+ MATRIX_PASS=0
158
+ MATRIX_RESULTS=()
159
+ VERDICT=""
160
+
161
+ # validate_probe_url(url): refuses to run against a value that doesn't look
162
+ # like an http(s) URL (T-06-03 / V5 input validation) before any curl use.
163
+ # Never includes the URL value itself in the error message (T-06-02).
164
+ validate_probe_url() {
165
+ local url="$1"
166
+ if [[ ! "$url" =~ ^https?:// ]]; then
167
+ echo "FATAL: FASTCONTEXT_PROBE_URL does not look like an http(s) URL (value withheld)" >&2
168
+ exit 1
169
+ fi
170
+ }
171
+
172
+ # log_endpoint_presence(): echoes only whether FASTCONTEXT_PROBE_URL was
173
+ # overridden from the loopback default — never the value itself (T-06-02,
174
+ # mirrors log_env_presence() in verify-opencode-live-parity.sh).
175
+ log_endpoint_presence() {
176
+ local overridden="no"
177
+ [[ "$FASTCONTEXT_PROBE_URL" != "$FASTCONTEXT_PROBE_URL_DEFAULT" ]] && overridden="yes"
178
+ echo "[env] FASTCONTEXT_PROBE_URL overridden from loopback default: $overridden" >&2
179
+ }
180
+
181
+ # append_evidence(line): appends one line to the evidence log, creating the
182
+ # parent directory if needed. Callers are responsible for never passing the
183
+ # endpoint URL or a secret through this helper.
184
+ append_evidence() {
185
+ local line="$1"
186
+ mkdir -p "$(dirname "$FASTCONTEXT_EVIDENCE_LOG")"
187
+ echo "$line" >> "$FASTCONTEXT_EVIDENCE_LOG"
188
+ }
189
+
190
+ # record_props_evidence(props_json, log_target): records chat_template_tool_use
191
+ # (present-or-absent), chat_template_caps, build_info, and the raw
192
+ # chat_template verbatim to log_target (if non-empty), sets the GATE1_STATUS
193
+ # global, and echoes the gate-1 line to stdout (used by self-test to assert
194
+ # on the recorded outcome without a live network call).
195
+ record_props_evidence() {
196
+ local props="$1"
197
+ local log_target="${2:-}"
198
+ local gate1_line
199
+
200
+ if echo "$props" | jq -e 'has("chat_template_tool_use")' >/dev/null 2>&1; then
201
+ GATE1_STATUS="PRESENT"
202
+ gate1_line="[gate-1] chat_template_tool_use PRESENT"
203
+ else
204
+ GATE1_STATUS="ABSENT"
205
+ gate1_line="[gate-1] chat_template_tool_use ABSENT (expected for a single-unified-template Qwen3-family GGUF - see 06-RESEARCH.md finding #1; recorded as evidence, NOT a script bug and NOT an auto-no-go per refined D-05)"
206
+ fi
207
+
208
+ if [[ -n "$log_target" ]]; then
209
+ mkdir -p "$(dirname "$log_target")"
210
+ {
211
+ echo "=== /props snapshot ($(date -u +%FT%TZ)) ==="
212
+ echo "$props" | jq '{chat_template_tool_use, chat_template_caps, build_info}'
213
+ echo "raw chat_template:"
214
+ echo "$props" | jq -r '.chat_template // "MISSING"'
215
+ echo "$gate1_line"
216
+ } >> "$log_target"
217
+ fi
218
+
219
+ echo "$gate1_line"
220
+ }
221
+
222
+ # inspect_props(url): GET {url}/props (falling back to {url%/v1}/props),
223
+ # records the raw payload verbatim via record_props_evidence(), and sets
224
+ # PROPS_RAW for later use (verdict finalize, Task 3). An absent
225
+ # chat_template_tool_use field never triggers a retry or a non-zero return
226
+ # on its own.
227
+ inspect_props() {
228
+ local url="$1"
229
+ local props
230
+ props=$(curl -sf --max-time 15 "${url}/props" 2>/dev/null) || \
231
+ props=$(curl -sf --max-time 15 "${url%/v1}/props" 2>/dev/null) || {
232
+ echo "[inspect_props] FAIL: unable to reach a /props endpoint" >&2
233
+ return 1
234
+ }
235
+ PROPS_RAW="$props"
236
+ record_props_evidence "$props" "$FASTCONTEXT_EVIDENCE_LOG"
237
+ }
238
+
239
+ # assert_tool_call_turn(response_json): PASS only when finish_reason ==
240
+ # "tool_calls" AND message.tool_calls is a non-empty array - gated strictly
241
+ # on jq JSON structure, never a substring match on content. The stale-doc
242
+ # value "tool" (singular, docs/function-calling.md's example - confirmed
243
+ # stale against the current llama.cpp source in 06-RESEARCH.md finding #2)
244
+ # is deliberately NOT accepted as a pass; do not "fix" this to match the
245
+ # stale doc if a future build is observed to emit it.
246
+ assert_tool_call_turn() {
247
+ local response_json="$1"
248
+ local finish_reason n_calls
249
+
250
+ finish_reason=$(echo "$response_json" | jq -r '.choices[0].finish_reason // "missing"')
251
+ n_calls=$(echo "$response_json" | jq '.choices[0].message.tool_calls | length? // 0')
252
+
253
+ if [[ "$finish_reason" == "tool_calls" && "$n_calls" -gt 0 ]]; then
254
+ return 0
255
+ fi
256
+ return 1
257
+ }
258
+
259
+ # tool_result_message(tool_call_id): the stubbed role:"tool" reply appended
260
+ # after every observed tool_calls turn (Pattern 1 - the probe never executes
261
+ # real read/glob/grep against the filesystem; it only observes whether the
262
+ # model reliably emits well-formed tool calls turn after turn).
263
+ tool_result_message() {
264
+ local tool_call_id="$1"
265
+ jq -n --arg id "$tool_call_id" '{
266
+ role: "tool",
267
+ tool_call_id: $id,
268
+ content: "1: fn placeholder() {}\n2: // stubbed tool result for reliability probing\n3: }"
269
+ }'
270
+ }
271
+
272
+ # normalize_tool_call_ids(message_json, id_prefix): guarantees every tool_call
273
+ # in an assistant message carries a non-empty id, synthesizing "<prefix>_<idx>"
274
+ # for any that lack one. Fixes CR-01: the replay previously answered only
275
+ # tool_calls[0].id, so a turn with PARALLEL tool_calls (or a missing .id)
276
+ # desynced the re-sent OpenAI transcript and biased later turns toward
277
+ # narration - a false NO-GO. With ids normalized, exactly one role:"tool"
278
+ # reply is matched to each call.
279
+ normalize_tool_call_ids() {
280
+ local message_json="$1" prefix="$2"
281
+ echo "$message_json" | jq --arg pfx "$prefix" '
282
+ if (.tool_calls | type) == "array" then
283
+ .tool_calls |= (to_entries | map(
284
+ .value + (if ((.value.id // "") == "")
285
+ then {id: ($pfx + "_" + (.key | tostring))}
286
+ else {} end)))
287
+ else . end'
288
+ }
289
+
290
+ # run_turn_matrix(url, turns_per_prompt): the gate #2 matrix. For each
291
+ # EXPLORATION_PROMPTS entry, runs a genuine multi-turn loop over an
292
+ # accumulating messages array (system + user, then each assistant turn and
293
+ # stubbed tool result appended in place - never rebuilt as a fresh length-2
294
+ # array between turns, Pitfall 3). Populates MATRIX_TOTAL/MATRIX_PASS/
295
+ # MATRIX_RESULTS for compute_verdict() and finalize_evidence_log() (Task 3).
296
+ run_turn_matrix() {
297
+ local url="$1"
298
+ local turns_per_prompt="${2:-3}"
299
+ local prompt prompt_idx=0 turn_idx
300
+ local messages request response n_calls tool_call_id assistant_msg tool_msg result
301
+
302
+ MATRIX_TOTAL=0
303
+ MATRIX_PASS=0
304
+ MATRIX_RESULTS=()
305
+
306
+ for prompt in "${EXPLORATION_PROMPTS[@]}"; do
307
+ prompt_idx=$((prompt_idx + 1))
308
+ messages=$(jq -n --arg sys "$SYSTEM_PROMPT" --arg user "$prompt" \
309
+ '[{role: "system", content: $sys}, {role: "user", content: $user}]')
310
+
311
+ for ((turn_idx = 1; turn_idx <= turns_per_prompt; turn_idx++)); do
312
+ MATRIX_TOTAL=$((MATRIX_TOTAL + 1))
313
+
314
+ request=$(jq -n --argjson messages "$messages" --argjson tools "$TOOL_SCHEMAS_JSON" \
315
+ --arg model "$FASTCONTEXT_MODEL_ALIAS" \
316
+ '{model: $model, temperature: 0, stream: false, tool_choice: "auto", tools: $tools, messages: $messages}')
317
+
318
+ if ! response=$(curl -sf --max-time 60 "${url}/chat/completions" \
319
+ -H "Content-Type: application/json" -d "$request" 2>/dev/null); then
320
+ echo "[matrix] FAIL: request error at prompt $prompt_idx turn $turn_idx" >&2
321
+ MATRIX_RESULTS+=("$prompt_idx|$turn_idx|FAIL|request-error|0")
322
+ append_evidence "[matrix] prompt=$prompt_idx turn=$turn_idx result=FAIL reason=request-error"
323
+ continue
324
+ fi
325
+
326
+ # WR-01: a 2xx body that is not valid JSON must be recorded as a FAIL,
327
+ # not abort the whole probe. Under `set -euo pipefail` a bare jq parse
328
+ # error downstream would exit the script mid-matrix, so validate up front
329
+ # and record-then-continue instead of silently dying.
330
+ if ! echo "$response" | jq -e . >/dev/null 2>&1; then
331
+ echo "[matrix] FAIL: malformed JSON body at prompt $prompt_idx turn $turn_idx" >&2
332
+ MATRIX_RESULTS+=("$prompt_idx|$turn_idx|FAIL|malformed-json|0")
333
+ append_evidence "[matrix] prompt=$prompt_idx turn=$turn_idx result=FAIL reason=malformed-json"
334
+ continue
335
+ fi
336
+
337
+ # Log the raw tool-call arguments even though the result is stubbed -
338
+ # a free early signal for Phase 7 planning (Pitfall 4).
339
+ echo "$response" | jq -c '.choices[0].message.tool_calls[]?.function.arguments // empty' 2>/dev/null \
340
+ | while IFS= read -r args; do
341
+ append_evidence "[tool-call-args] prompt=$prompt_idx turn=$turn_idx args=$args"
342
+ done
343
+
344
+ n_calls=$(echo "$response" | jq '.choices[0].message.tool_calls | length? // 0')
345
+ if assert_tool_call_turn "$response"; then
346
+ MATRIX_PASS=$((MATRIX_PASS + 1))
347
+ result="PASS"
348
+ else
349
+ result="FAIL"
350
+ fi
351
+ local finish_reason
352
+ finish_reason=$(echo "$response" | jq -r '.choices[0].finish_reason // "missing"')
353
+ MATRIX_RESULTS+=("$prompt_idx|$turn_idx|$result|$finish_reason|$n_calls")
354
+ append_evidence "[matrix] prompt=$prompt_idx turn=$turn_idx result=$result finish_reason=$finish_reason tool_calls=$n_calls"
355
+
356
+ # Append this turn to the accumulating conversation and continue -
357
+ # never reset to a fresh length-2 messages array (Pitfall 3). Normalize
358
+ # tool_call ids first (CR-01) so the appended assistant message and its
359
+ # replies agree on every id, including any the model omitted.
360
+ assistant_msg=$(echo "$response" | jq '.choices[0].message')
361
+ assistant_msg=$(normalize_tool_call_ids "$assistant_msg" "call_p${prompt_idx}_t${turn_idx}")
362
+ messages=$(echo "$messages" | jq --argjson m "$assistant_msg" '. + [$m]')
363
+
364
+ # CR-01: reply to EVERY tool_call with its own role:"tool" message, not
365
+ # just tool_calls[0]. Parallel calls (common for Qwen3-family exploration
366
+ # models) otherwise leave dangling calls and desync the next turn.
367
+ while IFS= read -r tool_call_id; do
368
+ [[ -z "$tool_call_id" ]] && continue
369
+ tool_msg=$(tool_result_message "$tool_call_id")
370
+ messages=$(echo "$messages" | jq --argjson t "$tool_msg" '. + [$t]')
371
+ done < <(echo "$assistant_msg" | jq -r '.tool_calls[]?.id // empty')
372
+ done
373
+ done
374
+ }
375
+
376
+ # compute_verdict(): the refined-D-05 scoring ("Evidence, not hard gate",
377
+ # this plan's <d05_refinement>). GO only when every turn in the matrix
378
+ # passed assert_tool_call_turn (gate #2, D-06 hard blocker on any single
379
+ # narration/malformed turn). Gate #1 (chat_template_tool_use, from
380
+ # GATE1_STATUS) is recorded as evidence alongside the verdict and MUST NOT
381
+ # by itself force a NO-GO - its absence is expected and architecturally
382
+ # explainable for a single-unified-template Qwen3-family GGUF.
383
+ compute_verdict() {
384
+ if [[ "$MATRIX_TOTAL" -gt 0 && "$MATRIX_PASS" -eq "$MATRIX_TOTAL" ]]; then
385
+ VERDICT="GO"
386
+ else
387
+ VERDICT="NO-GO"
388
+ fi
389
+
390
+ echo "[verdict] gate-1 chat_template_tool_use: ${GATE1_STATUS:-UNKNOWN} (absence alone never forces NO-GO - refined D-05)"
391
+ echo "[verdict] gate-2 tool-call matrix: ${MATRIX_PASS}/${MATRIX_TOTAL} turns passed"
392
+ echo "[verdict] VERDICT: $VERDICT"
393
+ }
394
+
395
+ # run_token_miser_corroboration(): optional stage 3 (D-04). Corroboration
396
+ # only - never the verdict basis, and its absence is never a failure.
397
+ run_token_miser_corroboration() {
398
+ if command -v token_miser >/dev/null 2>&1; then
399
+ local out
400
+ echo "[stage-3] token_miser found on PATH - running corroboration explore against this repo"
401
+ out=$(cd "$ROOT_DIR" && timeout 60 token_miser explore --repo-root . 2>&1) || true
402
+ # WR-02: token_miser output can echo endpoint/env config; keep the raw text
403
+ # out of the evidence log (which advertises URL/secret-free) - surface it on
404
+ # stderr for the operator and record only a redacted status line.
405
+ echo "$out" >&2
406
+ append_evidence "[stage-3] token_miser corroboration ran ($(printf '%s' "$out" | wc -l) lines); raw output withheld from evidence log to preserve the URL/secret-free invariant"
407
+ else
408
+ local msg="[stage-3] token_miser absent from PATH - optional corroboration skipped (D-04); verdict remains anchored to the raw endpoint (D-03)"
409
+ echo "$msg"
410
+ append_evidence "$msg"
411
+ fi
412
+ }
413
+
414
+ # finalize_evidence_log(): appends the per-turn results table and the D-08
415
+ # pinned-combination block (build_info, chat_template excerpt, gate-1
416
+ # status from /props) to the evidence log, then re-affirms the URL/secret
417
+ # scrub - only a presence indicator is ever written, never the endpoint
418
+ # value or a bearer token.
419
+ finalize_evidence_log() {
420
+ {
421
+ echo "=== per-turn results ($(date -u +%FT%TZ)) ==="
422
+ echo "prompt|turn|result|finish_reason|tool_calls_count"
423
+ local r
424
+ for r in "${MATRIX_RESULTS[@]:-}"; do
425
+ [[ -n "$r" ]] && echo "$r"
426
+ done
427
+
428
+ echo "=== pinned combination (D-08) ==="
429
+ if [[ -n "$PROPS_RAW" ]]; then
430
+ echo "build_info: $(echo "$PROPS_RAW" | jq -r '.build_info // "unknown"')"
431
+ echo "chat_template_tool_use: ${GATE1_STATUS:-UNKNOWN}"
432
+ echo "--jinja: assumed on (server-side flag, not queryable via /props)"
433
+ echo "chat_template excerpt (first 400 chars):"
434
+ echo "$PROPS_RAW" | jq -r '.chat_template // "unknown"' | head -c 400
435
+ echo
436
+ else
437
+ echo "PROPS_RAW unavailable - /props was not fetched this run"
438
+ fi
439
+
440
+ echo "=== verdict ==="
441
+ echo "gate-1 chat_template_tool_use: ${GATE1_STATUS:-UNKNOWN}"
442
+ echo "gate-2 matrix: ${MATRIX_PASS}/${MATRIX_TOTAL}"
443
+ echo "VERDICT: ${VERDICT:-UNKNOWN}"
444
+ echo "[scrub-check] this log never contains FASTCONTEXT_PROBE_URL's value or an Authorization header/API key"
445
+ } >> "$FASTCONTEXT_EVIDENCE_LOG"
446
+
447
+ echo "[finalize] evidence log updated at $FASTCONTEXT_EVIDENCE_LOG (URL/secrets never written)"
448
+ }
449
+
450
+ # self_test_verdict(): proves compute_verdict()'s refined-D-05 scoring
451
+ # offline - (a) an all-PASS matrix yields GO, (b) a single narration turn
452
+ # yields NO-GO (D-06 hard blocker), and (c) a chat_template_tool_use-ABSENT
453
+ # fixture with an all-PASS matrix still yields GO (the refined-D-05 guard:
454
+ # field absence alone must never block).
455
+ self_test_verdict() {
456
+ local failures=0
457
+
458
+ MATRIX_TOTAL=15
459
+ MATRIX_PASS=15
460
+ GATE1_STATUS="ABSENT"
461
+ compute_verdict >/dev/null
462
+ if [[ "$VERDICT" != "GO" ]]; then
463
+ echo "[self-test:verdict] FAIL: all-PASS matrix + gate1 ABSENT expected GO, got $VERDICT" >&2
464
+ failures=1
465
+ fi
466
+
467
+ MATRIX_TOTAL=15
468
+ MATRIX_PASS=14
469
+ GATE1_STATUS="ABSENT"
470
+ compute_verdict >/dev/null
471
+ if [[ "$VERDICT" != "NO-GO" ]]; then
472
+ echo "[self-test:verdict] FAIL: one-narration-turn matrix expected NO-GO, got $VERDICT" >&2
473
+ failures=1
474
+ fi
475
+
476
+ MATRIX_TOTAL=15
477
+ MATRIX_PASS=15
478
+ GATE1_STATUS="PRESENT"
479
+ compute_verdict >/dev/null
480
+ if [[ "$VERDICT" != "GO" ]]; then
481
+ echo "[self-test:verdict] FAIL: all-PASS matrix + gate1 PRESENT expected GO, got $VERDICT" >&2
482
+ failures=1
483
+ fi
484
+
485
+ if [[ "$failures" -eq 0 ]]; then
486
+ echo "[self-test:verdict] OK: GO/NO-GO logic verified, including the refined-D-05 absent-field guard"
487
+ return 0
488
+ fi
489
+ return 1
490
+ }
491
+
492
+ # self_test_matrix_assertion(): proves assert_tool_call_turn() discriminates
493
+ # a PASS fixture (finish_reason == "tool_calls" AND a non-empty tool_calls
494
+ # array) from a narration-FAIL fixture (finish_reason == "stop", content-only)
495
+ # - offline, no network call.
496
+ self_test_matrix_assertion() {
497
+ local fixture_pass fixture_fail
498
+
499
+ fixture_pass='{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"grep","arguments":"{\"regex\":\"selftest\"}"}}]}}]}'
500
+ fixture_fail='{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"I will grep for selftest next..."}}]}'
501
+
502
+ if ! assert_tool_call_turn "$fixture_pass"; then
503
+ echo "[self-test:matrix] FAIL: PASS fixture was rejected by assert_tool_call_turn" >&2
504
+ return 1
505
+ fi
506
+
507
+ if assert_tool_call_turn "$fixture_fail"; then
508
+ echo "[self-test:matrix] FAIL: narration-FAIL fixture was incorrectly accepted by assert_tool_call_turn" >&2
509
+ return 1
510
+ fi
511
+
512
+ echo "[self-test:matrix] OK: PASS fixture accepted, narration-FAIL fixture rejected"
513
+ }
514
+
515
+ # self_test_props(): exercises record_props_evidence() against a
516
+ # chat_template_tool_use-ABSENT fixture and a -PRESENT fixture, entirely
517
+ # offline (no curl, no FASTCONTEXT_PROBE_URL reachability required), and
518
+ # asserts the absent case is recorded as evidence rather than erroring.
519
+ self_test_props() {
520
+ local tmp_log fixture_absent fixture_present out
521
+
522
+ tmp_log=$(mktemp)
523
+
524
+ fixture_absent='{"chat_template":"{% raw fixture, single unified template %}","chat_template_caps":{"tools":true},"build_info":"b0000-selftest"}'
525
+ fixture_present='{"chat_template":"{% raw fixture, default variant %}","chat_template_tool_use":"{% raw fixture, tool_use variant %}","chat_template_caps":{"tools":true},"build_info":"b0000-selftest"}'
526
+
527
+ out=$(record_props_evidence "$fixture_absent" "$tmp_log")
528
+ if ! echo "$out" | grep -q "ABSENT"; then
529
+ echo "[self-test:props] FAIL: absent-field fixture was not recorded as ABSENT" >&2
530
+ rm -f "$tmp_log"
531
+ return 1
532
+ fi
533
+
534
+ out=$(record_props_evidence "$fixture_present" "$tmp_log")
535
+ if ! echo "$out" | grep -q "PRESENT"; then
536
+ echo "[self-test:props] FAIL: present-field fixture was not recorded as PRESENT" >&2
537
+ rm -f "$tmp_log"
538
+ return 1
539
+ fi
540
+
541
+ rm -f "$tmp_log"
542
+ echo "[self-test:props] OK: absent-field and present-field /props fixtures both recorded without error"
543
+ }
544
+
545
+ # self_test_parallel_tool_replies(): proves the CR-01 fix offline - a turn with
546
+ # two tool_calls (one of them missing an id) is normalized so that every call
547
+ # gets a distinct, non-empty id, i.e. one role:"tool" reply per call rather than
548
+ # only the first. Guards against a regression back to the single-reply desync.
549
+ self_test_parallel_tool_replies() {
550
+ local fixture normalized ids id_count distinct_count
551
+ # Two parallel calls; the first deliberately omits "id" to exercise synthesis.
552
+ fixture='{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"grep","arguments":"{}"}},{"id":"call_b","type":"function","function":{"name":"read","arguments":"{}"}}]}'
553
+ normalized=$(normalize_tool_call_ids "$fixture" "call_pX_tY")
554
+ ids=$(echo "$normalized" | jq -r '.tool_calls[].id // ""')
555
+ id_count=$(printf '%s\n' "$ids" | grep -c .)
556
+ distinct_count=$(printf '%s\n' "$ids" | sort -u | grep -c .)
557
+
558
+ if printf '%s\n' "$ids" | grep -q '^$'; then
559
+ echo "[self-test:parallel] FAIL: a tool_call was left without an id: [$ids]" >&2
560
+ return 1
561
+ fi
562
+ if [[ "$id_count" -ne 2 || "$distinct_count" -ne 2 ]]; then
563
+ echo "[self-test:parallel] FAIL: expected 2 distinct tool_call ids, got count=$id_count distinct=$distinct_count" >&2
564
+ return 1
565
+ fi
566
+ echo "[self-test:parallel] OK: every tool_call (incl. missing-id) gets a distinct reply id (CR-01 guard)"
567
+ }
568
+
569
+ # run_self_test(): the offline self-test entry point. Task 1 covers only the
570
+ # /props recording path; Tasks 2-3 extend this with the tool-call-matrix
571
+ # assertion and verdict-scoring fixtures.
572
+ run_self_test() {
573
+ local failures=0
574
+
575
+ self_test_props || failures=1
576
+ self_test_matrix_assertion || failures=1
577
+ self_test_verdict || failures=1
578
+ self_test_parallel_tool_replies || failures=1
579
+
580
+ if [[ "$failures" -ne 0 ]]; then
581
+ echo "[self-test] FAILED" >&2
582
+ return 1
583
+ fi
584
+ echo "[self-test] PASSED"
585
+ }
586
+
587
+ main() {
588
+ case "${1:-}" in
589
+ --self-test)
590
+ run_self_test
591
+ ;;
592
+ --props-only)
593
+ validate_probe_url "$FASTCONTEXT_PROBE_URL"
594
+ log_endpoint_presence
595
+ inspect_props "$FASTCONTEXT_PROBE_URL"
596
+ ;;
597
+ --full)
598
+ validate_probe_url "$FASTCONTEXT_PROBE_URL"
599
+ log_endpoint_presence
600
+ if ! inspect_props "$FASTCONTEXT_PROBE_URL"; then
601
+ echo "[main:--full] FATAL: /props unreachable - server not up per D-07, recording as a no-go blocker" >&2
602
+ append_evidence "[main:--full] FATAL: /props unreachable at run time; treated as a documented no-go blocker (D-08 - never a silent skip)"
603
+ exit 1
604
+ fi
605
+ run_turn_matrix "$FASTCONTEXT_PROBE_URL" 3
606
+ compute_verdict
607
+ run_token_miser_corroboration
608
+ finalize_evidence_log
609
+ if [[ "$VERDICT" == "GO" ]]; then
610
+ exit 0
611
+ fi
612
+ exit 1
613
+ ;;
614
+ -h|--help)
615
+ usage
616
+ ;;
617
+ *)
618
+ usage >&2
619
+ exit 2
620
+ ;;
621
+ esac
622
+ }
623
+
624
+ main "$@"