codehere 0.6.2 → 0.7.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/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +28 -4
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +94 -6
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- package/package.json +4 -2
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.7 · verify-claim use case
|
|
3
|
+
*
|
|
4
|
+
* Pure function that compares what the Final Report TEXT claims against
|
|
5
|
+
* the structured reality (artifacts + steps) captured during the session.
|
|
6
|
+
*
|
|
7
|
+
* Codehere's #1 differentiator (per claim_market_position v4 + landing copy):
|
|
8
|
+
* agents round to optimism, the verifier catches the rounding.
|
|
9
|
+
*
|
|
10
|
+
* Examples this catches today:
|
|
11
|
+
* - Report says "18 tests passed" but artifacts.tests.passed = 17 → MISMATCH
|
|
12
|
+
* - Report says "all tests green" but artifacts.tests.failed > 0 → MISMATCH
|
|
13
|
+
* - Report says "no files changed" but artifacts.files has entries → MISMATCH
|
|
14
|
+
* - Report says "5 files modified" but artifacts.files.length === 3 → MISMATCH
|
|
15
|
+
*
|
|
16
|
+
* NOT a substitute for human review · this is a tripwire for the easy lies.
|
|
17
|
+
* The verifier is intentionally conservative: only flag claims it can
|
|
18
|
+
* compare against structured data. Unverifiable claims (prose-only,
|
|
19
|
+
* intent-only) pass through silently.
|
|
20
|
+
*
|
|
21
|
+
* I/O is deferred to the CLI/UI surface. This file is pure: same input,
|
|
22
|
+
* same output, no side-effects, no clock, no filesystem.
|
|
23
|
+
*/
|
|
24
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
25
|
+
/* Main verifier */
|
|
26
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
27
|
+
export function verifyClaim(input) {
|
|
28
|
+
if (!input.summary || !input.summary.trim()) {
|
|
29
|
+
return {
|
|
30
|
+
status: 'no-report',
|
|
31
|
+
findings: [
|
|
32
|
+
{
|
|
33
|
+
severity: 'warn',
|
|
34
|
+
kind: 'no-report',
|
|
35
|
+
claim: '(no Final Report generated)',
|
|
36
|
+
reality: 'nothing to verify',
|
|
37
|
+
message: 'No Final Report available for this session. Nothing to verify against.',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
summary: 'No Final Report to verify.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const findings = [
|
|
44
|
+
...verifyTestsClaim(input.summary, input.artifacts),
|
|
45
|
+
...verifyFilesClaim(input.summary, input.artifacts),
|
|
46
|
+
];
|
|
47
|
+
const hasFail = findings.some((f) => f.severity === 'fail');
|
|
48
|
+
const hasNon = findings.length > 0;
|
|
49
|
+
const status = hasFail ? 'broken' : hasNon ? 'partial' : 'verified';
|
|
50
|
+
const summary = buildVerdictSummary(status, findings);
|
|
51
|
+
return { status, findings, summary };
|
|
52
|
+
}
|
|
53
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
54
|
+
/* Tests claim verification */
|
|
55
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
56
|
+
/**
|
|
57
|
+
* Detect tests-related claims in the report and cross-check against
|
|
58
|
+
* artifacts.tests. Catches "X tests passed" exaggeration AND "all tests
|
|
59
|
+
* green" when failed > 0.
|
|
60
|
+
*/
|
|
61
|
+
export function verifyTestsClaim(summary, artifacts) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const lower = summary.toLowerCase();
|
|
64
|
+
const tests = artifacts?.tests;
|
|
65
|
+
// Extract numeric "X tests passed" / "X passed" / "X of Y" patterns.
|
|
66
|
+
// We deliberately keep the regex tight to avoid false positives on prose
|
|
67
|
+
// like "this is the 5th attempt".
|
|
68
|
+
const countClaim = matchTestCountClaim(summary);
|
|
69
|
+
if (countClaim !== null) {
|
|
70
|
+
if (!tests) {
|
|
71
|
+
// The agent claimed a test count but the adapter recorded no test
|
|
72
|
+
// artifact. Suspicious but not necessarily wrong (the adapter may not
|
|
73
|
+
// capture test results yet). Flag as warn.
|
|
74
|
+
out.push({
|
|
75
|
+
severity: 'warn',
|
|
76
|
+
kind: 'tests',
|
|
77
|
+
claim: countClaim.quote,
|
|
78
|
+
reality: 'no test artifact captured',
|
|
79
|
+
message: `Final Report claims "${countClaim.quote}" but no test results were recorded by the agent.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const actualPassed = tests.passed;
|
|
84
|
+
const actualFailed = tests.failed;
|
|
85
|
+
const actualTotal = actualPassed + actualFailed;
|
|
86
|
+
// Per codex Round 1: "X of Y tests passed" must check BOTH numbers.
|
|
87
|
+
// A claim like "18 of 18 tests passed" with reality {passed: 17, failed: 1}
|
|
88
|
+
// has matching total (18 ran) but mismatched passed (17 ≠ 18) · we flag.
|
|
89
|
+
if (countClaim.passedCount !== undefined && countClaim.passedCount !== actualPassed) {
|
|
90
|
+
out.push({
|
|
91
|
+
severity: 'fail',
|
|
92
|
+
kind: 'tests',
|
|
93
|
+
claim: countClaim.quote,
|
|
94
|
+
reality: `${actualPassed} passed, ${actualFailed} failed (${actualTotal} ran)`,
|
|
95
|
+
message: `Final Report claims ${countClaim.passedCount} tests passed but reality is ${actualPassed} passed and ${actualFailed} failed.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (countClaim.totalCount !== undefined && countClaim.totalCount !== actualTotal) {
|
|
99
|
+
out.push({
|
|
100
|
+
severity: 'fail',
|
|
101
|
+
kind: 'tests',
|
|
102
|
+
claim: countClaim.quote,
|
|
103
|
+
reality: `${actualTotal} ran (${actualPassed} passed, ${actualFailed} failed)`,
|
|
104
|
+
message: `Final Report claims ${countClaim.totalCount} tests ran but reality is ${actualTotal} (${actualPassed} passed, ${actualFailed} failed).`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Universal "all tests pass" / "all green" claims. Requires explicit
|
|
110
|
+
// universal qualifier ("all", "every", "each", "no failures") so that
|
|
111
|
+
// count claims like "18 tests passed" don't also match — count
|
|
112
|
+
// mismatches are caught above by matchTestCountClaim.
|
|
113
|
+
const claimsAllPass = /\b(?:all|every|each)\s+tests?\s+(?:pass(?:ed|ing)?|green|succeed(?:ed)?)\b/i.test(lower) ||
|
|
114
|
+
/\btests?\s+all\s+(?:pass(?:ed|ing)?|green)\b/i.test(lower) ||
|
|
115
|
+
/\ball\s+(?:tests?\s+)?(?:green|passing)\b/i.test(lower) ||
|
|
116
|
+
/\bno\s+(?:test\s+)?(?:failures|regressions?)\b/i.test(lower);
|
|
117
|
+
if (claimsAllPass && tests && tests.failed > 0) {
|
|
118
|
+
out.push({
|
|
119
|
+
severity: 'fail',
|
|
120
|
+
kind: 'tests',
|
|
121
|
+
claim: 'all tests pass / green / no failures',
|
|
122
|
+
reality: `${tests.failed} test${tests.failed === 1 ? '' : 's'} failed`,
|
|
123
|
+
message: `Final Report claims all tests pass but ${tests.failed} actually failed.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse the first explicit test-count claim from the summary.
|
|
130
|
+
* Returns null if no specific count is mentioned.
|
|
131
|
+
*
|
|
132
|
+
* Patterns matched (a single claim can assert BOTH a passed-count AND a total):
|
|
133
|
+
* "18 tests passed" → { passedCount: 18 }
|
|
134
|
+
* "18 of 18 tests passed" → { passedCount: 18, totalCount: 18 }
|
|
135
|
+
* "passed 18 tests" → { passedCount: 18 }
|
|
136
|
+
* "ran 25 tests" → { totalCount: 25 }
|
|
137
|
+
*
|
|
138
|
+
* Per codex Round 1 review on commit 8481412: "X of Y tests passed" must
|
|
139
|
+
* check BOTH numbers, not just total, otherwise "18 of 18 passed" with
|
|
140
|
+
* reality {passed: 17, failed: 1} slips through (total still equals 18).
|
|
141
|
+
*/
|
|
142
|
+
function matchTestCountClaim(summary) {
|
|
143
|
+
// "X of Y tests passed/green/succeeded" → X is passed AND Y is total.
|
|
144
|
+
// Per codex Round 2: require explicit pass-context to populate passedCount
|
|
145
|
+
// so negative phrasing ("1 of 3 tests failed") doesn't become a false-positive
|
|
146
|
+
// "1 passed" claim.
|
|
147
|
+
const ofPassMatch = summary.match(/\b(\d+)\s+(?:of|\/)\s+(\d+)\s+tests?\s+(?:pass(?:ed|ing)?|green|succeed(?:ed)?)\b/i);
|
|
148
|
+
if (ofPassMatch) {
|
|
149
|
+
return {
|
|
150
|
+
quote: ofPassMatch[0],
|
|
151
|
+
passedCount: parseInt(ofPassMatch[1], 10),
|
|
152
|
+
totalCount: parseInt(ofPassMatch[2], 10),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// "X of Y tests" without pass-context → only Y is a total-count claim. X
|
|
156
|
+
// is ambient (failed / ran / partial / etc) and we don't pretend to know.
|
|
157
|
+
// Per codex Round 3: this branch preserves total-count detection for
|
|
158
|
+
// bare X-of-Y phrasing so the verifier still catches mismatched totals.
|
|
159
|
+
const ofMatch = summary.match(/\b(\d+)\s+(?:of|\/)\s+(\d+)\s+tests?\b/i);
|
|
160
|
+
if (ofMatch) {
|
|
161
|
+
return {
|
|
162
|
+
quote: ofMatch[0],
|
|
163
|
+
totalCount: parseInt(ofMatch[2], 10),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// "X tests passed" / "X tests pass"
|
|
167
|
+
const passedMatch = summary.match(/\b(\d+)\s+tests?\s+(?:pass(?:ed|ing)?|green)\b/i);
|
|
168
|
+
if (passedMatch) {
|
|
169
|
+
return { quote: passedMatch[0], passedCount: parseInt(passedMatch[1], 10) };
|
|
170
|
+
}
|
|
171
|
+
// "passed X tests" / "ran X tests"
|
|
172
|
+
const reverseMatch = summary.match(/\b(?:pass(?:ed)?|ran)\s+(\d+)\s+tests?\b/i);
|
|
173
|
+
if (reverseMatch) {
|
|
174
|
+
const isPass = /pass/i.test(reverseMatch[0]);
|
|
175
|
+
return {
|
|
176
|
+
quote: reverseMatch[0],
|
|
177
|
+
passedCount: isPass ? parseInt(reverseMatch[1], 10) : undefined,
|
|
178
|
+
totalCount: isPass ? undefined : parseInt(reverseMatch[1], 10),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
184
|
+
/* Files claim verification */
|
|
185
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
186
|
+
/**
|
|
187
|
+
* Detect files-related claims in the report and cross-check against
|
|
188
|
+
* artifacts.files. Catches "no files changed" when files DID change,
|
|
189
|
+
* and "X files changed" count mismatches.
|
|
190
|
+
*/
|
|
191
|
+
export function verifyFilesClaim(summary, artifacts) {
|
|
192
|
+
const out = [];
|
|
193
|
+
// Claim: "no files were changed" / "no files modified" / similar
|
|
194
|
+
const claimsNoFiles = /\bno\s+files?\s+(were\s+)?(changed|modified|touched|written|created)\b/i.test(summary) ||
|
|
195
|
+
/\bsession\s+was\s+read-only\b/i.test(summary) ||
|
|
196
|
+
/\bnothing\s+(was\s+)?(written|changed|modified)\b/i.test(summary);
|
|
197
|
+
// Claim: "X files changed" / "modified X files" / "wrote X files"
|
|
198
|
+
const countClaim = matchFilesCountClaim(summary);
|
|
199
|
+
// Per codex Round 1: if the adapter didn't capture file artifacts at all,
|
|
200
|
+
// we can't verify file claims · warn rather than fail. Mirrors the tests
|
|
201
|
+
// path (missing tests artifact → warn). Distinguishes from `files: []`
|
|
202
|
+
// which is a valid "explicitly captured zero files" reality.
|
|
203
|
+
if (artifacts?.files === undefined) {
|
|
204
|
+
if (claimsNoFiles) {
|
|
205
|
+
out.push({
|
|
206
|
+
severity: 'warn',
|
|
207
|
+
kind: 'files',
|
|
208
|
+
claim: 'no files changed / read-only',
|
|
209
|
+
reality: 'no file artifact captured',
|
|
210
|
+
message: 'Final Report claims no files changed but no file results were recorded by the agent.',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (countClaim !== null) {
|
|
214
|
+
out.push({
|
|
215
|
+
severity: 'warn',
|
|
216
|
+
kind: 'files',
|
|
217
|
+
claim: countClaim.quote,
|
|
218
|
+
reality: 'no file artifact captured',
|
|
219
|
+
message: `Final Report claims "${countClaim.quote}" but no file results were recorded by the agent.`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
const actualCount = artifacts.files.length;
|
|
225
|
+
if (claimsNoFiles && actualCount > 0) {
|
|
226
|
+
out.push({
|
|
227
|
+
severity: 'fail',
|
|
228
|
+
kind: 'files',
|
|
229
|
+
claim: 'no files changed / read-only',
|
|
230
|
+
reality: `${actualCount} file${actualCount === 1 ? '' : 's'} changed`,
|
|
231
|
+
message: `Final Report claims no files were changed but ${actualCount} actually were.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (countClaim !== null && countClaim.value !== actualCount) {
|
|
235
|
+
out.push({
|
|
236
|
+
severity: 'fail',
|
|
237
|
+
kind: 'files',
|
|
238
|
+
claim: countClaim.quote,
|
|
239
|
+
reality: `${actualCount} file${actualCount === 1 ? '' : 's'} changed`,
|
|
240
|
+
message: `Final Report claims ${countClaim.value} file${countClaim.value === 1 ? '' : 's'} were changed but ${actualCount} actually were.`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
function matchFilesCountClaim(summary) {
|
|
246
|
+
// "X files changed" / "X files modified" / "X files were touched"
|
|
247
|
+
const m1 = summary.match(/\b(\d+)\s+files?\s+(were\s+)?(?:changed|modified|touched|written|created|added|updated)\b/i);
|
|
248
|
+
if (m1)
|
|
249
|
+
return { quote: m1[0], value: parseInt(m1[1], 10) };
|
|
250
|
+
// "changed X files" / "modified X files"
|
|
251
|
+
const m2 = summary.match(/\b(?:changed|modified|touched|wrote|created|added|updated)\s+(\d+)\s+files?\b/i);
|
|
252
|
+
if (m2)
|
|
253
|
+
return { quote: m2[0], value: parseInt(m2[1], 10) };
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
257
|
+
/* Verdict summary */
|
|
258
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
259
|
+
function buildVerdictSummary(status, findings) {
|
|
260
|
+
if (status === 'verified')
|
|
261
|
+
return 'Claims match the recorded reality. No gaps detected.';
|
|
262
|
+
if (status === 'no-report')
|
|
263
|
+
return 'No Final Report to verify.';
|
|
264
|
+
const failCount = findings.filter((f) => f.severity === 'fail').length;
|
|
265
|
+
const warnCount = findings.filter((f) => f.severity === 'warn').length;
|
|
266
|
+
if (failCount > 0 && warnCount > 0) {
|
|
267
|
+
return `${failCount} claim${failCount === 1 ? '' : 's'} contradict the recorded reality, plus ${warnCount} unverifiable claim${warnCount === 1 ? '' : 's'}.`;
|
|
268
|
+
}
|
|
269
|
+
if (failCount > 0) {
|
|
270
|
+
return `${failCount} claim${failCount === 1 ? '' : 's'} contradict the recorded reality.`;
|
|
271
|
+
}
|
|
272
|
+
return `${warnCount} unverifiable claim${warnCount === 1 ? '' : 's'}.`;
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=verify-claim.js.map
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-Pass (W-PASS) primitive · v0.9 S6 · the WRITE counterpart to S1's
|
|
3
|
+
* context-source READ primitive.
|
|
4
|
+
*
|
|
5
|
+
* Demo v12 lines 2030-2050. Every time an agent wants to make a stateful
|
|
6
|
+
* call to a third-party tool (Linear status update, GitHub PR comment,
|
|
7
|
+
* Notion page edit, etc.) it emits a `WritePassIntent`. Codehere mediates:
|
|
8
|
+
*
|
|
9
|
+
* 1. Intent · agent → codehere · {target, method, payload, reason}
|
|
10
|
+
* 2. Approval · user → codehere · approve / edit / cancel
|
|
11
|
+
* 3. Mediated send · codehere → third-party · captures response
|
|
12
|
+
* 4. Audit · chained entry for each step
|
|
13
|
+
*
|
|
14
|
+
* Why this primitive exists: AI 2027 forecasts agentic AI doing autonomous
|
|
15
|
+
* multi-tool writes by mid-2027. Without W-PASS we either (a) hand the
|
|
16
|
+
* agent raw API keys + tokens — every write is invisible to the human,
|
|
17
|
+
* audit-impossible — or (b) ban writes entirely and lose half the value.
|
|
18
|
+
* W-PASS is the middle path: writes happen, but every payload is human-
|
|
19
|
+
* mediated and chain-recorded.
|
|
20
|
+
*
|
|
21
|
+
* This file is PURE LOGIC · no fs, no fetch. CLI + HTTP + UI wrap it.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID, createHash } from 'crypto';
|
|
24
|
+
/** Errors raised by W-PASS primitive functions. */
|
|
25
|
+
export class WritePassError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(`[w-pass] ${code}: ${message}`);
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
33
|
+
/* Pure helpers */
|
|
34
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
35
|
+
/** Generate a fresh W-PASS id · stable across the intent → decision → send cycle. */
|
|
36
|
+
export function newPassId() {
|
|
37
|
+
return randomUUID();
|
|
38
|
+
}
|
|
39
|
+
/** Allowed URL schemes for W-PASS targets · matches context-source's namespace
|
|
40
|
+
* (we want symmetry: read via context-source, write via write-pass, same identifiers). */
|
|
41
|
+
const ALLOWED_SCHEMES = new Set(['linear', 'github', 'figma', 'drawio', 'notion']);
|
|
42
|
+
/**
|
|
43
|
+
* Validate that an intent is well-formed before showing it to the user.
|
|
44
|
+
* Refuse anything we can't audit (unknown scheme, unsupported method, empty
|
|
45
|
+
* reason, etc.). Throws WritePassError with a precise code.
|
|
46
|
+
*/
|
|
47
|
+
export function validateIntent(intent) {
|
|
48
|
+
if (typeof intent !== 'object' || intent === null) {
|
|
49
|
+
throw new WritePassError('invalid_intent', 'intent must be an object');
|
|
50
|
+
}
|
|
51
|
+
const it = intent;
|
|
52
|
+
if (typeof it.id !== 'string' || it.id.length === 0) {
|
|
53
|
+
throw new WritePassError('invalid_intent', 'id required');
|
|
54
|
+
}
|
|
55
|
+
if (typeof it.sessionId !== 'string' || it.sessionId.length === 0) {
|
|
56
|
+
throw new WritePassError('invalid_intent', 'sessionId required');
|
|
57
|
+
}
|
|
58
|
+
if (typeof it.target !== 'string' || it.target.length === 0) {
|
|
59
|
+
throw new WritePassError('invalid_intent', 'target required');
|
|
60
|
+
}
|
|
61
|
+
const schemeMatch = it.target.match(/^([a-z][a-z0-9+.-]*):\/\//);
|
|
62
|
+
if (!schemeMatch) {
|
|
63
|
+
throw new WritePassError('invalid_intent', `target must be a URL with a scheme · got "${it.target}"`);
|
|
64
|
+
}
|
|
65
|
+
if (!ALLOWED_SCHEMES.has(schemeMatch[1])) {
|
|
66
|
+
throw new WritePassError('unsupported_target', `scheme "${schemeMatch[1]}" not allowed · supported: ${[...ALLOWED_SCHEMES].sort().join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof it.method !== 'string') {
|
|
69
|
+
throw new WritePassError('invalid_intent', 'method required');
|
|
70
|
+
}
|
|
71
|
+
const m = it.method.toUpperCase();
|
|
72
|
+
if (m !== 'POST' && m !== 'PUT' && m !== 'PATCH' && m !== 'DELETE') {
|
|
73
|
+
throw new WritePassError('unsupported_method', `method "${it.method}" not allowed for W-PASS (use context-source for GET)`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof it.reason !== 'string' || it.reason.trim().length === 0) {
|
|
76
|
+
throw new WritePassError('invalid_intent', 'reason required · must be a non-empty one-line "why"');
|
|
77
|
+
}
|
|
78
|
+
if (typeof it.emittedAt !== 'number' || !Number.isFinite(it.emittedAt)) {
|
|
79
|
+
throw new WritePassError('invalid_intent', 'emittedAt required (epoch ms)');
|
|
80
|
+
}
|
|
81
|
+
// payload may be any JSON value · we don't constrain shape here · the user
|
|
82
|
+
// sees it verbatim and decides.
|
|
83
|
+
return {
|
|
84
|
+
id: it.id,
|
|
85
|
+
sessionId: it.sessionId,
|
|
86
|
+
target: it.target,
|
|
87
|
+
method: m,
|
|
88
|
+
payload: it.payload,
|
|
89
|
+
reason: it.reason,
|
|
90
|
+
emittedAt: it.emittedAt,
|
|
91
|
+
emittedBy: typeof it.emittedBy === 'string' ? it.emittedBy : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Validate a decision belongs to its intent and carries the fields required for its kind. */
|
|
95
|
+
export function validateDecision(decision, intent) {
|
|
96
|
+
if (typeof decision !== 'object' || decision === null) {
|
|
97
|
+
throw new WritePassError('invalid_decision', 'decision must be an object');
|
|
98
|
+
}
|
|
99
|
+
const d = decision;
|
|
100
|
+
if (d.passId !== intent.id) {
|
|
101
|
+
throw new WritePassError('invalid_decision', `decision.passId "${d.passId}" doesn't match intent.id "${intent.id}"`);
|
|
102
|
+
}
|
|
103
|
+
const kind = d.kind;
|
|
104
|
+
if (kind !== 'approve' && kind !== 'edit' && kind !== 'cancel') {
|
|
105
|
+
throw new WritePassError('invalid_decision', `kind must be approve | edit | cancel · got "${d.kind}"`);
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'edit' && d.editedPayload === undefined) {
|
|
108
|
+
throw new WritePassError('invalid_decision', 'edit requires editedPayload');
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.decidedAt !== 'number' || !Number.isFinite(d.decidedAt)) {
|
|
111
|
+
throw new WritePassError('invalid_decision', 'decidedAt required (epoch ms)');
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
passId: intent.id,
|
|
115
|
+
kind,
|
|
116
|
+
editedPayload: kind === 'edit' ? d.editedPayload : undefined,
|
|
117
|
+
note: typeof d.note === 'string' ? d.note : undefined,
|
|
118
|
+
decidedAt: d.decidedAt,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Apply a decision to an intent and produce the next status.
|
|
123
|
+
* Pure · no I/O. The caller (daemon route) does the network call and produces
|
|
124
|
+
* a `WritePassResponse` which then transitions 'approved'/'edited' → 'sent'.
|
|
125
|
+
*/
|
|
126
|
+
export function applyDecision(intent, decision) {
|
|
127
|
+
if (decision.kind === 'cancel') {
|
|
128
|
+
return { kind: 'cancelled', intent, decision };
|
|
129
|
+
}
|
|
130
|
+
if (decision.kind === 'edit') {
|
|
131
|
+
return { kind: 'edited', intent, decision, effectivePayload: decision.editedPayload };
|
|
132
|
+
}
|
|
133
|
+
return { kind: 'approved', intent, decision };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* After codehere does the mediated send, record the response and transition
|
|
137
|
+
* to 'sent' (2xx/3xx) or 'failed' (everything else).
|
|
138
|
+
*/
|
|
139
|
+
export function applyResponse(status, response) {
|
|
140
|
+
const effectivePayload = status.kind === 'edited' ? status.effectivePayload : status.intent.payload;
|
|
141
|
+
const ok = response.status >= 200 && response.status < 400;
|
|
142
|
+
return ok
|
|
143
|
+
? { kind: 'sent', intent: status.intent, decision: status.decision, effectivePayload, response }
|
|
144
|
+
: { kind: 'failed', intent: status.intent, decision: status.decision, effectivePayload, response };
|
|
145
|
+
}
|
|
146
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
147
|
+
/* Audit-line shaping */
|
|
148
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
149
|
+
/**
|
|
150
|
+
* Deterministic JSON for hashing into the audit chain. Same canonical-form
|
|
151
|
+
* rules as thread-export's canonicalize · keys sorted, undefined dropped,
|
|
152
|
+
* non-finite numbers refused. We want two machines that re-serialize the
|
|
153
|
+
* same intent to compute the same hash.
|
|
154
|
+
*/
|
|
155
|
+
export function canonicalizeForAudit(value) {
|
|
156
|
+
if (value === null)
|
|
157
|
+
return 'null';
|
|
158
|
+
if (typeof value === 'number') {
|
|
159
|
+
if (!Number.isFinite(value))
|
|
160
|
+
throw new Error(`cannot canonicalize non-finite number: ${value}`);
|
|
161
|
+
return JSON.stringify(value);
|
|
162
|
+
}
|
|
163
|
+
if (typeof value === 'string' || typeof value === 'boolean')
|
|
164
|
+
return JSON.stringify(value);
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
return '[' + value.map(canonicalizeForAudit).join(',') + ']';
|
|
167
|
+
}
|
|
168
|
+
if (typeof value === 'object') {
|
|
169
|
+
const obj = value;
|
|
170
|
+
const keys = Object.keys(obj).sort();
|
|
171
|
+
const parts = keys
|
|
172
|
+
.filter((k) => obj[k] !== undefined)
|
|
173
|
+
.map((k) => JSON.stringify(k) + ':' + canonicalizeForAudit(obj[k]));
|
|
174
|
+
return '{' + parts.join(',') + '}';
|
|
175
|
+
}
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
return 'null';
|
|
178
|
+
throw new Error(`cannot canonicalize type: ${typeof value}`);
|
|
179
|
+
}
|
|
180
|
+
/** SHA-256 hash of canonicalize(value) · 16-char hex prefix for inline display. */
|
|
181
|
+
export function shortContentHash(value) {
|
|
182
|
+
return createHash('sha256').update(canonicalizeForAudit(value)).digest('hex').slice(0, 16);
|
|
183
|
+
}
|
|
184
|
+
/** Render a status transition as an audit-chain entry. Caller appends it. */
|
|
185
|
+
export function statusToAuditLine(s) {
|
|
186
|
+
const sessionId = s.intent.sessionId;
|
|
187
|
+
const timestamp = new Date('response' in s ? s.response.receivedAt : 'decision' in s ? s.decision.decidedAt : s.intent.emittedAt).toISOString();
|
|
188
|
+
const baseSummary = {
|
|
189
|
+
passId: s.intent.id,
|
|
190
|
+
target: s.intent.target,
|
|
191
|
+
method: s.intent.method,
|
|
192
|
+
reason: s.intent.reason,
|
|
193
|
+
payloadHash: shortContentHash(s.intent.payload),
|
|
194
|
+
};
|
|
195
|
+
switch (s.kind) {
|
|
196
|
+
case 'pending':
|
|
197
|
+
return {
|
|
198
|
+
toolName: 'WritePass.intent',
|
|
199
|
+
toolInput: canonicalizeForAudit(baseSummary),
|
|
200
|
+
riskScore: 0,
|
|
201
|
+
blocked: false,
|
|
202
|
+
findings: 0,
|
|
203
|
+
criticalFindings: 0,
|
|
204
|
+
sessionId,
|
|
205
|
+
timestamp,
|
|
206
|
+
};
|
|
207
|
+
case 'approved':
|
|
208
|
+
return {
|
|
209
|
+
toolName: 'WritePass.approved',
|
|
210
|
+
toolInput: canonicalizeForAudit({ ...baseSummary, note: s.decision.note }),
|
|
211
|
+
riskScore: 0,
|
|
212
|
+
blocked: false,
|
|
213
|
+
findings: 0,
|
|
214
|
+
criticalFindings: 0,
|
|
215
|
+
sessionId,
|
|
216
|
+
timestamp,
|
|
217
|
+
};
|
|
218
|
+
case 'edited':
|
|
219
|
+
return {
|
|
220
|
+
toolName: 'WritePass.edited',
|
|
221
|
+
toolInput: canonicalizeForAudit({
|
|
222
|
+
...baseSummary,
|
|
223
|
+
editedPayloadHash: shortContentHash(s.effectivePayload),
|
|
224
|
+
note: s.decision.note,
|
|
225
|
+
}),
|
|
226
|
+
riskScore: 0,
|
|
227
|
+
blocked: false,
|
|
228
|
+
findings: 0,
|
|
229
|
+
criticalFindings: 0,
|
|
230
|
+
sessionId,
|
|
231
|
+
timestamp,
|
|
232
|
+
};
|
|
233
|
+
case 'cancelled':
|
|
234
|
+
return {
|
|
235
|
+
toolName: 'WritePass.cancelled',
|
|
236
|
+
toolInput: canonicalizeForAudit({ ...baseSummary, note: s.decision.note }),
|
|
237
|
+
riskScore: 0,
|
|
238
|
+
blocked: true,
|
|
239
|
+
findings: 0,
|
|
240
|
+
criticalFindings: 0,
|
|
241
|
+
sessionId,
|
|
242
|
+
timestamp,
|
|
243
|
+
};
|
|
244
|
+
case 'sent':
|
|
245
|
+
return {
|
|
246
|
+
toolName: 'WritePass.sent',
|
|
247
|
+
toolInput: canonicalizeForAudit({
|
|
248
|
+
...baseSummary,
|
|
249
|
+
effectivePayloadHash: shortContentHash(s.effectivePayload),
|
|
250
|
+
responseStatus: s.response.status,
|
|
251
|
+
}),
|
|
252
|
+
riskScore: 0,
|
|
253
|
+
blocked: false,
|
|
254
|
+
findings: 0,
|
|
255
|
+
criticalFindings: 0,
|
|
256
|
+
sessionId,
|
|
257
|
+
timestamp,
|
|
258
|
+
};
|
|
259
|
+
case 'failed':
|
|
260
|
+
return {
|
|
261
|
+
toolName: 'WritePass.failed',
|
|
262
|
+
toolInput: canonicalizeForAudit({
|
|
263
|
+
...baseSummary,
|
|
264
|
+
effectivePayloadHash: shortContentHash(s.effectivePayload),
|
|
265
|
+
responseStatus: s.response.status,
|
|
266
|
+
error: s.response.error,
|
|
267
|
+
}),
|
|
268
|
+
riskScore: 0,
|
|
269
|
+
blocked: false,
|
|
270
|
+
findings: 1,
|
|
271
|
+
criticalFindings: 0,
|
|
272
|
+
sessionId,
|
|
273
|
+
timestamp,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=write-pass.js.map
|