cleargate 0.10.0 → 0.11.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +11 -1
  3. package/dist/MANIFEST.json +40 -26
  4. package/dist/chunk-HZPJ5QX4.js +459 -0
  5. package/dist/chunk-HZPJ5QX4.js.map +1 -0
  6. package/dist/cli.cjs +419 -202
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +387 -513
  9. package/dist/cli.js.map +1 -1
  10. package/dist/lib/lifecycle-reconcile.cjs +497 -0
  11. package/dist/lib/lifecycle-reconcile.cjs.map +1 -0
  12. package/dist/lib/lifecycle-reconcile.d.cts +136 -0
  13. package/dist/lib/lifecycle-reconcile.d.ts +136 -0
  14. package/dist/lib/lifecycle-reconcile.js +20 -0
  15. package/dist/lib/lifecycle-reconcile.js.map +1 -0
  16. package/dist/templates/cleargate-planning/.claude/agents/architect.md +55 -2
  17. package/dist/templates/cleargate-planning/.claude/agents/developer.md +22 -0
  18. package/dist/templates/cleargate-planning/.claude/agents/devops.md +249 -0
  19. package/dist/templates/cleargate-planning/.claude/agents/qa.md +41 -0
  20. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +44 -8
  21. package/dist/templates/cleargate-planning/.claude/hooks/pre-commit-surface-gate.sh +21 -0
  22. package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +12 -1
  23. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +21 -1
  24. package/dist/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +200 -29
  25. package/dist/templates/cleargate-planning/.cleargate/knowledge/mid-sprint-triage-rubric.md +160 -0
  26. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +41 -9
  27. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +98 -16
  28. package/dist/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +3 -3
  29. package/dist/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +86 -10
  30. package/dist/templates/cleargate-planning/.cleargate/scripts/run_script.sh +173 -87
  31. package/dist/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +150 -22
  32. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  33. package/dist/templates/cleargate-planning/.cleargate/scripts/validate_state.mjs +32 -8
  34. package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +12 -1
  35. package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +3 -0
  36. package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +3 -0
  37. package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +3 -0
  38. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +3 -0
  39. package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +1 -1
  40. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_context.md +8 -0
  41. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +3 -0
  42. package/dist/templates/cleargate-planning/CLAUDE.md +3 -1
  43. package/dist/templates/cleargate-planning/MANIFEST.json +40 -26
  44. package/package.json +8 -5
  45. package/templates/cleargate-planning/.claude/agents/architect.md +55 -2
  46. package/templates/cleargate-planning/.claude/agents/developer.md +22 -0
  47. package/templates/cleargate-planning/.claude/agents/devops.md +249 -0
  48. package/templates/cleargate-planning/.claude/agents/qa.md +41 -0
  49. package/templates/cleargate-planning/.claude/agents/reporter.md +44 -8
  50. package/templates/cleargate-planning/.claude/hooks/pre-commit-surface-gate.sh +21 -0
  51. package/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +12 -1
  52. package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +21 -1
  53. package/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +200 -29
  54. package/templates/cleargate-planning/.cleargate/knowledge/mid-sprint-triage-rubric.md +160 -0
  55. package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +41 -9
  56. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +98 -16
  57. package/templates/cleargate-planning/.cleargate/scripts/gate-checks.json +3 -3
  58. package/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +86 -10
  59. package/templates/cleargate-planning/.cleargate/scripts/run_script.sh +173 -87
  60. package/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +150 -22
  61. package/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  62. package/templates/cleargate-planning/.cleargate/scripts/validate_state.mjs +32 -8
  63. package/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +12 -1
  64. package/templates/cleargate-planning/.cleargate/templates/Bug.md +3 -0
  65. package/templates/cleargate-planning/.cleargate/templates/CR.md +3 -0
  66. package/templates/cleargate-planning/.cleargate/templates/epic.md +3 -0
  67. package/templates/cleargate-planning/.cleargate/templates/hotfix.md +3 -0
  68. package/templates/cleargate-planning/.cleargate/templates/initiative.md +1 -1
  69. package/templates/cleargate-planning/.cleargate/templates/sprint_context.md +8 -0
  70. package/templates/cleargate-planning/.cleargate/templates/story.md +3 -0
  71. package/templates/cleargate-planning/CLAUDE.md +3 -1
  72. package/templates/cleargate-planning/MANIFEST.json +40 -26
@@ -1,123 +1,209 @@
1
1
  #!/usr/bin/env bash
2
- # run_script.sh — Wrapper that captures stdout/stderr separately and prints a
3
- # structured diagnostic block on non-zero exit.
4
- # Usage: run_script.sh <script-name> [args...]
5
- # Supported extensions: .mjs (runs via node), .sh (runs via bash)
2
+ # run_script.sh — Arbitrary-command wrapper that captures stdout/stderr independently
3
+ # and writes a structured JSON incident file on non-zero exit.
4
+ #
5
+ # Interface: bash run_script.sh <command> [args...]
6
+ # <command> — any executable on PATH (e.g. node, bash, sh, true, false)
7
+ # [args...] — forwarded to the command unchanged
8
+ #
9
+ # Example (node script):
10
+ # bash run_script.sh node .cleargate/scripts/update_state.mjs STORY-01 Done
11
+ # Example (bash script):
12
+ # bash run_script.sh bash .cleargate/scripts/pre_gate_runner.sh qa .worktrees/X sprint/S-01
13
+ #
14
+ # On success (exit 0): stdout+stderr are passed through; no incident file written.
15
+ # On failure (exit ≠ 0): stdout+stderr are passed through AND a JSON incident is
16
+ # written to .cleargate/sprint-runs/<active-sprint>/.script-incidents/<ts>-<hash>.json
17
+ #
18
+ # Self-exemption: if RUN_SCRIPT_ACTIVE=1 is already set, the wrapper is already
19
+ # running — do not nest. Execute the command directly to avoid infinite recursion.
20
+ # This guard implements the self-exempt contract documented in SKILL.md §C.x.
21
+ #
22
+ # Env vars read:
23
+ # ORCHESTRATOR_PROJECT_DIR — project root override (falls back to CLAUDE_PROJECT_DIR,
24
+ # then to git rev-parse --show-toplevel, then script dir ancestor)
25
+ # AGENT_TYPE — populates incident JSON agent_type field (null if empty)
26
+ # WORK_ITEM_ID — populates incident JSON work_item_id field (null if empty)
27
+ # RUN_SCRIPT_ACTIVE — self-exemption guard (set to 1 by this wrapper)
28
+
6
29
  set -euo pipefail
7
30
 
8
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ # ---------------------------------------------------------------------------
32
+ # Self-exemption guard — do not wrap recursively
33
+ # ---------------------------------------------------------------------------
34
+ if [[ "${RUN_SCRIPT_ACTIVE:-}" == "1" ]]; then
35
+ # Already inside a wrapper invocation; pass through directly, no JSON capture
36
+ exec "$@"
37
+ fi
9
38
 
10
39
  # ---------------------------------------------------------------------------
11
40
  # Usage guard
12
41
  # ---------------------------------------------------------------------------
13
42
  if [[ $# -lt 1 ]]; then
14
- echo "Usage: run_script.sh <script-name> [args...]" >&2
43
+ echo "Usage: bash run_script.sh <command> [args...]" >&2
15
44
  exit 2
16
45
  fi
17
46
 
18
- SCRIPT_NAME="$1"
19
- shift
20
- SCRIPT_ARGS=()
21
- if [[ $# -gt 0 ]]; then
22
- SCRIPT_ARGS=("$@")
23
- fi
24
-
25
47
  # ---------------------------------------------------------------------------
26
- # Resolve path script may be an absolute path or relative to SCRIPT_DIR
48
+ # Resolve project root for incident file path
27
49
  # ---------------------------------------------------------------------------
28
- if [[ "$SCRIPT_NAME" == /* ]]; then
29
- SCRIPT_PATH="$SCRIPT_NAME"
30
- else
31
- SCRIPT_PATH="${SCRIPT_DIR}/${SCRIPT_NAME}"
32
- fi
50
+ _resolve_project_root() {
51
+ # Priority: ORCHESTRATOR_PROJECT_DIR → CLAUDE_PROJECT_DIR → git toplevel → script dir ancestor
52
+ if [[ -n "${ORCHESTRATOR_PROJECT_DIR:-}" ]]; then
53
+ echo "$ORCHESTRATOR_PROJECT_DIR"
54
+ return
55
+ fi
56
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
57
+ echo "$CLAUDE_PROJECT_DIR"
58
+ return
59
+ fi
60
+ if _root=$(git rev-parse --show-toplevel 2>/dev/null); then
61
+ echo "$_root"
62
+ return
63
+ fi
64
+ # Fallback: assume run_script.sh lives in <repo>/.cleargate/scripts/
65
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../.. && pwd)"
66
+ }
33
67
 
34
- # ---------------------------------------------------------------------------
35
- # Extension routing
36
- # ---------------------------------------------------------------------------
37
- EXT="${SCRIPT_NAME##*.}"
38
- case "$EXT" in
39
- mjs) RUNNER="node" ;;
40
- sh) RUNNER="bash" ;;
41
- *)
42
- echo "unsupported extension: .${EXT}" >&2
43
- exit 2
44
- ;;
45
- esac
68
+ PROJECT_ROOT="$(_resolve_project_root)"
69
+ SPRINT_RUNS_DIR="${PROJECT_ROOT}/.cleargate/sprint-runs"
70
+ ACTIVE_FILE="${SPRINT_RUNS_DIR}/.active"
46
71
 
47
- # ---------------------------------------------------------------------------
48
- # Check script exists
49
- # ---------------------------------------------------------------------------
50
- if [[ ! -f "$SCRIPT_PATH" ]]; then
51
- echo "run_script.sh: script not found: ${SCRIPT_PATH}" >&2
52
- exit 2
72
+ # Determine sprint incident dir
73
+ if [[ -f "$ACTIVE_FILE" ]]; then
74
+ SPRINT_ID="$(cat "$ACTIVE_FILE" | tr -d '[:space:]')"
75
+ INCIDENTS_DIR="${SPRINT_RUNS_DIR}/${SPRINT_ID}/.script-incidents"
76
+ else
77
+ # No active sprint sentinel → write to _off-sprint bucket
78
+ INCIDENTS_DIR="${SPRINT_RUNS_DIR}/_off-sprint/.script-incidents"
53
79
  fi
54
80
 
55
81
  # ---------------------------------------------------------------------------
56
82
  # Capture stdout + stderr to temp files
57
83
  # ---------------------------------------------------------------------------
58
- STDOUT_FILE="$(mktemp)"
59
- STDERR_FILE="$(mktemp)"
60
- trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT
84
+ STDOUT_TMP="$(mktemp)"
85
+ STDERR_TMP="$(mktemp)"
86
+ trap 'rm -f "$STDOUT_TMP" "$STDERR_TMP"' EXIT
87
+
88
+ # Mark self as active before running the wrapped command
89
+ export RUN_SCRIPT_ACTIVE=1
61
90
 
62
91
  EXIT_CODE=0
63
- if [[ ${#SCRIPT_ARGS[@]} -gt 0 ]]; then
64
- "$RUNNER" "$SCRIPT_PATH" "${SCRIPT_ARGS[@]}" > "$STDOUT_FILE" 2> "$STDERR_FILE" || EXIT_CODE=$?
65
- else
66
- "$RUNNER" "$SCRIPT_PATH" > "$STDOUT_FILE" 2> "$STDERR_FILE" || EXIT_CODE=$?
67
- fi
92
+ "$@" >"$STDOUT_TMP" 2>"$STDERR_TMP" || EXIT_CODE=$?
68
93
 
69
94
  # ---------------------------------------------------------------------------
70
- # On success: pass through and exit 0
95
+ # Always pass through stdout + stderr to the caller
96
+ # ---------------------------------------------------------------------------
97
+ cat "$STDOUT_TMP"
98
+ cat "$STDERR_TMP" >&2
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # On success: nothing more to do
71
102
  # ---------------------------------------------------------------------------
72
103
  if [[ $EXIT_CODE -eq 0 ]]; then
73
- cat "$STDOUT_FILE"
74
- cat "$STDERR_FILE" >&2
75
104
  exit 0
76
105
  fi
77
106
 
78
107
  # ---------------------------------------------------------------------------
79
- # On failure: pass stdout through, then print structured diagnostic to stderr
108
+ # On failure: write structured JSON incident file
80
109
  # ---------------------------------------------------------------------------
81
- cat "$STDOUT_FILE"
82
-
83
- # Root-cause heuristic (6-branch)
84
- STDERR_CONTENT="$(cat "$STDERR_FILE")"
85
- ROOT_CAUSE="unknown error"
86
- SUGGESTED_FIX="check the script output above for details"
87
-
88
- if echo "$STDERR_CONTENT" | grep -q "state\.json not found"; then
89
- ROOT_CAUSE="state.json not found — sprint may not be initialized"
90
- SUGGESTED_FIX="run: node .cleargate/scripts/init_sprint.mjs <sprint-id> --stories <ids>"
91
- elif echo "$STDERR_CONTENT" | grep -qi "ENOENT"; then
92
- ROOT_CAUSE="missing file (ENOENT)"
93
- SUGGESTED_FIX="verify all required files exist at the expected paths"
94
- elif echo "$STDERR_CONTENT" | grep -qi "EACCES"; then
95
- ROOT_CAUSE="permission denied (EACCES)"
96
- SUGGESTED_FIX="chmod 755 the target file or directory"
97
- elif echo "$STDERR_CONTENT" | grep -qi "SyntaxError"; then
98
- ROOT_CAUSE="JavaScript syntax error"
99
- SUGGESTED_FIX="fix the syntax error in the script; run: node --check <file>"
100
- elif echo "$STDERR_CONTENT" | grep -qi "Cannot find module"; then
101
- ROOT_CAUSE="missing module (import resolution failure)"
102
- SUGGESTED_FIX="run npm install in the relevant package directory"
103
- elif echo "$STDERR_CONTENT" | grep -qi "command not found"; then
104
- ROOT_CAUSE="required command not found on PATH"
105
- SUGGESTED_FIX="install the missing tool or add it to PATH"
110
+ MAX_BYTES=4096
111
+ TRUNCATION_SUFFIX="... [truncated]"
112
+
113
+ _truncate_stream() {
114
+ # Byte-correct truncation: use head -c for POSIX byte-count (not bash ${var:0:N}
115
+ # which is char-index wrong for UTF-8 multi-byte input).
116
+ # Trade-off: output may end with a partial multi-byte char boundary; downstream
117
+ # Node.js JSON.stringify escapes the partial sequence, producing valid JSON.
118
+ local file="$1"
119
+ local file_bytes
120
+ file_bytes="$(wc -c < "$file")"
121
+ if [[ $file_bytes -le $MAX_BYTES ]]; then
122
+ # File fits within budget emit as-is (no suffix)
123
+ head -c "$MAX_BYTES" "$file"
124
+ else
125
+ # Truncation occurred emit first MAX_BYTES bytes then TRUNCATION_SUFFIX
126
+ printf '%s' "$(head -c "$MAX_BYTES" "$file")${TRUNCATION_SUFFIX}"
127
+ fi
128
+ }
129
+
130
+ STDOUT_CAPTURED="$(_truncate_stream "$STDOUT_TMP")"
131
+ STDERR_CAPTURED="$(_truncate_stream "$STDERR_TMP")"
132
+
133
+ # Build filename: <ts>-<hash>.json
134
+ TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
135
+ TS_FILE="$(date -u +%Y%m%dT%H%M%SZ)"
136
+ # Hash the full command string (first arg + all args)
137
+ HASH="$(printf '%s' "$*" | shasum -a 1 | cut -c1-12)"
138
+ INCIDENT_FILE="${INCIDENTS_DIR}/${TS_FILE}-${HASH}.json"
139
+
140
+ # Ensure incident directory exists
141
+ mkdir -p "$INCIDENTS_DIR"
142
+
143
+ # Collect context
144
+ COMMAND="$1"
145
+ shift
146
+ ARGS_JSON="["
147
+ FIRST=1
148
+ for ARG in "$@"; do
149
+ if [[ $FIRST -eq 1 ]]; then
150
+ FIRST=0
151
+ else
152
+ ARGS_JSON="${ARGS_JSON},"
153
+ fi
154
+ # JSON-escape the arg: replace \ with \\, " with \", newlines with \n
155
+ ARG_ESCAPED="${ARG//\\/\\\\}"
156
+ ARG_ESCAPED="${ARG_ESCAPED//\"/\\\"}"
157
+ ARG_ESCAPED="${ARG_ESCAPED//$'\n'/\\n}"
158
+ ARGS_JSON="${ARGS_JSON}\"${ARG_ESCAPED}\""
159
+ done
160
+ ARGS_JSON="${ARGS_JSON}]"
161
+
162
+ CWD="$(pwd)"
163
+
164
+ # Encode agent_type + work_item_id (null if empty)
165
+ AGENT_TYPE_VAL="${AGENT_TYPE:-}"
166
+ WORK_ITEM_ID_VAL="${WORK_ITEM_ID:-}"
167
+
168
+ if [[ -z "$AGENT_TYPE_VAL" ]]; then
169
+ AGENT_TYPE_JSON="null"
170
+ else
171
+ AGENT_TYPE_JSON="\"${AGENT_TYPE_VAL}\""
106
172
  fi
107
173
 
174
+ if [[ -z "$WORK_ITEM_ID_VAL" ]]; then
175
+ WORK_ITEM_ID_JSON="null"
176
+ else
177
+ WORK_ITEM_ID_JSON="\"${WORK_ITEM_ID_VAL}\""
178
+ fi
179
+
180
+ # JSON-encode captured streams (escape backslash, double-quote, and newlines)
181
+ _json_str() {
182
+ local s="$1"
183
+ s="${s//\\/\\\\}"
184
+ s="${s//\"/\\\"}"
185
+ s="${s//$'\n'/\\n}"
186
+ s="${s//$'\r'/\\r}"
187
+ printf '%s' "$s"
188
+ }
189
+
190
+ STDOUT_JSON="$(_json_str "$STDOUT_CAPTURED")"
191
+ STDERR_JSON="$(_json_str "$STDERR_CAPTURED")"
192
+ COMMAND_JSON="$(_json_str "$COMMAND")"
193
+ CWD_JSON="$(_json_str "$CWD")"
194
+
195
+ cat > "$INCIDENT_FILE" <<JSON
108
196
  {
109
- echo ""
110
- echo "## Script Incident"
111
- echo "Script: ${SCRIPT_NAME}"
112
- echo "Runner: ${RUNNER}"
113
- echo "Exit code: ${EXIT_CODE}"
114
- echo ""
115
- echo "### First 10 lines of stderr:"
116
- echo "$STDERR_CONTENT" | head -10
117
- echo ""
118
- echo "Root cause: ${ROOT_CAUSE}"
119
- echo "Suggested fix: ${SUGGESTED_FIX}"
120
- echo "## End Incident"
121
- } >&2
197
+ "ts": "${TS}",
198
+ "command": "${COMMAND_JSON}",
199
+ "args": ${ARGS_JSON},
200
+ "cwd": "${CWD_JSON}",
201
+ "exit_code": ${EXIT_CODE},
202
+ "stdout": "${STDOUT_JSON}",
203
+ "stderr": "${STDERR_JSON}",
204
+ "agent_type": ${AGENT_TYPE_JSON},
205
+ "work_item_id": ${WORK_ITEM_ID_JSON}
206
+ }
207
+ JSON
122
208
 
123
209
  exit $EXIT_CODE
@@ -131,8 +131,73 @@ function atomicWrite(filePath, content) {
131
131
  fs.renameSync(tmpFile, filePath);
132
132
  }
133
133
 
134
+ /**
135
+ * Read all ledger entries from a token-ledger.jsonl file.
136
+ * @param {string} ledgerPath
137
+ * @returns {{ work_item_id: string, agent_type: string, session_id?: string, sprint_id?: string }[]}
138
+ */
139
+ function readLedgerEntries(ledgerPath) {
140
+ if (!fs.existsSync(ledgerPath)) return [];
141
+ const lines = fs.readFileSync(ledgerPath, 'utf8').split('\n').filter(l => l.trim());
142
+ const entries = [];
143
+ for (const line of lines) {
144
+ try {
145
+ const entry = JSON.parse(line);
146
+ if (entry.work_item_id && entry.agent_type) {
147
+ entries.push({
148
+ work_item_id: entry.work_item_id,
149
+ agent_type: entry.agent_type,
150
+ session_id: entry.session_id ?? '',
151
+ sprint_id: entry.sprint_id ?? '',
152
+ });
153
+ }
154
+ } catch { /* skip malformed lines */ }
155
+ }
156
+ return entries;
157
+ }
158
+
159
+ /**
160
+ * Check if a (work_item_id|agent_type) bucket is a session-attribution artifact.
161
+ *
162
+ * Session-shared filter (CR-056): When multiple Architect (or other agent) dispatches run
163
+ * within the same session, the token ledger attributes them all to the same bucket keyed
164
+ * by the first-merged work_item_id. The canonical example is "CR-045 × architect" in
165
+ * SPRINT-23/SPRINT-24 — all 17 entries share the SAME session_id 48aa90c9-..., making
166
+ * them one session mis-attributed as 17 distinct repeats.
167
+ *
168
+ * Rule: session-attribution artifact if ALL entries with a known session_id share the
169
+ * SAME session_id (i.e., exactly 1 distinct session across all entries). When multiple
170
+ * distinct sessions are present, there is genuine independent repetition.
171
+ *
172
+ * Known false-positive class: "CR-045 × architect" — 17 entries, 1 session UUID.
173
+ *
174
+ * @param {{ session_id?: string }[]} entries all entries for this bucket (across sprints)
175
+ * @returns {boolean} true if bucket should be filtered as session-shared artifact
176
+ */
177
+ function isSessionShared(entries) {
178
+ if (entries.length < 3) return false;
179
+ const distinctSessions = new Set(
180
+ entries.map(e => e.session_id ?? '').filter(s => s !== '')
181
+ );
182
+ // If exactly 1 distinct session accounts for all entries, this is a session-attribution artifact
183
+ return distinctSessions.size === 1;
184
+ }
185
+
134
186
  /**
135
187
  * Scan token-ledger.jsonl and FLASHCARD.md for skill creation candidates.
188
+ *
189
+ * Heuristic (CR-056 tightened):
190
+ * 1. Session-shared filter: if ≥2 of ≥3 entries for a bucket share the same
191
+ * session_id → skip (token-attribution artifact, not a real recurring pattern).
192
+ * Known false-positive class: "CR-045 × architect" from SPRINT-23/SPRINT-24 —
193
+ * all 17 entries share session_id 48aa90c9-... (session-shared).
194
+ * 2. Cross-sprint aggregation: collect entries from the current sprint's ledger PLUS
195
+ * prior sprints' ledgers (via CLEARGATE_SPRINT_RUNS_DIR). Count ≥3× total across
196
+ * ≥2 distinct sprints.
197
+ * 3. Cross-sprint dedup: if the candidate's hash already appears in any prior sprint's
198
+ * improvement-suggestions.md → surface as seen_in: [...] instead of re-flagging.
199
+ * 4. Threshold raised to "≥3× across ≥2 distinct sprints AND not session-shared".
200
+ *
136
201
  * Appends or replaces the "## Skill Creation Candidates" section in improvement-suggestions.md.
137
202
  * @param {string} sprintId
138
203
  * @param {string} sprintDir
@@ -144,24 +209,72 @@ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
144
209
  : path.join(REPO_ROOT, '.cleargate', 'FLASHCARD.md');
145
210
  const ledgerPath = path.join(sprintDir, 'token-ledger.jsonl');
146
211
 
147
- // Count repeated (work_item_id, agent_type) tuples from token-ledger.jsonl
148
- /** @type {Map<string, number>} */
149
- const tupleCounts = new Map();
150
- if (fs.existsSync(ledgerPath)) {
151
- const lines = fs.readFileSync(ledgerPath, 'utf8').split('\n').filter(l => l.trim());
152
- for (const line of lines) {
153
- try {
154
- const entry = JSON.parse(line);
155
- if (entry.work_item_id && entry.agent_type) {
156
- const key = `${entry.work_item_id}|${entry.agent_type}`;
157
- tupleCounts.set(key, (tupleCounts.get(key) ?? 0) + 1);
158
- }
159
- } catch { /* skip malformed lines */ }
212
+ // Resolve sprint-runs root (used for cross-sprint lookback)
213
+ const sprintRunsDir = process.env.CLEARGATE_SPRINT_RUNS_DIR
214
+ ? path.resolve(process.env.CLEARGATE_SPRINT_RUNS_DIR)
215
+ : path.dirname(sprintDir);
216
+
217
+ // ── Step 1: collect all ledger entries across current + prior sprints ─────────
218
+ // Collect from current sprint
219
+ const currentEntries = readLedgerEntries(ledgerPath);
220
+
221
+ // Collect from prior sprints (all sprint dirs except current)
222
+ const allPriorEntries = [];
223
+ const priorSuggestionsContents = [];
224
+ if (fs.existsSync(sprintRunsDir)) {
225
+ let priorDirs;
226
+ try {
227
+ priorDirs = fs.readdirSync(sprintRunsDir)
228
+ .filter(name => name !== sprintId && !name.startsWith('.'))
229
+ .map(name => path.join(sprintRunsDir, name))
230
+ .filter(p => {
231
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
232
+ });
233
+ } catch { priorDirs = []; }
234
+
235
+ for (const priorDir of priorDirs) {
236
+ const priorLedger = path.join(priorDir, 'token-ledger.jsonl');
237
+ const entries = readLedgerEntries(priorLedger);
238
+ allPriorEntries.push(...entries);
239
+
240
+ // Collect prior improvement-suggestions.md for cross-sprint dedup
241
+ const priorSuggFile = path.join(priorDir, 'improvement-suggestions.md');
242
+ if (fs.existsSync(priorSuggFile)) {
243
+ try {
244
+ priorSuggestionsContents.push(fs.readFileSync(priorSuggFile, 'utf8'));
245
+ } catch { /* skip unreadable */ }
246
+ }
160
247
  }
161
248
  }
162
249
 
163
- // Find tuples repeated ≥3×
164
- const repeatedTuples = [...tupleCounts.entries()].filter(([, count]) => count >= 3);
250
+ // ── Step 2: build cross-sprint bucket map ────────────────────────────────────
251
+ // Map: key (work_item_id|agent_type) → { entries: [...], sprintIds: Set<string> }
252
+ /** @type {Map<string, { entries: { session_id?: string, sprint_id?: string }[], sprintIds: Set<string> }>} */
253
+ const buckets = new Map();
254
+
255
+ for (const e of currentEntries) {
256
+ const key = `${e.work_item_id}|${e.agent_type}`;
257
+ if (!buckets.has(key)) buckets.set(key, { entries: [], sprintIds: new Set() });
258
+ const b = buckets.get(key);
259
+ b.entries.push(e);
260
+ b.sprintIds.add(sprintId);
261
+ }
262
+ for (const e of allPriorEntries) {
263
+ const key = `${e.work_item_id}|${e.agent_type}`;
264
+ if (!buckets.has(key)) buckets.set(key, { entries: [], sprintIds: new Set() });
265
+ const b = buckets.get(key);
266
+ b.entries.push(e);
267
+ if (e.sprint_id) b.sprintIds.add(e.sprint_id);
268
+ }
269
+
270
+ // ── Step 3: apply heuristic filters ──────────────────────────────────────────
271
+ // Threshold: ≥3× total AND ≥2 distinct sprints AND NOT session-shared
272
+ const repeatedTuples = [...buckets.entries()].filter(([, b]) => {
273
+ if (b.entries.length < 3) return false;
274
+ if (b.sprintIds.size < 2) return false;
275
+ if (isSessionShared(b.entries)) return false;
276
+ return true;
277
+ });
165
278
 
166
279
  // Grep FLASHCARD.md for "also do" patterns
167
280
  const alsoDoMatches = [];
@@ -175,21 +288,35 @@ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
175
288
  }
176
289
  }
177
290
 
178
- // Read existing suggestions file content
291
+ // Read existing suggestions file content (current sprint)
179
292
  let existingContent = fs.existsSync(suggestionsFile)
180
293
  ? fs.readFileSync(suggestionsFile, 'utf8')
181
294
  : `# Improvement Suggestions — ${sprintId}\n\nGenerated by \`suggest_improvements.mjs\`. Append-only; IDs are stable.\nVocabulary: Templates | Handoffs | Skills | Process | Tooling\n\n---\n\n`;
182
295
 
296
+ /**
297
+ * Check if a hash already appears in current sprint's suggestions OR any prior sprint's.
298
+ * @param {string} hash
299
+ * @returns {boolean}
300
+ */
301
+ function hashAlreadySeen(hash) {
302
+ const marker = `<!-- hash:${hash} -->`;
303
+ if (existingContent.includes(marker)) return true;
304
+ for (const priorContent of priorSuggestionsContents) {
305
+ if (priorContent.includes(marker)) return true;
306
+ }
307
+ return false;
308
+ }
309
+
183
310
  // Build the candidates
184
311
  const candidates = [];
185
312
  let candN = 1;
186
- for (const [key] of repeatedTuples) {
313
+ for (const [key, bucket] of repeatedTuples) {
187
314
  const [workItemId, agentType] = key.split('|');
188
315
  const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
189
316
  const hashKey = `skill|${key}`;
190
317
  const hash = stableHash(hashKey);
191
- if (!existingContent.includes(`<!-- hash:${hash} -->`)) {
192
- candidates.push({ candId, hash, workItemId, agentType, source: 'ledger' });
318
+ if (!hashAlreadySeen(hash)) {
319
+ candidates.push({ candId, hash, workItemId, agentType, source: 'ledger', sprintIds: bucket.sprintIds });
193
320
  candN++;
194
321
  }
195
322
  }
@@ -197,8 +324,8 @@ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
197
324
  const candId = `CAND-${sprintId}-S${String(candN).padStart(2, '0')}`;
198
325
  const hashKey = `skill|flashcard|${line.slice(0, 60)}`;
199
326
  const hash = stableHash(hashKey);
200
- if (!existingContent.includes(`<!-- hash:${hash} -->`)) {
201
- candidates.push({ candId, hash, workItemId: null, agentType: null, source: 'flashcard', line });
327
+ if (!hashAlreadySeen(hash)) {
328
+ candidates.push({ candId, hash, workItemId: null, agentType: null, source: 'flashcard', line, sprintIds: new Set() });
202
329
  candN++;
203
330
  }
204
331
  }
@@ -220,7 +347,8 @@ function scanSkillCandidates(sprintId, sprintDir, suggestionsFile) {
220
347
  sectionLines.push(`<!-- hash:${c.hash} -->`);
221
348
  sectionLines.push('');
222
349
  if (c.source === 'ledger') {
223
- sectionLines.push(`**Pattern detected:** ${c.workItemId} × ${c.agentType} repeated ≥3× in token-ledger`);
350
+ const sprintList = c.sprintIds ? [...c.sprintIds].sort().join(', ') : sprintId;
351
+ sectionLines.push(`**Pattern detected:** ${c.workItemId} × ${c.agentType} repeated ≥3× across ≥2 distinct sprints (${sprintList})`);
224
352
  } else {
225
353
  sectionLines.push(`**Pattern detected:** "also do" pattern in FLASHCARD.md`);
226
354
  sectionLines.push(`**Source line:** \`${c.line}\``);
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Strategy: grep-based. Creates a synthetic dev-report fixture with
6
6
  # flashcards_flagged, simulates a mock worktree-creation step, and asserts
7
- # the gate contract documented in protocol §4 and the agent output-shape
7
+ # the gate contract documented in protocol §18 and the agent output-shape
8
8
  # blocks in developer.md + qa.md.
9
9
  #
10
10
  # Usage: bash .cleargate/scripts/test/test_flashcard_gate.sh
@@ -30,14 +30,14 @@ fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
30
30
  # And upon approval each card is appended to .cleargate/FLASHCARD.md
31
31
  # And only then does worktree creation proceed
32
32
  #
33
- # This script validates the contract (protocol §4 + output-shape fields)
33
+ # This script validates the contract (protocol §18 + output-shape fields)
34
34
  # that makes the scenario enforceable. The gate logic itself is orchestrator-
35
35
  # out-of-band (v1: informational; v2: mandatory). The test therefore checks:
36
36
  # (a) developer.md output-shape contains flashcards_flagged field
37
37
  # (b) qa.md output-shape contains flashcards_flagged field
38
- # (c) protocol.md §4 exists with the correct heading
39
- # (d) §4 specifies the approve/reject processing rule
40
- # (e) §4 specifies the worktree creation gate
38
+ # (c) protocol.md §18 exists with the correct heading
39
+ # (d) §18 specifies the approve/reject processing rule
40
+ # (e) §18 specifies the worktree creation gate
41
41
  # (f) live vs mirror diff is empty for all three files
42
42
 
43
43
  DEV_MD="$REPO_ROOT/.claude/agents/developer.md"
@@ -62,25 +62,25 @@ else
62
62
  fail "qa.md missing flashcards_flagged field in output-shape"
63
63
  fi
64
64
 
65
- # (c) protocol.md §4 heading exists
65
+ # (c) protocol.md §18 heading exists
66
66
  if grep -q "^## 18. Immediate Flashcard Gate (v2)" "$PROTOCOL_MD"; then
67
67
  pass "protocol.md contains ## 18. Immediate Flashcard Gate (v2)"
68
68
  else
69
69
  fail "protocol.md missing ## 18. Immediate Flashcard Gate (v2)"
70
70
  fi
71
71
 
72
- # (d) §4 specifies approve + reject processing
72
+ # (d) §18 specifies approve + reject processing
73
73
  if grep -q "Approve" "$PROTOCOL_MD" && grep -q "Reject" "$PROTOCOL_MD"; then
74
- pass "protocol §4 documents Approve/Reject processing rule"
74
+ pass "protocol §18 documents Approve/Reject processing rule"
75
75
  else
76
- fail "protocol §4 missing Approve/Reject processing rule"
76
+ fail "protocol §18 missing Approve/Reject processing rule"
77
77
  fi
78
78
 
79
- # (e) §4 specifies the worktree creation gate
79
+ # (e) §18 specifies the worktree creation gate
80
80
  if grep -q "Worktree creation gate\|worktree creation gate\|MUST NOT.*worktree\|worktree.*MUST NOT" "$PROTOCOL_MD"; then
81
- pass "protocol §4 documents worktree creation gate"
81
+ pass "protocol §18 documents worktree creation gate"
82
82
  else
83
- fail "protocol §4 missing worktree creation gate rule"
83
+ fail "protocol §18 missing worktree creation gate rule"
84
84
  fi
85
85
 
86
86
  # (f) qa.md says QA list is additive to Developer's
@@ -90,18 +90,18 @@ else
90
90
  fail "qa.md missing note that flashcards_flagged is additive"
91
91
  fi
92
92
 
93
- # (g) developer.md references protocol §4
94
- if grep -q "protocol §4\|§4" "$DEV_MD"; then
95
- pass "developer.md references protocol §4"
93
+ # (g) developer.md references protocol §18
94
+ if grep -q "protocol §18\|§18" "$DEV_MD"; then
95
+ pass "developer.md references protocol §18"
96
96
  else
97
- fail "developer.md does not reference protocol §4"
97
+ fail "developer.md does not reference protocol §18"
98
98
  fi
99
99
 
100
- # (h) qa.md references protocol §4
101
- if grep -q "protocol §4\|§4" "$QA_MD"; then
102
- pass "qa.md references protocol §4"
100
+ # (h) qa.md references protocol §18
101
+ if grep -q "protocol §18\|§18" "$QA_MD"; then
102
+ pass "qa.md references protocol §18"
103
103
  else
104
- fail "qa.md does not reference protocol §4"
104
+ fail "qa.md does not reference protocol §18"
105
105
  fi
106
106
 
107
107
  # (i) three-surface diff: live developer.md vs mirror