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.
- package/AGENTS.md +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +4 -2
- package/ax +475 -17
- package/bin/atris.js +197 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +554 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +250 -9
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- 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 };
|
package/commands/worktree.js
CHANGED
|
@@ -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
|
|
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,
|
|
227
|
-
runGit(['worktree', 'add', '-b', branch, target,
|
|
228
|
-
runGit(['config', `branch.${branch}.atris-base`,
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|