atris 3.34.0 → 3.35.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.
Files changed (53) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. package/atris/learnings.jsonl +0 -1
@@ -0,0 +1,44 @@
1
+ const path = require('path');
2
+ const { spawnSync } = require('child_process');
3
+ const {
4
+ isGitCheckout,
5
+ getNpmSelfUpdateCommand,
6
+ getNpmSelfUpdateSpawnArgs,
7
+ } = require('../utils/update-check');
8
+
9
+ const DEFAULT_PACKAGE_ROOT = path.join(__dirname, '..');
10
+
11
+ function updateSelf(options = {}) {
12
+ const packageRoot = options.packageRoot || DEFAULT_PACKAGE_ROOT;
13
+ const spawnImpl = options.spawnSync || spawnSync;
14
+ const log = options.log || console.log;
15
+ const errorLog = options.errorLog || console.error;
16
+
17
+ if (isGitCheckout(packageRoot)) {
18
+ errorLog('this atris install is a git checkout; use git to update the cli, not atris update --self.');
19
+ return { ok: false, reason: 'git-checkout' };
20
+ }
21
+
22
+ const { command, args } = getNpmSelfUpdateSpawnArgs();
23
+ log('installing latest atris from npm...');
24
+
25
+ const result = spawnImpl(command, args, {
26
+ stdio: options.stdio || 'inherit',
27
+ shell: options.shell !== undefined ? options.shell : true,
28
+ });
29
+
30
+ if (result.status === 0) {
31
+ log('atris updated successfully.');
32
+ log('run `atris update` in your projects to sync local files.');
33
+ return { ok: true };
34
+ }
35
+
36
+ errorLog('update failed. try running manually:');
37
+ errorLog(` ${getNpmSelfUpdateCommand()}`);
38
+ return { ok: false, reason: 'install-failed', status: result.status };
39
+ }
40
+
41
+ module.exports = {
42
+ updateSelf,
43
+ DEFAULT_PACKAGE_ROOT,
44
+ };
@@ -0,0 +1,38 @@
1
+ const { createOfficialCliCommand } = require('../lib/official-cli-integration');
2
+
3
+ const vercelCommand = createOfficialCliCommand({
4
+ name: 'vercel',
5
+ binary: 'vercel',
6
+ versionArgs: ['--version'],
7
+ authArgs: ['whoami'],
8
+ installHint: 'https://vercel.com/docs/cli',
9
+ loginHint: 'vercel login',
10
+ commands: [
11
+ {
12
+ usage: 'deploy',
13
+ match: ['deploy'],
14
+ forward: ['deploy'],
15
+ description: 'deploy the current project',
16
+ },
17
+ {
18
+ usage: 'ls',
19
+ match: ['ls'],
20
+ forward: ['ls'],
21
+ description: 'list deployments',
22
+ },
23
+ {
24
+ usage: 'logs',
25
+ match: ['logs'],
26
+ forward: ['logs'],
27
+ description: 'stream or inspect deployment logs',
28
+ },
29
+ {
30
+ usage: 'inspect',
31
+ match: ['inspect'],
32
+ forward: ['inspect'],
33
+ description: 'inspect a deployment',
34
+ },
35
+ ],
36
+ });
37
+
38
+ module.exports = { vercelCommand };
@@ -215,17 +215,30 @@ function printStatus() {
215
215
  // Programmatic core shared by `atris worktree start` and `atris mission start
216
216
  // --worktree`: creates the branch + isolated checkout + identity sidecar and
217
217
  // returns the facts. Throws on failure; callers own messaging and next steps.
218
+ //
219
+ // checkoutBase (what the branch is cut from) and shipBase (what `atris
220
+ // worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
221
+ // separate on purpose. checkoutBase defaults to the launcher's own
222
+ // upstream/default remote base, so the agent starts from current work.
223
+ // shipBase defaults to origin/master for agent/member worktrees regardless of
224
+ // what branch the launcher happened to have checked out. recording the
225
+ // launcher's own feature branch as the ship target false-landed two PRs on
226
+ // 2026-07-04 (ship merged into the launcher's branch, never reached master).
227
+ // --base/--target still overrides both when the caller wants a different
228
+ // checkout point and ship target on purpose.
218
229
  function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
219
230
  const owner = member || agent;
220
231
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
221
232
  const branch = branchOverride || branchName(owner, task, now);
222
233
  const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
223
- const base = normalizeTargetRef(root, baseOverride || defaultStartBase(root));
234
+ const explicitBase = Boolean(baseOverride);
235
+ const checkoutBase = normalizeTargetRef(root, baseOverride || defaultStartBase(root));
236
+ const shipBase = explicitBase ? checkoutBase : normalizeTargetRef(root, 'origin/master');
224
237
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
225
238
  fs.mkdirSync(path.dirname(target), { recursive: true });
226
- refreshRemoteRef(root, base);
227
- runGit(['worktree', 'add', '-b', branch, target, base], { cwd: root });
228
- runGit(['config', `branch.${branch}.atris-base`, base], { cwd: target, check: false });
239
+ refreshRemoteRef(root, checkoutBase);
240
+ runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root });
241
+ runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target, check: false });
229
242
  runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target, check: false });
230
243
  runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target, check: false });
231
244
  fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
@@ -237,13 +250,14 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
237
250
  owner,
238
251
  task,
239
252
  branch,
240
- base,
253
+ base: shipBase,
254
+ checkout_base: checkoutBase,
241
255
  workspace_root: root,
242
256
  created_at: now.toISOString(),
243
257
  }, null, 2) + '\n',
244
258
  'utf8'
245
259
  );
246
- return { path: target, branch, base, owner };
260
+ return { path: target, branch, base: shipBase, checkoutBase, owner };
247
261
  }
248
262
 
249
263
  function startWorktree(args) {
@@ -298,12 +312,21 @@ function startWorktree(args) {
298
312
 
299
313
  function createOrFindPr(root, branch, targetRef, title, dryRun) {
300
314
  const targetBranch = baseBranchName(targetRef);
301
- const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url', '--jq', '"\\(.number) \\(.url)"'], {
315
+ // `gh pr view` (no --state filter) returns ANY PR for this branch name,
316
+ // including one already MERGED or CLOSED. A worktree branch that ships
317
+ // more than once (this same branch, more commits, another `worktree ship`)
318
+ // would then reuse the dead PR number: `gh pr merge <dead pr> --merge`
319
+ // exits 0 with just a warning (never fails), so the new commits silently
320
+ // never land while the ship command still reports success. Only reuse a
321
+ // PR that is still OPEN; anything else gets a fresh one.
322
+ const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
302
323
  cwd: root,
303
324
  encoding: 'utf8',
304
325
  });
305
326
  if (existing.status === 0 && existing.stdout.trim()) {
306
- return existing.stdout.trim();
327
+ const parts = existing.stdout.trim().split(/\s+/);
328
+ const state = parts.pop();
329
+ if (state === 'OPEN') return parts.join(' ');
307
330
  }
308
331
  const body = [
309
332
  'Automated Atris worktree ship.',
@@ -329,7 +352,17 @@ function prMergeRef(prOutput) {
329
352
  return text.split(/\s+/)[0];
330
353
  }
331
354
 
355
+ function shipHelp() {
356
+ console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>]');
357
+ console.log('');
358
+ console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
359
+ }
360
+
332
361
  function shipWorktree(args) {
362
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
363
+ shipHelp();
364
+ return 0;
365
+ }
333
366
  const root = repoRoot();
334
367
  const dryRun = hasFlag(args, '--dry-run');
335
368
  const noPr = hasFlag(args, '--no-pr');
@@ -409,6 +442,11 @@ function shipWorktree(args) {
409
442
  mergeArgs.push('--merge');
410
443
  const merged = spawnSync('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
411
444
  if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
445
+ // `gh pr merge` on an already-merged PR exits 0 with a warning
446
+ // instead of failing, which would otherwise report a false landing.
447
+ if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
448
+ throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
449
+ }
412
450
  console.log('merge: merged');
413
451
  const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
414
452
  if (deleted.status === 0) {
@@ -456,6 +494,19 @@ function prune(args) {
456
494
 
457
495
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
458
496
 
497
+ // A worktree fresh off `worktree start` is merged into base by definition
498
+ // (zero commits yet), so without a grace window the janitor reaps it while
499
+ // the engine that requested it is still booting inside.
500
+ const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
501
+
502
+ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
503
+ try {
504
+ return now - fs.statSync(worktreePath).mtimeMs < WORKTREE_REAP_GRACE_MS;
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+
459
510
  function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false } = {}) {
460
511
  const worktrees = listWorktrees(root);
461
512
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
@@ -503,6 +554,10 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
503
554
  kept.push({ ...item, reason: 'unmerged' });
504
555
  continue;
505
556
  }
557
+ if (worktreeWithinReapGrace(wtPath)) {
558
+ kept.push({ ...item, reason: 'fresh_worktree_grace' });
559
+ continue;
560
+ }
506
561
  const candidate = { ...item, reason: 'merged_into_base' };
507
562
  candidates.push(candidate);
508
563
  if (!apply) continue;
@@ -560,7 +615,8 @@ function guide() {
560
615
  console.log(' atris mission tick <id> --verify --complete-on-pass');
561
616
  console.log('');
562
617
  console.log('4. Ship only from the isolated worktree:');
563
- console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge');
618
+ console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge [--target <ref>]');
619
+ console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
564
620
  console.log('');
565
621
  console.log('5. Clean merged worktrees:');
566
622
  console.log(' atris worktree cleanup');
@@ -576,7 +632,8 @@ function help() {
576
632
  console.log('');
577
633
  console.log(' atris worktree guide');
578
634
  console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim]');
579
- console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge]');
635
+ console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>]');
636
+ console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
580
637
  console.log(' atris worktree status');
581
638
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
582
639
  console.log(' atris worktree prune [--apply]');
@@ -607,6 +664,7 @@ function worktreeCommand(args = []) {
607
664
  module.exports = {
608
665
  branchName,
609
666
  createAgentWorktree,
667
+ createOrFindPr,
610
668
  cleanupWorktrees,
611
669
  defaultShipTarget,
612
670
  defaultStartBase,
@@ -0,0 +1,399 @@
1
+ // atris write — guided writing sessions (plan-do-review for prose).
2
+ //
3
+ // The contract: the human types every word of the draft. Atris structures the
4
+ // session (outline beats with states), tracks progress against the plan, and
5
+ // reviews against the taste gate (writing policy + slop detector). It NEVER
6
+ // writes or rewrites prose — review output is suggestions, not edits.
7
+ //
8
+ // Session = a folder of plain markdown, so the same files open in the web and
9
+ // desktop editors with no extra format:
10
+ // atris/writing/<slug>/plan.md outline beats: [ ] empty, [~] drafted, [x] passed
11
+ // atris/writing/<slug>/draft.md the piece — human-only territory
12
+ //
13
+ // Usage:
14
+ // atris write start "<topic>" [--dump "raw ideas"] [--beats "a | b | c"]
15
+ // atris write status [slug] progress: which beats are landed
16
+ // atris write review [slug] taste gate: policy checklist + slop scan (read-only)
17
+ // atris write pass <n> [slug] mark beat n passed (human calls it, not the AI)
18
+ // atris write list list sessions
19
+ //
20
+ // Zero external deps (Node built-ins only) — repo contract.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { spawnSync } = require('child_process');
25
+ const { scanFile, RULES, loadProjectRules } = require('./slop');
26
+
27
+ const WRITING_DIR = path.join('atris', 'writing');
28
+ // Beats are question-shaped (Pollan: "almost everything I write is a quest to
29
+ // answer a question"). The piece has suspense built in structurally: each beat
30
+ // exists to answer its question, and the reader wonders if you will.
31
+ const DEFAULT_BEATS = [
32
+ { title: 'Hook', q: 'What is the moment this became a question for you? Start naive: wonder on page one, never lecture.' },
33
+ { title: 'Thesis', q: 'What is the question this piece exists to answer? Pose it so the reader has to know.' },
34
+ { title: 'Evidence', q: 'What did you actually see happen? The specific thing, not the category. Spread facts thin so the story never sags.' },
35
+ { title: 'Counterpunch', q: 'What would a smart skeptic say back? Steelman it, then answer in your own words.' },
36
+ { title: 'Landing', q: 'Did you answer the question you posed? Say what the reader should do or believe now, and stop.' },
37
+ ];
38
+ const STATE_ICON = { ' ': '·', '~': '◐', x: '●' };
39
+ const STATE_WORD = { ' ': 'empty', '~': 'drafted', x: 'passed' };
40
+
41
+ function slugify(topic) {
42
+ return topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'untitled';
43
+ }
44
+
45
+ function sessionDir(slug, root = process.cwd()) {
46
+ return path.join(root, WRITING_DIR, slug);
47
+ }
48
+
49
+ function listSessions(root = process.cwd()) {
50
+ const dir = path.join(root, WRITING_DIR);
51
+ let names;
52
+ try { names = fs.readdirSync(dir); } catch { return []; }
53
+ return names
54
+ .filter((n) => fs.existsSync(path.join(dir, n, 'plan.md')))
55
+ .map((n) => ({ slug: n, mtime: fs.statSync(path.join(dir, n, 'plan.md')).mtimeMs }))
56
+ .sort((a, b) => b.mtime - a.mtime);
57
+ }
58
+
59
+ function resolveSlug(maybeSlug, root = process.cwd()) {
60
+ if (maybeSlug && fs.existsSync(path.join(sessionDir(maybeSlug, root), 'plan.md'))) return maybeSlug;
61
+ if (maybeSlug) return null;
62
+ const sessions = listSessions(root);
63
+ return sessions.length ? sessions[0].slug : null;
64
+ }
65
+
66
+ // Parse plan.md → { topic, beats: [{ n, state, title, q }] }
67
+ // A beat line may be followed by an indented `? question` line: the question that beat answers.
68
+ function readPlan(slug, root = process.cwd()) {
69
+ const text = fs.readFileSync(path.join(sessionDir(slug, root), 'plan.md'), 'utf8');
70
+ const topic = (text.match(/^# plan: (.+)$/m) || [, slug])[1];
71
+ const beats = [];
72
+ const lines = text.split('\n');
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const m = lines[i].match(/^- \[([ ~x])\] (\d+)\. (.+)$/);
75
+ if (!m) continue;
76
+ const qm = (lines[i + 1] || '').match(/^\s+\? (.+)$/);
77
+ beats.push({ state: m[1], n: +m[2], title: m[3].trim(), q: qm ? qm[1].trim() : null });
78
+ }
79
+ return { topic, beats, text };
80
+ }
81
+
82
+ function writePlanStates(slug, beats, root = process.cwd()) {
83
+ const file = path.join(sessionDir(slug, root), 'plan.md');
84
+ let text = fs.readFileSync(file, 'utf8');
85
+ for (const b of beats) {
86
+ text = text.replace(new RegExp(`^- \\[[ ~x]\\] ${b.n}\\. `, 'm'), `- [${b.state}] ${b.n}. `);
87
+ }
88
+ fs.writeFileSync(file, text);
89
+ }
90
+
91
+ // Words the human has typed under each `## beat` heading in draft.md.
92
+ function draftWordCounts(slug, beats, root = process.cwd()) {
93
+ let text = '';
94
+ try { text = fs.readFileSync(path.join(sessionDir(slug, root), 'draft.md'), 'utf8'); } catch {}
95
+ const lines = text.split('\n');
96
+ const counts = new Map(beats.map((b) => [b.n, 0]));
97
+ let current = null;
98
+ for (const line of lines) {
99
+ const h = line.match(/^## (.+)$/);
100
+ if (h) {
101
+ const beat = beats.find((b) => h[1].trim().toLowerCase() === b.title.toLowerCase());
102
+ current = beat ? beat.n : null;
103
+ continue;
104
+ }
105
+ if (current !== null) {
106
+ const words = line.trim().split(/\s+/).filter(Boolean).length;
107
+ counts.set(current, counts.get(current) + words);
108
+ }
109
+ }
110
+ return counts;
111
+ }
112
+
113
+ // Sync beat states from the draft: empty→drafted when words land. Never downgrades passed.
114
+ function syncStates(slug, root = process.cwd()) {
115
+ const plan = readPlan(slug, root);
116
+ const counts = draftWordCounts(slug, plan.beats, root);
117
+ let changed = false;
118
+ for (const b of plan.beats) {
119
+ const words = counts.get(b.n) || 0;
120
+ if (b.state === ' ' && words > 0) { b.state = '~'; changed = true; }
121
+ if (b.state === '~' && words === 0) { b.state = ' '; changed = true; }
122
+ }
123
+ if (changed) writePlanStates(slug, plan.beats, root);
124
+ return { ...plan, counts };
125
+ }
126
+
127
+ function start(argv, root = process.cwd()) {
128
+ const topic = argv.find((a) => !a.startsWith('-'));
129
+ if (!topic) { console.error(' usage: atris write start "<topic>" [--dump "..."] [--beats "a | b | c"]'); return 2; }
130
+ const flag = (name) => { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; };
131
+ const dump = flag('--dump');
132
+ const beatsArg = flag('--beats');
133
+ const beats = beatsArg
134
+ ? beatsArg.split('|').map((s) => s.trim()).filter(Boolean).map((t) => ({ title: t, q: GENERIC_QUESTION(t) }))
135
+ : DEFAULT_BEATS;
136
+
137
+ const slug = slugify(topic);
138
+ const dir = sessionDir(slug, root);
139
+ if (fs.existsSync(path.join(dir, 'plan.md'))) { console.error(` session already exists: ${slug} (atris write status ${slug})`); return 2; }
140
+ fs.mkdirSync(dir, { recursive: true });
141
+
142
+ const dumpLines = dump ? dump.split(/\n|(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean).map((s) => `- ${s}`) : ['- (dump raw ideas here)'];
143
+ const plan = [
144
+ `# plan: ${topic}`, '',
145
+ '## Dump', ...dumpLines, '',
146
+ '## Outline',
147
+ ...beats.flatMap((b, i) => [`- [ ] ${i + 1}. ${b.title}`, ` ? ${b.q}`]), '',
148
+ '## Gate',
149
+ '- review: atris write review runs the taste checklist + slop scan',
150
+ '- pass: you call `atris write pass <n>` when a beat holds up. The AI never calls it.',
151
+ '- inflow: once drafting starts, no new research. Work with what you have.', '',
152
+ ].join('\n');
153
+ const draft = [`# ${topic}`, '', ...beats.flatMap((b) => [`## ${b.title}`, '']), ''].join('\n');
154
+
155
+ fs.writeFileSync(path.join(dir, 'plan.md'), plan);
156
+ fs.writeFileSync(path.join(dir, 'draft.md'), draft);
157
+
158
+ console.log(`\n ✓ session started: ${slug}`);
159
+ console.log(` plan: ${path.relative(root, path.join(dir, 'plan.md'))}`);
160
+ console.log(` draft: ${path.relative(root, path.join(dir, 'draft.md'))} ← you write here, every word`);
161
+ console.log(`\n beats: ${beats.map((b) => b.title).join(' → ')}`);
162
+ console.log(` first question: ${beats[0].q}`);
163
+ console.log(` next: open the draft, answer under "## ${beats[0].title}", then \`atris write\`\n`);
164
+ return 0;
165
+ }
166
+
167
+ function status(argv, root = process.cwd()) {
168
+ const slug = resolveSlug(argv.find((a) => !a.startsWith('-')), root);
169
+ if (!slug) { console.error(' no writing session found. start one: atris write start "<topic>"'); return 2; }
170
+ const { topic, beats, counts } = syncStates(slug, root);
171
+ const done = beats.filter((b) => b.state === 'x').length;
172
+ const drafted = beats.filter((b) => b.state === '~').length;
173
+ const totalWords = [...counts.values()].reduce((a, b) => a + b, 0);
174
+
175
+ console.log(`\n ${topic} (${slug})`);
176
+ console.log(` ${beats.map((b) => STATE_ICON[b.state]).join(' ')} ${done} passed · ${drafted} drafted · ${totalWords} words\n`);
177
+ for (const b of beats) {
178
+ const words = counts.get(b.n) || 0;
179
+ console.log(` ${STATE_ICON[b.state]} ${String(b.n).padStart(2)}. ${b.title.padEnd(24)} ${STATE_WORD[b.state].padEnd(8)} ${words ? `${words} words` : ''}`);
180
+ }
181
+ const next = beats.find((b) => b.state === ' ') || beats.find((b) => b.state === '~');
182
+ if (next) console.log(`\n next: ${next.state === ' ' ? `answer "${beatQuestion(next)}"` : `review "${next.title}", then \`atris write pass ${next.n}\` if it holds`}\n`);
183
+ else console.log(`\n all beats passed. run \`atris write review\` for the final gate.\n`);
184
+ return 0;
185
+ }
186
+
187
+ function review(argv, root = process.cwd()) {
188
+ const json = argv.includes('--json');
189
+ const slug = resolveSlug(argv.find((a) => !a.startsWith('-')), root);
190
+ if (!slug) { console.error(' no writing session found. start one: atris write start "<topic>"'); return 2; }
191
+ const { topic, beats, counts } = syncStates(slug, root);
192
+ const draftPath = path.join(sessionDir(slug, root), 'draft.md');
193
+ const findings = scanFile(draftPath, RULES.concat(loadProjectRules(root)));
194
+ const empty = beats.filter((b) => (counts.get(b.n) || 0) === 0);
195
+
196
+ if (json) {
197
+ console.log(JSON.stringify({
198
+ slug, topic, ok: findings.length === 0 && empty.length === 0,
199
+ beats: beats.map((b) => ({ n: b.n, title: b.title, state: STATE_WORD[b.state], words: counts.get(b.n) || 0 })),
200
+ slop: findings.map((f) => ({ line: f.line, rule: f.rule, why: f.why, snippet: f.snippet })),
201
+ }, null, 2));
202
+ return findings.length || empty.length ? 1 : 0;
203
+ }
204
+
205
+ console.log(`\n review: ${topic}\n`);
206
+ if (empty.length) {
207
+ console.log(` ⚠ ${empty.length} beat${empty.length === 1 ? '' : 's'} still empty: ${empty.map((b) => b.title).join(', ')}`);
208
+ }
209
+ if (findings.length) {
210
+ console.log(`\n slop tells (fix them yourself — atris never edits your draft):`);
211
+ for (const f of findings) console.log(` ✗ draft.md:${f.line} ${f.rule.padEnd(16)} ${f.why}`);
212
+ } else {
213
+ console.log(` ✓ slop scan clean`);
214
+ }
215
+ console.log(`\n human passes (atris/policies/writing.md):`);
216
+ console.log(` - read it out loud: mark stumbles, boredom, and the parts you speed up`);
217
+ console.log(` - voice check: would someone recognize this as yours, blind?`);
218
+ console.log(` - one-sentence test: can you state the core insight in one line?`);
219
+ console.log(` - would you send it to someone you respect?`);
220
+ console.log(`\n when a beat holds up: atris write pass <n>\n`);
221
+ return findings.length || empty.length ? 1 : 0;
222
+ }
223
+
224
+ function pass(argv, root = process.cwd()) {
225
+ const n = parseInt(argv[0], 10);
226
+ if (!n) { console.error(' usage: atris write pass <beat-number> [slug]'); return 2; }
227
+ const slug = resolveSlug(argv[1], root);
228
+ if (!slug) { console.error(' no writing session found.'); return 2; }
229
+ const plan = readPlan(slug, root);
230
+ const beat = plan.beats.find((b) => b.n === n);
231
+ if (!beat) { console.error(` no beat ${n} in ${slug}`); return 2; }
232
+ const counts = draftWordCounts(slug, plan.beats, root);
233
+ if ((counts.get(n) || 0) === 0) { console.error(` beat ${n} "${beat.title}" has no words yet — write it first`); return 2; }
234
+ beat.state = 'x';
235
+ writePlanStates(slug, plan.beats, root);
236
+ console.log(` ● passed: ${n}. ${beat.title}`);
237
+ return 0;
238
+ }
239
+
240
+ function list(root = process.cwd()) {
241
+ const sessions = listSessions(root);
242
+ if (!sessions.length) { console.log('\n no writing sessions yet. start one: atris write start "<topic>"\n'); return 0; }
243
+ console.log('');
244
+ for (const s of sessions) {
245
+ const { topic, beats } = readPlan(s.slug, root);
246
+ console.log(` ${beats.map((b) => STATE_ICON[b.state]).join('')} ${s.slug.padEnd(32)} ${topic}`);
247
+ }
248
+ console.log('');
249
+ return 0;
250
+ }
251
+
252
+ // ---- coach: intelligent, proactive, gets the writer going. Never writes prose. ----
253
+
254
+ const GENERIC_QUESTION = (title) => `What question does "${title}" answer for the reader? Answer it in your own words.`;
255
+
256
+ function beatQuestion(beat) {
257
+ return beat.q || GENERIC_QUESTION(beat.title);
258
+ }
259
+
260
+ // Pollan: re-edit the whole piece before writing anything new. It primes the pump
261
+ // and loads the draft back into short-term memory. Fires when you return to a
262
+ // session on a later day.
263
+ function isReturningSession(slug, root) {
264
+ try {
265
+ const m = fs.statSync(path.join(sessionDir(slug, root), 'draft.md')).mtime;
266
+ return m.toDateString() !== new Date().toDateString();
267
+ } catch { return false; }
268
+ }
269
+
270
+ function dumpSeeds(planText) {
271
+ const m = planText.match(/## Dump\n([\s\S]*?)(\n## |$)/);
272
+ if (!m) return [];
273
+ return m[1].split('\n').map((l) => l.replace(/^- /, '').trim()).filter((l) => l && !l.startsWith('(dump'));
274
+ }
275
+
276
+ function coachLogPath(slug, root) { return path.join(sessionDir(slug, root), 'coach.md'); }
277
+
278
+ function appendCoachNote(slug, note, root = process.cwd()) {
279
+ const file = coachLogPath(slug, root);
280
+ const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
281
+ const header = fs.existsSync(file) ? '' : `# coach log\n\nStyle lessons and session notes. This is the training data for your voice.\n`;
282
+ fs.appendFileSync(file, `${header}\n## ${stamp}\n${note.trim()}\n`);
283
+ }
284
+
285
+ function claudeAvailable() {
286
+ try { return spawnSync('claude', ['--version'], { timeout: 8000 }).status === 0; } catch { return false; }
287
+ }
288
+
289
+ function coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat }) {
290
+ return [
291
+ 'You are a writing coach inside atris write. HARD RULES:',
292
+ '- You NEVER write, rewrite, or suggest replacement prose for the draft. Not one sentence.',
293
+ '- You coach: point at what the writer did well IN THEIR OWN WORDS, ask one question, teach one style lesson.',
294
+ '- Be warm and direct. No em dashes. No hype words. Sentence case. Under 140 words total.',
295
+ '',
296
+ 'Coaching doctrine (Pollan):',
297
+ '- Every beat answers a question. Suspense comes from whether the writer answers it.',
298
+ '- Idiot on page one: the hook should wonder, never lecture. Voice starts naive, sophisticates as it goes.',
299
+ '- Narrative depends on conflict; spread exposition thin or the story sags.',
300
+ '- Once drafting starts, no new information: work with what the writer has and what they remember.',
301
+ '',
302
+ 'Output EXACTLY these three sections:',
303
+ 'CHEER: quote the writer\'s single best sentence from the draft verbatim and say specifically why it works. If the draft is empty, cheer the strongest dump line instead.',
304
+ `QUESTION: one pointed question to get the next words out for the beat "${currentBeat}". A question they answer in their own voice.`,
305
+ 'LESSON: one concrete, reusable style observation about THIS writer (pattern you see in their words, good or fixable). One sentence.',
306
+ '',
307
+ `Topic: ${topic}`,
308
+ `Beats: ${beats.map((b) => `${b.title}(${STATE_WORD[b.state]},${counts.get(b.n) || 0}w)`).join(' ')}`,
309
+ findings.length ? `Slop findings: ${findings.map((f) => `${f.rule}@${f.line}`).join(', ')}` : 'Slop scan: clean',
310
+ '',
311
+ '--- plan.md ---', planText,
312
+ '--- draft.md ---', draftText || '(empty)',
313
+ ].join('\n');
314
+ }
315
+
316
+ function coach(argv, root = process.cwd()) {
317
+ const offline = argv.includes('--offline');
318
+ const slug = resolveSlug(argv.find((a) => !a.startsWith('-')), root);
319
+ if (!slug) { console.error(' no writing session found. start one: atris write start "<topic>"'); return 2; }
320
+ const { topic, beats, counts, text: planText } = syncStates(slug, root);
321
+ const draftFile = path.join(sessionDir(slug, root), 'draft.md');
322
+ let draftText = ''; try { draftText = fs.readFileSync(draftFile, 'utf8'); } catch {}
323
+ const findings = scanFile(draftFile, RULES.concat(loadProjectRules(root)));
324
+ const current = beats.find((b) => b.state === ' ') || beats.find((b) => b.state === '~') || beats[beats.length - 1];
325
+ const done = beats.filter((b) => b.state === 'x').length;
326
+ const totalWords = [...counts.values()].reduce((a, b) => a + b, 0);
327
+
328
+ console.log(`\n coach · ${topic}`);
329
+ console.log(` ${beats.map((b) => STATE_ICON[b.state]).join(' ')} ${totalWords} words in, ${done}/${beats.length} beats passed\n`);
330
+
331
+ // Day-two ritual: prime the pump before anything new lands.
332
+ if (totalWords > 0 && isReturningSession(slug, root)) {
333
+ console.log(' welcome back. before anything new: read the whole draft, top to bottom, and touch it up.');
334
+ console.log(' it loads the piece back into your head, and it quietly makes your opening the best part.\n');
335
+ }
336
+
337
+ if (!offline && claudeAvailable()) {
338
+ const prompt = coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat: current.title });
339
+ const res = spawnSync('claude', ['-p', prompt], { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
340
+ const out = (res.stdout || '').trim();
341
+ if (res.status === 0 && /QUESTION:/.test(out)) {
342
+ console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
343
+ const lesson = (out.match(/LESSON:\s*([\s\S]*?)$/m) || [])[1];
344
+ if (lesson) appendCoachNote(slug, `- lesson: ${lesson.trim()}`, root);
345
+ console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
346
+ return 0;
347
+ }
348
+ console.log(' (claude coach unavailable, falling back to the offline coach)\n');
349
+ }
350
+
351
+ // Offline coach: still proactive, still gets you going. Seeds + one question.
352
+ const seeds = dumpSeeds(planText);
353
+ if (totalWords > 0) {
354
+ console.log(` you have ${totalWords} words down. inflow is closed: work with what you have and what you remember.`);
355
+ } else if (seeds.length) {
356
+ console.log(' your raw material (pick the line with the most heat and start there):');
357
+ for (const s of seeds.slice(0, 4)) console.log(` · ${s}`);
358
+ }
359
+ console.log(`\n next beat: ${current.title}`);
360
+ console.log(` question: ${beatQuestion(current)}`);
361
+ if (findings.length) console.log(`\n while you are in there: ${findings.length} slop tell${findings.length === 1 ? '' : 's'} to fix in your own words (atris write review for the list)`);
362
+ console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
363
+ return 0;
364
+ }
365
+
366
+ function help() {
367
+ console.log(`
368
+ atris write — guided writing sessions (you write every word; atris structures + reviews)
369
+
370
+ atris write the coach: knows where you are, gets you going
371
+ atris write start "<topic>" [--dump "..."] [--beats "a | b | c"]
372
+ atris write coach [slug] the coach: cheers your best line, asks the next question (--offline for no-LLM)
373
+ atris write status [slug] progress against the outline (beats landed)
374
+ atris write review [slug] taste gate: slop scan + writing-policy passes (read-only)
375
+ atris write pass <n> [slug] mark beat n passed — the human calls it
376
+ atris write list all sessions
377
+
378
+ sessions are plain markdown in atris/writing/<slug>/ — the same files open
379
+ in the web and desktop editors. atris never writes or edits your draft.
380
+ `);
381
+ return 0;
382
+ }
383
+
384
+ function writeCommand(argv) {
385
+ const sub = argv[0];
386
+ if (sub === 'start') return start(argv.slice(1));
387
+ if (sub === 'coach' || sub === 'kick') return coach(argv.slice(1));
388
+ if (sub === 'status') return status(argv.slice(1));
389
+ if (sub === 'review') return review(argv.slice(1));
390
+ if (sub === 'pass') return pass(argv.slice(1));
391
+ if (sub === 'list') return list();
392
+ // bare `atris write` (flags allowed) IS the coach: one door, it knows where you are and gets you going
393
+ if (!sub || sub.startsWith('-')) return listSessions().length ? coach(argv) : help();
394
+ if (sub === 'help') return help();
395
+ // `atris write "some topic"` sugar → start
396
+ return start(argv);
397
+ }
398
+
399
+ module.exports = { writeCommand, start, status, review, pass, coach, dumpSeeds, listSessions, readPlan, draftWordCounts, syncStates, slugify };