@cleocode/core 2026.4.98 → 2026.4.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- 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
|
+
}
|