codehere 1.0.1 → 1.0.2
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 +36 -192
- package/dist/application/use-cases/check-claimed-tests.js +81 -0
- package/dist/application/use-cases/detect-test-weakening.js +237 -0
- package/dist/application/use-cases/rerun-tests.js +110 -7
- package/dist/index.js +38 -1212
- package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -323
- package/dist/presentation/cli/commands/check-command.js +178 -23
- package/dist/presentation/cli/commands/help-command.js +21 -285
- package/dist/ui.js +5 -236
- package/dist/utils/version.js +0 -6
- package/package.json +9 -38
- package/dist/application/services/verdict-service.js +0 -154
- package/dist/application/use-cases/assess-cross-vendor.js +0 -126
- package/dist/application/use-cases/build-cross-vendor-verdict.js +0 -118
- package/dist/application/use-cases/domain-trust.js +0 -110
- package/dist/application/use-cases/export-aat.js +0 -107
- package/dist/application/use-cases/generate-acceptance-criteria.js +0 -223
- package/dist/application/use-cases/generate-final-report.js +0 -259
- package/dist/application/use-cases/generate-narration.js +0 -130
- package/dist/application/use-cases/generate-thread-title.js +0 -181
- package/dist/application/use-cases/narrator-prompt-template.js +0 -158
- package/dist/application/use-cases/plan-auto-review.js +0 -57
- package/dist/application/use-cases/reconcile-audit.js +0 -100
- package/dist/application/use-cases/search-sessions.js +0 -70
- package/dist/application/use-cases/should-auto-review.js +0 -71
- package/dist/application/use-cases/thread-export.js +0 -245
- package/dist/application/use-cases/verify-claim.js +0 -456
- package/dist/application/use-cases/write-pass.js +0 -277
- package/dist/domain/audit.js +0 -11
- package/dist/domain/services/codebase-detector.js +0 -85
- package/dist/domain/task.js +0 -91
- package/dist/error-handler.js +0 -139
- package/dist/infrastructure/completion/bash-completion.js +0 -29
- package/dist/infrastructure/completion/fish-completion.js +0 -17
- package/dist/infrastructure/completion/zsh-completion.js +0 -25
- package/dist/infrastructure/config/config-manager.js +0 -174
- package/dist/infrastructure/config/product-context.js +0 -49
- package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
- package/dist/infrastructure/context/adapters/figma-source.js +0 -198
- package/dist/infrastructure/context/adapters/github-issue-source.js +0 -160
- package/dist/infrastructure/context/adapters/linear-source.js +0 -190
- package/dist/infrastructure/context/context-resolver.js +0 -144
- package/dist/infrastructure/context/context-source.js +0 -114
- package/dist/infrastructure/context/github-api.js +0 -110
- package/dist/infrastructure/context/github-device-flow.js +0 -65
- package/dist/infrastructure/context/oauth-flow.js +0 -207
- package/dist/infrastructure/context/oauth-store.js +0 -122
- package/dist/infrastructure/context/registry-bootstrap.js +0 -95
- package/dist/infrastructure/context/write-pass-resolver.js +0 -106
- package/dist/infrastructure/cost/budget-config.js +0 -114
- package/dist/infrastructure/cost/cost-router.js +0 -97
- package/dist/infrastructure/cost/spend-aggregator.js +0 -222
- package/dist/infrastructure/cost/team-ledger.js +0 -355
- package/dist/infrastructure/discovery/discovery-log.js +0 -85
- package/dist/infrastructure/hooks/audit-tip-signature.js +0 -336
- package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -1018
- package/dist/infrastructure/hooks/hook-mode.js +0 -87
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -49
- package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
- package/dist/infrastructure/hooks/session-analyzer.js +0 -177
- package/dist/infrastructure/observability/execution-tracer.js +0 -227
- package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
- package/dist/infrastructure/orchestration/agent-installer.js +0 -101
- package/dist/infrastructure/orchestration/agents-config-seed.js +0 -68
- package/dist/infrastructure/orchestration/aider-adapter.js +0 -611
- package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
- package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -592
- package/dist/infrastructure/orchestration/codex-adapter.js +0 -574
- package/dist/infrastructure/orchestration/conductor-adapter.js +0 -416
- package/dist/infrastructure/orchestration/context-handoff.js +0 -162
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
- package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
- package/dist/infrastructure/orchestration/detect-ollama.js +0 -75
- package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
- package/dist/infrastructure/orchestration/failover-config.js +0 -67
- package/dist/infrastructure/orchestration/failover-policy.js +0 -106
- package/dist/infrastructure/orchestration/gemini-adapter.js +0 -663
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -689
- package/dist/infrastructure/orchestration/graphify-context.js +0 -209
- package/dist/infrastructure/orchestration/memory-injector.js +0 -380
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -388
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
- package/dist/infrastructure/orchestration/opencode-adapter.js +0 -580
- package/dist/infrastructure/orchestration/progress-aggregator.js +0 -906
- package/dist/infrastructure/orchestration/readiness.js +0 -103
- package/dist/infrastructure/orchestration/session-manager.js +0 -1590
- package/dist/infrastructure/orchestration/session-persistence.js +0 -394
- package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -38
- package/dist/infrastructure/protocols/cloud-sync.js +0 -207
- package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
- package/dist/infrastructure/protocols/mcp-server-impl.js +0 -3935
- package/dist/infrastructure/protocols/mcp-tools.js +0 -401
- package/dist/infrastructure/protocols/probe-port.js +0 -36
- package/dist/infrastructure/protocols/thread-phase.js +0 -105
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +0 -121
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +0 -44
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +0 -56
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +0 -588
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +0 -168
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +0 -67
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +0 -115
- package/dist/infrastructure/protocols/ui/app-script-failover.js +0 -133
- package/dist/infrastructure/protocols/ui/app-script-github.js +0 -113
- package/dist/infrastructure/protocols/ui/app-script-observer.js +0 -104
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +0 -80
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +0 -203
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +0 -136
- package/dist/infrastructure/protocols/ui/app-script-render.js +0 -166
- package/dist/infrastructure/protocols/ui/app-script-share.js +0 -78
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +0 -194
- package/dist/infrastructure/protocols/ui/app-script-tour.js +0 -78
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +0 -312
- package/dist/infrastructure/protocols/ui/app-script.js +0 -2998
- package/dist/infrastructure/protocols/ui/font-faces.js +0 -7
- package/dist/infrastructure/protocols/ui/styles.js +0 -2147
- package/dist/infrastructure/protocols/ui/tokens.js +0 -96
- package/dist/infrastructure/protocols/ui-page-v2.js +0 -320
- package/dist/infrastructure/protocols/verify-page.js +0 -1677
- package/dist/infrastructure/security/file-collection.js +0 -118
- package/dist/infrastructure/security/fix-code.js +0 -144
- package/dist/infrastructure/security/fix-secret.js +0 -89
- package/dist/infrastructure/security/permissions-config.js +0 -235
- package/dist/infrastructure/security/scan-types.js +0 -10
- package/dist/infrastructure/security/ship-scan.js +0 -195
- package/dist/infrastructure/security/trust-scorer.js +0 -724
- package/dist/infrastructure/security/vibe-checks.js +0 -99
- package/dist/infrastructure/storage/atomic-write.js +0 -86
- package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
- package/dist/infrastructure/storage/project-thread-migration.js +0 -237
- package/dist/infrastructure/storage/project-thread-mirror.js +0 -671
- package/dist/infrastructure/storage/project-thread-reader.js +0 -409
- package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
- package/dist/monitoring.js +0 -212
- package/dist/presentation/cli/commands/audit-command.js +0 -201
- package/dist/presentation/cli/commands/budget-command.js +0 -79
- package/dist/presentation/cli/commands/config-command.js +0 -125
- package/dist/presentation/cli/commands/context-source-command.js +0 -217
- package/dist/presentation/cli/commands/hook-command.js +0 -265
- package/dist/presentation/cli/commands/init-command.js +0 -590
- package/dist/presentation/cli/commands/memory-command.js +0 -1031
- package/dist/presentation/cli/commands/narrator-command.js +0 -101
- package/dist/presentation/cli/commands/permission-command.js +0 -105
- package/dist/presentation/cli/commands/protocols-builder.js +0 -254
- package/dist/presentation/cli/commands/protocols-command.js +0 -84
- package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
- package/dist/presentation/cli/commands/report-command.js +0 -403
- package/dist/presentation/cli/commands/scan-command.js +0 -52
- package/dist/presentation/cli/commands/scan-tool-command.js +0 -110
- package/dist/presentation/cli/commands/search-command.js +0 -51
- package/dist/presentation/cli/commands/setup-command.js +0 -330
- package/dist/presentation/cli/commands/status-command.js +0 -614
- package/dist/presentation/cli/commands/team-command.js +0 -256
- package/dist/presentation/cli/commands/thread-command.js +0 -370
- package/dist/presentation/cli/commands/trust-command.js +0 -328
- package/dist/presentation/cli/commands/verify-command.js +0 -298
- package/dist/presentation/cli/error-display.js +0 -328
- package/dist/presentation/cli/repl.js +0 -805
- package/dist/presentation/cli/task-deliverable.js +0 -153
- package/dist/testing/cassette.js +0 -125
- package/dist/testing/replay-adapter.js +0 -147
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project thread reader — v0.6.0 M1 (UI-prep slice).
|
|
3
|
-
*
|
|
4
|
-
* Reads ~/.codehere/projects/<slug>/thread.jsonl files and reconstructs
|
|
5
|
-
* the per-project thread state for API consumers (the new UI rendering
|
|
6
|
-
* the persistent project thread shape).
|
|
7
|
-
*
|
|
8
|
-
* Reconstruction rules:
|
|
9
|
-
* - One file per project, identified by slug (sha1(cwd)[:12])
|
|
10
|
-
* - First line is `{_meta: 'project', ...}` — project header
|
|
11
|
-
* - Subsequent lines are `{_meta: 'project-update', ...}` (latest wins per
|
|
12
|
-
* field — Title agent uses this to set the title) OR `{type: 'task', ...}`
|
|
13
|
-
* (per-session entries; later entries with same sessionId override
|
|
14
|
-
* earlier — covers spawn entry → completion entry update)
|
|
15
|
-
*
|
|
16
|
-
* NOT in scope of this module:
|
|
17
|
-
* - Writing (handled by project-thread-mirror + project-thread-migration)
|
|
18
|
-
* - UI rendering (the new thread view will consume this reader's output)
|
|
19
|
-
* - Live SSE updates (polling is fine for the alpha; SSE in v0.6.x patch)
|
|
20
|
-
*/
|
|
21
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
22
|
-
import { join } from 'path';
|
|
23
|
-
import { homedir } from 'os';
|
|
24
|
-
import { estimateCostUsd } from '../cost/spend-aggregator.js';
|
|
25
|
-
const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
26
|
-
/**
|
|
27
|
-
* Normalize a git remote URL to a stable identity key `host/org/repo`:
|
|
28
|
-
* - strips scheme (https/http/ssh/git) + any user@ info
|
|
29
|
-
* - handles scp-like `git@host:org/repo`
|
|
30
|
-
* - drops a trailing `.git` and trailing slashes
|
|
31
|
-
* - lowercases the host (path case preserved)
|
|
32
|
-
* Returns null for empty/garbage input. Shared by the reader (identity grouping)
|
|
33
|
-
* and the forward-mirror (write-time detection) so both compute the same id.
|
|
34
|
-
*/
|
|
35
|
-
export function normalizeRemote(url) {
|
|
36
|
-
if (!url)
|
|
37
|
-
return null;
|
|
38
|
-
let s = String(url).trim();
|
|
39
|
-
if (!s)
|
|
40
|
-
return null;
|
|
41
|
-
const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // git@host:org/repo(.git)
|
|
42
|
-
if (scp) {
|
|
43
|
-
s = scp[1] + '/' + scp[2];
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); // scheme://
|
|
47
|
-
s = s.replace(/^[^@/]+@/, ''); // user@ (ssh://user@host/…)
|
|
48
|
-
}
|
|
49
|
-
s = s.replace(/\.git$/i, '').replace(/\/+$/, '');
|
|
50
|
-
if (!s)
|
|
51
|
-
return null;
|
|
52
|
-
const slash = s.indexOf('/');
|
|
53
|
-
if (slash === -1)
|
|
54
|
-
return s.toLowerCase();
|
|
55
|
-
return s.slice(0, slash).toLowerCase() + s.slice(slash);
|
|
56
|
-
}
|
|
57
|
-
/** True for hosts a clone must never reach — loopback, link-local (incl. cloud
|
|
58
|
-
* metadata 169.254.169.254), and RFC-1918 private ranges. `host` is the
|
|
59
|
-
* `host[:port]` segment from a normalized remote. */
|
|
60
|
-
function isBlockedCloneHost(host) {
|
|
61
|
-
const h = String(host ?? '')
|
|
62
|
-
.split(':')[0]
|
|
63
|
-
.toLowerCase();
|
|
64
|
-
if (!h)
|
|
65
|
-
return true;
|
|
66
|
-
if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.endsWith('.localhost'))
|
|
67
|
-
return true;
|
|
68
|
-
if (/^127\./.test(h))
|
|
69
|
-
return true; // loopback
|
|
70
|
-
if (/^169\.254\./.test(h))
|
|
71
|
-
return true; // link-local + cloud metadata
|
|
72
|
-
if (/^10\./.test(h))
|
|
73
|
-
return true; // RFC-1918
|
|
74
|
-
if (/^192\.168\./.test(h))
|
|
75
|
-
return true; // RFC-1918
|
|
76
|
-
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
|
|
77
|
-
return true; // RFC-1918
|
|
78
|
-
if (/^0\./.test(h))
|
|
79
|
-
return true; // "this" network
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Parse a user-pasted GitHub (or any git) URL into a cloneable URL + a
|
|
84
|
-
* predictable local target directory. Pure (no IO) so the clone route can be
|
|
85
|
-
* tested without a network or filesystem. Accepts the forms a developer
|
|
86
|
-
* actually pastes: full https, ssh (git@host:org/repo), bare host/org/repo,
|
|
87
|
-
* and the `org/repo` GitHub shorthand. Rejects anything without a real
|
|
88
|
-
* host/org/repo shape (or with whitespace — a git URL never has any).
|
|
89
|
-
*
|
|
90
|
-
* The target dir is `<baseDir>/<repo>` — visible + predictable, NOT buried in
|
|
91
|
-
* a hidden dir, so the user can `cd` to it. The caller decides what to do when
|
|
92
|
-
* the dir already exists (we open it rather than re-clone — see the route).
|
|
93
|
-
*/
|
|
94
|
-
export function parseGitHubCloneTarget(rawUrl, baseDir) {
|
|
95
|
-
const raw = String(rawUrl ?? '').trim();
|
|
96
|
-
if (!raw)
|
|
97
|
-
return { ok: false, error: 'url_required' };
|
|
98
|
-
if (/\s/.test(raw))
|
|
99
|
-
return { ok: false, error: 'invalid_url' };
|
|
100
|
-
let cloneUrl;
|
|
101
|
-
if (/^[^@/]+@[^:/]+:.+/.test(raw)) {
|
|
102
|
-
cloneUrl = raw; // git@host:org/repo(.git)
|
|
103
|
-
}
|
|
104
|
-
else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
|
|
105
|
-
cloneUrl = raw; // already has a scheme (https://, ssh://, …)
|
|
106
|
-
}
|
|
107
|
-
else if (/^[\w.-]+\.[\w.-]+\/.+/.test(raw)) {
|
|
108
|
-
cloneUrl = 'https://' + raw; // host.tld/org/repo (no scheme)
|
|
109
|
-
}
|
|
110
|
-
else if (/^[\w.-]+\/[\w.-]+$/.test(raw)) {
|
|
111
|
-
cloneUrl = 'https://github.com/' + raw; // org/repo GitHub shorthand
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
return { ok: false, error: 'invalid_url' };
|
|
115
|
-
}
|
|
116
|
-
// Scheme allowlist — only https + ssh reach `git clone`. Rejects file:// (local
|
|
117
|
-
// file read), http:// (plaintext + the cloud-metadata / localhost SSRF vector),
|
|
118
|
-
// and every other transport. The scp form (git@host:…) carries no scheme and is
|
|
119
|
-
// allowed. This is the SSRF/file:// surface a pre-push review flagged.
|
|
120
|
-
const scheme = cloneUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
|
|
121
|
-
if (scheme && !['https', 'ssh'].includes(scheme[1].toLowerCase()))
|
|
122
|
-
return { ok: false, error: 'invalid_url' };
|
|
123
|
-
const norm = normalizeRemote(cloneUrl); // host/org/repo, lowercased host
|
|
124
|
-
if (!norm)
|
|
125
|
-
return { ok: false, error: 'invalid_url' };
|
|
126
|
-
const parts = norm.split('/');
|
|
127
|
-
if (parts.length < 3)
|
|
128
|
-
return { ok: false, error: 'invalid_url' }; // need host + org + repo
|
|
129
|
-
// Internal-host denylist — don't let a clone target loopback / link-local /
|
|
130
|
-
// RFC-1918 (the rest of the SSRF surface: metadata endpoints, internal probing).
|
|
131
|
-
if (isBlockedCloneHost(parts[0]))
|
|
132
|
-
return { ok: false, error: 'invalid_url' };
|
|
133
|
-
const repo = (parts[parts.length - 1] || '').replace(/[^\w.-]/g, '');
|
|
134
|
-
// Reject '.'/'..'/empty — '.' and '..' survive the char-class strip and would
|
|
135
|
-
// traverse out of baseDir (join(baseDir, '..') = its parent). The clone dir
|
|
136
|
-
// must be a fresh leaf inside baseDir, nothing else.
|
|
137
|
-
if (!repo || repo === '.' || repo === '..')
|
|
138
|
-
return { ok: false, error: 'invalid_url' };
|
|
139
|
-
return { ok: true, cloneUrl, repo, dir: join(baseDir, repo) };
|
|
140
|
-
}
|
|
141
|
-
/** List all known projects with summary stats. Cheap; no full task content. */
|
|
142
|
-
export function listProjects(options) {
|
|
143
|
-
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
144
|
-
if (!existsSync(root))
|
|
145
|
-
return [];
|
|
146
|
-
let stat;
|
|
147
|
-
try {
|
|
148
|
-
stat = statSync(root);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return [];
|
|
152
|
-
}
|
|
153
|
-
if (!stat.isDirectory())
|
|
154
|
-
return [];
|
|
155
|
-
const slugs = readdirSync(root).filter((entry) => {
|
|
156
|
-
const p = join(root, entry);
|
|
157
|
-
try {
|
|
158
|
-
return statSync(p).isDirectory();
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
const perSlug = [];
|
|
165
|
-
for (const slug of slugs) {
|
|
166
|
-
// Pattern 4 — sidebar presence dots need agent attribution per project.
|
|
167
|
-
// Pull the full task list so we can compute agents + per-project cost.
|
|
168
|
-
const thread = readProjectThread(slug, { projectsDir: root });
|
|
169
|
-
if (!thread)
|
|
170
|
-
continue;
|
|
171
|
-
const agentChrono = thread.tasks
|
|
172
|
-
.filter((t) => typeof t.agent === 'string' && t.agent)
|
|
173
|
-
.sort((a, b) => b.startedAt - a.startedAt) // most-recent first
|
|
174
|
-
.map((t) => t.agent);
|
|
175
|
-
// Per-project spend — same estimator the per-task /api/tasks cost uses.
|
|
176
|
-
let totalCostCents = 0;
|
|
177
|
-
for (const t of thread.tasks) {
|
|
178
|
-
const u = t.artifacts?.usage;
|
|
179
|
-
if (!u)
|
|
180
|
-
continue;
|
|
181
|
-
totalCostCents += estimateCostUsd(t.agent, u) * 100;
|
|
182
|
-
}
|
|
183
|
-
perSlug.push({
|
|
184
|
-
slug: thread.slug,
|
|
185
|
-
id: normalizeRemote(thread.remote) ?? 'local:' + thread.slug,
|
|
186
|
-
remote: thread.remote,
|
|
187
|
-
cwd: thread.cwd,
|
|
188
|
-
title: thread.title,
|
|
189
|
-
taskCount: thread.taskCount,
|
|
190
|
-
totalCostCents,
|
|
191
|
-
lastActivityAt: thread.lastActivityAt,
|
|
192
|
-
agentChrono,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
// Second pass — merge slugs that resolve to the same project id (clones of
|
|
196
|
-
// one git remote). Local-only projects have a unique `local:<slug>` id and
|
|
197
|
-
// never merge. The most-recently-active member is canonical (its slug + title
|
|
198
|
-
// win, so rename / room links keep working).
|
|
199
|
-
const byId = new Map();
|
|
200
|
-
for (const s of perSlug) {
|
|
201
|
-
const g = byId.get(s.id);
|
|
202
|
-
if (g)
|
|
203
|
-
g.push(s);
|
|
204
|
-
else
|
|
205
|
-
byId.set(s.id, [s]);
|
|
206
|
-
}
|
|
207
|
-
const summaries = [];
|
|
208
|
-
for (const [id, members] of byId) {
|
|
209
|
-
members.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
210
|
-
const head = members[0];
|
|
211
|
-
const localPaths = [...new Set(members.map((m) => m.cwd).filter((c) => !!c))];
|
|
212
|
-
// Recency-ordered agent stack across all members (head first).
|
|
213
|
-
const recentAgents = [];
|
|
214
|
-
const seen = new Set();
|
|
215
|
-
for (const m of members) {
|
|
216
|
-
for (const a of m.agentChrono) {
|
|
217
|
-
if (seen.has(a))
|
|
218
|
-
continue;
|
|
219
|
-
seen.add(a);
|
|
220
|
-
recentAgents.push(a);
|
|
221
|
-
if (recentAgents.length >= 3)
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
if (recentAgents.length >= 3)
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
summaries.push({
|
|
228
|
-
id,
|
|
229
|
-
remote: head.remote,
|
|
230
|
-
slug: head.slug,
|
|
231
|
-
cwd: head.cwd,
|
|
232
|
-
localPaths,
|
|
233
|
-
title: head.title,
|
|
234
|
-
taskCount: members.reduce((n, m) => n + m.taskCount, 0),
|
|
235
|
-
totalCostCents: Math.round(members.reduce((n, m) => n + m.totalCostCents, 0) * 100) / 100,
|
|
236
|
-
lastActivityAt: members.reduce((acc, m) => (m.lastActivityAt && (acc === null || m.lastActivityAt > acc) ? m.lastActivityAt : acc), null),
|
|
237
|
-
lastAgent: recentAgents[0] ?? null,
|
|
238
|
-
recentAgents,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
// Sort by recency (most recent first)
|
|
242
|
-
summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
243
|
-
return summaries;
|
|
244
|
-
}
|
|
245
|
-
/** Read a single project thread by slug. Returns null if not found. */
|
|
246
|
-
export function readProjectThread(slug, options) {
|
|
247
|
-
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
248
|
-
const file = join(root, slug, 'thread.jsonl');
|
|
249
|
-
if (!existsSync(file))
|
|
250
|
-
return null;
|
|
251
|
-
let content;
|
|
252
|
-
try {
|
|
253
|
-
content = readFileSync(file, 'utf-8');
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
let cwd = null;
|
|
259
|
-
let title = null;
|
|
260
|
-
let remote = null;
|
|
261
|
-
const tasksById = new Map();
|
|
262
|
-
const archivedState = new Map(); // sessionId -> archived (latest event wins)
|
|
263
|
-
for (const line of content.split('\n')) {
|
|
264
|
-
const trimmed = line.trim();
|
|
265
|
-
if (!trimmed)
|
|
266
|
-
continue;
|
|
267
|
-
let rec;
|
|
268
|
-
try {
|
|
269
|
-
rec = JSON.parse(trimmed);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (rec._meta === 'project') {
|
|
275
|
-
if (typeof rec.cwd === 'string')
|
|
276
|
-
cwd = rec.cwd;
|
|
277
|
-
if (typeof rec.title === 'string')
|
|
278
|
-
title = rec.title;
|
|
279
|
-
if (typeof rec.remote === 'string')
|
|
280
|
-
remote = rec.remote;
|
|
281
|
-
}
|
|
282
|
-
else if (rec._meta === 'project-update') {
|
|
283
|
-
if (typeof rec.title === 'string')
|
|
284
|
-
title = rec.title;
|
|
285
|
-
if (typeof rec.cwd === 'string' && cwd === null)
|
|
286
|
-
cwd = rec.cwd;
|
|
287
|
-
// Latest non-empty remote wins; an explicit null (checked, none found)
|
|
288
|
-
// leaves the prior value alone — we only ever upgrade toward a real remote.
|
|
289
|
-
if (typeof rec.remote === 'string' && rec.remote)
|
|
290
|
-
remote = rec.remote;
|
|
291
|
-
}
|
|
292
|
-
else if (rec._meta === 'archived' && typeof rec.sessionId === 'string') {
|
|
293
|
-
archivedState.set(rec.sessionId, rec.archived === true);
|
|
294
|
-
}
|
|
295
|
-
else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
|
|
296
|
-
const existing = tasksById.get(rec.sessionId) ?? {
|
|
297
|
-
sessionId: rec.sessionId,
|
|
298
|
-
agent: '',
|
|
299
|
-
prompt: '',
|
|
300
|
-
cwd: '',
|
|
301
|
-
startedAt: 0,
|
|
302
|
-
};
|
|
303
|
-
// Merge fields — later entries override earlier (covers
|
|
304
|
-
// spawn → completion update pattern from forward-mirror)
|
|
305
|
-
for (const k of [
|
|
306
|
-
'agent',
|
|
307
|
-
'prompt',
|
|
308
|
-
'cwd',
|
|
309
|
-
'startedAt',
|
|
310
|
-
'status',
|
|
311
|
-
'durationMs',
|
|
312
|
-
'completedAt',
|
|
313
|
-
'steps',
|
|
314
|
-
'artifacts',
|
|
315
|
-
'parentSessionId',
|
|
316
|
-
'finalReport',
|
|
317
|
-
]) {
|
|
318
|
-
if (rec[k] !== undefined) {
|
|
319
|
-
existing[k] = rec[k];
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
tasksById.set(rec.sessionId, existing);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
// Stamp the archived flag (soft-delete) from the latest archived event per task.
|
|
326
|
-
for (const t of tasksById.values()) {
|
|
327
|
-
const a = archivedState.get(t.sessionId);
|
|
328
|
-
if (a !== undefined)
|
|
329
|
-
t.archived = a;
|
|
330
|
-
}
|
|
331
|
-
const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
332
|
-
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
333
|
-
const ts = t.completedAt ?? t.startedAt;
|
|
334
|
-
if (ts && (acc === null || ts > acc))
|
|
335
|
-
return ts;
|
|
336
|
-
return acc;
|
|
337
|
-
}, null);
|
|
338
|
-
const limit = options?.taskLimit;
|
|
339
|
-
const cappedTasks = limit === undefined ? tasks : limit === 0 ? [] : tasks.slice(-limit);
|
|
340
|
-
return {
|
|
341
|
-
slug,
|
|
342
|
-
cwd,
|
|
343
|
-
title,
|
|
344
|
-
remote,
|
|
345
|
-
// Count only what the UI actually shows: the sidebar tree + room list both
|
|
346
|
-
// hide archived + demo/seed tasks, so counting them inflated the badge
|
|
347
|
-
// (a project read "420" while showing a handful).
|
|
348
|
-
taskCount: countVisibleTasks(tasks),
|
|
349
|
-
tasks: cappedTasks,
|
|
350
|
-
lastActivityAt,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
/** The honest THREAD count: distinct top-level conversations a user started.
|
|
354
|
-
* Excludes soft-deleted (archived) and demo/seed tasks, and — the big one —
|
|
355
|
-
* tasks with a parentSessionId, which are reply turns + auto cross-vendor
|
|
356
|
-
* reviewers (continuations of a thread, not new threads). Counting those made a
|
|
357
|
-
* project read "400+" while showing a handful of real threads. */
|
|
358
|
-
function countVisibleTasks(tasks) {
|
|
359
|
-
return tasks.filter((t) => {
|
|
360
|
-
if (t.archived || t.parentSessionId)
|
|
361
|
-
return false;
|
|
362
|
-
if (/^demo-/.test(t.sessionId || ''))
|
|
363
|
-
return false;
|
|
364
|
-
// Failover-lineage marker ("↻ Switched from X to Y (hit a rate limit)") — a
|
|
365
|
-
// continuation edge, not a thread the user started. Matches the sidebar tree.
|
|
366
|
-
const p = t.prompt || '';
|
|
367
|
-
if (p.includes('Switched from') && p.includes('rate limit'))
|
|
368
|
-
return false;
|
|
369
|
-
return true;
|
|
370
|
-
}).length;
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Like readProjectThread, but merges every clone of the same repo (slugs that share
|
|
374
|
-
* a git remote) into ONE thread — so a project room shows the repo's FULL history,
|
|
375
|
-
* not just the single slug the UI happened to open. A local-only project (no remote)
|
|
376
|
-
* has no clones to merge and is returned unchanged. Tasks are deduped by sessionId.
|
|
377
|
-
*/
|
|
378
|
-
export function readMergedProjectThread(slug, options) {
|
|
379
|
-
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
380
|
-
const head = readProjectThread(slug, { projectsDir: root });
|
|
381
|
-
if (!head || !head.remote)
|
|
382
|
-
return head;
|
|
383
|
-
const id = normalizeRemote(head.remote);
|
|
384
|
-
let entries;
|
|
385
|
-
try {
|
|
386
|
-
entries = readdirSync(root).filter((e) => /^[a-f0-9]{12}$/.test(e) && e !== slug);
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
return head;
|
|
390
|
-
}
|
|
391
|
-
const byId = new Map();
|
|
392
|
-
for (const t of head.tasks)
|
|
393
|
-
byId.set(t.sessionId, t);
|
|
394
|
-
for (const e of entries) {
|
|
395
|
-
const sib = readProjectThread(e, { projectsDir: root });
|
|
396
|
-
if (!sib || normalizeRemote(sib.remote) !== id)
|
|
397
|
-
continue;
|
|
398
|
-
for (const t of sib.tasks)
|
|
399
|
-
if (!byId.has(t.sessionId))
|
|
400
|
-
byId.set(t.sessionId, t);
|
|
401
|
-
}
|
|
402
|
-
const tasks = [...byId.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
403
|
-
const lastActivityAt = tasks.reduce((acc, t) => {
|
|
404
|
-
const ts = t.completedAt ?? t.startedAt;
|
|
405
|
-
return ts && (acc === null || ts > acc) ? ts : acc;
|
|
406
|
-
}, null);
|
|
407
|
-
return { ...head, tasks, taskCount: countVisibleTasks(tasks), lastActivityAt };
|
|
408
|
-
}
|
|
409
|
-
//# sourceMappingURL=project-thread-reader.js.map
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Title backfill — v0.6.0 follow-up to M9.
|
|
3
|
-
*
|
|
4
|
-
* The M9 hidden Title agent (generate-thread-title.ts) only fires on the FIRST
|
|
5
|
-
* task entry written to a project's thread.jsonl. Projects seeded by the v0.6
|
|
6
|
-
* migration (project-thread-migration.ts) bypass that path — they get a
|
|
7
|
-
* project-meta header but no Title agent invocation. Result: 5/7 projects on
|
|
8
|
-
* a typical dogfood machine show "(untitled)" in the /threads switcher even
|
|
9
|
-
* though their first task carries enough signal to title from.
|
|
10
|
-
*
|
|
11
|
-
* This backfill closes that gap. For every project with title=null and at
|
|
12
|
-
* least one task entry, it fires the Title agent on the first task's prompt +
|
|
13
|
-
* cwd and appends a `{_meta: 'project-update', title: ...}` line. Reader
|
|
14
|
-
* (project-thread-reader.ts) takes the latest project-update title.
|
|
15
|
-
*
|
|
16
|
-
* Idempotent: skips projects that already have a title (or that have NO tasks
|
|
17
|
-
* to title from). Safe to run on every daemon boot. Best-effort: any LLM
|
|
18
|
-
* failure (provider missing, timeout, rate limit) skips that project silently.
|
|
19
|
-
*
|
|
20
|
-
* Cost budget: ~1 LLM call per untitled project on first run after this lands.
|
|
21
|
-
* Subsequent boots are no-ops since titles persist.
|
|
22
|
-
*/
|
|
23
|
-
import { existsSync, readdirSync, readFileSync, appendFileSync, statSync } from 'fs';
|
|
24
|
-
import { join } from 'path';
|
|
25
|
-
import { homedir } from 'os';
|
|
26
|
-
import { generateThreadTitle } from '../../application/use-cases/generate-thread-title.js';
|
|
27
|
-
const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
28
|
-
function scanThread(slug, file) {
|
|
29
|
-
let title = null;
|
|
30
|
-
let firstPrompt = null;
|
|
31
|
-
let firstCwd = null;
|
|
32
|
-
let firstStartedAt = Number.MAX_SAFE_INTEGER;
|
|
33
|
-
let content;
|
|
34
|
-
try {
|
|
35
|
-
content = readFileSync(file, 'utf-8');
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return { slug, title, firstPrompt, firstCwd };
|
|
39
|
-
}
|
|
40
|
-
for (const line of content.split('\n')) {
|
|
41
|
-
const trimmed = line.trim();
|
|
42
|
-
if (!trimmed)
|
|
43
|
-
continue;
|
|
44
|
-
let rec;
|
|
45
|
-
try {
|
|
46
|
-
rec = JSON.parse(trimmed);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
if (rec._meta === 'project') {
|
|
52
|
-
if (typeof rec.title === 'string')
|
|
53
|
-
title = rec.title;
|
|
54
|
-
}
|
|
55
|
-
else if (rec._meta === 'project-update') {
|
|
56
|
-
if (typeof rec.title === 'string')
|
|
57
|
-
title = rec.title;
|
|
58
|
-
}
|
|
59
|
-
else if (rec.type === 'task') {
|
|
60
|
-
const startedAt = typeof rec.startedAt === 'number' ? rec.startedAt : Number.MAX_SAFE_INTEGER;
|
|
61
|
-
if (startedAt < firstStartedAt) {
|
|
62
|
-
firstStartedAt = startedAt;
|
|
63
|
-
if (typeof rec.prompt === 'string')
|
|
64
|
-
firstPrompt = rec.prompt;
|
|
65
|
-
if (typeof rec.cwd === 'string')
|
|
66
|
-
firstCwd = rec.cwd;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return { slug, title, firstPrompt, firstCwd };
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Backfill missing titles. Returns counts; never throws (best-effort per project).
|
|
74
|
-
*
|
|
75
|
-
* Disable via CODEHERE_DISABLE_TITLE_AGENT=1 (same env var that gates M9 itself —
|
|
76
|
-
* keeps the toggle surface minimal).
|
|
77
|
-
*/
|
|
78
|
-
export async function backfillThreadTitles(options) {
|
|
79
|
-
const start = Date.now();
|
|
80
|
-
const result = {
|
|
81
|
-
scanned: 0,
|
|
82
|
-
alreadyTitled: 0,
|
|
83
|
-
noTasks: 0,
|
|
84
|
-
titlesGenerated: 0,
|
|
85
|
-
titleAgentFailed: 0,
|
|
86
|
-
durationMs: 0,
|
|
87
|
-
};
|
|
88
|
-
if (process.env.CODEHERE_DISABLE_TITLE_AGENT === '1') {
|
|
89
|
-
result.durationMs = Date.now() - start;
|
|
90
|
-
return result;
|
|
91
|
-
}
|
|
92
|
-
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
93
|
-
if (!existsSync(root)) {
|
|
94
|
-
result.durationMs = Date.now() - start;
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
|
-
let stat;
|
|
98
|
-
try {
|
|
99
|
-
stat = statSync(root);
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
result.durationMs = Date.now() - start;
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
if (!stat.isDirectory()) {
|
|
106
|
-
result.durationMs = Date.now() - start;
|
|
107
|
-
return result;
|
|
108
|
-
}
|
|
109
|
-
let slugs;
|
|
110
|
-
try {
|
|
111
|
-
slugs = readdirSync(root);
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
result.durationMs = Date.now() - start;
|
|
115
|
-
return result;
|
|
116
|
-
}
|
|
117
|
-
const cap = options?.maxProjects ?? Number.MAX_SAFE_INTEGER;
|
|
118
|
-
let backfilledCount = 0;
|
|
119
|
-
for (const slug of slugs) {
|
|
120
|
-
if (backfilledCount >= cap)
|
|
121
|
-
break;
|
|
122
|
-
const projectDir = join(root, slug);
|
|
123
|
-
let s;
|
|
124
|
-
try {
|
|
125
|
-
s = statSync(projectDir);
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
if (!s.isDirectory())
|
|
131
|
-
continue;
|
|
132
|
-
const file = join(projectDir, 'thread.jsonl');
|
|
133
|
-
if (!existsSync(file))
|
|
134
|
-
continue;
|
|
135
|
-
result.scanned++;
|
|
136
|
-
const scanned = scanThread(slug, file);
|
|
137
|
-
if (scanned.title) {
|
|
138
|
-
result.alreadyTitled++;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (!scanned.firstPrompt || !scanned.firstCwd) {
|
|
142
|
-
// Nothing to title from — skip. (Migration-seeded headers without task
|
|
143
|
-
// entries fall here, as do projects where the first task lost its prompt
|
|
144
|
-
// somehow.)
|
|
145
|
-
result.noTasks++;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
let title;
|
|
149
|
-
try {
|
|
150
|
-
title = await generateThreadTitle(scanned.firstPrompt, scanned.firstCwd);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
// Title agent should never throw (it returns null on failure), but guard
|
|
154
|
-
// anyway so one bad call doesn't sink the whole backfill.
|
|
155
|
-
title = null;
|
|
156
|
-
}
|
|
157
|
-
if (!title) {
|
|
158
|
-
result.titleAgentFailed++;
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
// Race guard: backfill scanned BEFORE the LLM call, but the LLM blocks
|
|
162
|
-
// for seconds. A user rename in that window leaves a userOverride:true
|
|
163
|
-
// marker the pre-scan never saw. Re-read the same file just before the
|
|
164
|
-
// write and skip if the user got there first. (Inlined rather than calling
|
|
165
|
-
// hasManualOverride() because that helper hardcodes the production
|
|
166
|
-
// projects dir; backfill supports a custom projectsDir for tests.)
|
|
167
|
-
let userBeatUs = false;
|
|
168
|
-
try {
|
|
169
|
-
const recheck = readFileSync(file, 'utf-8');
|
|
170
|
-
for (const line of recheck.split('\n')) {
|
|
171
|
-
const t = line.trim();
|
|
172
|
-
if (!t)
|
|
173
|
-
continue;
|
|
174
|
-
try {
|
|
175
|
-
const rec = JSON.parse(t);
|
|
176
|
-
if (rec._meta === 'project-update' && rec.userOverride === true) {
|
|
177
|
-
userBeatUs = true;
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
/* skip malformed */
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
/* if we can't read, fall through and let the append fail */
|
|
188
|
-
}
|
|
189
|
-
if (userBeatUs) {
|
|
190
|
-
result.alreadyTitled++;
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
try {
|
|
194
|
-
const updateLine = JSON.stringify({
|
|
195
|
-
_meta: 'project-update',
|
|
196
|
-
slug,
|
|
197
|
-
title,
|
|
198
|
-
cwd: scanned.firstCwd,
|
|
199
|
-
backfilledAt: Date.now(),
|
|
200
|
-
updatedAt: Date.now(),
|
|
201
|
-
});
|
|
202
|
-
appendFileSync(file, updateLine + '\n', 'utf-8');
|
|
203
|
-
result.titlesGenerated++;
|
|
204
|
-
backfilledCount++;
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
// Append failure is logged via counter but doesn't sink the loop.
|
|
208
|
-
result.titleAgentFailed++;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
result.durationMs = Date.now() - start;
|
|
212
|
-
return result;
|
|
213
|
-
}
|
|
214
|
-
//# sourceMappingURL=project-thread-title-backfill.js.map
|