@erclx/canon 5.63.0 → 5.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/context-fold/REQUIREMENT.md +2 -3
- package/claude/skills/context-fold/SKILL.md +1 -11
- package/claude/skills/context-fold/references/completion-report.md +8 -9
- package/claude/skills/create-skill/references/skill-practice.md +16 -1
- package/claude/skills/design-taste/references/adopted.md +17 -97
- package/claude/skills/design-taste/references/tells.md +2 -2
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +3 -14
- package/claude/skills/document-health/references/axes.md +14 -16
- package/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/agents/audits.md +2 -6
- package/docs/agents/context-audit.md +1 -1
- package/governance/ledger/skills/api-design/SKILL.md +62 -0
- package/governance/ledger/skills/code-craft/SKILL.md +53 -0
- package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
- package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
- package/governance/ledger/skills/codebase-layout/tests.md +26 -0
- package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
- package/governance/ledger/skills/design-taste/SKILL.md +64 -0
- package/governance/ledger/skills/design-taste/craft.md +66 -0
- package/governance/ledger/skills/design-taste/kinds.md +58 -0
- package/governance/ledger/skills/design-taste/systems.md +42 -0
- package/governance/ledger/skills/design-taste/tells.md +103 -0
- package/governance/ledger/skills/review-craft/SKILL.md +61 -0
- package/governance/ledger/skills/review-craft/security.md +34 -0
- package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
- package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
- package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
- package/governance/ledger/skills/test-craft/SKILL.md +67 -0
- package/governance/ledger/skills/test-craft/component.md +49 -0
- package/governance/ledger/skills/test-craft/e2e.md +55 -0
- package/governance/ledger/skills/test-craft/unit.md +54 -0
- package/package.json +3 -3
- package/src/audits/catalog.ts +0 -52
- package/src/claude/skills-audit.ts +124 -41
- package/src/cli/retired.ts +8 -0
- package/src/commands/claude.ts +11 -2
- package/src/commands/context.ts +0 -425
- package/src/commands/sandbox.ts +247 -2
- package/src/context/citations.ts +9 -3
- package/src/gate/measures.ts +4 -1
- package/src/gate/stages.ts +25 -0
- package/src/git/files.ts +8 -3
- package/src/markdown/gated.ts +1 -1
- package/src/markdown/scope.ts +0 -1
- package/src/migrate/records.ts +6 -0
- package/src/sandbox/replay/expected.ts +137 -0
- package/src/sandbox/replay/run.ts +323 -0
- package/src/sandbox/replay/verdict.ts +102 -0
- package/tooling/astro/configs/playwright.config.ts +2 -2
- package/tooling/nextjs/configs/playwright.config.ts +2 -2
- package/tooling/vite-react/configs/playwright.config.ts +2 -2
- package/claude/skills/context-fold/references/classify.md +0 -87
- package/src/context/classify/extract.ts +0 -443
- package/src/context/classify/ollama.ts +0 -172
- package/src/context/classify/patterns.ts +0 -75
- package/src/context/classify/prompts.ts +0 -74
- package/src/context/classify/run.ts +0 -348
- package/src/context/classify/settings.ts +0 -196
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The local Ollama backend: a reachability probe and one chat call per item.
|
|
3
|
-
*
|
|
4
|
-
* One chunk or section per call, never batched. The groundwork spike measured
|
|
5
|
-
* batching 16 hunks into a single call returning KEEP for every one, so a
|
|
6
|
-
* caller here (`run.ts`) invokes `chat` once per item rather than folding a
|
|
7
|
-
* set into one prompt.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Nothing in the groundwork pins a request timeout: the spike scripts used a
|
|
14
|
-
* 600s ceiling meant for a batch research run, not a per-call budget for an
|
|
15
|
-
* interactive verb. 30s is chosen here as a deviation, wide enough that a
|
|
16
|
-
* shared GPU under another local session's load (measured coexisting at 12GB)
|
|
17
|
-
* still has room, while short enough that a `context-fold` run does not hang
|
|
18
|
-
* indefinitely on a backend that stopped responding mid-call.
|
|
19
|
-
*/
|
|
20
|
-
export const OLLAMA_TIMEOUT_MS = 30_000
|
|
21
|
-
|
|
22
|
-
/** Parsed straight off the model's own JSON, upper-cased for a loose model. */
|
|
23
|
-
export interface ParsedVerdict {
|
|
24
|
-
readonly verdict: string
|
|
25
|
-
readonly quote: string
|
|
26
|
-
readonly reason: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type ChatOutcome =
|
|
30
|
-
| {
|
|
31
|
-
readonly kind: 'ok'
|
|
32
|
-
readonly parsed: ParsedVerdict
|
|
33
|
-
readonly raw: string
|
|
34
|
-
}
|
|
35
|
-
| { readonly kind: 'unparsed'; readonly raw: string }
|
|
36
|
-
| { readonly kind: 'unreachable'; readonly message: string }
|
|
37
|
-
| { readonly kind: 'timeout' }
|
|
38
|
-
|
|
39
|
-
function describeError(error: unknown): string {
|
|
40
|
-
return error instanceof Error ? error.message : String(error)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function isTimeout(error: unknown): boolean {
|
|
44
|
-
return error instanceof DOMException && error.name === 'TimeoutError'
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Reads the model's reply out of its own JSON body.
|
|
49
|
-
*
|
|
50
|
-
* Ported from `classify.py`'s `_parse`: find the first `{` and the last `}`,
|
|
51
|
-
* so a model that wraps its JSON in a sentence or a fence still parses. Diff
|
|
52
|
-
* mode returns a single `quote` string and sweep mode returns a `quotes`
|
|
53
|
-
* array, and both are read here since the caller decides which mode it asked
|
|
54
|
-
* for by which prompt it sent, not by which shape came back.
|
|
55
|
-
*/
|
|
56
|
-
export function parseVerdictText(text: string): ParsedVerdict | undefined {
|
|
57
|
-
const start = text.indexOf('{')
|
|
58
|
-
const end = text.lastIndexOf('}')
|
|
59
|
-
if (start === -1 || end === -1 || end < start) return undefined
|
|
60
|
-
|
|
61
|
-
let data: unknown
|
|
62
|
-
try {
|
|
63
|
-
data = JSON.parse(text.slice(start, end + 1))
|
|
64
|
-
} catch {
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (typeof data !== 'object' || data === null) return undefined
|
|
69
|
-
const record = data as Record<string, unknown>
|
|
70
|
-
|
|
71
|
-
const verdict = record.verdict
|
|
72
|
-
if (typeof verdict !== 'string' || verdict === '') return undefined
|
|
73
|
-
|
|
74
|
-
const quote =
|
|
75
|
-
typeof record.quote === 'string'
|
|
76
|
-
? record.quote
|
|
77
|
-
: Array.isArray(record.quotes)
|
|
78
|
-
? record.quotes.map((entry) => String(entry)).join(' | ')
|
|
79
|
-
: ''
|
|
80
|
-
|
|
81
|
-
const reason = typeof record.reason === 'string' ? record.reason : ''
|
|
82
|
-
|
|
83
|
-
return { verdict: verdict.toUpperCase(), quote, reason }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Whether the backend answers at all, checked before the real call so a
|
|
88
|
-
* per-item timeout is never the thing that discovers an unreachable Ollama.
|
|
89
|
-
*/
|
|
90
|
-
export async function probeOllama(
|
|
91
|
-
baseUrl: string,
|
|
92
|
-
timeoutMs = OLLAMA_TIMEOUT_MS,
|
|
93
|
-
): Promise<boolean> {
|
|
94
|
-
try {
|
|
95
|
-
const response = await fetch(`${baseUrl}/api/tags`, {
|
|
96
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
97
|
-
})
|
|
98
|
-
return response.ok
|
|
99
|
-
} catch {
|
|
100
|
-
return false
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* One chat call: JSON output format, temperature 0, thinking off.
|
|
106
|
-
*
|
|
107
|
-
* Thinking is always off. The groundwork spike measured it never catching a
|
|
108
|
-
* flag thinking-off missed, at roughly five times the latency, and in sweep
|
|
109
|
-
* mode it lost three real flags by reasoning itself past them. There is no
|
|
110
|
-
* option to turn it on here, matching the groundwork decision to defer that
|
|
111
|
-
* rather than build an unused knob.
|
|
112
|
-
*/
|
|
113
|
-
export async function chat(opts: {
|
|
114
|
-
readonly baseUrl: string
|
|
115
|
-
readonly model: string
|
|
116
|
-
readonly system: string
|
|
117
|
-
readonly user: string
|
|
118
|
-
readonly timeoutMs?: number
|
|
119
|
-
}): Promise<ChatOutcome> {
|
|
120
|
-
const timeoutMs = opts.timeoutMs ?? OLLAMA_TIMEOUT_MS
|
|
121
|
-
|
|
122
|
-
let response: Response
|
|
123
|
-
try {
|
|
124
|
-
response = await fetch(`${opts.baseUrl}/api/chat`, {
|
|
125
|
-
method: 'POST',
|
|
126
|
-
headers: { 'Content-Type': 'application/json' },
|
|
127
|
-
body: JSON.stringify({
|
|
128
|
-
model: opts.model,
|
|
129
|
-
stream: false,
|
|
130
|
-
think: false,
|
|
131
|
-
format: 'json',
|
|
132
|
-
options: { temperature: 0, num_ctx: 16384 },
|
|
133
|
-
messages: [
|
|
134
|
-
{ role: 'system', content: opts.system },
|
|
135
|
-
{ role: 'user', content: opts.user },
|
|
136
|
-
],
|
|
137
|
-
}),
|
|
138
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
139
|
-
})
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (isTimeout(error)) return { kind: 'timeout' }
|
|
142
|
-
return { kind: 'unreachable', message: describeError(error) }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!response.ok) {
|
|
146
|
-
return {
|
|
147
|
-
kind: 'unreachable',
|
|
148
|
-
message: `ollama returned ${response.status}`,
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
let body: unknown
|
|
153
|
-
try {
|
|
154
|
-
body = await response.json()
|
|
155
|
-
} catch (error) {
|
|
156
|
-
return { kind: 'unparsed', raw: describeError(error) }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const content =
|
|
160
|
-
typeof body === 'object' && body !== null
|
|
161
|
-
? (body as { message?: { content?: unknown } }).message?.content
|
|
162
|
-
: undefined
|
|
163
|
-
|
|
164
|
-
if (typeof content !== 'string') {
|
|
165
|
-
return { kind: 'unparsed', raw: JSON.stringify(body) }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const parsed = parseVerdictText(content)
|
|
169
|
-
return parsed === undefined
|
|
170
|
-
? { kind: 'unparsed', raw: content }
|
|
171
|
-
: { kind: 'ok', parsed, raw: content }
|
|
172
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regex layer for the doc classifier, ported from the groundwork spike at
|
|
3
|
-
* `.canon/groundwork/93-canonical-doc-drift/scripts/heuristic.py`. It never
|
|
4
|
-
* needs a model installed, so it is the layer that always runs.
|
|
5
|
-
*
|
|
6
|
-
* Diff mode reads one changed chunk and answers KEEP, HISTORY, or MOVE. It
|
|
7
|
-
* never answers REPLACE, since telling a restated figure from a genuinely new
|
|
8
|
-
* one needs the section the hunk landed in, which the model layer reads and
|
|
9
|
-
* this one does not. Sweep mode reads one whole section and answers KEEP,
|
|
10
|
-
* REWRITE, or MOVE, collapsing REPLACE and HISTORY the way the sweep prompt
|
|
11
|
-
* does, since a section already carries its own history in view.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export type DiffVerdict = 'KEEP' | 'REPLACE' | 'HISTORY' | 'MOVE'
|
|
15
|
-
export type SweepVerdict = 'KEEP' | 'REWRITE' | 'MOVE'
|
|
16
|
-
|
|
17
|
-
export interface PatternVerdict<V extends string> {
|
|
18
|
-
readonly verdict: V
|
|
19
|
-
/** Absent on KEEP, since nothing decided against the text. */
|
|
20
|
-
readonly quote?: string
|
|
21
|
-
readonly reason: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Narrates how a fact got here rather than stating it: a branch or PR name, a
|
|
26
|
-
* closed or retired marker, a review-pass story. Ported from
|
|
27
|
-
* `heuristic.py`'s `NARRATION` pattern with one change: the bare
|
|
28
|
-
* `on \d{4}-\d{2}-\d{2}` alternative is dropped.
|
|
29
|
-
*
|
|
30
|
-
* That alternative was measured to false-flag a gotcha ending "Measured at
|
|
31
|
-
* `<sha>` on <date>.", a dating anchor both prompts call correct on a current
|
|
32
|
-
* statement. Every tuned hunk the bare date branch caught also carries a
|
|
33
|
-
* narration verb elsewhere in the same text (`closed on`, `moved again`,
|
|
34
|
-
* `did not survive`), so dropping it costs no measured catch and removes the
|
|
35
|
-
* one measured false flag.
|
|
36
|
-
*/
|
|
37
|
-
const NARRATION =
|
|
38
|
-
/moved (again|twice|on)|has since|at this branch|on `feat|Measured in PR|did not survive|the operator picked|picked arm|\barm \d|closed on|was retired|no longer|used to|\bround\b|a review pass|this pass/i
|
|
39
|
-
|
|
40
|
-
function narrationQuote(text: string): string | undefined {
|
|
41
|
-
return text.match(NARRATION)?.[0]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Classifies one diff-mode chunk from its added text alone. */
|
|
45
|
-
export function diffPatternVerdict(added: string): PatternVerdict<DiffVerdict> {
|
|
46
|
-
const quote = narrationQuote(added)
|
|
47
|
-
if (quote !== undefined) {
|
|
48
|
-
return { verdict: 'HISTORY', quote, reason: 'narrates how this got here' }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return { verdict: 'KEEP', reason: 'no narration or wrong-surface pattern' }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Classifies one sweep-mode section from its whole body.
|
|
56
|
-
*
|
|
57
|
-
* REWRITE stands in for both REPLACE and HISTORY, matching `sweep-v1.md`'s
|
|
58
|
-
* own three-verdict vocabulary: a section already shows its own history in
|
|
59
|
-
* full view, so the model layer does not need the diff-mode split and the
|
|
60
|
-
* regex layer follows it.
|
|
61
|
-
*/
|
|
62
|
-
export function sweepPatternVerdict(
|
|
63
|
-
body: string,
|
|
64
|
-
): PatternVerdict<SweepVerdict> {
|
|
65
|
-
const quote = narrationQuote(body)
|
|
66
|
-
if (quote !== undefined) {
|
|
67
|
-
return {
|
|
68
|
-
verdict: 'REWRITE',
|
|
69
|
-
quote,
|
|
70
|
-
reason: 'carries its own history rather than the current state alone',
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return { verdict: 'KEEP', reason: 'no narration or wrong-surface pattern' }
|
|
75
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* System prompts and user-message builders for the model layer, ported
|
|
3
|
-
* verbatim as data from the groundwork spike at
|
|
4
|
-
* `.canon/groundwork/93-canonical-doc-drift/scripts/prompts/v3.md` (diff
|
|
5
|
-
* mode) and `sweep-v1.md` (sweep mode). The diff prompt is verbatim. The sweep
|
|
6
|
-
* prompt was tuned past `sweep-v1.md` with one decision check, measured
|
|
7
|
-
* against the groundwork's labelled set in
|
|
8
|
-
* `canon/context/context-model.md`.
|
|
9
|
-
*
|
|
10
|
-
* Each prompt already states its own rules per canonical doc type inside one
|
|
11
|
-
* string, which is the shape the spike measured, so there is one prompt per
|
|
12
|
-
* mode rather than one per doc type.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const DIFF_SYSTEM_PROMPT = `You review one hunk just added to a project's canonical documentation. The docs state the project AS IT STANDS NOW. Judge only the ADDED text.
|
|
16
|
-
|
|
17
|
-
File types and what they hold:
|
|
18
|
-
- context/<domain>.md: one domain's structure, decisions (choice + the alternative that lost), gotchas. No history of how the domain got here, no change numbers, branch names, or dates attached to a change.
|
|
19
|
-
- DESIGN.md: tokens and visual rules, current state only.
|
|
20
|
-
|
|
21
|
-
Verdicts (pick exactly one):
|
|
22
|
-
- KEEP: states current design, a rule, copy, a decision with its rejected alternative, or a live gotcha, on the right surface.
|
|
23
|
-
- REPLACE: restates a fact or figure the existing section already carries ("moved again", "now", "the count moved", a newer number appended after an older one). The old statement should be rewritten in place, not appended to.
|
|
24
|
-
- HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR.
|
|
25
|
-
- MOVE: correct content on the wrong surface (a rule written into a context entry, or a measured result written into the requirements record).
|
|
26
|
-
|
|
27
|
-
Return only JSON: {"verdict": "...", "quote": "<the shortest added phrase that decided it>", "reason": "<one sentence>"}
|
|
28
|
-
|
|
29
|
-
Decision checks, in order:
|
|
30
|
-
1. If the hunk REMOVED text and the added text is the same statement rewritten (a table row, a figure, a sentence updated in place), that is the correct way to update: KEEP, unless the new wording itself narrates history.
|
|
31
|
-
2. A trailing "Measured at <sha> on <date>" or a branch name does not make a sentence current. If the section already carries an earlier figure or statement on the same subject and the hunk removed nothing, the answer is REPLACE.
|
|
32
|
-
3. Judge only what the added text says. Narration elsewhere in the section is not evidence about this hunk.
|
|
33
|
-
`
|
|
34
|
-
|
|
35
|
-
export const SWEEP_SYSTEM_PROMPT = `You review one whole section of a project's canonical documentation, as it stands today. The docs must state the project AS IT IS NOW, once, on the right surface.
|
|
36
|
-
|
|
37
|
-
File types and what they hold:
|
|
38
|
-
- context/<domain>.md: one domain's structure, decisions (choice + the alternative that lost), gotchas. No history of how the domain got here, no change numbers, branch names, or dates attached to a change.
|
|
39
|
-
- DESIGN.md: tokens and visual rules, current state only.
|
|
40
|
-
- REQUIREMENTS.md: problem, goals, non-goals, scope, constraints. Never measured results.
|
|
41
|
-
|
|
42
|
-
Verdicts (pick exactly one):
|
|
43
|
-
- KEEP: the section states the current design, rules, copy, or decisions once, on the right surface. A single commit anchor on a figure is fine.
|
|
44
|
-
- REWRITE: the section carries its own history: a figure followed by a later corrected figure, "superseded", "now", "moved", "no longer", "reverses the earlier reading", branch or PR narration, pick-by-pick rounds, a heading that narrates an event. The subject belongs here, but it must be restated as the current state.
|
|
45
|
-
- MOVE: the section, or most of it, belongs on another surface (results in requirements).
|
|
46
|
-
|
|
47
|
-
Decision checks, in order:
|
|
48
|
-
1. Judge what the text says, not how long it is. A long section that states current design once is KEEP.
|
|
49
|
-
2. A trailing "Measured at <sha> on <date>" does not make a paragraph current if another paragraph in the section states an older value on the same subject.
|
|
50
|
-
3. Short rationale for a current rule ("the form checks length itself because...") is KEEP. A story of the rounds that produced the rule is REWRITE.
|
|
51
|
-
4. Rationale is KEEP even when it is told in the past tense. A reason naming why an alternative was left out, what an earlier approach got wrong, what a measurement caught, or what a past run cost is the evidence for the current rule, so a sentence such as "the first form timed out" or "two runs took 1281s and 1924s" or "reading figures from real data caught three wrong claims" supports a rule that stands and stays KEEP. Past tense alone is never the test.
|
|
52
|
-
5. REWRITE needs the section to restate its own subject as a sequence: a superseded figure kept beside its replacement, "now" or "no longer" marking a change, branch or PR narration, or rounds of candidates. A maintenance step or an operator decision stated as a rule is current, not history.
|
|
53
|
-
|
|
54
|
-
Return only JSON: {"verdict": "...", "quotes": ["<up to three short phrases copied from the section that decided it>"], "reason": "<one sentence>"}
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
/** Ported from `classify.py`'s `_user_message`, the unmarked diff-mode branch. */
|
|
58
|
-
export function diffUserMessage(opts: {
|
|
59
|
-
readonly file: string
|
|
60
|
-
readonly removed: string
|
|
61
|
-
readonly added: string
|
|
62
|
-
readonly sectionAfter: string
|
|
63
|
-
}): string {
|
|
64
|
-
const removed = opts.removed === '' ? '(nothing)' : opts.removed
|
|
65
|
-
return `FILE: ${opts.file}\n\nREMOVED IN THIS HUNK:\n${removed}\n\nADDED IN THIS HUNK:\n${opts.added}\n\nTHE SECTION AFTER THE CHANGE (for context):\n${opts.sectionAfter}`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Ported from `classify.py`'s `_user_message`, the sweep-mode branch. */
|
|
69
|
-
export function sweepUserMessage(opts: {
|
|
70
|
-
readonly file: string
|
|
71
|
-
readonly body: string
|
|
72
|
-
}): string {
|
|
73
|
-
return `FILE: ${opts.file}\n\nSECTION:\n${opts.body}`
|
|
74
|
-
}
|
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CanonicalDocType,
|
|
3
|
-
extractDiffChunks,
|
|
4
|
-
extractSweepSections,
|
|
5
|
-
type ExtractRefusal,
|
|
6
|
-
} from '@/context/classify/extract'
|
|
7
|
-
import {
|
|
8
|
-
chat as ollamaChat,
|
|
9
|
-
type ChatOutcome,
|
|
10
|
-
DEFAULT_OLLAMA_BASE_URL,
|
|
11
|
-
probeOllama,
|
|
12
|
-
} from '@/context/classify/ollama'
|
|
13
|
-
import {
|
|
14
|
-
diffPatternVerdict,
|
|
15
|
-
type DiffVerdict,
|
|
16
|
-
type PatternVerdict,
|
|
17
|
-
sweepPatternVerdict,
|
|
18
|
-
type SweepVerdict,
|
|
19
|
-
} from '@/context/classify/patterns'
|
|
20
|
-
import {
|
|
21
|
-
DIFF_SYSTEM_PROMPT,
|
|
22
|
-
diffUserMessage,
|
|
23
|
-
SWEEP_SYSTEM_PROMPT,
|
|
24
|
-
sweepUserMessage,
|
|
25
|
-
} from '@/context/classify/prompts'
|
|
26
|
-
import {
|
|
27
|
-
type ClassifierBackend,
|
|
28
|
-
type ClassifierFlags,
|
|
29
|
-
type ClassifierResolution,
|
|
30
|
-
resolveClassifier,
|
|
31
|
-
type SettingSource,
|
|
32
|
-
} from '@/context/classify/settings'
|
|
33
|
-
|
|
34
|
-
const DIFF_VERDICTS: readonly DiffVerdict[] = [
|
|
35
|
-
'KEEP',
|
|
36
|
-
'REPLACE',
|
|
37
|
-
'HISTORY',
|
|
38
|
-
'MOVE',
|
|
39
|
-
]
|
|
40
|
-
const SWEEP_VERDICTS: readonly SweepVerdict[] = ['KEEP', 'REWRITE', 'MOVE']
|
|
41
|
-
|
|
42
|
-
function isDiffVerdict(value: string): value is DiffVerdict {
|
|
43
|
-
return (DIFF_VERDICTS as readonly string[]).includes(value)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isSweepVerdict(value: string): value is SweepVerdict {
|
|
47
|
-
return (SWEEP_VERDICTS as readonly string[]).includes(value)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A pattern verdict never carries a quote on KEEP, since nothing decided
|
|
52
|
-
* against the text. The finding's `regex` field is a `LayerVerdict` rather
|
|
53
|
-
* than a `PatternVerdict`, so it prints the same as a model layer verdict
|
|
54
|
-
* without every reader having to branch on an absent field.
|
|
55
|
-
*/
|
|
56
|
-
function withQuote<V extends string>(
|
|
57
|
-
verdict: PatternVerdict<V>,
|
|
58
|
-
): LayerVerdict<V> {
|
|
59
|
-
return {
|
|
60
|
-
verdict: verdict.verdict,
|
|
61
|
-
quote: verdict.quote ?? '',
|
|
62
|
-
reason: verdict.reason,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface LayerVerdict<V extends string> {
|
|
67
|
-
readonly verdict: V
|
|
68
|
-
readonly quote: string
|
|
69
|
-
readonly reason: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Why the model layer did not decide a run, named alongside `'ran'` so a
|
|
74
|
-
* caller reading the record never has to infer the reason from an absent
|
|
75
|
-
* field. `'off'` and `'skipped-no-model'` come from settings resolution
|
|
76
|
-
* alone; `'skipped-unreachable'` is the one state a probe call decides.
|
|
77
|
-
*/
|
|
78
|
-
export type ModelLayerState =
|
|
79
|
-
| 'off'
|
|
80
|
-
| 'ran'
|
|
81
|
-
| 'skipped-no-model'
|
|
82
|
-
| 'skipped-unreachable'
|
|
83
|
-
|
|
84
|
-
export interface DiffFinding {
|
|
85
|
-
readonly file: string
|
|
86
|
-
readonly docType: CanonicalDocType
|
|
87
|
-
readonly regex: LayerVerdict<DiffVerdict>
|
|
88
|
-
readonly model?: LayerVerdict<DiffVerdict>
|
|
89
|
-
/** Set when the model ran but its reply carried no verdict this could read. */
|
|
90
|
-
readonly modelUnparsed?: boolean
|
|
91
|
-
readonly verdict: DiffVerdict
|
|
92
|
-
readonly decidedBy: 'regex' | 'model'
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface SweepFinding {
|
|
96
|
-
readonly file: string
|
|
97
|
-
readonly docType: CanonicalDocType
|
|
98
|
-
readonly heading: string
|
|
99
|
-
readonly regex: LayerVerdict<SweepVerdict>
|
|
100
|
-
readonly model?: LayerVerdict<SweepVerdict>
|
|
101
|
-
readonly modelUnparsed?: boolean
|
|
102
|
-
readonly verdict: SweepVerdict
|
|
103
|
-
readonly decidedBy: 'regex' | 'model'
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
interface ClassifyRecordBase {
|
|
107
|
-
readonly backend: ClassifierBackend | undefined
|
|
108
|
-
readonly model: string | undefined
|
|
109
|
-
readonly modelLayer: ModelLayerState
|
|
110
|
-
readonly settingsSource: SettingSource
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface DiffRecord extends ClassifyRecordBase {
|
|
114
|
-
readonly mode: 'diff'
|
|
115
|
-
readonly findings: readonly DiffFinding[]
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export interface SweepRecord extends ClassifyRecordBase {
|
|
119
|
-
readonly mode: 'sweep'
|
|
120
|
-
readonly findings: readonly SweepFinding[]
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export type ClassifyRefusal = ExtractRefusal
|
|
124
|
-
|
|
125
|
-
export type ClassifyOutcome<Record> =
|
|
126
|
-
| { readonly kind: 'ok'; readonly record: Record }
|
|
127
|
-
| {
|
|
128
|
-
readonly kind: 'refused'
|
|
129
|
-
readonly reason: ClassifyRefusal
|
|
130
|
-
readonly message: string
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* The network boundary, injected so a test drives the merge logic below
|
|
135
|
-
* without a live Ollama. `src/version/skew.ts` takes the same shape for the
|
|
136
|
-
* npm registry lookup it wraps.
|
|
137
|
-
*/
|
|
138
|
-
export interface ModelClient {
|
|
139
|
-
readonly probe: (baseUrl: string, timeoutMs?: number) => Promise<boolean>
|
|
140
|
-
readonly chat: (opts: {
|
|
141
|
-
readonly baseUrl: string
|
|
142
|
-
readonly model: string
|
|
143
|
-
readonly system: string
|
|
144
|
-
readonly user: string
|
|
145
|
-
readonly timeoutMs?: number
|
|
146
|
-
}) => Promise<ChatOutcome>
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const DEFAULT_CLIENT: ModelClient = { probe: probeOllama, chat: ollamaChat }
|
|
150
|
-
|
|
151
|
-
export interface ClassifyOptions {
|
|
152
|
-
readonly flags?: ClassifierFlags
|
|
153
|
-
readonly docTypes?: readonly CanonicalDocType[]
|
|
154
|
-
readonly baseUrl?: string
|
|
155
|
-
readonly client?: ModelClient
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
interface ModelLayerPlan {
|
|
159
|
-
readonly modelLayer: ModelLayerState
|
|
160
|
-
readonly backend?: ClassifierBackend
|
|
161
|
-
readonly model?: string
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Decides whether the model layer runs, probing reachability before any real
|
|
166
|
-
* call. A configured-but-unreachable backend is a plan the caller reports and
|
|
167
|
-
* falls back from, never a refusal: the groundwork decision reads "warn and
|
|
168
|
-
* continue," and a refused run here would fail `context-fold` for a reason that
|
|
169
|
-
* has nothing to do with the branch it is checking.
|
|
170
|
-
*/
|
|
171
|
-
async function planModelLayer(
|
|
172
|
-
resolution: ClassifierResolution,
|
|
173
|
-
client: ModelClient,
|
|
174
|
-
baseUrl: string,
|
|
175
|
-
): Promise<ModelLayerPlan> {
|
|
176
|
-
if (resolution.kind === 'off') return { modelLayer: 'off' }
|
|
177
|
-
if (resolution.kind === 'no-model') {
|
|
178
|
-
return { modelLayer: 'skipped-no-model', backend: resolution.backend }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const reachable = await client.probe(baseUrl)
|
|
182
|
-
if (!reachable) {
|
|
183
|
-
return {
|
|
184
|
-
modelLayer: 'skipped-unreachable',
|
|
185
|
-
backend: resolution.backend,
|
|
186
|
-
model: resolution.model,
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
modelLayer: 'ran',
|
|
192
|
-
backend: resolution.backend,
|
|
193
|
-
model: resolution.model,
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* One chunk or section, one model call, matching the measured constraint that
|
|
199
|
-
* batching multiple items into one prompt returned KEEP for everything. The
|
|
200
|
-
* caller (`classifyDiff`/`classifySweep`) awaits this once per item rather
|
|
201
|
-
* than issuing every call in parallel, since the items already share one
|
|
202
|
-
* backend and the groundwork measurements are all serial per-call numbers.
|
|
203
|
-
*/
|
|
204
|
-
async function modelVerdictFor<V extends string>(opts: {
|
|
205
|
-
readonly plan: ModelLayerPlan
|
|
206
|
-
readonly client: ModelClient
|
|
207
|
-
readonly baseUrl: string
|
|
208
|
-
readonly system: string
|
|
209
|
-
readonly user: string
|
|
210
|
-
readonly isVerdict: (value: string) => value is V
|
|
211
|
-
}): Promise<{
|
|
212
|
-
readonly verdict?: LayerVerdict<V>
|
|
213
|
-
readonly unparsed: boolean
|
|
214
|
-
}> {
|
|
215
|
-
if (opts.plan.modelLayer !== 'ran' || !opts.plan.model) {
|
|
216
|
-
return { unparsed: false }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const outcome = await opts.client.chat({
|
|
220
|
-
baseUrl: opts.baseUrl,
|
|
221
|
-
model: opts.plan.model,
|
|
222
|
-
system: opts.system,
|
|
223
|
-
user: opts.user,
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
if (outcome.kind !== 'ok' || !opts.isVerdict(outcome.parsed.verdict)) {
|
|
227
|
-
return { unparsed: true }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
verdict: {
|
|
232
|
-
verdict: outcome.parsed.verdict,
|
|
233
|
-
quote: outcome.parsed.quote,
|
|
234
|
-
reason: outcome.parsed.reason,
|
|
235
|
-
},
|
|
236
|
-
unparsed: false,
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Runs diff mode over one git range: the regex layer over every extracted
|
|
242
|
-
* chunk, and the model layer over the same chunks when configured and
|
|
243
|
-
* reachable.
|
|
244
|
-
*
|
|
245
|
-
* A finding's `verdict` takes the model's reading when the model ran and
|
|
246
|
-
* parsed, and the regex reading otherwise, since the groundwork measurements
|
|
247
|
-
* show the model catching what the regex layer structurally cannot
|
|
248
|
-
* (REPLACE and MOVE). Both readings stay on the finding
|
|
249
|
-
* regardless of which one decided, so a caller can see where they disagreed.
|
|
250
|
-
*/
|
|
251
|
-
export async function classifyDiff(
|
|
252
|
-
root: string,
|
|
253
|
-
ref: string | undefined,
|
|
254
|
-
opts: ClassifyOptions = {},
|
|
255
|
-
): Promise<ClassifyOutcome<DiffRecord>> {
|
|
256
|
-
const extraction = await extractDiffChunks(root, ref, opts.docTypes)
|
|
257
|
-
if (extraction.kind === 'refused') return extraction
|
|
258
|
-
|
|
259
|
-
const resolution = resolveClassifier(root, opts.flags ?? {})
|
|
260
|
-
const client = opts.client ?? DEFAULT_CLIENT
|
|
261
|
-
const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
|
|
262
|
-
const plan = await planModelLayer(resolution, client, baseUrl)
|
|
263
|
-
|
|
264
|
-
const findings: DiffFinding[] = []
|
|
265
|
-
for (const chunk of extraction.chunks) {
|
|
266
|
-
const regex = withQuote(diffPatternVerdict(chunk.added))
|
|
267
|
-
const model = await modelVerdictFor({
|
|
268
|
-
plan,
|
|
269
|
-
client,
|
|
270
|
-
baseUrl,
|
|
271
|
-
system: DIFF_SYSTEM_PROMPT,
|
|
272
|
-
user: diffUserMessage(chunk),
|
|
273
|
-
isVerdict: isDiffVerdict,
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
findings.push({
|
|
277
|
-
file: chunk.file,
|
|
278
|
-
docType: chunk.docType,
|
|
279
|
-
regex,
|
|
280
|
-
model: model.verdict,
|
|
281
|
-
modelUnparsed: model.unparsed || undefined,
|
|
282
|
-
verdict: model.verdict?.verdict ?? regex.verdict,
|
|
283
|
-
decidedBy: model.verdict ? 'model' : 'regex',
|
|
284
|
-
})
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
kind: 'ok',
|
|
289
|
-
record: {
|
|
290
|
-
mode: 'diff',
|
|
291
|
-
backend: plan.backend,
|
|
292
|
-
model: plan.model,
|
|
293
|
-
modelLayer: plan.modelLayer,
|
|
294
|
-
settingsSource: resolution.source,
|
|
295
|
-
findings,
|
|
296
|
-
},
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Runs sweep mode over every extracted section, mirroring `classifyDiff`. */
|
|
301
|
-
export async function classifySweep(
|
|
302
|
-
root: string,
|
|
303
|
-
opts: ClassifyOptions = {},
|
|
304
|
-
): Promise<ClassifyOutcome<SweepRecord>> {
|
|
305
|
-
const extraction = await extractSweepSections(root, opts.docTypes)
|
|
306
|
-
if (extraction.kind === 'refused') return extraction
|
|
307
|
-
|
|
308
|
-
const resolution = resolveClassifier(root, opts.flags ?? {})
|
|
309
|
-
const client = opts.client ?? DEFAULT_CLIENT
|
|
310
|
-
const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
|
|
311
|
-
const plan = await planModelLayer(resolution, client, baseUrl)
|
|
312
|
-
|
|
313
|
-
const findings: SweepFinding[] = []
|
|
314
|
-
for (const section of extraction.sections) {
|
|
315
|
-
const regex = withQuote(sweepPatternVerdict(section.body))
|
|
316
|
-
const model = await modelVerdictFor({
|
|
317
|
-
plan,
|
|
318
|
-
client,
|
|
319
|
-
baseUrl,
|
|
320
|
-
system: SWEEP_SYSTEM_PROMPT,
|
|
321
|
-
user: sweepUserMessage(section),
|
|
322
|
-
isVerdict: isSweepVerdict,
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
findings.push({
|
|
326
|
-
file: section.file,
|
|
327
|
-
docType: section.docType,
|
|
328
|
-
heading: section.heading,
|
|
329
|
-
regex,
|
|
330
|
-
model: model.verdict,
|
|
331
|
-
modelUnparsed: model.unparsed || undefined,
|
|
332
|
-
verdict: model.verdict?.verdict ?? regex.verdict,
|
|
333
|
-
decidedBy: model.verdict ? 'model' : 'regex',
|
|
334
|
-
})
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
kind: 'ok',
|
|
339
|
-
record: {
|
|
340
|
-
mode: 'sweep',
|
|
341
|
-
backend: plan.backend,
|
|
342
|
-
model: plan.model,
|
|
343
|
-
modelLayer: plan.modelLayer,
|
|
344
|
-
settingsSource: resolution.source,
|
|
345
|
-
findings,
|
|
346
|
-
},
|
|
347
|
-
}
|
|
348
|
-
}
|