codehere 0.7.1 → 0.8.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/dist/application/use-cases/narrator-prompt-template.js +1 -1
- package/dist/index.js +10 -6
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +1 -1
- package/dist/infrastructure/hooks/claude-sdk-hook.js +7 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +1 -1
- package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
- package/dist/infrastructure/orchestration/failover-config.js +1 -1
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +2 -2
- package/dist/infrastructure/orchestration/memory-injector.js +4 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +4 -1
- package/dist/infrastructure/orchestration/progress-aggregator.js +41 -6
- package/dist/infrastructure/protocols/cloud-sync.js +2 -2
- package/dist/infrastructure/protocols/mcp-server-impl.js +63 -1
- package/dist/infrastructure/protocols/ui-page-v2.js +102 -14
- package/dist/infrastructure/protocols/verify-page.js +42 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +1 -1
- package/dist/infrastructure/storage/project-thread-reader.js +1 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/presentation/cli/commands/init-command.js +66 -55
- package/package.json +1 -1
|
@@ -79,7 +79,7 @@ Write the paragraph now. Do not preface with "Update:" or anything similar.
|
|
|
79
79
|
Plain prose. No "I'm currently…" — narrate the AGENT, not yourself.
|
|
80
80
|
`;
|
|
81
81
|
function templateFile() {
|
|
82
|
-
return join(homedir(), '.codehere', 'narrator-prompt.md');
|
|
82
|
+
return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'narrator-prompt.md');
|
|
83
83
|
}
|
|
84
84
|
/** Read the user-customized template if present and valid. Falls back to
|
|
85
85
|
* baked-in default on missing file, parse failure, or missing required
|
package/dist/index.js
CHANGED
|
@@ -661,8 +661,8 @@ program
|
|
|
661
661
|
try {
|
|
662
662
|
const { migrate } = await import('./infrastructure/storage/project-thread-migration.js');
|
|
663
663
|
const result = migrate({
|
|
664
|
-
sourceJsonl: join(homedir(), '.codehere/sessions.jsonl'),
|
|
665
|
-
targetDir: join(homedir(), '.codehere/projects'),
|
|
664
|
+
sourceJsonl: join(process.env.CODEHERE_HOME || homedir(), '.codehere/sessions.jsonl'),
|
|
665
|
+
targetDir: join(process.env.CODEHERE_HOME || homedir(), '.codehere/projects'),
|
|
666
666
|
});
|
|
667
667
|
if (result.threadsWritten > 0) {
|
|
668
668
|
console.log(colors.dim(` v0.6 migration: created ${result.threadsWritten} project thread(s) from ${result.sessionsProcessed} sessions (${result.durationMs}ms)`));
|
|
@@ -765,8 +765,12 @@ program
|
|
|
765
765
|
console.log(colors.dim(` version: ${existing.version}`));
|
|
766
766
|
}
|
|
767
767
|
newline();
|
|
768
|
-
|
|
769
|
-
|
|
768
|
+
// Honor --no-open here too — found via the v1 stranger walk: a second
|
|
769
|
+
// `start --no-open` against a running daemon popped a browser anyway.
|
|
770
|
+
if (shouldOpen) {
|
|
771
|
+
console.log(colors.dim(' Opening browser...'));
|
|
772
|
+
await openBrowser();
|
|
773
|
+
}
|
|
770
774
|
process.exit(0);
|
|
771
775
|
}
|
|
772
776
|
console.error(error(`Port ${port} is in use by another process.`));
|
|
@@ -947,8 +951,8 @@ program
|
|
|
947
951
|
program
|
|
948
952
|
.command('migrate-threads', { hidden: true }) // hidden from --help (v0.6.0 internal — auto-runs on daemon start once wired)
|
|
949
953
|
.description('Migrate sessions.jsonl into per-project thread.jsonl files (v0.6.0 schema)')
|
|
950
|
-
.option('--source <path>', 'Source sessions.jsonl', `${homedir()}/.codehere/sessions.jsonl`)
|
|
951
|
-
.option('--target <dir>', 'Target projects dir', `${homedir()}/.codehere/projects`)
|
|
954
|
+
.option('--source <path>', 'Source sessions.jsonl', `${process.env.CODEHERE_HOME || homedir()}/.codehere/sessions.jsonl`)
|
|
955
|
+
.option('--target <dir>', 'Target projects dir', `${process.env.CODEHERE_HOME || homedir()}/.codehere/projects`)
|
|
952
956
|
.option('--dry-run', 'Report what migrate WOULD do, write nothing', false)
|
|
953
957
|
.option('--rollback', 'Remove the projects dir; sessions.jsonl untouched', false)
|
|
954
958
|
.option('--keep-temp-dirs', 'Include /tmp + /var/folders cwds (default skips them)', false)
|
|
@@ -29,7 +29,7 @@ const DEFAULT_CONFIG = {
|
|
|
29
29
|
warnAtPct: 70,
|
|
30
30
|
};
|
|
31
31
|
function configFile() {
|
|
32
|
-
return join(homedir(), '.codehere', 'budget.yaml');
|
|
32
|
+
return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'budget.yaml');
|
|
33
33
|
}
|
|
34
34
|
/** Parse a tiny subset of YAML — `key: value` pairs, # comments, blank lines.
|
|
35
35
|
* No nesting, no lists, no multi-line. Same approach as permissions-config:
|
|
@@ -25,7 +25,7 @@ import { join } from 'path';
|
|
|
25
25
|
/** Resolved at call time, not module load, so test setups that swap $HOME
|
|
26
26
|
* after import still get a fresh path on each invocation. */
|
|
27
27
|
function sessionsFile() {
|
|
28
|
-
return join(homedir(), '.codehere', 'sessions.jsonl');
|
|
28
|
+
return join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
29
29
|
}
|
|
30
30
|
/** USD per million OUTPUT tokens at the agent's typical 2026 tier. Input is
|
|
31
31
|
* estimated at 1/5 of output rate (Anthropic + OpenAI both publish ~5x
|
|
@@ -396,8 +396,14 @@ export function calculateRiskScore(findings) {
|
|
|
396
396
|
/**
|
|
397
397
|
* Audit log path — defaults to ~/.codehere/audit/hook-events.jsonl.
|
|
398
398
|
* Override at process start with CODEHERE_AUDIT_FILE env var (used by tests, optional for users).
|
|
399
|
+
* Honors CODEHERE_HOME like sessions/config/threads — found via the v1 stranger
|
|
400
|
+
* walk: an "isolated" daemon (verify-all's :3779, the walk's scratch home) was
|
|
401
|
+
* writing mock audit entries into the REAL ~/.codehere/audit while verify-all
|
|
402
|
+
* printed "Real ~/.codehere untouched" (the footer showed 162 entries · 51
|
|
403
|
+
* agents in a brand-new home — all of them pollution from past mock runs).
|
|
399
404
|
*/
|
|
400
|
-
export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE ||
|
|
405
|
+
export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE ||
|
|
406
|
+
join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'audit', 'hook-events.jsonl');
|
|
401
407
|
const GENESIS_PREV_HASH = '0'.repeat(64);
|
|
402
408
|
/**
|
|
403
409
|
* Canonicalize an audit entry for hashing. We sort keys alphabetically and
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
16
16
|
import { homedir } from 'os';
|
|
17
17
|
import { join, dirname } from 'path';
|
|
18
|
-
const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
|
|
18
|
+
const CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.yaml');
|
|
19
19
|
const DEFAULT_YAML = `# codehere — pluggable agent adapters
|
|
20
20
|
#
|
|
21
21
|
# Built-in adapters (no configuration needed — just install the CLI):
|
|
@@ -38,7 +38,7 @@ const NULL_RECORDER = {
|
|
|
38
38
|
export function startRecording(opts) {
|
|
39
39
|
if (process.env[ENV_FLAG] !== '1')
|
|
40
40
|
return NULL_RECORDER;
|
|
41
|
-
const outDir = opts.outDir ?? join(homedir(), '.codehere', 'cassettes', opts.adapter);
|
|
41
|
+
const outDir = opts.outDir ?? join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'cassettes', opts.adapter);
|
|
42
42
|
const scenario = `organic-${opts.sessionId.slice(0, 8)}`;
|
|
43
43
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
44
44
|
const path = join(outDir, `${scenario}-${stamp}.jsonl`);
|
|
@@ -23,7 +23,7 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
23
23
|
import { homedir } from 'os';
|
|
24
24
|
import { join } from 'path';
|
|
25
25
|
import { validateFailoverChain } from './failover-policy.js';
|
|
26
|
-
const GLOBAL_CONFIG_PATH = join(homedir(), '.codehere', 'config.json');
|
|
26
|
+
const GLOBAL_CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'config.json');
|
|
27
27
|
/**
|
|
28
28
|
* Load failover configuration from ~/.codehere/config.json.
|
|
29
29
|
* Returns undefined if the config file does not exist or has no failover section.
|
|
@@ -38,8 +38,8 @@ import { randomUUID } from 'crypto';
|
|
|
38
38
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
39
39
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
40
40
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
41
|
-
const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
|
|
42
|
-
const CONFIG_PATH_JSON = join(homedir(), '.codehere', 'agents.json');
|
|
41
|
+
const CONFIG_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.yaml');
|
|
42
|
+
const CONFIG_PATH_JSON = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'agents.json');
|
|
43
43
|
/** Minimal YAML parser for the narrow shape above. Intentionally NOT a full YAML
|
|
44
44
|
* parser — avoids dragging in a dep. Handles: `key: value`, lists with `- key: val`,
|
|
45
45
|
* nested lists of simple strings via `[a, b]`, comments after `#`. Errors loud.
|
|
@@ -279,8 +279,11 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
|
|
|
279
279
|
*
|
|
280
280
|
* Stops at the filesystem root. Symlink-following on read is the right
|
|
281
281
|
* asymmetry per ADR-012 (the symlink defense applies to writes, not reads).
|
|
282
|
+
*
|
|
283
|
+
* Exported for the daemon's /api/memory/status route (the room's init nudge
|
|
284
|
+
* must report the SAME readiness the injector itself would act on).
|
|
282
285
|
*/
|
|
283
|
-
function findProjectRoot(startPath) {
|
|
286
|
+
export function findProjectRoot(startPath) {
|
|
284
287
|
let current;
|
|
285
288
|
try {
|
|
286
289
|
current = realpathSync(resolve(startPath));
|
|
@@ -194,7 +194,10 @@ async function runOpencode() {
|
|
|
194
194
|
out({ type: 'tool_use', part: { tool, state: { input: s.input, output: s.result, status: 'completed' } } });
|
|
195
195
|
}
|
|
196
196
|
await sleep(120);
|
|
197
|
-
|
|
197
|
+
// Real opencode carries narrative text in part.text (the adapter's summary
|
|
198
|
+
// extraction and the aggregator's say capture both read that shape) — the
|
|
199
|
+
// prior top-level `text` field was mock drift no real parser ever saw.
|
|
200
|
+
out({ type: 'text', part: { text: 'Completed (mock): ' + prompt } });
|
|
198
201
|
}
|
|
199
202
|
async function main() {
|
|
200
203
|
if (!prompt)
|
|
@@ -169,6 +169,14 @@ export class ProgressAggregator {
|
|
|
169
169
|
// real gap surfaced by the mock harness). Build a step on the terminal
|
|
170
170
|
// (status==='completed') event so opencode is a first-class agent.
|
|
171
171
|
ingestOpencode(type, data) {
|
|
172
|
+
// OpenCode's narrative text rides `text` events with part.text (the same
|
|
173
|
+
// shape the adapter's summary extraction reads) — cross-vendor say capture
|
|
174
|
+
// per the v1 spec, so opencode's Final Report stops degrading to the
|
|
175
|
+
// deterministic fallback.
|
|
176
|
+
if (type === 'text') {
|
|
177
|
+
const part = (data.part ?? {});
|
|
178
|
+
return this.pushSay(String(part.text ?? ''));
|
|
179
|
+
}
|
|
172
180
|
if (type !== 'tool_use')
|
|
173
181
|
return false;
|
|
174
182
|
const part = (data.part ?? {});
|
|
@@ -213,6 +221,24 @@ export class ProgressAggregator {
|
|
|
213
221
|
const idx = this._steps.push({ label, state, startedAt: Date.now() }) - 1;
|
|
214
222
|
return idx;
|
|
215
223
|
}
|
|
224
|
+
/** The agent's own narration as a kind:'say' step — the "watch it work"
|
|
225
|
+
* signal AND what the Final Report engine reuses as the summary. Cross-
|
|
226
|
+
* vendor by decision of the v1 spec (the room thread is "tool steps woven
|
|
227
|
+
* with the agent's plain-English narration" — for every agent, not just
|
|
228
|
+
* claude/aider). Same 500-char cap as the original claude capture. */
|
|
229
|
+
pushSay(text) {
|
|
230
|
+
const said = String(text ?? '').trim();
|
|
231
|
+
if (!said)
|
|
232
|
+
return false;
|
|
233
|
+
this._steps.push({
|
|
234
|
+
kind: 'say',
|
|
235
|
+
label: said.length > 500 ? said.slice(0, 500) + '\\u2026' : said,
|
|
236
|
+
state: 'done',
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
completedAt: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
216
242
|
ingestClaude(type, data) {
|
|
217
243
|
if (type === 'assistant') {
|
|
218
244
|
const msg = (data.message ?? {});
|
|
@@ -322,6 +348,12 @@ export class ProgressAggregator {
|
|
|
322
348
|
return true;
|
|
323
349
|
}
|
|
324
350
|
}
|
|
351
|
+
// Gemini's narrative text rides `content` events — same cross-vendor say
|
|
352
|
+
// capture as claude/aider/codex (v1 spec: narration woven into the thread,
|
|
353
|
+
// and the Final Report engine reuses it as the summary).
|
|
354
|
+
if (type === 'content' && typeof data.text === 'string') {
|
|
355
|
+
return this.pushSay(data.text);
|
|
356
|
+
}
|
|
325
357
|
return false;
|
|
326
358
|
}
|
|
327
359
|
/**
|
|
@@ -642,12 +674,15 @@ export class ProgressAggregator {
|
|
|
642
674
|
});
|
|
643
675
|
return true;
|
|
644
676
|
}
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
677
|
+
// agent_message = codex's own narration. Captured as kind:'say' since the
|
|
678
|
+
// v1 spec settled the taste call that used to be flagged here (pm-layers
|
|
679
|
+
// "just chatter" decision): the room thread is "tool steps woven with the
|
|
680
|
+
// agent's plain-English narration" for EVERY vendor, and the Final Report
|
|
681
|
+
// engine reuses 'say' steps as the summary — without this, codex always
|
|
682
|
+
// degraded to the deterministic fallback.
|
|
683
|
+
if (itemType === 'agent_message') {
|
|
684
|
+
return this.pushSay(String(item.text ?? ''));
|
|
685
|
+
}
|
|
651
686
|
return false;
|
|
652
687
|
}
|
|
653
688
|
}
|
|
@@ -16,8 +16,8 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
16
16
|
import { join, dirname } from 'path';
|
|
17
17
|
import { homedir, hostname } from 'os';
|
|
18
18
|
const CLOUD_URL = 'https://codehere.uk/api/sync';
|
|
19
|
-
const TOKEN_PATH = join(homedir(), '.codehere', 'cloud-token');
|
|
20
|
-
const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
|
|
19
|
+
const TOKEN_PATH = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'cloud-token');
|
|
20
|
+
const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
21
21
|
// Lazy-loaded session manager — imported once to avoid circular deps
|
|
22
22
|
let _sessionManager = null;
|
|
23
23
|
async function getSessionManager() {
|
|
@@ -46,6 +46,8 @@ import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuth
|
|
|
46
46
|
import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
|
|
47
47
|
import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
|
|
48
48
|
import { isAbsolute } from 'path';
|
|
49
|
+
import { findProjectRoot } from '../orchestration/memory-injector.js';
|
|
50
|
+
import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
|
|
49
51
|
import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
|
|
50
52
|
import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
|
|
51
53
|
import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
|
|
@@ -545,7 +547,7 @@ export function startMCPServer(options = {}) {
|
|
|
545
547
|
// Tails sessions.jsonl and pushes "change" events so the /v2 UI can
|
|
546
548
|
// re-fetch task state instantly instead of waiting 8s for the next poll.
|
|
547
549
|
// Same pattern as /events (audit log tail) but for sessions.
|
|
548
|
-
const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
|
|
550
|
+
const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
|
|
549
551
|
if (req.method === 'GET' && req.url === '/api/events/sessions') {
|
|
550
552
|
res.writeHead(200, {
|
|
551
553
|
'Content-Type': 'text/event-stream',
|
|
@@ -1211,6 +1213,66 @@ export function startMCPServer(options = {}) {
|
|
|
1211
1213
|
}
|
|
1212
1214
|
return;
|
|
1213
1215
|
}
|
|
1216
|
+
// ── v1 project-memory routes (the room's init nudge) ─────────────────────
|
|
1217
|
+
// Found by the stranger walk: web-only users never run `codehere init`, so
|
|
1218
|
+
// the spec's "agent remembers the project" half of nothing-lost silently
|
|
1219
|
+
// doesn't apply. Status reports the SAME readiness the injector acts on
|
|
1220
|
+
// (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
|
|
1221
|
+
// does (shared scaffoldProjectAt — symlink defenses included).
|
|
1222
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
|
|
1223
|
+
try {
|
|
1224
|
+
const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
|
|
1225
|
+
const cwd = u.searchParams.get('cwd') || '';
|
|
1226
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
|
|
1227
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1228
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const root = findProjectRoot(cwd);
|
|
1232
|
+
const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
|
|
1233
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1234
|
+
res.end(JSON.stringify({ ready, projectRoot: root }));
|
|
1235
|
+
}
|
|
1236
|
+
catch (e) {
|
|
1237
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1238
|
+
res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
|
|
1239
|
+
}
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (req.method === 'POST' && req.url === '/api/memory/init') {
|
|
1243
|
+
(async () => {
|
|
1244
|
+
try {
|
|
1245
|
+
const body = await readBody(req, res);
|
|
1246
|
+
let cwd = '';
|
|
1247
|
+
try {
|
|
1248
|
+
cwd = String(JSON.parse(body || '{}').cwd || '');
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
/* falls through to the validation below */
|
|
1252
|
+
}
|
|
1253
|
+
if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
1254
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1255
|
+
res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const r = scaffoldProjectAt(cwd);
|
|
1259
|
+
if (!r.ok) {
|
|
1260
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1261
|
+
res.end(JSON.stringify({ ok: false, reason: r.reason }));
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1265
|
+
res.end(JSON.stringify({ ok: true, created: r.created.length, skipped: r.skipped.length, refused: r.refused.length + r.refusedDirs.length }));
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1269
|
+
res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
|
|
1270
|
+
}
|
|
1271
|
+
})().catch(() => {
|
|
1272
|
+
/* handled above — keep the route from rejecting unhandled */
|
|
1273
|
+
});
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1214
1276
|
// ── v0.6.0 M1 project thread routes ──────────────────────────────────────
|
|
1215
1277
|
// GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
|
|
1216
1278
|
// GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
|
|
@@ -662,6 +662,23 @@ const SHARED_STYLES = `
|
|
|
662
662
|
.audit-line b { color: var(--fg-90); font-weight: 600; }
|
|
663
663
|
.audit-line code { background: hsl(0 0% 9%); padding: 1px 5px; border-radius: 3px; color: var(--fg-70); }
|
|
664
664
|
|
|
665
|
+
/* project-memory nudge · same quiet family as .audit-line (trust present-
|
|
666
|
+
but-quiet); .on = the post-click confirmation state */
|
|
667
|
+
.mem-nudge {
|
|
668
|
+
font-family: var(--mono); font-size: 10.5px; color: var(--fg-40);
|
|
669
|
+
padding: 6px 12px; margin: 8px 0;
|
|
670
|
+
background: rgba(0,0,0,0.2);
|
|
671
|
+
border-left: 2px solid var(--border); border-radius: 3px;
|
|
672
|
+
line-height: 1.55;
|
|
673
|
+
}
|
|
674
|
+
.mem-nudge .mem-btn {
|
|
675
|
+
background: none; border: 1px solid var(--border); border-radius: 4px;
|
|
676
|
+
color: var(--fg-70); font-family: var(--mono); font-size: 10.5px;
|
|
677
|
+
padding: 2px 8px; cursor: pointer; margin-left: 4px;
|
|
678
|
+
}
|
|
679
|
+
.mem-nudge .mem-btn:hover { border-color: var(--accent); color: var(--fg); }
|
|
680
|
+
.mem-nudge.on { border-left-color: rgba(40,200,64,0.4); color: var(--fg-70); }
|
|
681
|
+
|
|
665
682
|
/* auditor persona · audit-chain + verifier + hook-badges amplified,
|
|
666
683
|
summary muted (per demo v12 line 2462: "amplify audit-lines +
|
|
667
684
|
hook-badges + trust-grades") */
|
|
@@ -1545,8 +1562,9 @@ const SHARED_STYLES = `
|
|
|
1545
1562
|
.input-ta::placeholder { color: var(--fg-40); }
|
|
1546
1563
|
.input-ta:disabled { color: var(--fg-30); }
|
|
1547
1564
|
.input-meta { font-family: var(--mono); font-size: 10px; color: var(--fg-40); white-space: nowrap; display: inline-flex; gap: 8px; align-items: center; }
|
|
1548
|
-
.input-meta .pick-agent { color: var(--fg-70); cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: background 0.15s; }
|
|
1565
|
+
.input-meta .pick-agent { color: var(--fg-70); cursor: pointer; padding: 2px 6px; border-radius: 3px; transition: background 0.15s; background: transparent; border: none; font: inherit; -webkit-appearance: none; appearance: none; }
|
|
1549
1566
|
.input-meta .pick-agent:hover { background: rgba(255,255,255,0.05); color: var(--fg); }
|
|
1567
|
+
.input-meta select.pick-agent option { background: var(--bg-2, #1a1a1f); color: var(--fg); }
|
|
1550
1568
|
.input-meta .cost-est { color: var(--allow); padding: 2px 6px; border-radius: 3px; background: rgba(40,200,64,0.06); }
|
|
1551
1569
|
.send-btn {
|
|
1552
1570
|
background: var(--accent); color: #000; border: none; border-radius: 6px;
|
|
@@ -1567,6 +1585,10 @@ const SHARED_STYLES = `
|
|
|
1567
1585
|
const APP_SCRIPT = `
|
|
1568
1586
|
const _cached = {};
|
|
1569
1587
|
let _filter = 'all', _detail = null, _room = null, _offline = false, _autoOpened = false;
|
|
1588
|
+
// Project-memory state for the open room · null = unknown (fetch in flight),
|
|
1589
|
+
// false = agents forget this project (show the nudge), true = memory already
|
|
1590
|
+
// on (stay quiet), 'just-on' = turned on from the nudge this visit (confirm).
|
|
1591
|
+
let _roomMemory = null;
|
|
1570
1592
|
const ICONS = { queued:'\\u23F3', running:'\\u25CF', paused:'\\u23F8', blocked:'\\uD83D\\uDD12', done:'\\u2713', failed:'\\u2715', crashed:'!', killed:'\\u2298' };
|
|
1571
1593
|
const STAT_ORDER = ['running','queued','paused','blocked','done','failed','crashed','killed'];
|
|
1572
1594
|
const FILTERS = { all: s=>true, running: s=>['running','queued','paused','blocked'].includes(s.status), done: s=>['done','completed'].includes(s.status), failed: s=>['failed','crashed','killed','error'].includes(s.status) };
|
|
@@ -1733,12 +1755,48 @@ function render() {
|
|
|
1733
1755
|
// agent turns. Renders into #task-list (same surface as the goals list).
|
|
1734
1756
|
function openRoom(enc){
|
|
1735
1757
|
_room=decodeURIComponent(enc||''); _detail=null;
|
|
1758
|
+
// Project-memory status for the nudge — fetched once per entry from the
|
|
1759
|
+
// daemon (the SAME readiness the injector acts on, not a guess).
|
|
1760
|
+
_roomMemory=null;
|
|
1761
|
+
(function(forCwd){
|
|
1762
|
+
fetch('/api/memory/status?cwd='+encodeURIComponent(forCwd)).then(x=>x.json()).then(j=>{
|
|
1763
|
+
if(_room===forCwd){ _roomMemory=!!j.ready; render(); }
|
|
1764
|
+
}).catch(()=>{});
|
|
1765
|
+
})(_room);
|
|
1736
1766
|
document.getElementById('task-list-view').style.display='';
|
|
1737
1767
|
document.getElementById('task-detail-view').style.display='none';
|
|
1738
1768
|
document.body.classList.remove('detail-open');
|
|
1769
|
+
// Hand-off affordance: inside a room the composer targets THIS goal (cwd is
|
|
1770
|
+
// pinned in submit) and the picker defaults to whoever worked it last —
|
|
1771
|
+
// continuing is zero clicks, handing to a different agent is one visible
|
|
1772
|
+
// click. Set once on entry (not in renderRoom, which re-runs every poll and
|
|
1773
|
+
// would stomp the user's choice).
|
|
1774
|
+
const ta=document.getElementById('main-ta');
|
|
1775
|
+
if(ta) ta.placeholder='Add the next task in this goal\\u2026 any agent picks up where the last one left off';
|
|
1776
|
+
const ap=document.getElementById('agent-pick');
|
|
1777
|
+
if(ap){
|
|
1778
|
+
const turns=allS(_cached.tasks||{}).filter(x=>!/^demo-/.test(x.sessionId||'')&&(x.cwd||'')===_room&&x.agent).sort((a,b)=>(a.startedAt||0)-(b.startedAt||0));
|
|
1779
|
+
const raw=turns.length?String(turns[turns.length-1].agent):'';
|
|
1780
|
+
ap.value=['claude-code','codex','aider','gemini','opencode'].indexOf(raw)>=0?raw:'auto';
|
|
1781
|
+
}
|
|
1782
|
+
render();
|
|
1783
|
+
}
|
|
1784
|
+
function closeRoom(){
|
|
1785
|
+
_room=null; _roomMemory=null;
|
|
1786
|
+
const ta=document.getElementById('main-ta');
|
|
1787
|
+
if(ta) ta.placeholder='Describe a task or ask a question...';
|
|
1788
|
+
const ap=document.getElementById('agent-pick'); if(ap) ap.value='auto';
|
|
1739
1789
|
render();
|
|
1740
1790
|
}
|
|
1741
|
-
function
|
|
1791
|
+
async function initRoomMemory(btn){
|
|
1792
|
+
if(!_room) return;
|
|
1793
|
+
if(btn){ btn.disabled=true; btn.textContent='turning on\\u2026'; }
|
|
1794
|
+
try{
|
|
1795
|
+
const r=await fetch('/api/memory/init',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cwd:_room})});
|
|
1796
|
+
if(r.ok){ _roomMemory='just-on'; render(); }
|
|
1797
|
+
else { const e=await r.json().catch(()=>({})); al('Could not turn on memory: '+(e.error||e.reason||'unknown')); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
|
|
1798
|
+
}catch(e){ al('Network error: '+e.message); if(btn){btn.disabled=false;btn.textContent='turn on project memory';} }
|
|
1799
|
+
}
|
|
1742
1800
|
function renderRoom(){
|
|
1743
1801
|
const c=document.getElementById('task-list');
|
|
1744
1802
|
const _hs=document.getElementById('host-sessions-strip'); if(_hs){ _hs.style.display='none'; }
|
|
@@ -1769,6 +1827,16 @@ function renderRoom(){
|
|
|
1769
1827
|
} else {
|
|
1770
1828
|
html+='<div class="empty-state"><h2>No tasks in this goal yet.</h2></div>';
|
|
1771
1829
|
}
|
|
1830
|
+
// Project-memory nudge (stranger-walk finding 2026-06-06): web-only users
|
|
1831
|
+
// never run codehere-init, so agents silently get NO memory of the
|
|
1832
|
+
// project. Honest + quiet: rendered ONLY when the daemon says memory is
|
|
1833
|
+
// off; the confirmation says "from the next task" because injection
|
|
1834
|
+
// happens at spawn — no fake instant effect.
|
|
1835
|
+
if(_roomMemory===false){
|
|
1836
|
+
html+='<div class="mem-nudge">🧠 agents forget this project between runs \\u00b7 <button class="mem-btn" onclick="initRoomMemory(this)">turn on project memory</button></div>';
|
|
1837
|
+
} else if(_roomMemory==='just-on'){
|
|
1838
|
+
html+='<div class="mem-nudge on">\\u2713 project memory is on \\u2014 agents will remember this project from the next task</div>';
|
|
1839
|
+
}
|
|
1772
1840
|
c.innerHTML=html;
|
|
1773
1841
|
}
|
|
1774
1842
|
|
|
@@ -1852,14 +1920,11 @@ function renderL() {
|
|
|
1852
1920
|
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,ch=>({'&':'&','<':'<','>':'>','"':'"'}[ch]));
|
|
1853
1921
|
const fl=all.filter(FILTERS[_filter]||FILTERS.all);
|
|
1854
1922
|
let html='';
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1923
|
+
// Demo-shape sessions (id prefix 'demo-') render AFTER the goals — they
|
|
1924
|
+
// demonstrate the product, but the home leads with YOUR goals (spec:
|
|
1925
|
+
// "Home is a list of your goals"; demos were burying it).
|
|
1857
1926
|
const demo=fl.filter(x=>/^demo-/.test(x.sessionId||''));
|
|
1858
1927
|
const rest=fl.filter(x=>!/^demo-/.test(x.sessionId||''));
|
|
1859
|
-
if(demo.length){
|
|
1860
|
-
html+='<div class="section-label featured">Featured</div>';
|
|
1861
|
-
for(const t of demo) html+=cardH(t);
|
|
1862
|
-
}
|
|
1863
1928
|
// Goal-first home: group the rest by project (the goal/room), not by status.
|
|
1864
1929
|
// cwd → project title (from /api/projects); fall back to the folder name.
|
|
1865
1930
|
const projects=_cached.projects||[];
|
|
@@ -1891,7 +1956,7 @@ function renderL() {
|
|
|
1891
1956
|
g.items.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
|
|
1892
1957
|
const running=g.items.filter(x=>['running','queued','paused','blocked'].includes(x.status)).length;
|
|
1893
1958
|
const meta=g.items.length+' task'+(g.items.length===1?'':'s')+(running?' · '+running+' running':'');
|
|
1894
|
-
html+='<div class="goal-group"><div class="goal-head" onclick="openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')"><span class="goal-title">'+esc(titleFor(g.cwd))+'</span><span class="goal-meta">'+esc(meta)+' \\u00b7 open \\u2192</span></div>';
|
|
1959
|
+
html+='<div class="goal-group"><div class="goal-head" data-goal-cwd="'+encodeURIComponent(g.cwd||'')+'" onclick="openRoom(\\''+encodeURIComponent(g.cwd||'')+'\\')"><span class="goal-title">'+esc(titleFor(g.cwd))+'</span><span class="goal-meta">'+esc(meta)+' \\u00b7 open \\u2192</span></div>';
|
|
1895
1960
|
// Time-gap separator within a goal · a big gap between consecutive tasks
|
|
1896
1961
|
// in the same goal is meaningful ("picked this back up 3 days later").
|
|
1897
1962
|
let prevTs=null;
|
|
@@ -1906,6 +1971,10 @@ function renderL() {
|
|
|
1906
1971
|
}
|
|
1907
1972
|
html+='</div>';
|
|
1908
1973
|
}
|
|
1974
|
+
if(demo.length){
|
|
1975
|
+
html+='<div class="section-label featured">Featured</div>';
|
|
1976
|
+
for(const t of demo) html+=cardH(t);
|
|
1977
|
+
}
|
|
1909
1978
|
if(!html){
|
|
1910
1979
|
const h={all:'No goals yet. Type a task below to start one.',running:'No running tasks.',done:'No completed tasks.',failed:'No failed tasks.'};
|
|
1911
1980
|
html='<div class="empty-state"><h2>'+(h[_filter]||'No tasks found.')+'</h2></div>';
|
|
@@ -3316,8 +3385,11 @@ async function fetchAll() {
|
|
|
3316
3385
|
if(_all.length){
|
|
3317
3386
|
_autoOpened=true;
|
|
3318
3387
|
if(_detail===null){
|
|
3319
|
-
|
|
3320
|
-
|
|
3388
|
+
// Demos never hijack first load — auto-open is for YOUR work
|
|
3389
|
+
// (a seeded demo stuck on "running" was stealing it · 2026-06-06 walk).
|
|
3390
|
+
const _real=_all.filter(x=>!/^demo-/.test(x.sessionId||''));
|
|
3391
|
+
const _act=_real.filter(x=>['running','queued','paused','blocked'].includes(x.status));
|
|
3392
|
+
const _pool=_act.length?_act:_real.slice();
|
|
3321
3393
|
_pool.sort((a,b)=>(b.startedAt||0)-(a.startedAt||0));
|
|
3322
3394
|
const _pick=_pool[0];
|
|
3323
3395
|
if(_pick&&_pick.sessionId){ showD(_pick.sessionId); }
|
|
@@ -3352,7 +3424,20 @@ async function submit(ta,btn,taskId){
|
|
|
3352
3424
|
// which is the disabled-composer bug's mirror image: composer enabled but
|
|
3353
3425
|
// routed wrong.
|
|
3354
3426
|
const url = taskId ? '/api/task/'+taskId+'/message' : '/api/task';
|
|
3355
|
-
|
|
3427
|
+
let body;
|
|
3428
|
+
if(taskId){ body=JSON.stringify({text:p}); }
|
|
3429
|
+
else {
|
|
3430
|
+
// Room-aware spawn: typing while a goal room is open adds the task TO
|
|
3431
|
+
// that goal (cwd pinned to the room) — before this, the room composer
|
|
3432
|
+
// silently spawned OUTSIDE the room, the same routing lie as the
|
|
3433
|
+
// follow-up bug above. Agent comes from the visible picker; 'auto'
|
|
3434
|
+
// omits it so the server's conductor default applies unchanged.
|
|
3435
|
+
const payload={prompt:p};
|
|
3436
|
+
if(_room) payload.cwd=_room;
|
|
3437
|
+
const ap=document.getElementById('agent-pick');
|
|
3438
|
+
if(ap&&ap.value&&ap.value!=='auto') payload.agent=ap.value;
|
|
3439
|
+
body=JSON.stringify(payload);
|
|
3440
|
+
}
|
|
3356
3441
|
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body});
|
|
3357
3442
|
if(!r.ok){const e=await r.json();al('Failed: '+(e.error||'unknown'));btn.disabled=false;ta.disabled=false;_submitting=false;}else{
|
|
3358
3443
|
ta.value='';btn.textContent='\\u2713';setTimeout(()=>{btn.textContent='\\u2191';btn.disabled=false;ta.disabled=false;_submitting=false;ta.focus();},1200);
|
|
@@ -4129,7 +4214,6 @@ export const TASK_HTML_V2 = `<!doctype html>
|
|
|
4129
4214
|
|
|
4130
4215
|
<div class="content">
|
|
4131
4216
|
<div id="task-list-view" class="content-inner">
|
|
4132
|
-
<div id="host-sessions-strip" style="display:none"></div>
|
|
4133
4217
|
<div id="task-list">
|
|
4134
4218
|
<div class="skeleton" id="skeleton">
|
|
4135
4219
|
<div class="skeleton-card"></div>
|
|
@@ -4137,6 +4221,10 @@ export const TASK_HTML_V2 = `<!doctype html>
|
|
|
4137
4221
|
<div class="skeleton-card"></div>
|
|
4138
4222
|
</div>
|
|
4139
4223
|
</div>
|
|
4224
|
+
<!-- Observed host sessions live BELOW the goals: the spec's home is
|
|
4225
|
+
"a list of your goals" — ambient observation must not bury the
|
|
4226
|
+
thesis surface (found burying it during the 2026-06-06 walk). -->
|
|
4227
|
+
<div id="host-sessions-strip" style="display:none"></div>
|
|
4140
4228
|
</div>
|
|
4141
4229
|
<div id="task-detail-view" style="display:none" class="content-inner">
|
|
4142
4230
|
<div id="detail-view"></div>
|
|
@@ -4153,7 +4241,7 @@ export const TASK_HTML_V2 = `<!doctype html>
|
|
|
4153
4241
|
<div class="input-area">
|
|
4154
4242
|
<div class="input-bar">
|
|
4155
4243
|
<textarea class="input-ta composer-bar" id="main-ta" placeholder="Describe a task or ask a question..." rows="1"></textarea>
|
|
4156
|
-
<div class="input-meta composer-meta"><
|
|
4244
|
+
<div class="input-meta composer-meta"><select class="pick-agent" id="agent-pick" title="Who does this task — Auto picks the best fit" aria-label="Pick the agent for this task"><option value="auto">↳ Auto</option><option value="claude-code">claude-code</option><option value="codex">codex</option><option value="aider">aider</option><option value="gemini">gemini</option><option value="opencode">opencode</option></select><span class="cost-est" title="Estimated cost · based on prompt length + recent agent pricing">~$0.02</span></div>
|
|
4157
4245
|
<button class="send-btn composer-send" id="main-btn">↑</button>
|
|
4158
4246
|
</div>
|
|
4159
4247
|
<div class="kbd-hint">
|
|
@@ -298,6 +298,23 @@ export const VERIFY_HTML = `<!doctype html>
|
|
|
298
298
|
{ id:"F-TASK-CARD", name:"Task card", surface:"list", sel:".task-card", status:"shipped", tier:"v0.7" },
|
|
299
299
|
{ id:"F-TIME-GAP-PILL", name:"Brand pill + relative-time sub", surface:"list", sel:".time-gap", status:"shipped", tier:"v0.7" },
|
|
300
300
|
{ id:"F-WORKTREE-ALERT", name:"Worktree alert", surface:"list", sel:".worktree-alert", status:"partial", tier:"v0.7" },
|
|
301
|
+
/* goal-first home + goal room (v1 spec · shared-room hand-off). Goal groups
|
|
302
|
+
render from REAL non-demo tasks (same data dependency as F-TIME-GAP-PILL);
|
|
303
|
+
room features are observed by driving the REAL openRoom() on the first
|
|
304
|
+
goal-head's data-goal-cwd, then closeRoom() — same pattern as showD(). */
|
|
305
|
+
{ id:"F-GOAL-GROUP", name:"Goal-first home · tasks grouped by goal", surface:"list", sel:".goal-group", status:"shipped", tier:"v1" },
|
|
306
|
+
{ id:"F-GOAL-HEAD-OPEN", name:"Goal head opens its room (carries cwd)", surface:"list", sel:".goal-head[data-goal-cwd]", status:"shipped", tier:"v1" },
|
|
307
|
+
{ id:"F-AGENT-PICK", name:"Composer agent picker (Auto + 5 agents)", surface:"list", sel:"select#agent-pick", status:"shipped", tier:"v1" },
|
|
308
|
+
/* room selectors are scoped to #task-list: the hidden detail view's DOM
|
|
309
|
+
persists (display:none still matches querySelectorAll) and also has an
|
|
310
|
+
.audit-line — unscoped, a dead room would false-OBSERVE off it. */
|
|
311
|
+
{ id:"F-ROOM-HEAD", name:"Goal room header (title + outcome sub)", surface:"room", sel:"#task-list .room-head .room-title", status:"shipped", tier:"v1" },
|
|
312
|
+
{ id:"F-ROOM-BACK", name:"Room back-to-all-goals affordance", surface:"room", sel:"#task-list .room-back", status:"shipped", tier:"v1" },
|
|
313
|
+
{ id:"F-ROOM-TURNS", name:"Turns in order (how agents worked the goal)", surface:"room", sel:"#task-list .turns-label", status:"partial", tier:"v1" },
|
|
314
|
+
{ id:"F-ROOM-AUDIT", name:"Room audit line (steps in local chain)", surface:"room", sel:"#task-list .audit-line", status:"partial", tier:"v1" },
|
|
315
|
+
/* partial by nature: renders ONLY when the daemon reports project memory
|
|
316
|
+
OFF for the opened room (initialized projects correctly show nothing). */
|
|
317
|
+
{ id:"F-ROOM-MEM-NUDGE", name:"Project-memory init nudge (turn on memory)", surface:"room", sel:"#task-list .mem-nudge", status:"partial", tier:"v1" },
|
|
301
318
|
{ id:"M-OVERLAY-LINEAR", name:"Overlay-renderer Linear", surface:"list", sel:".or-overlay", status:"shipped", tier:"v0.9" },
|
|
302
319
|
{ id:"M-PRIMITIVES-NOTE", name:"Primitives note", surface:"list", sel:".primitives-note .prims .p", status:"shipped", tier:"v0.8" },
|
|
303
320
|
{ id:"M-RESHAPE-AGENT-COMPOSE", name:"Agent-compose animation", surface:"list", sel:".agent-compose .line", status:"shipped", tier:"v0.8" },
|
|
@@ -651,6 +668,31 @@ export const VERIFY_HTML = `<!doctype html>
|
|
|
651
668
|
});
|
|
652
669
|
});
|
|
653
670
|
}, Promise.resolve());
|
|
671
|
+
}).then(function(){
|
|
672
|
+
// room-surface features · drive the REAL openRoom() (same pattern as
|
|
673
|
+
// showD above) on the first goal-head's data-goal-cwd, observe, close.
|
|
674
|
+
// No goal-head on the home = no real tasks; the room genuinely cannot
|
|
675
|
+
// be opened, so room features go MISSING/PARTIAL-GAP per their status —
|
|
676
|
+
// honest, and F-GOAL-GROUP fails alongside so the cause is visible.
|
|
677
|
+
var room = FEATURES.filter(function(f){ return f.surface==="room" && f.status!=="roadmap" && f.status!=="future"; });
|
|
678
|
+
if (!room.length) return;
|
|
679
|
+
var w0 = frameWin();
|
|
680
|
+
var gh = frameDoc().querySelector(".goal-head[data-goal-cwd]");
|
|
681
|
+
var check = function(){
|
|
682
|
+
var d = frameDoc();
|
|
683
|
+
room.forEach(function(f){
|
|
684
|
+
var present = d.querySelectorAll(f.sel).length > 0;
|
|
685
|
+
var r = { id:f.id, present:present, verdict:covVerdict(f, present) };
|
|
686
|
+
covResults.push(r); paintCov(r);
|
|
687
|
+
});
|
|
688
|
+
};
|
|
689
|
+
if (!gh){ check(); return; }
|
|
690
|
+
try { w0.openRoom(gh.getAttribute("data-goal-cwd")); } catch(e){}
|
|
691
|
+
return sleep(200).then(function(){
|
|
692
|
+
check();
|
|
693
|
+
try { if (w0.closeRoom) w0.closeRoom(); } catch(e){}
|
|
694
|
+
return sleep(120);
|
|
695
|
+
});
|
|
654
696
|
}).then(function(){
|
|
655
697
|
// roadmap / future: SKIP (correctly absent), no observation
|
|
656
698
|
FEATURES.filter(function(f){ return f.status==="roadmap" || f.status==="future"; }).forEach(function(f){
|
|
@@ -31,7 +31,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
|
|
|
31
31
|
import { homedir } from 'os';
|
|
32
32
|
import { join } from 'path';
|
|
33
33
|
import { createHash } from 'crypto';
|
|
34
|
-
const PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
34
|
+
const PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
35
35
|
/** Match migration module's defaults. Same regex set kept in sync. */
|
|
36
36
|
const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
|
|
37
37
|
function isTempDir(cwd) {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import { homedir } from 'os';
|
|
24
|
-
const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
24
|
+
const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
25
25
|
/** List all known projects with summary stats. Cheap; no full task content. */
|
|
26
26
|
export function listProjects(options) {
|
|
27
27
|
const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
|
|
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, appendFileSync, statSync } from
|
|
|
24
24
|
import { join } from 'path';
|
|
25
25
|
import { homedir } from 'os';
|
|
26
26
|
import { generateThreadTitle } from '../../application/use-cases/generate-thread-title.js';
|
|
27
|
-
const DEFAULT_PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
|
|
27
|
+
const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
|
|
28
28
|
function scanThread(slug, file) {
|
|
29
29
|
let title = null;
|
|
30
30
|
let firstPrompt = null;
|
|
@@ -430,69 +430,81 @@ function writeTemplate(codehereDir, tmpl, force) {
|
|
|
430
430
|
writeFileSync(target, tmpl.body);
|
|
431
431
|
return 'created';
|
|
432
432
|
}
|
|
433
|
+
export function scaffoldProjectAt(root, options = {}) {
|
|
434
|
+
const codehereDir = join(root, '.codehere');
|
|
435
|
+
const empty = { created: [], skipped: [], refused: [], refusedDirs: [] };
|
|
436
|
+
const noEpisodic = { result: 'failed', sourcePath: '', targetPath: '' };
|
|
437
|
+
// Refuse if .codehere itself is a symlink — would redirect every subsequent write.
|
|
438
|
+
if (existsSync(codehereDir) && isSymlink(codehereDir)) {
|
|
439
|
+
return { ok: false, reason: 'codehere-dir-is-symlink', ...empty, episodic: noEpisodic, gitignore: { result: 'skipped' } };
|
|
440
|
+
}
|
|
441
|
+
if (!existsSync(codehereDir)) {
|
|
442
|
+
mkdirSync(codehereDir, { recursive: true });
|
|
443
|
+
}
|
|
444
|
+
else if (!statSync(codehereDir).isDirectory()) {
|
|
445
|
+
return { ok: false, reason: 'codehere-not-a-directory', ...empty, episodic: noEpisodic, gitignore: { result: 'skipped' } };
|
|
446
|
+
}
|
|
447
|
+
// F3 layout: ensure all directories exist before writing any seeds.
|
|
448
|
+
// Per-segment symlink check on EACH directory's ancestors; if .codehere/memory
|
|
449
|
+
// (or skills, or protocols, etc.) is a symlink, refuse to mkdir into it.
|
|
450
|
+
const refusedDirs = [];
|
|
451
|
+
for (const dir of F3_DIRECTORIES) {
|
|
452
|
+
const offending = findSymlinkAncestor(codehereDir, dir);
|
|
453
|
+
const fullPath = join(codehereDir, dir);
|
|
454
|
+
if (offending !== null || isSymlink(fullPath)) {
|
|
455
|
+
refusedDirs.push(dir);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
mkdirSync(fullPath, { recursive: true });
|
|
459
|
+
}
|
|
460
|
+
const created = [];
|
|
461
|
+
const skipped = [];
|
|
462
|
+
const refused = [];
|
|
463
|
+
// v0.4.1 templates first (CONTEXT/HANDOVER/PROMPTS at the top level).
|
|
464
|
+
for (const tmpl of TEMPLATES) {
|
|
465
|
+
const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
|
|
466
|
+
if (outcome === 'created')
|
|
467
|
+
created.push(tmpl.filename);
|
|
468
|
+
else if (outcome === 'skipped')
|
|
469
|
+
skipped.push(tmpl.filename);
|
|
470
|
+
else
|
|
471
|
+
refused.push(tmpl.filename);
|
|
472
|
+
}
|
|
473
|
+
// F3 v0.5 seed files (memory layers + skills + protocols).
|
|
474
|
+
for (const tmpl of F3_TEMPLATES) {
|
|
475
|
+
const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
|
|
476
|
+
if (outcome === 'created')
|
|
477
|
+
created.push(tmpl.filename);
|
|
478
|
+
else if (outcome === 'skipped')
|
|
479
|
+
skipped.push(tmpl.filename);
|
|
480
|
+
else
|
|
481
|
+
refused.push(tmpl.filename);
|
|
482
|
+
}
|
|
483
|
+
// F3 Task 3 — episodic AGENT_LEARNINGS.jsonl symlink to user's session log.
|
|
484
|
+
const episodic = ensureEpisodicSymlink(codehereDir, resolveHome());
|
|
485
|
+
const gitignore = options.noGitignore ? { result: 'skipped' } : ensureGitignore(root);
|
|
486
|
+
return { ok: true, created, skipped, refused, refusedDirs, episodic, gitignore };
|
|
487
|
+
}
|
|
433
488
|
export function executeInitCommand(options = {}) {
|
|
434
489
|
try {
|
|
435
490
|
const { root, type } = resolveTargetRoot();
|
|
436
|
-
const codehereDir = join(root, '.codehere');
|
|
437
491
|
newline();
|
|
438
492
|
console.log(colors.bold('codehere init'));
|
|
439
493
|
console.log(colors.dim(` scaffolding .codehere/ at ${type} root: ${root}`));
|
|
440
494
|
newline();
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
495
|
+
const r = scaffoldProjectAt(root, options);
|
|
496
|
+
if (!r.ok) {
|
|
497
|
+
if (r.reason === 'codehere-dir-is-symlink') {
|
|
498
|
+
console.log(` ${error('refused')} .codehere is a symlink — refusing to follow. Remove it first.`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
console.log(` ${error('refused')} .codehere exists but is not a directory.`);
|
|
502
|
+
}
|
|
444
503
|
newline();
|
|
445
504
|
process.exitCode = 1;
|
|
446
505
|
return;
|
|
447
506
|
}
|
|
448
|
-
|
|
449
|
-
mkdirSync(codehereDir, { recursive: true });
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
const codehereStat = statSync(codehereDir);
|
|
453
|
-
if (!codehereStat.isDirectory()) {
|
|
454
|
-
console.log(` ${error('refused')} .codehere exists but is not a directory.`);
|
|
455
|
-
newline();
|
|
456
|
-
process.exitCode = 1;
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
// F3 layout: ensure all directories exist before writing any seeds.
|
|
461
|
-
// Per-segment symlink check on EACH directory's ancestors; if .codehere/memory
|
|
462
|
-
// (or skills, or protocols, etc.) is a symlink, refuse to mkdir into it.
|
|
463
|
-
const refusedDirs = [];
|
|
464
|
-
for (const dir of F3_DIRECTORIES) {
|
|
465
|
-
const offending = findSymlinkAncestor(codehereDir, dir);
|
|
466
|
-
const fullPath = join(codehereDir, dir);
|
|
467
|
-
if (offending !== null || isSymlink(fullPath)) {
|
|
468
|
-
refusedDirs.push(dir);
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
mkdirSync(fullPath, { recursive: true });
|
|
472
|
-
}
|
|
473
|
-
const created = [];
|
|
474
|
-
const skipped = [];
|
|
475
|
-
const refused = [];
|
|
476
|
-
// v0.4.1 templates first (CONTEXT/HANDOVER/PROMPTS at the top level).
|
|
477
|
-
for (const tmpl of TEMPLATES) {
|
|
478
|
-
const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
|
|
479
|
-
if (outcome === 'created')
|
|
480
|
-
created.push(tmpl.filename);
|
|
481
|
-
else if (outcome === 'skipped')
|
|
482
|
-
skipped.push(tmpl.filename);
|
|
483
|
-
else
|
|
484
|
-
refused.push(tmpl.filename);
|
|
485
|
-
}
|
|
486
|
-
// F3 v0.5 seed files (memory layers + skills + protocols).
|
|
487
|
-
for (const tmpl of F3_TEMPLATES) {
|
|
488
|
-
const outcome = writeTemplate(codehereDir, tmpl, !!options.force);
|
|
489
|
-
if (outcome === 'created')
|
|
490
|
-
created.push(tmpl.filename);
|
|
491
|
-
else if (outcome === 'skipped')
|
|
492
|
-
skipped.push(tmpl.filename);
|
|
493
|
-
else
|
|
494
|
-
refused.push(tmpl.filename);
|
|
495
|
-
}
|
|
507
|
+
const { created, skipped, refused, refusedDirs } = r;
|
|
496
508
|
for (const f of created) {
|
|
497
509
|
console.log(` ${success(`created`)} .codehere/${f}`);
|
|
498
510
|
}
|
|
@@ -506,8 +518,7 @@ export function executeInitCommand(options = {}) {
|
|
|
506
518
|
console.log(` ${error(`refused`)} .codehere/${d}/ ${colors.dim('(intermediate symlink — remove it first)')}`);
|
|
507
519
|
}
|
|
508
520
|
// F3 Task 3 — episodic AGENT_LEARNINGS.jsonl symlink to user's session log.
|
|
509
|
-
const
|
|
510
|
-
const episodic = ensureEpisodicSymlink(codehereDir, home);
|
|
521
|
+
const episodic = r.episodic;
|
|
511
522
|
switch (episodic.result) {
|
|
512
523
|
case 'created':
|
|
513
524
|
console.log(` ${success('linked ')} .codehere/memory/episodic/AGENT_LEARNINGS.jsonl → ${episodic.targetPath}`);
|
|
@@ -529,7 +540,7 @@ export function executeInitCommand(options = {}) {
|
|
|
529
540
|
break;
|
|
530
541
|
}
|
|
531
542
|
if (!options.noGitignore) {
|
|
532
|
-
const gitignoreResult =
|
|
543
|
+
const gitignoreResult = r.gitignore;
|
|
533
544
|
newline();
|
|
534
545
|
switch (gitignoreResult.result) {
|
|
535
546
|
case 'updated':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|