@usenexus/cli 0.0.2 → 0.1.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 +60 -2
- package/bin/nexus.mjs +72 -3
- package/dist/assets/index-Bj0JERnz.css +1 -0
- package/dist/assets/index-DycaEuDy.js +10 -0
- package/dist/index.html +14 -0
- package/package.json +45 -7
- package/prompts/adopt.md +119 -0
- package/prompts/pitch.md +93 -0
- package/server/gh.mjs +89 -0
- package/server/hub.mjs +104 -0
- package/server/normalize.mjs +89 -0
- package/server/preflight.mjs +59 -0
- package/server/provider.mjs +218 -0
- package/server/repo.mjs +192 -0
- package/server/server.mjs +279 -0
- package/server/sh.mjs +47 -0
- package/shared/beats.mjs +190 -0
- package/shared/route.mjs +60 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The provider seam: the only module that starts a Claude Code session.
|
|
2
|
+
//
|
|
3
|
+
// Primary: @anthropic-ai/claude-agent-sdk query() on the person's own
|
|
4
|
+
// Claude Code login (no API key). Fallback: `claude -p` speaking
|
|
5
|
+
// stream-json on stdin/stdout. Both deliver the same message shapes to
|
|
6
|
+
// `onMessage`, so everything downstream (transcript, beats) is shared.
|
|
7
|
+
//
|
|
8
|
+
// Session options (SDK):
|
|
9
|
+
// cwd: repo the adopter repo is the working directory
|
|
10
|
+
// settingSources: ['project'] load the repo's commands, skills and hooks
|
|
11
|
+
// includePartialMessages stream text as it is written
|
|
12
|
+
// canUseTool AskUserQuestion -> GUI buttons -> answers;
|
|
13
|
+
// other tools -> approve / deny in the GUI
|
|
14
|
+
// Subagent traffic (parent_tool_use_id) is kept: it is what the stage draws.
|
|
15
|
+
// Stop: interrupt() first so the turn's result still arrives, close() after
|
|
16
|
+
// a 1500 ms grace.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
export const STOP_GRACE_MS = 1500;
|
|
22
|
+
export const PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'plan'];
|
|
23
|
+
|
|
24
|
+
/** An async iterable fed by push(); the session's streaming input. */
|
|
25
|
+
export function inputQueue() {
|
|
26
|
+
const items = [];
|
|
27
|
+
let wake = null;
|
|
28
|
+
let done = false;
|
|
29
|
+
return {
|
|
30
|
+
push(v) { items.push(v); if (wake) { wake(); wake = null; } },
|
|
31
|
+
end() { done = true; if (wake) { wake(); wake = null; } },
|
|
32
|
+
async *[Symbol.asyncIterator]() {
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (items.length) { yield items.shift(); continue; }
|
|
35
|
+
if (done) return;
|
|
36
|
+
await new Promise((r) => { wake = r; });
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function userMessage(text) {
|
|
43
|
+
return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadSdk() {
|
|
47
|
+
if (process.env.NEXUS_PROVIDER === 'cli') return null;
|
|
48
|
+
try {
|
|
49
|
+
return await import('@anthropic-ai/claude-agent-sdk');
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {string} o.repo
|
|
58
|
+
* @param {(msg: object) => void} o.onMessage
|
|
59
|
+
* @param {(ask: {id, agent, questions}) => Promise<Record<string,string>>} o.onAsk
|
|
60
|
+
* @param {(req: {id, agent, tool, input, suggestions}) => Promise<{allow: boolean, always?: boolean}>} o.onPermission
|
|
61
|
+
* @param {(s: object) => void} o.onStatus
|
|
62
|
+
* @param {() => string} [o.permissionMode]
|
|
63
|
+
*/
|
|
64
|
+
export async function createProvider(o) {
|
|
65
|
+
const sdk = await loadSdk();
|
|
66
|
+
return sdk ? sdkProvider(sdk, o) : cliProvider(o);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sdkProvider(sdk, { repo, onMessage, onAsk, onPermission, onStatus, permissionMode = () => 'default' }) {
|
|
70
|
+
let session = null;
|
|
71
|
+
let lastSessionId = null;
|
|
72
|
+
|
|
73
|
+
const canUseTool = async (toolName, input, opts) => {
|
|
74
|
+
const id = opts.toolUseID || randomUUID();
|
|
75
|
+
const agent = opts.agentID || 'main';
|
|
76
|
+
if (toolName === 'AskUserQuestion') {
|
|
77
|
+
const answers = await onAsk({ id, agent, questions: input.questions || [], signal: opts.signal });
|
|
78
|
+
if (!answers) return { behavior: 'deny', message: 'The person dismissed the question. Take your recommended option and keep going.' };
|
|
79
|
+
return { behavior: 'allow', updatedInput: { ...input, answers } };
|
|
80
|
+
}
|
|
81
|
+
const d = await onPermission({ id, agent, tool: toolName, input, suggestions: opts.suggestions, reason: opts.decisionReason, signal: opts.signal });
|
|
82
|
+
if (d && d.allow) return { behavior: 'allow', updatedInput: input, ...(d.always && opts.suggestions ? { updatedPermissions: opts.suggestions } : {}) };
|
|
83
|
+
return { behavior: 'deny', message: 'The person denied this tool call from the nexus GUI.' };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function start(resume) {
|
|
87
|
+
const queue = inputQueue();
|
|
88
|
+
const q = sdk.query({
|
|
89
|
+
prompt: queue,
|
|
90
|
+
options: {
|
|
91
|
+
cwd: repo,
|
|
92
|
+
settingSources: ['project'],
|
|
93
|
+
includePartialMessages: true,
|
|
94
|
+
permissionMode: permissionMode(),
|
|
95
|
+
canUseTool,
|
|
96
|
+
...(resume ? { resume } : {}),
|
|
97
|
+
env: { ...process.env, CLAUDE_AGENT_SDK_CLIENT_APP: 'nexus-cli' },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const s = { q, queue, seen: 0, ended: false, sent: [] };
|
|
101
|
+
(async () => {
|
|
102
|
+
try {
|
|
103
|
+
for await (const msg of q) {
|
|
104
|
+
s.seen += 1;
|
|
105
|
+
if (msg.session_id) lastSessionId = msg.session_id;
|
|
106
|
+
onMessage(msg);
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (resume && s.seen === 0) {
|
|
110
|
+
// The stored session is gone: start fresh rather than fail the send.
|
|
111
|
+
lastSessionId = null;
|
|
112
|
+
if (session === s) {
|
|
113
|
+
session = start(null);
|
|
114
|
+
for (const t of s.sent) { session.sent.push(t); session.queue.push(userMessage(t)); }
|
|
115
|
+
}
|
|
116
|
+
onStatus({ state: 'running', note: 'previous session could not resume; started fresh' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
onMessage({ type: 'result', is_error: true, subtype: 'error', result: String(e && e.message ? e.message : e) });
|
|
120
|
+
} finally {
|
|
121
|
+
s.ended = true;
|
|
122
|
+
if (session === s) { session = null; onStatus({ state: 'idle' }); }
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'sdk',
|
|
130
|
+
send(text) {
|
|
131
|
+
if (!session) session = start(lastSessionId);
|
|
132
|
+
session.sent.push(text);
|
|
133
|
+
session.queue.push(userMessage(text));
|
|
134
|
+
onStatus({ state: 'running', provider: 'sdk' });
|
|
135
|
+
},
|
|
136
|
+
async interrupt() {
|
|
137
|
+
if (session) await session.q.interrupt().catch(() => {});
|
|
138
|
+
},
|
|
139
|
+
async stop() {
|
|
140
|
+
const s = session;
|
|
141
|
+
if (!s) return;
|
|
142
|
+
await s.q.interrupt().catch(() => {});
|
|
143
|
+
await new Promise((r) => setTimeout(r, STOP_GRACE_MS));
|
|
144
|
+
s.queue.end();
|
|
145
|
+
try { s.q.close(); } catch {}
|
|
146
|
+
if (session === s) session = null;
|
|
147
|
+
onStatus({ state: 'idle' });
|
|
148
|
+
},
|
|
149
|
+
fresh() {
|
|
150
|
+
lastSessionId = null;
|
|
151
|
+
return this.stop();
|
|
152
|
+
},
|
|
153
|
+
get active() { return !!session; },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fallback: `claude -p --input-format stream-json --output-format stream-json`.
|
|
159
|
+
* No canUseTool here: tools outside the repo's allowlist are denied by the
|
|
160
|
+
* CLI, and AskUserQuestion cannot be answered live (questions belong in the
|
|
161
|
+
* oversight queue anyway).
|
|
162
|
+
*/
|
|
163
|
+
function cliProvider({ repo, onMessage, onStatus, permissionMode = () => 'default' }) {
|
|
164
|
+
let child = null;
|
|
165
|
+
let lastSessionId = null;
|
|
166
|
+
|
|
167
|
+
function start() {
|
|
168
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', permissionMode()];
|
|
169
|
+
if (lastSessionId) args.push('--resume', lastSessionId);
|
|
170
|
+
const c = process.platform === 'win32'
|
|
171
|
+
? spawn(['claude', ...args].join(' '), { cwd: repo, shell: true, windowsHide: true })
|
|
172
|
+
: spawn('claude', args, { cwd: repo, windowsHide: true });
|
|
173
|
+
let buf = '';
|
|
174
|
+
c.stdout.on('data', (d) => {
|
|
175
|
+
buf += d;
|
|
176
|
+
let i;
|
|
177
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
178
|
+
const line = buf.slice(0, i).trim();
|
|
179
|
+
buf = buf.slice(i + 1);
|
|
180
|
+
if (!line) continue;
|
|
181
|
+
try {
|
|
182
|
+
const msg = JSON.parse(line);
|
|
183
|
+
if (msg.session_id) lastSessionId = msg.session_id;
|
|
184
|
+
onMessage(msg);
|
|
185
|
+
} catch { /* a non-JSON line from the CLI; ignore */ }
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
c.on('error', (e) => onMessage({ type: 'result', is_error: true, subtype: 'error', result: e.code === 'ENOENT' ? 'claude is not installed or not on PATH.' : String(e.message) }));
|
|
189
|
+
c.on('close', () => { if (child === c) child = null; onStatus({ state: 'idle' }); });
|
|
190
|
+
return c;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
kind: 'cli',
|
|
195
|
+
send(text) {
|
|
196
|
+
if (!child) child = start();
|
|
197
|
+
child.stdin.write(`${JSON.stringify(userMessage(text))}\n`);
|
|
198
|
+
onStatus({ state: 'running', provider: 'cli' });
|
|
199
|
+
},
|
|
200
|
+
async interrupt() {
|
|
201
|
+
if (child) child.kill('SIGINT');
|
|
202
|
+
},
|
|
203
|
+
async stop() {
|
|
204
|
+
const c = child;
|
|
205
|
+
if (!c) return;
|
|
206
|
+
c.kill('SIGINT');
|
|
207
|
+
await new Promise((r) => setTimeout(r, STOP_GRACE_MS));
|
|
208
|
+
if (!c.killed || c.exitCode === null) c.kill();
|
|
209
|
+
try { c.stdin.end(); } catch {}
|
|
210
|
+
if (child === c) child = null;
|
|
211
|
+
},
|
|
212
|
+
fresh() {
|
|
213
|
+
lastSessionId = null;
|
|
214
|
+
return this.stop();
|
|
215
|
+
},
|
|
216
|
+
get active() { return !!child; },
|
|
217
|
+
};
|
|
218
|
+
}
|
package/server/repo.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// What the GUI reads from (and the little it writes to) the adopter repo.
|
|
2
|
+
// Reads: git, plan/**, .claude/commands, the repo's claims and progress
|
|
3
|
+
// scripts. Writes: only `.owner` (untracked, excluded) and GUI prefs kept
|
|
4
|
+
// inside .git, never a tracked file. Plan changes go through a session.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { run, json } from './sh.mjs';
|
|
8
|
+
|
|
9
|
+
export const CHUNK_ID = /^[0-9]{1,3}(?:\.[0-9a-z]+)?$/;
|
|
10
|
+
|
|
11
|
+
export async function gitPath(repo, name) {
|
|
12
|
+
const r = await run('git', ['rev-parse', '--git-path', name], { cwd: repo });
|
|
13
|
+
return r.ok ? path.resolve(repo, r.stdout.trim()) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function repoInfo(repo) {
|
|
17
|
+
const [top, branch, remote, head] = await Promise.all([
|
|
18
|
+
run('git', ['rev-parse', '--show-toplevel'], { cwd: repo }),
|
|
19
|
+
run('git', ['branch', '--show-current'], { cwd: repo }),
|
|
20
|
+
run('git', ['remote', 'get-url', 'origin'], { cwd: repo }),
|
|
21
|
+
run('git', ['log', '-1', '--oneline'], { cwd: repo }),
|
|
22
|
+
]);
|
|
23
|
+
return {
|
|
24
|
+
repo,
|
|
25
|
+
isGit: top.ok,
|
|
26
|
+
name: path.basename(top.ok ? top.stdout.trim() : repo),
|
|
27
|
+
branch: branch.stdout.trim() || null,
|
|
28
|
+
remote: remote.ok ? remote.stdout.trim() : null,
|
|
29
|
+
lastCommit: head.ok ? head.stdout.trim() : null,
|
|
30
|
+
owner: isOwner(repo),
|
|
31
|
+
adopted: isAdopted(repo),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isOwner(repo) {
|
|
36
|
+
return fs.existsSync(path.join(repo, '.owner'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A repo has adopted nexus when it carries the rule book and the plan. */
|
|
40
|
+
export function isAdopted(repo) {
|
|
41
|
+
const agents = path.join(repo, 'agents.md');
|
|
42
|
+
return fs.existsSync(agents) && fs.existsSync(path.join(repo, 'plan')) && fs.existsSync(path.join(repo, '.claude', 'commands'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Owner mode on: write `.owner` and keep it out of git via info/exclude. */
|
|
46
|
+
export async function setOwner(repo, on) {
|
|
47
|
+
const file = path.join(repo, '.owner');
|
|
48
|
+
if (!on) {
|
|
49
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
fs.writeFileSync(file, 'owner: push to main and settle (set from the nexus GUI)\n');
|
|
53
|
+
const exclude = await gitPath(repo, 'info/exclude');
|
|
54
|
+
if (exclude) {
|
|
55
|
+
const cur = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : '';
|
|
56
|
+
if (!/^\/?\.owner$/m.test(cur)) {
|
|
57
|
+
fs.mkdirSync(path.dirname(exclude), { recursive: true });
|
|
58
|
+
fs.appendFileSync(exclude, `${cur && !cur.endsWith('\n') ? '\n' : ''}.owner\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_PREFS = { agents: true, workflows: false, permissionMode: 'default', onboarded: false };
|
|
65
|
+
|
|
66
|
+
export async function readPrefs(repo) {
|
|
67
|
+
const file = await gitPath(repo, 'nexus-gui.json');
|
|
68
|
+
const saved = file && fs.existsSync(file) ? json(fs.readFileSync(file, 'utf8')) : null;
|
|
69
|
+
return { ...DEFAULT_PREFS, ...(saved || {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function writePrefs(repo, patch) {
|
|
73
|
+
const next = { ...(await readPrefs(repo)) };
|
|
74
|
+
for (const k of Object.keys(DEFAULT_PREFS)) if (k in patch && typeof patch[k] === typeof DEFAULT_PREFS[k]) next[k] = patch[k];
|
|
75
|
+
const file = await gitPath(repo, 'nexus-gui.json');
|
|
76
|
+
if (file) fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const BUILTIN = [
|
|
81
|
+
{ name: 'explode', hint: 'dump messy thoughts; runs /messy' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** Slash commands the session will know: the repo's .claude/commands plus GUI aliases. */
|
|
85
|
+
export function listCommands(repo) {
|
|
86
|
+
const dir = path.join(repo, '.claude', 'commands');
|
|
87
|
+
const out = [];
|
|
88
|
+
if (fs.existsSync(dir)) {
|
|
89
|
+
for (const f of fs.readdirSync(dir)) {
|
|
90
|
+
if (!f.endsWith('.md')) continue;
|
|
91
|
+
const text = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
92
|
+
const desc = /^description:\s*(.+)$/m.exec(text);
|
|
93
|
+
out.push({ name: f.slice(0, -3), hint: desc ? desc[1].trim().replace(/^["']|["']$/g, '') : '' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const b of BUILTIN) if (!out.some((c) => c.name === b.name)) out.push(b);
|
|
97
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** First existing script among candidates, relative to the repo. */
|
|
101
|
+
export function findScript(repo, names) {
|
|
102
|
+
for (const n of names) if (fs.existsSync(path.join(repo, n))) return n;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const CLAIMS_SCRIPTS = ['scripts/claims.mjs', 'scripts/dev/claims.mjs', 'scripts/plan/claims.mjs', 'scripts/git/claims.mjs'];
|
|
107
|
+
export const PROGRESS_SCRIPTS = ['scripts/progress.mjs', 'scripts/dev/progress.mjs', 'scripts/plan/progress.mjs'];
|
|
108
|
+
|
|
109
|
+
export async function claims(repo) {
|
|
110
|
+
const script = findScript(repo, CLAIMS_SCRIPTS);
|
|
111
|
+
if (!script) return { available: false, rows: [], note: 'No claims module in this repo yet.' };
|
|
112
|
+
const r = await run('node', [script, 'list', '--json'], { cwd: repo, timeout: 30000 });
|
|
113
|
+
const rows = json(r.stdout);
|
|
114
|
+
if (!r.ok || !Array.isArray(rows)) return { available: true, rows: [], error: (r.stderr || r.stdout).trim().slice(0, 400) };
|
|
115
|
+
return { available: true, rows, script };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function releaseClaim(repo, id) {
|
|
119
|
+
if (!CHUNK_ID.test(id)) return { ok: false, error: 'bad chunk id' };
|
|
120
|
+
if (!isOwner(repo)) return { ok: false, error: 'Releasing a claim needs owner mode.' };
|
|
121
|
+
const script = findScript(repo, CLAIMS_SCRIPTS);
|
|
122
|
+
if (!script) return { ok: false, error: 'no claims module' };
|
|
123
|
+
const r = await run('node', [script, 'release', id, '--force'], { cwd: repo, timeout: 60000 });
|
|
124
|
+
return { ok: r.ok, out: (r.stdout + r.stderr).trim() };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function progress(repo, spec) {
|
|
128
|
+
const script = findScript(repo, PROGRESS_SCRIPTS);
|
|
129
|
+
if (!script) return { available: false, text: '' };
|
|
130
|
+
const args = [script];
|
|
131
|
+
if (spec && /^[0-9]{1,3}$/.test(spec)) args.push(spec);
|
|
132
|
+
const r = await run('node', args, { cwd: repo, timeout: 30000 });
|
|
133
|
+
return { available: true, text: (r.stdout || r.stderr).replace(/\x1b\[[0-9;]*m/g, '') };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- durable questions -------------------------------------------------------
|
|
137
|
+
// Questions that wait in files: `[needs-user-call]` rows anywhere in plan/,
|
|
138
|
+
// and list items under an "Open questions" heading in a spec.
|
|
139
|
+
|
|
140
|
+
export function durableQuestions(repo) {
|
|
141
|
+
const root = path.join(repo, 'plan');
|
|
142
|
+
const out = [];
|
|
143
|
+
if (!fs.existsSync(root)) return out;
|
|
144
|
+
for (const file of walk(root)) {
|
|
145
|
+
if (!file.endsWith('.md')) continue;
|
|
146
|
+
const rel = path.relative(repo, file).split(path.sep).join('/');
|
|
147
|
+
if (rel.startsWith('plan/messy/')) continue;
|
|
148
|
+
out.push(...questionsIn(fs.readFileSync(file, 'utf8'), rel));
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function questionsIn(text, rel) {
|
|
154
|
+
const out = [];
|
|
155
|
+
const lines = text.split(/\r?\n/);
|
|
156
|
+
let inOpen = false;
|
|
157
|
+
lines.forEach((line, i) => {
|
|
158
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
159
|
+
if (h) { inOpen = /\bopen questions?\b/i.test(h[2]); return; }
|
|
160
|
+
const item = /^\s*[-*]\s+(?:\[ \]\s+)?(.*\S)\s*$/.exec(line);
|
|
161
|
+
// a row (list item or table row) carrying the flag, not a mention of it in prose or code
|
|
162
|
+
const flagged = /^\s*(?:[-*]|\d+\.|\|)\s/.test(line) && /(^|[^`])\[needs-user(?:-call)?\]/i.test(line);
|
|
163
|
+
if (flagged || (inOpen && item)) {
|
|
164
|
+
const body = (item ? item[1] : line).replace(/\[needs-user(?:-call)?\]\s*/i, '').trim();
|
|
165
|
+
if (body && !/^\[x\]/i.test(body) && !/^(none|n\/a)\.?$/i.test(body)) out.push({ id: `${rel}:${i + 1}`, file: rel, line: i + 1, text: body.slice(0, 600), source: flagged ? 'needs-user-call' : 'open-question' });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function* walk(dir) {
|
|
172
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
173
|
+
const p = path.join(dir, e.name);
|
|
174
|
+
if (e.isDirectory()) yield* walk(p);
|
|
175
|
+
else yield p;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The prompt that applies durable answers through /oversight and lands per owner mode. */
|
|
180
|
+
export function answersPrompt(answers, owner) {
|
|
181
|
+
const lines = answers.map((a) => `- ${a.file}:${a.line} -- "${a.text.replace(/\s+/g, ' ').slice(0, 200)}"\n answer: ${a.answer.replace(/\s+/g, ' ')}`);
|
|
182
|
+
const landing = owner
|
|
183
|
+
? 'This checkout is in owner mode: gate, commit, and push to main.'
|
|
184
|
+
: 'This checkout is in contributor mode: gate, commit on a new branch next/oversight-<date>, push it, and open a pull request with gh. Never push main.';
|
|
185
|
+
return [
|
|
186
|
+
'/oversight apply these answers from the nexus GUI. Do not ask again; the person already answered.',
|
|
187
|
+
'For each row: record the answer where the question lives (resolve the [needs-user-call] row or the open question), and act on it as /oversight would.',
|
|
188
|
+
landing,
|
|
189
|
+
'',
|
|
190
|
+
...lines,
|
|
191
|
+
].join('\n');
|
|
192
|
+
}
|