@cleocode/core 2026.4.98 → 2026.4.100

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 (85) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * BRAIN Ingester — Tier-2 proposal candidate source.
3
+ *
4
+ * Queries brain.db for recurring-pain observations (citation_count >= 3,
5
+ * last 7 days, quality_score >= 0.5) and returns ranked ProposalCandidate[].
6
+ *
7
+ * Design principles:
8
+ * - NO LLM calls. All data comes from structured SQL queries.
9
+ * - Title is template-generated: `[T2-BRAIN] Recurring issue: {title}`.
10
+ * This is the prompt-injection defence from T1008 §3.6.
11
+ * - Failures are swallowed: returns empty array + logs warning.
12
+ * Brain.db absence must never crash the propose tick.
13
+ *
14
+ * @task T1008
15
+ * @see ADR-054 — Sentient Loop Tier-2
16
+ */
17
+
18
+ import type { DatabaseSync } from 'node:sqlite';
19
+ import type { ProposalCandidate } from '@cleocode/contracts';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Brain observation row (raw SQL result)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface BrainObservationRow {
26
+ id: string;
27
+ title: string | null;
28
+ text: string;
29
+ citation_count: number;
30
+ quality_score: number;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Maximum candidates returned from a single brain ingester pass. */
38
+ export const BRAIN_INGESTER_LIMIT = 10;
39
+
40
+ /** Minimum citation count for a brain entry to be considered. */
41
+ export const BRAIN_MIN_CITATION_COUNT = 3;
42
+
43
+ /** Minimum quality score for a brain entry to be considered. */
44
+ export const BRAIN_MIN_QUALITY_SCORE = 0.5;
45
+
46
+ /** Lookback window in days for brain observations. */
47
+ export const BRAIN_LOOKBACK_DAYS = 7;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helpers
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Compute candidate weight from citation_count and quality_score.
55
+ * Formula: `(citation_count / 10) * quality_score` capped at 1.0.
56
+ */
57
+ export function computeBrainWeight(citationCount: number, qualityScore: number): number {
58
+ return Math.min((citationCount / 10) * qualityScore, 1.0);
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public API
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Run the BRAIN ingester against the provided DatabaseSync handle.
67
+ *
68
+ * Returns at most {@link BRAIN_INGESTER_LIMIT} candidates, sorted by weight
69
+ * descending. Returns an empty array if the database has no matching entries
70
+ * or if any error occurs (errors are swallowed to never crash the tick).
71
+ *
72
+ * @param nativeDb - Open DatabaseSync handle to brain.db. May be null if
73
+ * brain.db has not been initialised; this is treated as zero candidates.
74
+ * @returns Ranked ProposalCandidate array (may be empty).
75
+ */
76
+ export function runBrainIngester(nativeDb: DatabaseSync | null): ProposalCandidate[] {
77
+ if (!nativeDb) {
78
+ return [];
79
+ }
80
+
81
+ try {
82
+ const stmt = nativeDb.prepare(`
83
+ SELECT id, title, text, citation_count, quality_score
84
+ FROM brain_observations
85
+ WHERE type IN ('bugfix', 'decision')
86
+ AND citation_count >= :minCitations
87
+ AND created_at >= datetime('now', :lookback)
88
+ AND quality_score >= :minQuality
89
+ ORDER BY citation_count DESC, quality_score DESC
90
+ LIMIT :limit
91
+ `);
92
+
93
+ const rows = stmt.all({
94
+ minCitations: BRAIN_MIN_CITATION_COUNT,
95
+ lookback: `-${BRAIN_LOOKBACK_DAYS} days`,
96
+ minQuality: BRAIN_MIN_QUALITY_SCORE,
97
+ limit: BRAIN_INGESTER_LIMIT,
98
+ }) as unknown as BrainObservationRow[];
99
+
100
+ const candidates: ProposalCandidate[] = rows.map((row) => {
101
+ const label = row.title ?? row.text.slice(0, 80);
102
+ return {
103
+ source: 'brain' as const,
104
+ sourceId: row.id,
105
+ title: `[T2-BRAIN] Recurring issue: ${label}`,
106
+ rationale: `Brain entry ${row.id} cited ${row.citation_count} times (quality ${row.quality_score.toFixed(2)}) in the last ${BRAIN_LOOKBACK_DAYS} days`,
107
+ weight: computeBrainWeight(row.citation_count, row.quality_score),
108
+ };
109
+ });
110
+
111
+ // Sort descending by weight (DB ORDER BY handles primary sort, but
112
+ // weight formula may produce different ordering than raw column order).
113
+ candidates.sort((a, b) => b.weight - a.weight);
114
+
115
+ return candidates;
116
+ } catch (err) {
117
+ // Best-effort: log warning but never throw from an ingester.
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ process.stderr.write(`[sentient/brain-ingester] WARNING: ${message}\n`);
120
+ return [];
121
+ }
122
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Nexus Ingester — Tier-2 proposal candidate source.
3
+ *
4
+ * Queries nexus.db for structural anomalies and returns ranked
5
+ * ProposalCandidate[]. Two query patterns:
6
+ *
7
+ * A. Orphaned callees: functions that have many callers but make no calls
8
+ * themselves (zero-import, high-in-degree). Suggests dead-end sinks
9
+ * that may be candidates for abstraction or documentation.
10
+ *
11
+ * B. Over-coupled nodes: symbols with total degree (in + out edges) > 20,
12
+ * suggesting high coupling that should be refactored.
13
+ *
14
+ * Design principles:
15
+ * - NO LLM calls. All data comes from structured SQL queries.
16
+ * - Title is template-generated: `[T2-NEXUS] ...`. Prompt-injection defence.
17
+ * - Failures are swallowed: returns empty array + logs warning.
18
+ * Nexus.db absence must never crash the propose tick.
19
+ *
20
+ * @task T1008
21
+ * @see ADR-054 — Sentient Loop Tier-2
22
+ */
23
+
24
+ import type { DatabaseSync } from 'node:sqlite';
25
+ import type { ProposalCandidate } from '@cleocode/contracts';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Raw row types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface OrphanCalleeRow {
32
+ id: string;
33
+ name: string;
34
+ file_path: string;
35
+ caller_count: number;
36
+ }
37
+
38
+ interface HighDegreeRow {
39
+ id: string;
40
+ name: string;
41
+ file_path: string;
42
+ degree: number;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Constants
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Base weight for all nexus candidates (structural signals, lower priority than brain). */
50
+ export const NEXUS_BASE_WEIGHT = 0.3;
51
+
52
+ /** Minimum caller count for orphaned-callee detection. */
53
+ export const NEXUS_MIN_CALLER_COUNT = 5;
54
+
55
+ /** Minimum total degree for over-coupling detection. */
56
+ export const NEXUS_MIN_DEGREE = 20;
57
+
58
+ /** Maximum results per query. */
59
+ export const NEXUS_QUERY_LIMIT = 5;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Compute a deduplication key that is stable across query A and query B.
67
+ * Both queries reference the same nexus node table, so using the node id
68
+ * directly as sourceId is sufficient.
69
+ */
70
+ function toFingerprint(nodeId: string): string {
71
+ return nodeId;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Public API
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Run the Nexus ingester against the provided DatabaseSync handle.
80
+ *
81
+ * Returns candidates from both orphaned-callee (Query A) and high-degree
82
+ * (Query B) detection, merged without duplication. Returns an empty array
83
+ * if the database has no matching entries or if any error occurs.
84
+ *
85
+ * @param nativeDb - Open DatabaseSync handle to nexus.db. May be null if
86
+ * nexus.db has not been initialised; this is treated as zero candidates.
87
+ * @returns Ranked ProposalCandidate array (may be empty).
88
+ */
89
+ export function runNexusIngester(nativeDb: DatabaseSync | null): ProposalCandidate[] {
90
+ if (!nativeDb) {
91
+ return [];
92
+ }
93
+
94
+ const seenIds = new Set<string>();
95
+ const candidates: ProposalCandidate[] = [];
96
+
97
+ try {
98
+ // Query A: orphaned callees (many callers, zero outbound calls).
99
+ const stmtA = nativeDb.prepare(`
100
+ SELECT n.id, n.name, n.file_path, COUNT(r.id) as caller_count
101
+ FROM nexus_nodes n
102
+ JOIN nexus_relations r ON r.target_id = n.id AND r.kind = 'calls'
103
+ WHERE NOT EXISTS (
104
+ SELECT 1 FROM nexus_relations r2
105
+ WHERE r2.source_id = n.id AND r2.kind = 'calls'
106
+ )
107
+ AND n.kind = 'function'
108
+ GROUP BY n.id
109
+ HAVING caller_count > :minCallers
110
+ ORDER BY caller_count DESC
111
+ LIMIT :limit
112
+ `);
113
+
114
+ const rowsA = stmtA.all({
115
+ minCallers: NEXUS_MIN_CALLER_COUNT,
116
+ limit: NEXUS_QUERY_LIMIT,
117
+ }) as unknown as OrphanCalleeRow[];
118
+
119
+ for (const row of rowsA) {
120
+ const fp = toFingerprint(row.id);
121
+ if (seenIds.has(fp)) continue;
122
+ seenIds.add(fp);
123
+ candidates.push({
124
+ source: 'nexus' as const,
125
+ sourceId: row.id,
126
+ title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.caller_count} callers)`,
127
+ rationale: `Function ${row.name} in ${row.file_path} has ${row.caller_count} callers but makes no outbound calls — review for abstraction opportunity`,
128
+ weight: NEXUS_BASE_WEIGHT,
129
+ });
130
+ }
131
+ } catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ process.stderr.write(`[sentient/nexus-ingester] WARNING query A: ${message}\n`);
134
+ }
135
+
136
+ try {
137
+ // Query B: high-degree nodes (over-coupling).
138
+ const stmtB = nativeDb.prepare(`
139
+ SELECT n.id, n.name, n.file_path, COUNT(r.id) as degree
140
+ FROM nexus_nodes n
141
+ JOIN nexus_relations r ON r.source_id = n.id OR r.target_id = n.id
142
+ GROUP BY n.id
143
+ HAVING degree > :minDegree
144
+ ORDER BY degree DESC
145
+ LIMIT :limit
146
+ `);
147
+
148
+ const rowsB = stmtB.all({
149
+ minDegree: NEXUS_MIN_DEGREE,
150
+ limit: NEXUS_QUERY_LIMIT,
151
+ }) as unknown as HighDegreeRow[];
152
+
153
+ for (const row of rowsB) {
154
+ const fp = toFingerprint(row.id);
155
+ if (seenIds.has(fp)) continue;
156
+ seenIds.add(fp);
157
+ candidates.push({
158
+ source: 'nexus' as const,
159
+ sourceId: row.id,
160
+ title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.degree} edges)`,
161
+ rationale: `Symbol ${row.name} in ${row.file_path} has ${row.degree} total edges — review for over-coupling`,
162
+ weight: NEXUS_BASE_WEIGHT,
163
+ });
164
+ }
165
+ } catch (err) {
166
+ const message = err instanceof Error ? err.message : String(err);
167
+ process.stderr.write(`[sentient/nexus-ingester] WARNING query B: ${message}\n`);
168
+ }
169
+
170
+ return candidates;
171
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Test Ingester — Tier-2 proposal candidate source.
3
+ *
4
+ * Reads two data sources:
5
+ *
6
+ * Source A — `.cleo/audit/gates.jsonl`: CLEO evidence gate failure records.
7
+ * Each line is a JSONL record. Lines where `failCount > 0` produce a
8
+ * proposal suggesting a flaky-test guard be added for the failing task.
9
+ *
10
+ * Source B — `.cleo/coverage-summary.json`: vitest coverage JSON summary.
11
+ * Written by `vitest --coverage --reporter json-summary`. Lines where
12
+ * `lines.pct < 80` produce a proposal suggesting coverage improvement.
13
+ * If the file is absent, Source B returns zero candidates (no error).
14
+ *
15
+ * Design principles:
16
+ * - NO LLM calls. All data comes from structured file reads.
17
+ * - Title is template-generated. Prompt-injection defence (T1008 §3.6).
18
+ * - Failures are swallowed: returns empty array + logs warning.
19
+ *
20
+ * @task T1008
21
+ * @see ADR-054 — Sentient Loop Tier-2
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import type { ProposalCandidate } from '@cleocode/contracts';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** A single line from gates.jsonl. */
33
+ interface GateRecord {
34
+ taskId?: string;
35
+ gate?: string;
36
+ failCount?: number;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ /** Coverage summary entry for a single file. */
41
+ interface CoverageEntry {
42
+ lines?: { pct?: number };
43
+ statements?: { pct?: number };
44
+ functions?: { pct?: number };
45
+ branches?: { pct?: number };
46
+ }
47
+
48
+ /** Shape of the JSON coverage summary file. */
49
+ type CoverageSummary = Record<string, CoverageEntry>;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Constants
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Relative path from project root to gates.jsonl. */
56
+ export const GATES_JSONL_PATH = '.cleo/audit/gates.jsonl' as const;
57
+
58
+ /** Relative path from project root to the coverage summary. */
59
+ export const COVERAGE_SUMMARY_PATH = '.cleo/coverage-summary.json' as const;
60
+
61
+ /** Coverage line percentage below which a proposal is emitted. */
62
+ export const MIN_LINE_COVERAGE_PCT = 80;
63
+
64
+ /** Base weight for all test ingester candidates. */
65
+ export const TEST_BASE_WEIGHT = 0.5;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Source A: gates.jsonl
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Parse gates.jsonl and return one candidate per task that has any gate
73
+ * with `failCount > 0`.
74
+ *
75
+ * @param projectRoot - Absolute path to the project root.
76
+ * @returns Proposal candidates (may be empty).
77
+ */
78
+ function runGatesIngester(projectRoot: string): ProposalCandidate[] {
79
+ const gatesPath = join(projectRoot, GATES_JSONL_PATH);
80
+
81
+ let raw: string;
82
+ try {
83
+ raw = readFileSync(gatesPath, 'utf-8');
84
+ } catch {
85
+ // File absent or unreadable — not an error.
86
+ return [];
87
+ }
88
+
89
+ const candidates: ProposalCandidate[] = [];
90
+ const seenKeys = new Set<string>();
91
+
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+
96
+ let record: GateRecord;
97
+ try {
98
+ record = JSON.parse(trimmed) as GateRecord;
99
+ } catch {
100
+ // Skip malformed lines.
101
+ continue;
102
+ }
103
+
104
+ const taskId = record.taskId;
105
+ const gate = record.gate ?? 'unknown';
106
+ const failCount = record.failCount ?? 0;
107
+
108
+ if (typeof taskId !== 'string' || failCount <= 0) continue;
109
+
110
+ const key = `${taskId}.${gate}`;
111
+ if (seenKeys.has(key)) continue;
112
+ seenKeys.add(key);
113
+
114
+ candidates.push({
115
+ source: 'test' as const,
116
+ sourceId: key,
117
+ title: `[T2-TEST] Fix flaky gate: ${taskId}.${gate}`,
118
+ rationale: `Gate '${gate}' on task ${taskId} has failed ${failCount} time(s)`,
119
+ weight: TEST_BASE_WEIGHT,
120
+ });
121
+ }
122
+
123
+ return candidates;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Source B: coverage-summary.json
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Read the vitest coverage summary and return one candidate per file with
132
+ * line coverage below {@link MIN_LINE_COVERAGE_PCT}.
133
+ *
134
+ * @param projectRoot - Absolute path to the project root.
135
+ * @returns Proposal candidates (may be empty; empty if file absent).
136
+ */
137
+ function runCoverageIngester(projectRoot: string): ProposalCandidate[] {
138
+ const coveragePath = join(projectRoot, COVERAGE_SUMMARY_PATH);
139
+
140
+ let summary: CoverageSummary;
141
+ try {
142
+ const raw = readFileSync(coveragePath, 'utf-8');
143
+ summary = JSON.parse(raw) as CoverageSummary;
144
+ } catch {
145
+ // File absent or malformed — not an error, return zero candidates.
146
+ return [];
147
+ }
148
+
149
+ const candidates: ProposalCandidate[] = [];
150
+
151
+ for (const [filePath, entry] of Object.entries(summary)) {
152
+ // Skip the 'total' synthetic key if present.
153
+ if (filePath === 'total') continue;
154
+
155
+ const pct = entry?.lines?.pct;
156
+ if (typeof pct !== 'number' || pct >= MIN_LINE_COVERAGE_PCT) continue;
157
+
158
+ candidates.push({
159
+ source: 'test' as const,
160
+ sourceId: filePath,
161
+ title: `[T2-TEST] Increase coverage: ${filePath} (${pct}% lines)`,
162
+ rationale: `File ${filePath} has ${pct}% line coverage (target: ${MIN_LINE_COVERAGE_PCT}%)`,
163
+ weight: TEST_BASE_WEIGHT,
164
+ });
165
+ }
166
+
167
+ return candidates;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Public API
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Run the test ingester against both data sources.
176
+ *
177
+ * Merges Source A (gates.jsonl) and Source B (coverage-summary.json) without
178
+ * duplication. Returns an empty array if both sources yield nothing or if
179
+ * errors occur (errors are swallowed).
180
+ *
181
+ * @param projectRoot - Absolute path to the project root.
182
+ * @returns Combined ProposalCandidate array (may be empty).
183
+ */
184
+ export function runTestIngester(projectRoot: string): ProposalCandidate[] {
185
+ try {
186
+ const gatesCandidates = runGatesIngester(projectRoot);
187
+ const coverageCandidates = runCoverageIngester(projectRoot);
188
+
189
+ // Merge, deduplicate by sourceId.
190
+ const seenSourceIds = new Set<string>();
191
+ const merged: ProposalCandidate[] = [];
192
+
193
+ for (const candidate of [...gatesCandidates, ...coverageCandidates]) {
194
+ if (seenSourceIds.has(candidate.sourceId)) continue;
195
+ seenSourceIds.add(candidate.sourceId);
196
+ merged.push(candidate);
197
+ }
198
+
199
+ return merged;
200
+ } catch (err) {
201
+ const message = err instanceof Error ? err.message : String(err);
202
+ process.stderr.write(`[sentient/test-ingester] WARNING: ${message}\n`);
203
+ return [];
204
+ }
205
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Proposal Rate Limiter — Transactional DB-enforced daily cap.
3
+ *
4
+ * Enforces a maximum of N proposals per day per source tag, using a
5
+ * BEGIN IMMEDIATE transaction with COUNT + conditional INSERT pattern.
6
+ *
7
+ * Design rationale (T1008 §3.2):
8
+ * - In-process counters do not survive daemon restart, and two daemon
9
+ * instances could both allow N/day if enforcement is not in the DB.
10
+ * - The `sentient.lock` advisory lock (daemon.ts) prevents two daemons
11
+ * from running concurrently, but the transactional count check provides
12
+ * belt-and-suspenders protection against TOCTOU races.
13
+ * - SQLite partial unique indexes cannot enforce count > 1, so the
14
+ * BEGIN IMMEDIATE + COUNT + INSERT pattern is used instead.
15
+ *
16
+ * The "day" boundary is determined by SQLite's `date('now')` (UTC).
17
+ * Proposals counted include ALL non-terminal statuses (proposed, pending,
18
+ * active, done) — an accepted proposal still consumes a daily slot.
19
+ *
20
+ * @task T1008
21
+ * @see ADR-054 — Sentient Loop Tier-2
22
+ */
23
+
24
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * The meta proposedBy tag written to `tasks.metadata_json` by the Tier-2
32
+ * proposer. This tag is the query key for rate-limit counting.
33
+ */
34
+ export const SENTIENT_TIER2_TAG = 'sentient-tier2' as const;
35
+
36
+ /**
37
+ * Default maximum number of Tier-2 proposals per UTC day.
38
+ * Can be overridden by callers.
39
+ */
40
+ export const DEFAULT_DAILY_PROPOSAL_LIMIT = 3;
41
+
42
+ /**
43
+ * SQL error code string returned when BEGIN IMMEDIATE fails because another
44
+ * write transaction is in progress.
45
+ */
46
+ const SQLITE_BUSY_CODE = 'SQLITE_BUSY';
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Public API
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Count the number of Tier-2 proposals created today (UTC).
54
+ *
55
+ * Counts tasks where:
56
+ * - `labels_json` contains `'sentient-tier2'` (the Tier-2 marker label)
57
+ * - `date(created_at) = date('now')`
58
+ * - `status IN ('proposed', 'pending', 'active', 'done')` — terminal
59
+ * states that were proposed today still count toward the daily cap so
60
+ * that accepted proposals don't free a slot for another proposal.
61
+ *
62
+ * The `LIKE` pattern is intentional: labels_json is a JSON array stored as
63
+ * text, and `'sentient-tier2'` is always a complete JSON string value within
64
+ * that array, making substring matching safe here.
65
+ *
66
+ * @param nativeDb - Open DatabaseSync handle to tasks.db.
67
+ * @returns Number of proposals created today. Returns 0 if DB is null.
68
+ */
69
+ export function countTodayProposals(nativeDb: DatabaseSync | null): number {
70
+ if (!nativeDb) return 0;
71
+
72
+ const stmt = nativeDb.prepare(`
73
+ SELECT COUNT(*) as cnt
74
+ FROM tasks
75
+ WHERE labels_json LIKE :labelPattern
76
+ AND date(created_at) = date('now')
77
+ AND status IN ('proposed', 'pending', 'active', 'done')
78
+ `);
79
+
80
+ const row = stmt.get({ labelPattern: `%${SENTIENT_TIER2_TAG}%` }) as { cnt: number } | undefined;
81
+ return row?.cnt ?? 0;
82
+ }
83
+
84
+ /**
85
+ * Check whether the daily rate limit has been reached.
86
+ *
87
+ * @param nativeDb - Open DatabaseSync handle to tasks.db.
88
+ * @param limit - Daily cap (defaults to {@link DEFAULT_DAILY_PROPOSAL_LIMIT}).
89
+ * @returns `true` if the limit is reached or exceeded; `false` if capacity remains.
90
+ */
91
+ export function isRateLimitExceeded(
92
+ nativeDb: DatabaseSync | null,
93
+ limit = DEFAULT_DAILY_PROPOSAL_LIMIT,
94
+ ): boolean {
95
+ return countTodayProposals(nativeDb) >= limit;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Transactional INSERT guard
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Result of a transactional insert attempt. */
103
+ export interface TransactionalInsertResult {
104
+ /** Whether the INSERT was committed. */
105
+ inserted: boolean;
106
+ /**
107
+ * The count read at the start of the transaction. Used for diagnostics
108
+ * and tests — lets callers verify the guard saw the expected count.
109
+ */
110
+ countBeforeInsert: number;
111
+ /**
112
+ * If `inserted = false` and this is set, the limit was the reason.
113
+ */
114
+ reason?: 'rate-limit' | 'busy';
115
+ }
116
+
117
+ /**
118
+ * Attempt to insert a pre-built task row inside a BEGIN IMMEDIATE transaction
119
+ * with a count check.
120
+ *
121
+ * Steps:
122
+ * 1. BEGIN IMMEDIATE (exclusive write lock on tasks.db)
123
+ * 2. COUNT proposals created today
124
+ * 3. If count >= limit: ROLLBACK, return `{ inserted: false, reason: 'rate-limit' }`
125
+ * 4. Otherwise: INSERT the row, COMMIT, return `{ inserted: true }`
126
+ *
127
+ * On SQLITE_BUSY: ROLLBACK + return `{ inserted: false, reason: 'busy' }`.
128
+ *
129
+ * @param nativeDb - Open DatabaseSync handle to tasks.db.
130
+ * @param insertSql - Parameterized INSERT SQL string.
131
+ * @param insertParams - Named parameters for the INSERT statement.
132
+ * @param limit - Daily cap.
133
+ * @returns Insert result.
134
+ */
135
+ export function transactionalInsertProposal(
136
+ nativeDb: DatabaseSync,
137
+ insertSql: string,
138
+ insertParams: Record<string, SQLInputValue>,
139
+ limit = DEFAULT_DAILY_PROPOSAL_LIMIT,
140
+ ): TransactionalInsertResult {
141
+ try {
142
+ nativeDb.exec('BEGIN IMMEDIATE');
143
+ } catch (err) {
144
+ const msg = err instanceof Error ? err.message : String(err);
145
+ if (msg.includes(SQLITE_BUSY_CODE)) {
146
+ return { inserted: false, countBeforeInsert: 0, reason: 'busy' };
147
+ }
148
+ throw err;
149
+ }
150
+
151
+ try {
152
+ const countBeforeInsert = countTodayProposals(nativeDb);
153
+
154
+ if (countBeforeInsert >= limit) {
155
+ nativeDb.exec('ROLLBACK');
156
+ return { inserted: false, countBeforeInsert, reason: 'rate-limit' };
157
+ }
158
+
159
+ const stmt = nativeDb.prepare(insertSql);
160
+ stmt.run(insertParams);
161
+
162
+ nativeDb.exec('COMMIT');
163
+ return { inserted: true, countBeforeInsert };
164
+ } catch (err) {
165
+ try {
166
+ nativeDb.exec('ROLLBACK');
167
+ } catch {
168
+ // ignore rollback failure
169
+ }
170
+ throw err;
171
+ }
172
+ }