codehere 0.5.0-alpha.6 → 0.6.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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +89 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +515 -15
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +2872 -220
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/presentation/cli/repl.js +29 -0
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +2 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-vendor tool-call audit mirror — #110 slice 2 (Codex first).
|
|
3
|
+
*
|
|
4
|
+
* When non-Claude adapters emit a tool-call event, we can't BLOCK
|
|
5
|
+
* (no synchronous pre-tool intercept yet) but we CAN mirror the
|
|
6
|
+
* call into the audit log post-hoc. Closes the audit-trail-parity
|
|
7
|
+
* gap: every adapter's tool calls land in the same hook-events.jsonl
|
|
8
|
+
* the per-tool Claude hook writes to.
|
|
9
|
+
*
|
|
10
|
+
* Future slices (per-adapter blocking) replace the post-hoc mirror
|
|
11
|
+
* with pre-call intercepts. For now: visibility today, blocking next.
|
|
12
|
+
*/
|
|
13
|
+
import { scanPayload, calculateRiskScore, appendAuditLog } from './claude-sdk-hook.js';
|
|
14
|
+
export function mirrorToolCallToAudit(rec) {
|
|
15
|
+
// Run the same pattern bank the per-tool hook uses, so post-hoc
|
|
16
|
+
// findings show up in /history with the same severity calibration.
|
|
17
|
+
const findings = scanPayload(rec.toolInput);
|
|
18
|
+
const riskScore = calculateRiskScore(findings);
|
|
19
|
+
const truncated = rec.toolInput.length > 500 ? rec.toolInput.slice(0, 500) + '…' : rec.toolInput;
|
|
20
|
+
const entry = {
|
|
21
|
+
timestamp: new Date().toISOString(),
|
|
22
|
+
sessionId: rec.sessionId,
|
|
23
|
+
toolName: rec.toolName,
|
|
24
|
+
toolInput: JSON.stringify({
|
|
25
|
+
agent: rec.agent,
|
|
26
|
+
input: truncated,
|
|
27
|
+
cwd: rec.cwd ?? null,
|
|
28
|
+
inputLength: rec.toolInput.length,
|
|
29
|
+
mirrored: true, // marks post-hoc vs the synchronous Claude hook
|
|
30
|
+
}),
|
|
31
|
+
riskScore,
|
|
32
|
+
blocked: false, // post-hoc — can't block, only log
|
|
33
|
+
findings: findings.length,
|
|
34
|
+
criticalFindings: findings.filter((f) => f.severity === 'critical').length,
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
appendAuditLog(entry);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Audit-write failures don't propagate — the agent's already running.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=mirror-tool-call-to-audit.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-prompt safety scan — cross-vendor parity, task #110.
|
|
3
|
+
*
|
|
4
|
+
* The substrate the FAQ landing claims for v0.5.x → v0.6: "per-tool
|
|
5
|
+
* blocking parity for Codex / Aider / OpenCode / Gemini." Today, only
|
|
6
|
+
* Claude Code has a per-tool PreToolUse hook. The other 4 adapters
|
|
7
|
+
* spawn raw — every Bash/Edit they make goes unscanned.
|
|
8
|
+
*
|
|
9
|
+
* Closing that gap fully needs per-adapter pre-tool intercepts (huge
|
|
10
|
+
* scope per-adapter — different IPC + lifecycle for each CLI). This
|
|
11
|
+
* module ships the FIRST cross-vendor slice: scan the user's PROMPT
|
|
12
|
+
* before spawn. Catches the prompt-side leaks (API key in prompt,
|
|
13
|
+
* injection-pattern poisoning, credential paste) for ALL 5 adapters
|
|
14
|
+
* with one piece of code.
|
|
15
|
+
*
|
|
16
|
+
* Posture follows getHookMode():
|
|
17
|
+
* block → high+ severity findings refuse spawn (throw)
|
|
18
|
+
* advisory → findings logged + warning event, spawn proceeds
|
|
19
|
+
* log-only → findings logged, spawn proceeds, no warning
|
|
20
|
+
* off → no scan, spawn proceeds
|
|
21
|
+
*
|
|
22
|
+
* Every scan writes one audit-log entry with synthetic toolName
|
|
23
|
+
* 'pre_prompt' so the existing /history feed + hash chain pick it up
|
|
24
|
+
* without surface changes.
|
|
25
|
+
*/
|
|
26
|
+
import { scanPayload, calculateRiskScore, appendAuditLog, } from './claude-sdk-hook.js';
|
|
27
|
+
import { getHookMode } from './hook-mode.js';
|
|
28
|
+
import { filterAllowed } from '../security/permissions-config.js';
|
|
29
|
+
/**
|
|
30
|
+
* Scan a user prompt before spawn. Always writes one audit-log entry.
|
|
31
|
+
* Returns allowed=false ONLY when mode === 'block' AND there's a
|
|
32
|
+
* high-or-critical finding. Other modes always allow but still log.
|
|
33
|
+
*/
|
|
34
|
+
export function scanPromptBeforeSpawn(input) {
|
|
35
|
+
const rawFindings = scanPayload(input.prompt);
|
|
36
|
+
// P18 slice 1 — filter against the cwd's permissions.yaml allow-list.
|
|
37
|
+
// Allow-listed findings are dropped from the BLOCK calculation but
|
|
38
|
+
// still recorded in the audit-log allowed-list field so a forensic
|
|
39
|
+
// reviewer can see what was suppressed and why.
|
|
40
|
+
const { surviving, allowed } = input.cwd
|
|
41
|
+
? filterAllowed(rawFindings, input.cwd)
|
|
42
|
+
: { surviving: rawFindings, allowed: [] };
|
|
43
|
+
const findings = surviving;
|
|
44
|
+
const riskScore = calculateRiskScore(findings);
|
|
45
|
+
const mode = (() => {
|
|
46
|
+
try {
|
|
47
|
+
return getHookMode();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return 'off';
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
// Severity gate: only critical/high findings can refuse a spawn.
|
|
54
|
+
// Medium/low findings get logged but never block — they're flagged
|
|
55
|
+
// for visibility, not enforcement. Matches the per-tool hook's
|
|
56
|
+
// existing severity-to-action policy.
|
|
57
|
+
const hardFindings = findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
58
|
+
const wouldBlock = hardFindings.length > 0;
|
|
59
|
+
const blocked = mode === 'block' && wouldBlock;
|
|
60
|
+
// Audit entry — written for every scan, regardless of mode. Synthetic
|
|
61
|
+
// toolName 'pre_prompt' lets /history filter chips group these
|
|
62
|
+
// separately from per-tool entries (existing chip filters on tool).
|
|
63
|
+
const entry = {
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
sessionId: input.sessionId,
|
|
66
|
+
toolName: 'pre_prompt',
|
|
67
|
+
// Truncate the prompt for the audit record — full prompts can be
|
|
68
|
+
// 10K+ chars and we don't want the audit log to balloon.
|
|
69
|
+
toolInput: JSON.stringify({
|
|
70
|
+
agent: input.agent,
|
|
71
|
+
prompt: input.prompt.length > 500 ? input.prompt.slice(0, 500) + '…' : input.prompt,
|
|
72
|
+
cwd: input.cwd ?? null,
|
|
73
|
+
promptLength: input.prompt.length,
|
|
74
|
+
// P18 slice 1 — record what got allow-listed for forensic audit.
|
|
75
|
+
// Empty array when no permissions.yaml exists in the cwd.
|
|
76
|
+
...(allowed.length > 0
|
|
77
|
+
? {
|
|
78
|
+
allowedByPermissions: allowed.map((a) => ({
|
|
79
|
+
category: a.finding.category,
|
|
80
|
+
matched: a.finding.matched,
|
|
81
|
+
reason: a.entry.reason ?? '(no reason given)',
|
|
82
|
+
})),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
}),
|
|
86
|
+
riskScore,
|
|
87
|
+
blocked,
|
|
88
|
+
findings: findings.length,
|
|
89
|
+
criticalFindings: findings.filter((f) => f.severity === 'critical').length,
|
|
90
|
+
// Mode-driven override marker so the audit reader knows WHY a
|
|
91
|
+
// would-block finding wasn't enforced. Reuses the existing
|
|
92
|
+
// OverrideReason 'log_only_mode' for log-only AND advisory + off
|
|
93
|
+
// (all three skip the block; 'log_only_mode' is the closest
|
|
94
|
+
// shared semantic — the entry's mode field can be added as a new
|
|
95
|
+
// OverrideReason later without breaking the audit chain).
|
|
96
|
+
...(wouldBlock && !blocked ? { overrideReason: 'log_only_mode' } : {}),
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
appendAuditLog(entry);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Audit-write failure should NOT itself block a spawn — that would
|
|
103
|
+
// be a denial-of-service vector (fill the log dir → no spawns).
|
|
104
|
+
// The audit infra has its own failure-mode logging.
|
|
105
|
+
}
|
|
106
|
+
// Compose a block reason from the worst findings. Used by the
|
|
107
|
+
// session-manager to throw an actionable error.
|
|
108
|
+
let blockReason = '';
|
|
109
|
+
if (blocked) {
|
|
110
|
+
const top = hardFindings.slice(0, 3);
|
|
111
|
+
const summary = top.map((f) => `[${f.severity}] ${f.description}`).join('; ');
|
|
112
|
+
blockReason = `pre-prompt scan blocked: ${summary}`;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
allowed: !blocked,
|
|
116
|
+
blocked,
|
|
117
|
+
riskScore,
|
|
118
|
+
findings,
|
|
119
|
+
blockReason,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=pre-prompt-scan.js.map
|
|
@@ -17,6 +17,7 @@ import { EventEmitter } from 'events';
|
|
|
17
17
|
import { randomUUID } from 'crypto';
|
|
18
18
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
19
19
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
20
|
+
import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
|
|
20
21
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
21
22
|
const HEADLESS_FLAGS = [
|
|
22
23
|
'--yes-always', // auto-accept all file-edit confirmations
|
|
@@ -99,6 +100,32 @@ export function spawnAider(prompt, options = {}) {
|
|
|
99
100
|
if (event.type === 'stdout' && event.data?.line) {
|
|
100
101
|
const line = String(event.data.line);
|
|
101
102
|
textAccum += line + '\n';
|
|
103
|
+
// #110 slice 3 — mirror aider tool calls to the audit log. Match
|
|
104
|
+
// the same stdout patterns the aggregator parses (per
|
|
105
|
+
// learning_aider.md): "Running <cmd>" → Bash, "Applied edit to
|
|
106
|
+
// <path>" → Edit, "Creating empty file <path>" → Write.
|
|
107
|
+
// Strip ANSI as defense-in-depth (HEADLESS_FLAGS already includes
|
|
108
|
+
// --no-pretty but extraArgs could override).
|
|
109
|
+
const stripAnsi = (s) => s.replace(/\[[0-9;]*m/g, '');
|
|
110
|
+
const clean = stripAnsi(line).trim();
|
|
111
|
+
let toolName = '';
|
|
112
|
+
let input = '';
|
|
113
|
+
let mirrorMatch;
|
|
114
|
+
if ((mirrorMatch = clean.match(/^Running\s+(.+)$/))) {
|
|
115
|
+
toolName = 'Bash';
|
|
116
|
+
input = mirrorMatch[1];
|
|
117
|
+
}
|
|
118
|
+
else if ((mirrorMatch = clean.match(/^Applied edit to\s+(.+)$/))) {
|
|
119
|
+
toolName = 'Edit';
|
|
120
|
+
input = mirrorMatch[1];
|
|
121
|
+
}
|
|
122
|
+
else if ((mirrorMatch = clean.match(/^Creating empty file\s+(.+)$/))) {
|
|
123
|
+
toolName = 'Write';
|
|
124
|
+
input = mirrorMatch[1];
|
|
125
|
+
}
|
|
126
|
+
if (toolName && input) {
|
|
127
|
+
mirrorToolCallToAudit({ sessionId, agent: 'aider', toolName, toolInput: input, cwd });
|
|
128
|
+
}
|
|
102
129
|
// Aider emits a cost summary like:
|
|
103
130
|
// "Tokens: 1.2k sent, 412 received. Cost: $0.01 request, $0.01 session."
|
|
104
131
|
const m = line.match(/Tokens:\s*([\d.]+)([kM]?)\s*sent,\s*([\d.]+)([kM]?)\s*received/i);
|
|
@@ -330,6 +357,7 @@ export function spawnAider(prompt, options = {}) {
|
|
|
330
357
|
export const AiderAdapter = {
|
|
331
358
|
id: 'aider',
|
|
332
359
|
label: 'Aider',
|
|
360
|
+
description: 'Best when you already know which file to change and what to change in it. Tight, focused edits — no plan-then-explore phase.',
|
|
333
361
|
processPattern: /(?:^|\/)aider(?:\s|$)/,
|
|
334
362
|
isInstalled() {
|
|
335
363
|
try {
|
|
@@ -9,11 +9,81 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Agent-agnostic on purpose. Both Claude and Codex mutate the filesystem;
|
|
11
11
|
* git tells the truth regardless of which tool did the write.
|
|
12
|
+
*
|
|
13
|
+
* v0.6.x: dirty-baseline subtraction. Without it, files the user had
|
|
14
|
+
* uncommitted at spawn time would falsely appear in artifacts even though
|
|
15
|
+
* the agent never touched them — git diff vs spawn-HEAD includes EVERYTHING
|
|
16
|
+
* dirty in the working tree, not just what changed during the session.
|
|
17
|
+
* Now snapshotCwd captures `git status --porcelain` + per-file mtime/size at
|
|
18
|
+
* spawn time. extractArtifacts filters out files whose stat is unchanged
|
|
19
|
+
* since spawn — they were dirty before, not by this session. Files that were
|
|
20
|
+
* dirty at spawn AND further modified during the session still appear.
|
|
21
|
+
*
|
|
22
|
+
* Limitation: cannot distinguish concurrent human writes from agent writes
|
|
23
|
+
* during the session window. True PID-level attribution would require
|
|
24
|
+
* fanotify/audit subsystem. The dirty-baseline filter eliminates the most
|
|
25
|
+
* common false-positive (pre-existing uncommitted work) without that.
|
|
12
26
|
*/
|
|
13
27
|
import { execFileSync } from 'child_process';
|
|
14
28
|
import { existsSync, readFileSync, statSync } from 'fs';
|
|
15
29
|
import { join, isAbsolute } from 'path';
|
|
16
30
|
import { scoreFile } from '../security/trust-scorer.js';
|
|
31
|
+
/** Capture mtime + size for a single path. Returns null if stat fails. */
|
|
32
|
+
function statBaseline(fullPath) {
|
|
33
|
+
try {
|
|
34
|
+
const st = statSync(fullPath);
|
|
35
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Read `git status --porcelain` and stat every dirty path. Best-effort.
|
|
42
|
+
* Paths are git-relative (i.e. relative to repoRoot, not cwd). */
|
|
43
|
+
function captureDirtyBaseline(cwd, repoRoot) {
|
|
44
|
+
const baseline = new Map();
|
|
45
|
+
try {
|
|
46
|
+
const out = execFileSync('git', ['status', '--porcelain', '-z'], {
|
|
47
|
+
cwd,
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
timeout: 5000,
|
|
50
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
|
+
});
|
|
52
|
+
// Porcelain -z separates entries by NUL. First two chars are status, then space, then path.
|
|
53
|
+
// Renames have an extra NUL-separated old path which we don't need here.
|
|
54
|
+
for (const entry of out.split('\0')) {
|
|
55
|
+
if (!entry)
|
|
56
|
+
continue;
|
|
57
|
+
// Strip the 2-char status prefix + 1 separator (3 chars total)
|
|
58
|
+
const path = entry.slice(3);
|
|
59
|
+
if (!path)
|
|
60
|
+
continue;
|
|
61
|
+
const full = isAbsolute(path) ? path : join(repoRoot, path);
|
|
62
|
+
const b = statBaseline(full);
|
|
63
|
+
if (b)
|
|
64
|
+
baseline.set(path, b);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* best-effort — empty baseline means no filtering, status-quo behavior */
|
|
69
|
+
}
|
|
70
|
+
return baseline;
|
|
71
|
+
}
|
|
72
|
+
/** Get repo root via `git rev-parse --show-toplevel`. Returns null if not in a repo. */
|
|
73
|
+
function getRepoRoot(cwd) {
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
76
|
+
cwd,
|
|
77
|
+
encoding: 'utf-8',
|
|
78
|
+
timeout: 2000,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
80
|
+
}).trim();
|
|
81
|
+
return out.length > 0 ? out : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
17
87
|
/** Take a baseline so Layer 3 can diff against it on exit. Cheap; ~5ms. */
|
|
18
88
|
export function snapshotCwd(cwd) {
|
|
19
89
|
const spawnedAtMs = Date.now();
|
|
@@ -24,10 +94,26 @@ export function snapshotCwd(cwd) {
|
|
|
24
94
|
timeout: 2000,
|
|
25
95
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
96
|
}).trim();
|
|
27
|
-
|
|
97
|
+
const isGitRepo = out.length > 0;
|
|
98
|
+
const repoRoot = isGitRepo ? (getRepoRoot(cwd) ?? cwd) : cwd;
|
|
99
|
+
return {
|
|
100
|
+
cwd,
|
|
101
|
+
head: out,
|
|
102
|
+
isGitRepo,
|
|
103
|
+
spawnedAtMs,
|
|
104
|
+
repoRoot,
|
|
105
|
+
dirtyBaseline: isGitRepo ? captureDirtyBaseline(cwd, repoRoot) : new Map(),
|
|
106
|
+
};
|
|
28
107
|
}
|
|
29
108
|
catch {
|
|
30
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
cwd,
|
|
111
|
+
head: null,
|
|
112
|
+
isGitRepo: false,
|
|
113
|
+
spawnedAtMs,
|
|
114
|
+
repoRoot: cwd,
|
|
115
|
+
dirtyBaseline: new Map(),
|
|
116
|
+
};
|
|
31
117
|
}
|
|
32
118
|
}
|
|
33
119
|
/** Collect {path, +, -} from git diff (numstat) + untracked files (wc -l). */
|
|
@@ -69,9 +155,10 @@ function changedFiles(snap) {
|
|
|
69
155
|
for (const rel of untracked.split('\n')) {
|
|
70
156
|
if (!rel.trim())
|
|
71
157
|
continue;
|
|
72
|
-
// Count lines in the new file
|
|
158
|
+
// Count lines in the new file. Untracked paths come from git relative
|
|
159
|
+
// to repoRoot — must join with repoRoot, not cwd, when cwd is a subdir.
|
|
73
160
|
try {
|
|
74
|
-
const full = isAbsolute(rel) ? rel : join(snap.
|
|
161
|
+
const full = isAbsolute(rel) ? rel : join(snap.repoRoot, rel);
|
|
75
162
|
if (!existsSync(full))
|
|
76
163
|
continue;
|
|
77
164
|
const size = statSync(full).size;
|
|
@@ -125,7 +212,9 @@ function parseTestSummary(stdout) {
|
|
|
125
212
|
function annotateTrust(snap, files) {
|
|
126
213
|
return files.map((f) => {
|
|
127
214
|
try {
|
|
128
|
-
|
|
215
|
+
// Git-relative path: join with repoRoot, not cwd (matters when cwd is
|
|
216
|
+
// a subdirectory of the repo).
|
|
217
|
+
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
129
218
|
if (!existsSync(full))
|
|
130
219
|
return f;
|
|
131
220
|
const size = statSync(full).size;
|
|
@@ -154,7 +243,8 @@ function filterByMtime(snap, files) {
|
|
|
154
243
|
const grace = 1000; // ms; allow clock skew between git's touch and our Date.now()
|
|
155
244
|
return files.filter((f) => {
|
|
156
245
|
try {
|
|
157
|
-
|
|
246
|
+
// Git-relative path: join with repoRoot, not cwd.
|
|
247
|
+
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
158
248
|
if (!existsSync(full))
|
|
159
249
|
return true; // file was deleted — keep it
|
|
160
250
|
const { mtimeMs } = statSync(full);
|
|
@@ -165,10 +255,40 @@ function filterByMtime(snap, files) {
|
|
|
165
255
|
}
|
|
166
256
|
});
|
|
167
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Drop files that were dirty at spawn time and have NOT been further modified
|
|
260
|
+
* since. The git-diff-vs-spawn-HEAD path picks up the user's pre-existing
|
|
261
|
+
* uncommitted changes; without this filter, those files get mis-attributed to
|
|
262
|
+
* the agent's task. A file is considered "further modified" if its mtime OR
|
|
263
|
+
* size changed since the spawn snapshot — content-level tampering with same
|
|
264
|
+
* mtime/size is rare enough to ignore.
|
|
265
|
+
*/
|
|
266
|
+
function filterBySpawnBaseline(snap, files) {
|
|
267
|
+
if (snap.dirtyBaseline.size === 0)
|
|
268
|
+
return files;
|
|
269
|
+
return files.filter((f) => {
|
|
270
|
+
const baseline = snap.dirtyBaseline.get(f.path);
|
|
271
|
+
if (!baseline)
|
|
272
|
+
return true; // wasn't dirty at spawn — definitely a session change
|
|
273
|
+
try {
|
|
274
|
+
// Git-relative path: join with repoRoot, not cwd.
|
|
275
|
+
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
276
|
+
if (!existsSync(full))
|
|
277
|
+
return true; // deleted during session — that's a change
|
|
278
|
+
const { mtimeMs, size } = statSync(full);
|
|
279
|
+
// Further-modified if either mtime advanced or size differs from baseline.
|
|
280
|
+
return mtimeMs > baseline.mtimeMs || size !== baseline.size;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return true; // stat failed — be permissive (don't drop files we can't verify)
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
168
287
|
export function extractArtifacts(snap, collectedStdout) {
|
|
169
288
|
const raw = changedFiles(snap);
|
|
170
|
-
const
|
|
171
|
-
const
|
|
289
|
+
const sessionScoped = filterByMtime(snap, raw);
|
|
290
|
+
const agentLikely = filterBySpawnBaseline(snap, sessionScoped);
|
|
291
|
+
const files = annotateTrust(snap, agentLikely);
|
|
172
292
|
const tests = parseTestSummary(collectedStdout);
|
|
173
293
|
return { files, tests };
|
|
174
294
|
}
|
|
@@ -407,6 +407,7 @@ export function spawnClaudeCode(prompt, options = {}) {
|
|
|
407
407
|
export const ClaudeCodeAdapter = {
|
|
408
408
|
id: 'claude-code',
|
|
409
409
|
label: 'Claude Code',
|
|
410
|
+
description: 'Good default for editing code, running tests, and shell commands. Strongest safety net: codehere can stop risky actions before they run, not just record them after.',
|
|
410
411
|
processPattern: /(?:^|\/)claude(?:\s|$)/,
|
|
411
412
|
isInstalled() {
|
|
412
413
|
try {
|
|
@@ -19,6 +19,7 @@ import { randomUUID } from 'crypto';
|
|
|
19
19
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
20
20
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
21
21
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
22
|
+
import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
|
|
22
23
|
const HEADLESS_FLAGS = [
|
|
23
24
|
'exec',
|
|
24
25
|
'--json',
|
|
@@ -103,6 +104,18 @@ export function spawnCodex(prompt, options = {}) {
|
|
|
103
104
|
textAccum += item.aggregated_output + '\n';
|
|
104
105
|
if (item?.type === 'agent_message' && item.text)
|
|
105
106
|
textAccum += item.text + '\n';
|
|
107
|
+
// #110 slice 2 — mirror tool calls to the audit log so codex
|
|
108
|
+
// gets the same audit-trail surface as Claude's per-tool hook.
|
|
109
|
+
// Post-hoc only (no block); pre-tool intercept is a future slice.
|
|
110
|
+
if (item?.type === 'command_execution' && typeof item.command === 'string' && item.command) {
|
|
111
|
+
mirrorToolCallToAudit({
|
|
112
|
+
sessionId,
|
|
113
|
+
agent: 'codex',
|
|
114
|
+
toolName: 'Bash',
|
|
115
|
+
toolInput: item.command,
|
|
116
|
+
cwd,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
106
119
|
}
|
|
107
120
|
// Accumulate token usage across all turns (one turn.completed per turn).
|
|
108
121
|
if (event.type === 'turn.completed') {
|
|
@@ -358,6 +371,7 @@ export function spawnCodex(prompt, options = {}) {
|
|
|
358
371
|
export const CodexAdapter = {
|
|
359
372
|
id: 'codex',
|
|
360
373
|
label: 'Codex',
|
|
374
|
+
description: 'OpenAI\'s coding agent. Good when the task needs reading a lot of code first, then changing several files together.',
|
|
361
375
|
processPattern: /(?:^|\/)codex(?:\s|$)/,
|
|
362
376
|
isInstalled() {
|
|
363
377
|
try {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-handoff — structured cross-session context handoff protocol (P2).
|
|
3
|
+
*
|
|
4
|
+
* Bridges P0's auto-failover trigger with P1's graphify-ts context packs.
|
|
5
|
+
* When a session fails over (or a new session spawns), the handoff loader
|
|
6
|
+
* finds the most relevant context pack from recent session logs and injects
|
|
7
|
+
* it as a system hint so the replacement agent inherits continuity.
|
|
8
|
+
*
|
|
9
|
+
* Protocol:
|
|
10
|
+
* 1. loadHandoffContext(cwd, { fromSessionId?, sessionId?, maxAgeMs? })
|
|
11
|
+
* — scans .codehere/context-packs.jsonl for the most relevant pack
|
|
12
|
+
* 2. enrichPromptWithHandoff(prompt, cwd, handoff)
|
|
13
|
+
* — merges the handoff context into a prompt string
|
|
14
|
+
* 3. session handoff is automatic during failover (wired in session-manager)
|
|
15
|
+
*
|
|
16
|
+
* Handoff format (injected as system hint):
|
|
17
|
+
* [handoff: previous agent=<agent>, completed=<status>, pack=<packId>]
|
|
18
|
+
* Previous work summary: <pack.payloadPreview or compiled summary>
|
|
19
|
+
* ---
|
|
20
|
+
* <original prompt>
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
25
|
+
const CONTEXT_PACKS_DIR = '.codehere';
|
|
26
|
+
const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
|
|
27
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
28
|
+
const DEFAULT_MIN_NODES = 5;
|
|
29
|
+
// ── Session log scanning ───────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Load all recent context packs from disk, sorted by timestamp (newest first).
|
|
32
|
+
*/
|
|
33
|
+
function loadAllPacks(cwd) {
|
|
34
|
+
const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
|
|
35
|
+
if (!existsSync(packsFile)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const content = readFileSync(packsFile, 'utf-8');
|
|
40
|
+
const lines = content.split('\n').filter(Boolean);
|
|
41
|
+
return lines.map((line) => JSON.parse(line)).reverse();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Find the most relevant handoff context from previous sessions.
|
|
49
|
+
*
|
|
50
|
+
* Priority:
|
|
51
|
+
* 1. Same workspace, same prompt keywords (most recent pack)
|
|
52
|
+
* 2. Same workspace, any pack (most recent)
|
|
53
|
+
* 3. Fallback: no handoff
|
|
54
|
+
*/
|
|
55
|
+
export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_AGE_MS, minNodes = DEFAULT_MIN_NODES } = {}) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const packs = loadAllPacks(cwd);
|
|
58
|
+
// Filter by age
|
|
59
|
+
const recent = packs.filter((p) => {
|
|
60
|
+
const ts = p.timestamp;
|
|
61
|
+
return ts && (now - ts) < maxAgeMs;
|
|
62
|
+
});
|
|
63
|
+
// Prefer packs with enough graph structure to be useful
|
|
64
|
+
const structured = recent.filter((p) => {
|
|
65
|
+
const nodes = p.nodeCount ?? 0;
|
|
66
|
+
return nodes >= minNodes;
|
|
67
|
+
});
|
|
68
|
+
if (structured.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const last = structured[0];
|
|
71
|
+
return {
|
|
72
|
+
packId: String(last.packId ?? ''),
|
|
73
|
+
agent: String(last.agent ?? 'unknown'),
|
|
74
|
+
prompt: String(last.prompt ?? ''),
|
|
75
|
+
status: String(last.status ?? 'unknown'),
|
|
76
|
+
timestamp: last.timestamp,
|
|
77
|
+
nodeCount: last.nodeCount,
|
|
78
|
+
edgeCount: last.edgeCount,
|
|
79
|
+
task: String(last.task ?? 'explain'),
|
|
80
|
+
payloadPreview: String(last.payloadPreview ?? ''),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ── Prompt enrichment ──────────────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Inject handoff context into a prompt.
|
|
86
|
+
*
|
|
87
|
+
* Returns the enriched prompt string with the handoff as a system hint
|
|
88
|
+
* prepended before the original prompt. Returns the original prompt
|
|
89
|
+
* if no handoff is available.
|
|
90
|
+
*/
|
|
91
|
+
export function enrichPromptWithHandoff(prompt, handoff) {
|
|
92
|
+
if (!handoff)
|
|
93
|
+
return prompt;
|
|
94
|
+
const handoffLine = `[handoff: agent=${handoff.agent}, pack=${handoff.packId}, ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges]`;
|
|
95
|
+
const preview = handoff.payloadPreview ? ` ${handoff.payloadPreview.slice(0, 800)}` : '';
|
|
96
|
+
return [
|
|
97
|
+
`[Previous session context — ${handoffLine}]`,
|
|
98
|
+
`This workspace has recent structural context from a prior session.`,
|
|
99
|
+
`For details, refer to the knowledge graph at the workspace root.`,
|
|
100
|
+
`---`,
|
|
101
|
+
prompt,
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
// ── Failover handoff (P2 integration point) ────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Build a handoff context for a failover target.
|
|
107
|
+
*
|
|
108
|
+
* When session A fails and session B auto-spawns as replacement,
|
|
109
|
+
* this loads the most recent pack from the same workspace and
|
|
110
|
+
* merges it into the prompt so B has continuity with A's work.
|
|
111
|
+
*/
|
|
112
|
+
export function buildFailoverHandoff(cwd, { failedAgent, failedPrompt }) {
|
|
113
|
+
// Load most recent pack from workspace
|
|
114
|
+
const pack = loadHandoffContext(cwd, { maxAgeMs: DEFAULT_MAX_AGE_MS, minNodes: 3 });
|
|
115
|
+
if (!pack)
|
|
116
|
+
return null;
|
|
117
|
+
// Enrich the prompt with the handoff
|
|
118
|
+
const enrichedPrompt = enrichPromptWithHandoff(failedPrompt, {
|
|
119
|
+
...pack,
|
|
120
|
+
agent: failedAgent,
|
|
121
|
+
status: 'failed',
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
...pack,
|
|
125
|
+
agent: failedAgent,
|
|
126
|
+
prompt: enrichedPrompt,
|
|
127
|
+
status: 'failed',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// ── Handoff summary (for UI / API) ─────────────────────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* Generate a human-readable handoff summary for the dashboard.
|
|
133
|
+
*/
|
|
134
|
+
export function formatHandoffSummary(handoff) {
|
|
135
|
+
const ageMs = Date.now() - handoff.timestamp;
|
|
136
|
+
const ageStr = ageMs < 60_000
|
|
137
|
+
? `${Math.round(ageMs / 1000)}s ago`
|
|
138
|
+
: ageMs < 3600_000
|
|
139
|
+
? `${Math.round(ageMs / 60_000)}m ago`
|
|
140
|
+
: `${Math.round(ageMs / 3600_000)}h ago`;
|
|
141
|
+
return `Previous session (${handoff.agent}) completed ${ageStr}. ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges in workspace graph.`;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=context-handoff.js.map
|