atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
@@ -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 };
package/commands/xp.js CHANGED
@@ -18,6 +18,21 @@ const CAREER_XP_CURSOR_FILE = path.join('.atris', 'state', 'career_xp.cursor.jso
18
18
  const CAREER_XP_SESSIONS_DIR = path.join('.atris', 'state', 'career_xp_sessions');
19
19
  const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
20
20
  const CODEX_STATE_FILE = path.join(os.homedir(), '.codex', 'state_5.sqlite');
21
+
22
+ // Default single-workspace XP root. Local XP reads task_episodes.jsonl (written
23
+ // by task-db at the resolved workspace root) and writes its projection beside
24
+ // it, so the `--workspace` default must resolve the same shared root — otherwise
25
+ // `xp --local` from a subdir (e.g. backend/) read the wrong/empty episodes and
26
+ // split the projection into a nested .atris. Explicit --workspace still wins;
27
+ // the multi-root roster path (discoverCareerXpWorkspaces) is untouched. Falls
28
+ // back to raw cwd if the resolver can't load.
29
+ function defaultXpWorkspace() {
30
+ try {
31
+ return require('../lib/mission-root').resolveWorkspaceRoot(process.cwd());
32
+ } catch {
33
+ return process.cwd();
34
+ }
35
+ }
21
36
  // Codex moved native goals to goals_1.sqlite; thread metadata (cwd/title) stayed in state_5.sqlite.
22
37
  const CODEX_GOALS_FILE = path.join(os.homedir(), '.codex', 'goals_1.sqlite');
23
38
  const AGENT_XP_LABEL = 'AgentXP';
@@ -890,30 +905,33 @@ function readTaskEpisodeTail(episodePath, cursorPath, options = {}) {
890
905
  }
891
906
 
892
907
  function receiptFromTaskEpisode(episode) {
908
+ const episodeId = episode?.episode_id;
909
+ const label = episode?.rl?.label;
910
+ const rejected = label === 'rework_requested' || label === 'rejected';
893
911
  const doneForXp = String(episode?.state?.status || '').toLowerCase() === 'done';
894
912
  const hasExplicitCareerXp = episode?.career_xp && typeof episode.career_xp === 'object';
895
913
  const eligible = doneForXp && (
896
914
  episode?.career_xp?.eligible === true
897
- || (!hasExplicitCareerXp && episode?.rl?.label === 'accepted')
915
+ || (!hasExplicitCareerXp && label === 'accepted')
898
916
  );
899
917
  const proof = String(episode?.proof || '').trim();
900
918
  const reward = asNumber(episode?.career_xp?.reward ?? episode?.reward?.value, 0);
901
- if (!eligible || !proof || reward <= 0 || !episode?.episode_id) return null;
919
+ if (!episodeId || (!rejected && (!eligible || !proof || reward <= 0))) return null;
902
920
 
903
921
  return {
904
922
  schema: 'atris.career_xp_receipt.v1',
905
- receipt_id: `task_review:${episode.episode_id}`,
923
+ receipt_id: `task_review:${episodeId}${rejected ? ':rejected' : ''}`,
906
924
  source: 'atris-cli',
907
925
  source_type: 'task_review',
908
926
  source_task_id: episode.task_id || null,
909
- source_episode_id: episode.episode_id,
927
+ source_episode_id: episodeId,
910
928
  workspace_root: episode.workspace_root || null,
911
929
  actor: episode.action?.actor || null,
912
- outcome: 'accepted',
913
- xp: reward,
914
- reward,
915
- proof,
916
- proof_ref: proof,
930
+ outcome: rejected ? 'rejected' : 'accepted',
931
+ xp: rejected ? 0 : reward,
932
+ reward: rejected ? 0 : reward,
933
+ proof: proof || null,
934
+ proof_ref: proof || null,
917
935
  source_episode_hash: hashPayload(episode),
918
936
  title: episode.state?.title || null,
919
937
  goal: episode.goal || null,
@@ -1022,7 +1040,7 @@ function buildCareerXpProjection(receipts, workspace, integrity = {}) {
1022
1040
  }
1023
1041
 
1024
1042
  function collectLocalXpProjectionState(args = [], { write = true } = {}) {
1025
- const workspace = path.resolve(readFlag(args, '--workspace', process.cwd()));
1043
+ const workspace = path.resolve(readFlag(args, '--workspace', defaultXpWorkspace()));
1026
1044
  const episodePath = path.join(workspace, TASK_EPISODES_FILE);
1027
1045
  const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
1028
1046
  const projectionPath = path.join(workspace, CAREER_XP_PROJECTION_FILE);
@@ -1614,7 +1632,7 @@ LIMIT 25
1614
1632
  }
1615
1633
 
1616
1634
  function buildCareerXpSessionCapsule(args = []) {
1617
- const workspace = path.resolve(readFlag(args, '--workspace', process.cwd()));
1635
+ const workspace = path.resolve(readFlag(args, '--workspace', defaultXpWorkspace()));
1618
1636
  const sinceInput = readFlag(args, '--since', 'today');
1619
1637
  const untilInput = readFlag(args, '--until', null);
1620
1638
  const since = parseSessionBoundary(sinceInput, startOfToday());