@koreanhole/requirement-agent 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/requirement-agent.js +10 -0
- package/package.json +35 -4
- package/public/app.js +76 -0
- package/public/index.html +40 -0
- package/schemas/findings.input.schema.json +38 -0
- package/src/agents.js +131 -0
- package/src/citations.js +114 -0
- package/src/cli.js +192 -0
- package/src/documents.js +173 -0
- package/src/findings.js +112 -0
- package/src/orchestrator.js +217 -0
- package/src/report.js +42 -0
- package/src/roles.js +75 -0
- package/src/screen.js +31 -0
- package/src/server.js +93 -0
- package/src/store.js +73 -0
- package/src/util.js +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 koreanhole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Koreanhole Requirement Agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
요구사항 문서(스펙·기획서)를 여러 코딩 에이전트에 병렬로 리뷰시켜 모호성,
|
|
4
|
+
누락, 검증 불가 항목을 **근거 인용**과 함께 찾아내는 local-first CLI + 로컬 웹 UI.
|
|
5
|
+
|
|
6
|
+
## 설치
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @koreanhole/requirement-agent
|
|
10
|
+
requirement-agent --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node.js 22 이상. PDF는 `pdftotext`(poppler-utils), DOCX는 `unzip`이 필요합니다.
|
|
14
|
+
선택 사항: `codex`, `gemini`, `cline` 중 최소 하나.
|
|
15
|
+
|
|
16
|
+
## 사용
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 리뷰 실행 (처음 실행 시 문서를 받을 에이전트 목록 확인)
|
|
20
|
+
requirement-agent review ./specs --yes
|
|
21
|
+
|
|
22
|
+
# 역할·에이전트·예산 지정
|
|
23
|
+
requirement-agent review ./specs --roles ambiguity,testability --agent codex \
|
|
24
|
+
--max-doc-chars 60000 --max-total-chars 80000 --exclude 'drafts/**'
|
|
25
|
+
|
|
26
|
+
# 결과
|
|
27
|
+
requirement-agent runs
|
|
28
|
+
requirement-agent show <run-id>
|
|
29
|
+
requirement-agent show <run-id> --raw ambiguity
|
|
30
|
+
requirement-agent export <run-id> --format md
|
|
31
|
+
requirement-agent export <run-id> --format json
|
|
32
|
+
|
|
33
|
+
# 로컬 웹 UI (127.0.0.1 전용, 실행마다 새 토큰)
|
|
34
|
+
requirement-agent serve --allow-root ~/Documents
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
산출물 기본 위치: `${XDG_STATE_HOME:-~/.local/state}/requirement-agent/runs/<run-id>/`
|
|
38
|
+
(`run.json`, `findings.json`, `report.md`, `context.json`, `snapshot.json`, `raw/`).
|
|
39
|
+
|
|
40
|
+
## 동작
|
|
41
|
+
|
|
42
|
+
- 입력: Markdown, TXT, PDF, DOCX — 파일 1개 또는 디렉터리
|
|
43
|
+
- 6개 역할: ambiguity, completeness, testability, consistency, scope, feasibility
|
|
44
|
+
- 인용 검증: 원문에서 찾으면 `located`, 여러 번 나오면 `ambiguous`,
|
|
45
|
+
없으면 `not_located`; 누락 주장은 `anchor_located`
|
|
46
|
+
- 실패 격리: 한 리뷰어가 실패/타임아웃해도 나머지 결과 유지
|
|
47
|
+
- 리뷰 대상 트리의 순변경은 스냅샷 비교로 탐지 (종료 코드 5)
|
|
48
|
+
|
|
49
|
+
## 종료 코드
|
|
50
|
+
|
|
51
|
+
`0` 성공 · `1` 사용 오류 · `2` 성공한 리뷰어 없음 · `3` 사전 점검 실패 ·
|
|
52
|
+
`4` 산출물 경로가 리뷰 대상 내부 · `5` 리뷰 중 파일 변경.
|
|
53
|
+
|
|
54
|
+
## 보안·한계
|
|
55
|
+
|
|
56
|
+
- 로컬 단일 사용자, 웹은 loopback + 실행 토큰 + Host/Origin 검사만 지원
|
|
57
|
+
- **OS 수준 샌드박스 없음**: 리뷰어는 문서의 지시를 따르지 않도록 프롬프트로
|
|
58
|
+
지시받고 read-only 모드로 실행되지만, 강제되지는 않습니다. 순변경만 탐지합니다.
|
|
59
|
+
- 자격증명 스크리닝은 패턴 기반입니다. 알려진 형식(`sk-`, `ghp_`, `AKIA`,
|
|
60
|
+
Bearer, JWT 등)만 걸러지며, 미등록 형식은 통과할 수 있습니다.
|
|
61
|
+
- HWP/HWPX, OCR, 다중 사용자, 원격 모드는 범위 밖입니다.
|
|
62
|
+
|
|
63
|
+
## 개발
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm test # 단위 테스트
|
|
67
|
+
npm run test:e2e # 결정적 fake agent E2E
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Spec Kit 산출물: `specs/001-requirement-document-review/`.
|
|
71
|
+
리뷰 게이트: `REVIEW-GATE.md`. 구현 시 결정: `DECISIONS.md`.
|
|
72
|
+
|
|
73
|
+
## 라이선스
|
|
74
|
+
|
|
75
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koreanhole/requirement-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local-first review agent for requirement documents: parallel roles, citation-verified findings, CLI and loopback UI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"requirement-agent": "bin/requirement-agent.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"schemas/",
|
|
16
|
+
"public/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test tests/unit/*.test.js",
|
|
22
|
+
"test:e2e": "node --test e2e/*.e2e.mjs",
|
|
23
|
+
"check": "node scripts/check-docs.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"requirements",
|
|
27
|
+
"review",
|
|
28
|
+
"specification",
|
|
29
|
+
"cli",
|
|
30
|
+
"local-first"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/koreanhole/koreanhole-requirement-agent.git"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const token = new URLSearchParams(location.search).get('token') ?? localStorage.getItem('reqagent-token') ?? '';
|
|
2
|
+
if (token) localStorage.setItem('reqagent-token', token);
|
|
3
|
+
const api = (path, options = {}) => fetch(path, { ...options, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...(options.headers ?? {}) } });
|
|
4
|
+
const $ = (id) => document.getElementById(id);
|
|
5
|
+
const text = (tag, value, cls) => { const el = document.createElement(tag); el.textContent = value; if (cls) el.className = cls; return el; };
|
|
6
|
+
|
|
7
|
+
let acknowledged = null;
|
|
8
|
+
|
|
9
|
+
$('preflight').onclick = async () => {
|
|
10
|
+
const res = await api('/api/agents');
|
|
11
|
+
const body = await res.json();
|
|
12
|
+
const box = $('egress'); box.replaceChildren();
|
|
13
|
+
box.append(text('p', 'Agents that will receive document text:'));
|
|
14
|
+
acknowledged = body.agents.filter((a) => a.state !== 'missing').map((a) => a.id);
|
|
15
|
+
for (const a of body.agents) {
|
|
16
|
+
const label = document.createElement('label');
|
|
17
|
+
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = a.state !== 'missing'; cb.disabled = a.state === 'missing';
|
|
18
|
+
cb.onchange = () => { acknowledged = [...box.querySelectorAll('input:checked')].map((x) => x.value); };
|
|
19
|
+
cb.value = a.id;
|
|
20
|
+
label.append(cb, text('span', ` ${a.id} (${a.state})`));
|
|
21
|
+
box.append(label);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
$('start').onclick = async () => {
|
|
26
|
+
const status = $('status'); status.replaceChildren(text('p', 'Running…'));
|
|
27
|
+
const body = {
|
|
28
|
+
path: $('path').value.trim(),
|
|
29
|
+
exclude: $('exclude').value.split(',').map((s) => s.trim()).filter(Boolean),
|
|
30
|
+
agent: $('agent').value.trim() || undefined,
|
|
31
|
+
acknowledgedEgress: acknowledged ?? [],
|
|
32
|
+
};
|
|
33
|
+
const res = await api('/api/reviews', { method: 'POST', body: JSON.stringify(body) });
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
if (!res.ok) { status.replaceChildren(text('pre', JSON.stringify(data.error ?? data, null, 2))); return; }
|
|
36
|
+
await showRun(data.runId);
|
|
37
|
+
await loadRuns();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function showRun(id) {
|
|
41
|
+
const res = await api(`/api/runs/${id}`);
|
|
42
|
+
const run = await res.json();
|
|
43
|
+
const findingsRes = await api(`/api/runs/${id}/findings`);
|
|
44
|
+
const findings = await findingsRes.json();
|
|
45
|
+
const box = $('status'); box.replaceChildren();
|
|
46
|
+
box.append(text('h2', `Run ${run.runId} — ${run.status}`));
|
|
47
|
+
box.append(text('p', `blocker ${run.counts?.findingsBySeverity?.blocker ?? 0} · major ${run.counts?.findingsBySeverity?.major ?? 0} · minor ${run.counts?.findingsBySeverity?.minor ?? 0}`));
|
|
48
|
+
const sections = ['located', 'anchor_only', 'ambiguous', 'partial', 'unlocated'];
|
|
49
|
+
for (const section of sections) {
|
|
50
|
+
const list = findings.filter((f) => f.locatorState === section);
|
|
51
|
+
if (!list.length) continue;
|
|
52
|
+
box.append(text('h3', `${section} (${list.length})`));
|
|
53
|
+
for (const f of list) {
|
|
54
|
+
const div = document.createElement('div'); div.className = 'finding';
|
|
55
|
+
div.append(text('div', `${f.id} · ${f.severity} · ${f.roles.join(', ')}`, `sev-${f.severity}`));
|
|
56
|
+
for (const c of f.citations) {
|
|
57
|
+
div.append(text('div', `${c.document} ${c.lineStart ? `lines ${c.lineStart}-${c.lineEnd}` : '(no line range)'} · ${c.status}`));
|
|
58
|
+
div.append(text('pre', c.quote));
|
|
59
|
+
}
|
|
60
|
+
for (const c of f.contributions) div.append(text('p', `${c.role}: ${c.explanation}`));
|
|
61
|
+
box.append(div);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadRuns() {
|
|
67
|
+
const res = await api('/api/runs');
|
|
68
|
+
const { runs } = await res.json();
|
|
69
|
+
const box = $('runs'); box.replaceChildren(text('h2', 'History'));
|
|
70
|
+
for (const run of runs) {
|
|
71
|
+
const btn = text('button', `${run.runId} · ${run.status} · ${run.counts?.findingsBySeverity?.total ?? 0} findings`);
|
|
72
|
+
btn.onclick = () => showRun(run.runId);
|
|
73
|
+
box.append(btn);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
loadRuns();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Requirement Agent</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body { font: 15px/1.5 system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
|
|
10
|
+
fieldset { border: 1px solid #8884; border-radius: 8px; margin: 1rem 0; }
|
|
11
|
+
label { display: block; margin: .35rem 0; }
|
|
12
|
+
input[type=text] { width: 100%; padding: .45rem; }
|
|
13
|
+
button { padding: .5rem .9rem; margin-top: .5rem; cursor: pointer; }
|
|
14
|
+
pre { white-space: pre-wrap; word-break: break-word; background: #8881; padding: .8rem; border-radius: 8px; }
|
|
15
|
+
.finding { border-top: 1px solid #8884; padding: .8rem 0; }
|
|
16
|
+
.sev-blocker { color: #d33; font-weight: 700; }
|
|
17
|
+
.sev-major { color: #c80; font-weight: 600; }
|
|
18
|
+
.muted { color: #888; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<h1>Requirement Agent</h1>
|
|
23
|
+
<p class="muted">Local only. Document text goes to the agents you confirm.</p>
|
|
24
|
+
|
|
25
|
+
<fieldset>
|
|
26
|
+
<legend>New review</legend>
|
|
27
|
+
<label>Path <input id="path" type="text" placeholder="/absolute/path/to/specs"></label>
|
|
28
|
+
<label>Exclude globs (comma separated) <input id="exclude" type="text" placeholder="drafts/**, *.tmp"></label>
|
|
29
|
+
<label>Agent <input id="agent" type="text" placeholder="(per-role default)"></label>
|
|
30
|
+
<button id="preflight">Check agents</button>
|
|
31
|
+
<button id="start">Start review</button>
|
|
32
|
+
<div id="egress"></div>
|
|
33
|
+
</fieldset>
|
|
34
|
+
|
|
35
|
+
<div id="status"></div>
|
|
36
|
+
<div id="runs"></div>
|
|
37
|
+
|
|
38
|
+
<script type="module" src="/app.js"></script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://koreanhole.local/requirement-agent/findings-input.schema.json",
|
|
4
|
+
"title": "ReviewerOutput",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["findings"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"findings": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"maxItems": 50,
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["severity", "citations", "explanation"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"severity": { "enum": ["blocker", "major", "minor"] },
|
|
18
|
+
"evidenceKind": { "enum": ["quoted", "absent"] },
|
|
19
|
+
"citations": {
|
|
20
|
+
"type": "array", "minItems": 1, "maxItems": 12,
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object", "additionalProperties": false,
|
|
23
|
+
"required": ["document", "quote"],
|
|
24
|
+
"properties": {
|
|
25
|
+
"document": { "type": "string" },
|
|
26
|
+
"quote": { "type": "string" },
|
|
27
|
+
"section": { "type": ["string", "null"] },
|
|
28
|
+
"lineHint": { "type": ["integer", "null"] }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"explanation": { "type": "string" },
|
|
33
|
+
"suggestion": { "type": "string" }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const QUOTE = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
|
|
8
|
+
function onPath(bin) {
|
|
9
|
+
for (const dir of (process.env.PATH ?? '').split(':')) {
|
|
10
|
+
try { accessSync(join(dir, bin), constants.X_OK); return true; } catch { /* next */ }
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Adapter {
|
|
16
|
+
constructor(id) { this.id = id; }
|
|
17
|
+
probe() {
|
|
18
|
+
if (this.id === 'fake') return process.env.REQUIREMENT_AGENT_FAKE ? 'authenticated' : 'missing';
|
|
19
|
+
return onPath(this.bin) ? 'authenticated' : 'missing';
|
|
20
|
+
}
|
|
21
|
+
supportsModel() { return true; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class FakeAdapter extends Adapter {
|
|
25
|
+
constructor() { super('fake'); this.bin = 'node'; }
|
|
26
|
+
async run({ prompt, cwd, timeoutMs, onStart }) {
|
|
27
|
+
const script = process.env.REQUIREMENT_AGENT_FAKE;
|
|
28
|
+
if (!script) return { status: 'failed', error: 'REQUIREMENT_AGENT_FAKE is not set', raw: '' };
|
|
29
|
+
return runProcess(process.execPath, [script], { prompt, cwd, timeoutMs, onStart });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class CodexAdapter extends Adapter {
|
|
34
|
+
constructor() { super('codex'); this.bin = 'codex'; }
|
|
35
|
+
async run({ prompt, schemaPath, cwd, timeoutMs, model, onStart }) {
|
|
36
|
+
if (!onPath('script')) return { status: 'failed', error: 'script utility missing; codex needs a pty', raw: '' };
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'reqagent-'));
|
|
38
|
+
const out = join(dir, 'out.json');
|
|
39
|
+
const cmd = [
|
|
40
|
+
'codex exec --sandbox read-only --skip-git-repo-check',
|
|
41
|
+
`-C ${QUOTE(cwd)}`, `--output-schema ${QUOTE(schemaPath)}`,
|
|
42
|
+
model ? `-m ${QUOTE(model)}` : '', `-o ${QUOTE(out)} -`,
|
|
43
|
+
].filter(Boolean).join(' ');
|
|
44
|
+
try {
|
|
45
|
+
const res = await runProcess('script', ['-qfc', `${cmd}; printf '\\n__AGENT_EXIT=%s\\n' $?`, '/dev/null'], { prompt, cwd, timeoutMs, onStart });
|
|
46
|
+
const code = Number((res.raw.match(/__AGENT_EXIT=(\d+)/) ?? [])[1] ?? 0);
|
|
47
|
+
let parsed = res.output;
|
|
48
|
+
try { parsed = JSON.parse(readFileSync(out, 'utf8')); } catch { /* stdout fallback */ }
|
|
49
|
+
if (code !== 0) return { ...res, status: 'failed', error: `codex exited ${code}` };
|
|
50
|
+
return { ...res, output: parsed, status: 'succeeded' };
|
|
51
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class GeminiAdapter extends Adapter {
|
|
56
|
+
constructor() { super('gemini'); this.bin = 'gemini'; }
|
|
57
|
+
async run({ prompt, cwd, timeoutMs, model, onStart }) {
|
|
58
|
+
const args = ['-p', '-', '-o', 'json', '--approval-mode', 'plan'];
|
|
59
|
+
if (model) args.push('-m', model);
|
|
60
|
+
return runProcess('gemini', args, { prompt, cwd, timeoutMs, onStart });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ClineAdapter extends Adapter {
|
|
65
|
+
constructor() { super('cline'); this.bin = 'cline'; }
|
|
66
|
+
async run({ prompt, cwd, timeoutMs, model, onStart }) {
|
|
67
|
+
const args = ['-p', '--auto-approve', 'false'];
|
|
68
|
+
if (model) args.push('-m', model);
|
|
69
|
+
return runProcess('cline', args, { prompt, cwd, timeoutMs, onStart });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAdapter(id) {
|
|
74
|
+
switch (id) {
|
|
75
|
+
case 'fake': return new FakeAdapter();
|
|
76
|
+
case 'codex': return new CodexAdapter();
|
|
77
|
+
case 'gemini': return new GeminiAdapter();
|
|
78
|
+
case 'cline': return new ClineAdapter();
|
|
79
|
+
default: throw new Error(`unknown agent: ${id}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const AGENT_IDS = ['codex', 'gemini', 'cline'];
|
|
84
|
+
|
|
85
|
+
export function probeAgents() {
|
|
86
|
+
return ['fake', ...AGENT_IDS].map((id) => ({ id, state: createAdapter(id).probe() }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runProcess(cmd, args, { prompt, cwd, timeoutMs, onStart }) {
|
|
90
|
+
return new Promise((resolvePromise) => {
|
|
91
|
+
const child = spawn(cmd, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
92
|
+
onStart?.(child.pid);
|
|
93
|
+
let stdout = '';
|
|
94
|
+
let stderr = '';
|
|
95
|
+
let timedOut = false;
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
timedOut = true;
|
|
98
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
101
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
102
|
+
child.on('error', (err) => { clearTimeout(timer); resolvePromise({ status: 'failed', output: null, raw: stderr, error: err.message }); });
|
|
103
|
+
child.on('close', (code) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
if (timedOut) return resolvePromise({ status: 'timed_out', output: null, raw: `${stdout}\n${stderr}`, error: `timed out after ${timeoutMs}ms` });
|
|
106
|
+
if (code !== 0) return resolvePromise({ status: 'failed', output: null, raw: `${stdout}\n${stderr}`, error: `exit ${code}` });
|
|
107
|
+
const parsed = extractJson(stdout);
|
|
108
|
+
if (!parsed) return resolvePromise({ status: 'invalid_output', output: null, raw: `${stdout}\n${stderr}`, error: 'no JSON object in output' });
|
|
109
|
+
resolvePromise({ status: 'succeeded', output: parsed, raw: `${stdout}\n${stderr}`, error: null });
|
|
110
|
+
});
|
|
111
|
+
child.stdin.end(prompt);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function extractJson(text) {
|
|
116
|
+
const s = String(text ?? '');
|
|
117
|
+
for (let i = s.lastIndexOf('{'); i >= 0; i = s.lastIndexOf('{', i - 1)) {
|
|
118
|
+
const tail = s.slice(i);
|
|
119
|
+
try { return JSON.parse(tail); } catch { /* keep scanning */ }
|
|
120
|
+
const end = tail.lastIndexOf('}');
|
|
121
|
+
if (end > 0) { try { return JSON.parse(tail.slice(0, end + 1)); } catch { /* continue */ } }
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function sshQuote(s) { return QUOTE(s); }
|
|
127
|
+
export function verifyEscaping() {
|
|
128
|
+
const probe = `x'; echo PWNED; echo '$(id)`;
|
|
129
|
+
const r = spawnSync('sh', ['-c', `printf %s ${QUOTE(probe)}`]);
|
|
130
|
+
return r.stdout.toString() === probe;
|
|
131
|
+
}
|
package/src/citations.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const MIN_QUOTE_CHARS = 8;
|
|
4
|
+
|
|
5
|
+
export function normalizeWithMap(input) {
|
|
6
|
+
const s = String(input ?? '').normalize('NFC');
|
|
7
|
+
let out = '';
|
|
8
|
+
const map = [];
|
|
9
|
+
let pendingSpace = false;
|
|
10
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
11
|
+
const ch = s[i];
|
|
12
|
+
if (/\s/.test(ch)) { pendingSpace = out.length > 0; continue; }
|
|
13
|
+
if (pendingSpace) { out += ' '; map.push(i); pendingSpace = false; }
|
|
14
|
+
const folded = ch.toLowerCase();
|
|
15
|
+
for (let k = 0; k < folded.length; k += 1) { out += folded[k]; map.push(i); }
|
|
16
|
+
}
|
|
17
|
+
return { text: out, map };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const normalize = (input) => normalizeWithMap(input).text;
|
|
21
|
+
|
|
22
|
+
export function countLines(text, index) {
|
|
23
|
+
let line = 1;
|
|
24
|
+
for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === '\n') line += 1;
|
|
25
|
+
return line;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function lineRange(text, start, end) {
|
|
29
|
+
const lineStart = countLines(text, start);
|
|
30
|
+
const lineEnd = countLines(text, Math.max(start, end - 1));
|
|
31
|
+
return { lineStart, lineEnd };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sectionFor(text, lineStart) {
|
|
35
|
+
if (!lineStart) return null;
|
|
36
|
+
const lines = text.split('\n');
|
|
37
|
+
for (let i = Math.min(lineStart - 1, lines.length - 1); i >= 0; i -= 1) {
|
|
38
|
+
const m = lines[i].match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
39
|
+
if (m) return m[2].trim();
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeCitation(document, quote, section = null, lineHint = null) {
|
|
45
|
+
return { document, quote, section, lineHint, lineStart: null, lineEnd: null, status: 'not_located', reason: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifyCitation(citation, doc) {
|
|
49
|
+
const out = { ...citation, lineStart: null, lineEnd: null, section: citation.section ?? null, status: 'not_located', reason: null };
|
|
50
|
+
if (!doc) { out.reason = 'document_not_found'; return out; }
|
|
51
|
+
const raw = String(citation.quote ?? '');
|
|
52
|
+
const nq = normalize(raw).replace(/^ | $/g, '');
|
|
53
|
+
if (nq.replace(/\s/g, '').length < MIN_QUOTE_CHARS) { out.reason = 'quote_too_short'; return out; }
|
|
54
|
+
const { text, map } = normalizeWithMap(doc.text);
|
|
55
|
+
const hits = [];
|
|
56
|
+
let idx = text.indexOf(nq);
|
|
57
|
+
while (idx !== -1) { hits.push(idx); idx = text.indexOf(nq, idx + 1); }
|
|
58
|
+
if (!hits.length) { out.reason = 'quote_not_found'; return out; }
|
|
59
|
+
let chosen = null;
|
|
60
|
+
if (hits.length === 1) chosen = hits[0];
|
|
61
|
+
else if (citation.lineHint != null) {
|
|
62
|
+
chosen = hits.find((h) => lineRange(doc.text, map[h], map[h] + nq.length).lineStart === Number(citation.lineHint)) ?? null;
|
|
63
|
+
if (chosen == null) { out.status = 'ambiguous'; out.reason = 'repeated_occurrence'; return out; }
|
|
64
|
+
} else { out.status = 'ambiguous'; out.reason = 'repeated_occurrence'; return out; }
|
|
65
|
+
const start = map[chosen];
|
|
66
|
+
const end = map[chosen + nq.length - 1] + 1;
|
|
67
|
+
const range = lineRange(doc.text, start, end);
|
|
68
|
+
out.lineStart = range.lineStart;
|
|
69
|
+
out.lineEnd = range.lineEnd;
|
|
70
|
+
out.section = sectionFor(doc.text, out.lineStart);
|
|
71
|
+
out.status = 'located';
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function verifyFinding(finding, docsByPath) {
|
|
76
|
+
const citations = (finding.citations ?? []).map((c) => {
|
|
77
|
+
const doc = docsByPath.get(c.document);
|
|
78
|
+
if (finding.evidenceKind === 'absent') {
|
|
79
|
+
const out = { ...c, lineStart: null, lineEnd: null, section: c.section ?? null, status: 'not_located', reason: null };
|
|
80
|
+
if (!doc) { out.reason = 'document_not_found'; return out; }
|
|
81
|
+
const anchor = normalize(String(c.quote ?? ''));
|
|
82
|
+
if (anchor.replace(/\s/g, '').length < MIN_QUOTE_CHARS) { out.reason = 'quote_too_short'; return out; }
|
|
83
|
+
const { text, map } = normalizeWithMap(doc.text);
|
|
84
|
+
const at = text.indexOf(anchor);
|
|
85
|
+
if (at === -1) { out.reason = 'anchor_not_found'; return out; }
|
|
86
|
+
const start = map[at];
|
|
87
|
+
const end = map[at + anchor.length - 1] + 1;
|
|
88
|
+
const range = lineRange(doc.text, start, end);
|
|
89
|
+
out.lineStart = range.lineStart;
|
|
90
|
+
out.lineEnd = range.lineEnd;
|
|
91
|
+
out.section = sectionFor(doc.text, out.lineStart);
|
|
92
|
+
out.status = 'anchor_located';
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
return verifyCitation(c, doc);
|
|
96
|
+
});
|
|
97
|
+
const statuses = citations.map((c) => c.status);
|
|
98
|
+
let locatorState = 'unlocated';
|
|
99
|
+
if (statuses.length && statuses.every((s) => s === 'located')) locatorState = 'located';
|
|
100
|
+
else if (statuses.length && statuses.every((s) => s === 'anchor_located')) locatorState = 'anchor_only';
|
|
101
|
+
else if (statuses.length && statuses.every((s) => s === 'ambiguous')) locatorState = 'ambiguous';
|
|
102
|
+
else if (statuses.some((s) => s === 'located' || s === 'anchor_located')) locatorState = 'partial';
|
|
103
|
+
return { ...finding, citations, locatorState };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function citationDigest(citation, docPath) {
|
|
107
|
+
const line = citation.lineStart ?? -1;
|
|
108
|
+
return `${docPath}\u0000${createHash('sha256').update(normalize(citation.quote)).digest('hex')}\u0000${line}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function findingIdentity(finding) {
|
|
112
|
+
const basis = [...(finding.citations ?? []).map((c) => citationDigest(c, c.document))].sort().join('|');
|
|
113
|
+
return createHash('sha256').update(`${finding.evidenceKind}\u0000${basis}`).digest('hex');
|
|
114
|
+
}
|