atris 3.80.1 → 3.82.2

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -1
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +112 -14
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +150 -137
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +107 -1610
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/first-minute.js +37 -4
  46. package/lib/fleet.js +1 -1
  47. package/lib/integrations-core.js +1695 -0
  48. package/lib/intents.js +7 -0
  49. package/lib/json-file.js +22 -1
  50. package/lib/loop-doctor.js +36 -2
  51. package/lib/map-refs.js +68 -8
  52. package/lib/member-context.js +20 -1
  53. package/lib/member-engine.js +1 -1
  54. package/lib/member-map-fold.js +49 -11
  55. package/lib/member-map-inbox.js +2 -1
  56. package/lib/mission-core.js +9 -2
  57. package/lib/next-work-context.js +190 -0
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/task-db.js +107 -38
  62. package/lib/taste-notes.js +35 -0
  63. package/lib/team-lineup.js +3 -3
  64. package/lib/todo.js +2 -0
  65. package/lib/wiki.js +11 -3
  66. package/lib/worktree-core.js +2032 -0
  67. package/lib/xp-core.js +2172 -0
  68. package/lib/youtube-core.js +3178 -0
  69. package/lib/zen-apply.js +3 -1
  70. package/package.json +2 -2
@@ -7,7 +7,7 @@ const crypto = require('crypto');
7
7
  const readline = require('readline');
8
8
  const { spawn, spawnSync } = require('child_process');
9
9
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
10
- const { memberProcessPrompt } = require('../lib/member-context');
10
+ const { memberProcessPrompt, currentWorkPrompt } = require('../lib/member-context');
11
11
  const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
12
12
  const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
13
13
  const {
@@ -17,6 +17,7 @@ const {
17
17
  } = require('../lib/cli-json');
18
18
  const escapeRegExp = require('../lib/escape-regexp');
19
19
  const { sleepSync } = require('../lib/sleep-sync');
20
+ const { stealStaleLock } = require('../lib/file-lock');
20
21
  const {
21
22
  appendBriefRecord,
22
23
  stampBriefOutcome,
@@ -363,7 +364,20 @@ function isLiveInFlightMission(mission, now = Date.now(), root = process.cwd())
363
364
  if (!mission || !mission.id || mission.cloud === true) return false;
364
365
  const status = String(mission.status || '');
365
366
  if (TERMINAL_STATUSES.has(status) || status === 'paused' || status === 'blocked') return false;
366
- if (status === 'running') return true;
367
+ if (status === 'running') {
368
+ // Session-driven missions sit at running between ticks with no process,
369
+ // so a missing driver alone is normal. A recorded driver whose process is
370
+ // gone, plus a day with no tick, is a crashed run: not live.
371
+ const driver = missionDriverHealth(mission, root);
372
+ if (driver.alive === true || !driver.pid) return true;
373
+ const lastActivity = Math.max(
374
+ Date.parse(mission.last_tick_at || '') || 0,
375
+ Date.parse(mission.resumed_at || '') || 0,
376
+ Date.parse(mission.started_at || '') || 0,
377
+ Date.parse(mission.created_at || '') || 0,
378
+ );
379
+ return !lastActivity || now - lastActivity <= LIVE_MISSION_TICK_MS;
380
+ }
367
381
  const lastTickAt = Date.parse(mission.last_tick_at || '');
368
382
  if (!Number.isFinite(lastTickAt)) return false;
369
383
  const age = now - lastTickAt;
@@ -743,6 +757,48 @@ function resolveMission(ref, root = process.cwd()) {
743
757
  return null;
744
758
  }
745
759
 
760
+ // Operator halts (stop, pause, complete) land while a long tick or run is in
761
+ // flight. A writer that read the mission before the halt must not save its
762
+ // stale status over it. Returns the latest row when it halted since
763
+ // `baseline` was read, else null.
764
+ const OPERATOR_HALT_STATUSES = new Set(['stopped', 'paused', 'complete']);
765
+ const OPERATOR_HALT_FIELDS = [
766
+ 'status', 'stopped_at', 'paused_at', 'completed_at', 'stop_reason',
767
+ 'proof', 'completion_gate', 'landing', 'result', 'next_action',
768
+ ];
769
+
770
+ function missionHaltedSince(baseline, root = process.cwd()) {
771
+ if (!baseline || !baseline.id) return null;
772
+ const latest = loadMissionMap(root).get(baseline.id);
773
+ if (!latest) return null;
774
+ const status = String(latest.status || '');
775
+ if (!OPERATOR_HALT_STATUSES.has(status) || status === String(baseline.status || '')) return null;
776
+ return latest;
777
+ }
778
+
779
+ // Keep a halt that landed meanwhile: the writer's progress fields survive,
780
+ // the halt's status fields win.
781
+ function keepOperatorHalt(next, halted) {
782
+ if (!halted) return next;
783
+ const merged = { ...next };
784
+ for (const key of OPERATOR_HALT_FIELDS) {
785
+ if (Object.prototype.hasOwnProperty.call(halted, key)) merged[key] = halted[key];
786
+ }
787
+ return merged;
788
+ }
789
+
790
+ // Operator writes (stop, pause, complete) re-read right before saving, so a
791
+ // tick that saved while they ran keeps its progress instead of being rolled
792
+ // back to the copy read at the start of the command.
793
+ function withLatestMissionProgress(mission, root = process.cwd()) {
794
+ if (!mission || !mission.id) return mission;
795
+ const latest = loadMissionMap(root).get(mission.id);
796
+ if (!latest) return mission;
797
+ const latestAt = Date.parse(latest.updated_at || '') || 0;
798
+ const seenAt = Date.parse(mission.updated_at || '') || 0;
799
+ return latestAt > seenAt ? { ...mission, ...latest } : mission;
800
+ }
801
+
746
802
  function saveMission(mission, root = process.cwd(), eventType = 'mission_updated', payload = {}) {
747
803
  const paths = statePaths(root);
748
804
  const existing = mission && mission.id ? loadMissionMap(root).get(mission.id) : null;
@@ -7610,6 +7666,11 @@ function installDetachedMissionDriverLifecycle(mission, root = process.cwd()) {
7610
7666
  return { finish, setExitReason };
7611
7667
  }
7612
7668
 
7669
+ function missionHasDeadDriver(mission, root = process.cwd()) {
7670
+ const driver = missionDriverHealth(mission, root);
7671
+ return Boolean(driver.pid) && driver.alive !== true;
7672
+ }
7673
+
7613
7674
  function missionDriverHealth(mission, root = process.cwd()) {
7614
7675
  const paths = missionDriverPaths(mission, root);
7615
7676
  const driverState = readJson(paths.stateFile);
@@ -7648,6 +7709,10 @@ function acquireMissionLock(missionId, root = process.cwd(), options = {}) {
7648
7709
  return { ok: true, lockFile, fd, missionId, driverPid: process.pid, startedAt, recordLength: record.length };
7649
7710
  } catch (e) {
7650
7711
  if (e.code === 'EEXIST') {
7712
+ let judged = null;
7713
+ try { judged = fs.statSync(lockFile); } catch { continue; }
7714
+ let judgedRaw = null;
7715
+ try { judgedRaw = fs.readFileSync(lockFile, 'utf8'); } catch {}
7651
7716
  const info = readJson(lockFile, {}) || {};
7652
7717
  const holderPid = Number(info.pid);
7653
7718
  const holderKnown = Number.isInteger(holderPid) && holderPid > 0;
@@ -7659,13 +7724,13 @@ function acquireMissionLock(missionId, root = process.cwd(), options = {}) {
7659
7724
  // creation window (guards against a crash between openSync and writeSync).
7660
7725
  let stealable = holderKnown && !missionLockOwnerIsAlive(holderPid);
7661
7726
  if (!holderKnown) {
7662
- let ageMs = 0;
7663
- try { ageMs = Date.now() - fs.statSync(lockFile).mtimeMs; } catch { ageMs = 0; }
7664
- stealable = ageMs > 1000;
7727
+ stealable = Date.now() - judged.mtimeMs > 1000;
7665
7728
  }
7666
7729
  if (stealable) {
7730
+ // Rename-then-verify, not unlink: two waiters that both judged the
7731
+ // same dead lock used to unlink each other's fresh lock in turn.
7667
7732
  try {
7668
- fs.unlinkSync(lockFile);
7733
+ stealStaleLock(lockFile, judged, judgedRaw);
7669
7734
  continue;
7670
7735
  } catch {}
7671
7736
  }
@@ -7700,8 +7765,22 @@ function updateMissionLockOwner(lock, pid, extra = {}) {
7700
7765
 
7701
7766
  function releaseMissionLock(lock) {
7702
7767
  if (!lock || !lock.ok) return;
7768
+ // Unlink only while the path still names the file this lock opened; a
7769
+ // lock stolen as stale now belongs to someone else.
7770
+ let ours = true;
7771
+ if (lock.fd != null) {
7772
+ try {
7773
+ const mine = fs.fstatSync(lock.fd);
7774
+ const current = fs.statSync(lock.lockFile);
7775
+ ours = mine.ino === current.ino && mine.dev === current.dev;
7776
+ } catch {
7777
+ ours = false;
7778
+ }
7779
+ }
7703
7780
  try { if (lock.fd != null) fs.closeSync(lock.fd); } catch {}
7704
- try { fs.unlinkSync(lock.lockFile); } catch {}
7781
+ if (ours) {
7782
+ try { fs.unlinkSync(lock.lockFile); } catch {}
7783
+ }
7705
7784
  }
7706
7785
 
7707
7786
  // Runners that need a live human session cannot be driven headlessly; every
@@ -8068,6 +8147,8 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8068
8147
  ...turnDownLines,
8069
8148
  ...(sharedProcess ? ['', sharedProcess] : []),
8070
8149
  ...preloadedContextLines,
8150
+ '',
8151
+ currentWorkPrompt(cwd, { ...mission, verifier: frozen.verifier }),
8071
8152
  ``,
8072
8153
  `## Your task`,
8073
8154
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
@@ -9885,7 +9966,9 @@ async function executeMissionRunTicksPhase(context) {
9885
9966
  // demote terminal status back to ready (always-on + verifier-pass used to
9886
9967
  // reopen closed rooms, mission 32 complete@19:23:33 → ready@19:23:48).
9887
9968
  const latestOnDisk = loadMissionMap(cwd).get(mission.id) || mission;
9888
- const alreadyTerminal = TERMINAL_STATUSES.has(String(latestOnDisk.status || ''));
9969
+ // A pause issued during this tick stops the loop too, not just stop/complete.
9970
+ const pausedMeanwhile = String(latestOnDisk.status || '') === 'paused' && String(mission.status || '') !== 'paused';
9971
+ const alreadyTerminal = TERMINAL_STATUSES.has(String(latestOnDisk.status || '')) || pausedMeanwhile;
9889
9972
  // A passing verifier alone never completes a mission whose linked task
9890
9973
  // is not accepted: park it ready and name the accept command instead.
9891
9974
  const acceptGate = (verifierResult?.passed && completeOnPass && !fullBudgetMode && !mission.always_on && !xpReadyAction)
@@ -10611,8 +10694,11 @@ function tickMission(args) {
10611
10694
  ...(nextGoalChain ? { goal_chain: nextGoalChain } : {}),
10612
10695
  next_action: nextAction,
10613
10696
  };
10614
- const { mission: saved } = saveMission(nextMission, cwd, 'mission_tick', {
10697
+ // The tick can run for minutes; a stop/pause/complete issued meanwhile wins.
10698
+ const haltedMeanwhile = missionHaltedSince(mission, cwd);
10699
+ const { mission: saved } = saveMission(keepOperatorHalt(nextMission, haltedMeanwhile), cwd, 'mission_tick', {
10615
10700
  tick_index: tickIdx, verify, verifier_result: verifierResult, receipt_path: receiptPath, layer: tickRecord.layer,
10701
+ ...(haltedMeanwhile ? { kept_operator_status: haltedMeanwhile.status } : {}),
10616
10702
  });
10617
10703
  const blocker = selfDrive && saved.status === 'blocked'
10618
10704
  ? require('../lib/self-drive').handleMissionBlocker({
@@ -10808,7 +10894,7 @@ function completeMission(args) {
10808
10894
  const baselineSummary = pruneMissionWorktreeBaseline(mission, root);
10809
10895
  const completionGate = { ...gate, forced: force && !gate.ok, ...(force && !gate.ok ? { forced_reason: gate.reason } : {}) };
10810
10896
  const baseNext = {
10811
- ...mission,
10897
+ ...withLatestMissionProgress(mission, root),
10812
10898
  status: 'complete',
10813
10899
  completed_at: stampIso(),
10814
10900
  proof,
@@ -10881,7 +10967,7 @@ function stopMissionRecord(mission, reason, root = process.cwd(), options = {})
10881
10967
  }
10882
10968
  const baselineSummary = pause ? null : pruneMissionWorktreeBaseline(mission, root);
10883
10969
  const next = {
10884
- ...mission,
10970
+ ...withLatestMissionProgress(mission, root),
10885
10971
  status,
10886
10972
  stopped_at: status === 'stopped' ? stampIso() : mission.stopped_at || null,
10887
10973
  paused_at: status === 'paused' ? stampIso() : mission.paused_at || null,
@@ -10930,8 +11016,9 @@ function stopMission(args) {
10930
11016
  // A mission parked in paused/planning/ready and untouched for a week is
10931
11017
  // abandoned in practice, nobody resumes it, and each one is a line the
10932
11018
  // operator re-reads forever. The daily autoland tick expires them to
10933
- // stopped with a revive hint; running missions and anything touched
10934
- // recently are never aged out, and a tick on an expired id revives it.
11019
+ // stopped with a revive hint; a running mission is aged out only when its
11020
+ // recorded driver died, anything touched recently never is, and a tick on an
11021
+ // expired id revives it.
10935
11022
  const MISSION_IDLE_EXPIRY_DAYS = 7;
10936
11023
  const EXPIRABLE_STATUSES = new Set(['paused', 'planning', 'ready']);
10937
11024
 
@@ -10969,7 +11056,14 @@ function expireStaleMissions(root = process.cwd(), { idleDays = MISSION_IDLE_EXP
10969
11056
  const held = [];
10970
11057
  for (const mission of listMissions(root)) {
10971
11058
  const status = String(mission.status || '').toLowerCase();
10972
- if (!statuses.has(status)) continue;
11059
+ // A mission left at running by a driver that died is as abandoned as a
11060
+ // parked one. Only the default sweep takes it, and only when a driver was
11061
+ // recorded and its process is gone (session-driven runs have none).
11062
+ const deadRunning = status === 'running'
11063
+ && statuses === EXPIRABLE_STATUSES
11064
+ && mission.cloud !== true
11065
+ && missionHasDeadDriver(mission, root);
11066
+ if (!statuses.has(status) && !deadRunning) continue;
10973
11067
  // updated_at and paused_at are machine-polluted, status renders and
10974
11068
  // goal controllers re-save parked missions daily, so a mission nobody
10975
11069
  // has run since May reads as "touched today". Real activity is the
@@ -11858,6 +11952,10 @@ function missionCommand(args) {
11858
11952
  }
11859
11953
 
11860
11954
  module.exports = {
11955
+ isLiveInFlightMission,
11956
+ missionHaltedSince,
11957
+ keepOperatorHalt,
11958
+ withLatestMissionProgress,
11861
11959
  receiptLearnedLine,
11862
11960
  tickTouchedCheckDefinition,
11863
11961
  engineVerifierLineup,
@@ -12,8 +12,13 @@ const { runGit } = require('../lib/checkout-sync');
12
12
  * - Commits, tags, pushes
13
13
  * - Creates GitHub release via `gh`
14
14
  * - Drafts a /launch post (3 emoji bullets)
15
+ *
16
+ * A real release (not --dry-run) runs releasePreflight first, checked against the tag it is
17
+ * about to make: on master, clean, level with origin/master, the tag free, the tests green.
18
+ * Any failure refuses the release before anything is written and returns 1. A push that
19
+ * fails returns 1 too, with the exact commands to finish or undo by hand.
15
20
  */
16
- async function releaseAtris({ dryRun = false } = {}) {
21
+ async function releaseAtris({ dryRun = false, preflight = releasePreflight } = {}) {
17
22
  const cwd = process.cwd();
18
23
 
19
24
  // 1. Get last tag
@@ -80,7 +85,15 @@ async function releaseAtris({ dryRun = false } = {}) {
80
85
  return;
81
86
  }
82
87
 
83
- // 7. Let npm keep package.json and package-lock.json aligned.
88
+ // 7. Refuse to release from anywhere but a clean master that passes preflight.
89
+ const preflightCode = preflight({ cwd, tagVersion: nextVersion });
90
+ if (preflightCode !== 0) {
91
+ console.log('');
92
+ console.log('release refused: preflight failed, nothing was changed. fix the failures above and run `atris release` again.');
93
+ return 1;
94
+ }
95
+
96
+ // 8. Let npm keep package.json and package-lock.json aligned.
84
97
  execFileSync('npm', [
85
98
  'version',
86
99
  nextVersion,
@@ -89,22 +102,38 @@ async function releaseAtris({ dryRun = false } = {}) {
89
102
  ], { cwd, stdio: 'ignore' });
90
103
  console.log(`bumped package.json and package-lock.json to ${nextVersion}`);
91
104
 
92
- // 8. Commit
105
+ // 9. Commit
93
106
  execFileSync('git', ['add', 'package.json', 'package-lock.json'], { cwd });
94
107
  execFileSync('git', ['commit', '-m', `v${nextVersion}`], { cwd });
95
108
  console.log('committed');
96
109
 
97
- // 9. Tag
110
+ // 9b. Tag
98
111
  execFileSync('git', ['tag', `v${nextVersion}`], { cwd });
99
112
  console.log(`tagged v${nextVersion}`);
100
113
 
101
- // 10. Push + push tags
114
+ // 10. Push the commit, then the one tag. A failure stops here: a GitHub release made
115
+ // for a tag that never reached origin would create that tag on the wrong commit.
116
+ const tag = `v${nextVersion}`;
117
+ try {
118
+ execFileSync('git', ['push', 'origin', 'master'], { cwd, stdio: 'pipe' });
119
+ } catch (err) {
120
+ // Nothing reached origin. Drop the local tag so a retry is not blocked by it.
121
+ try { execFileSync('git', ['tag', '-d', tag], { cwd, stdio: 'pipe' }); } catch { /* the tag may not exist */ }
122
+ console.log(`push failed: the version commit ${tag} is only on this machine, and its local tag was deleted.`);
123
+ console.log('to finish by hand:');
124
+ console.log(` git push origin master && git tag ${tag} && git push origin ${tag}`);
125
+ console.log('or to undo the version commit:');
126
+ console.log(' git reset --hard HEAD~1');
127
+ return 1;
128
+ }
102
129
  try {
103
- execFileSync('git', ['push'], { cwd });
104
- execFileSync('git', ['push', '--tags'], { cwd });
130
+ execFileSync('git', ['push', 'origin', tag], { cwd, stdio: 'pipe' });
105
131
  console.log('pushed');
106
132
  } catch (err) {
107
- console.log('push failed, you may need to push manually');
133
+ console.log(`push failed: the version commit is on origin/master, but tag ${tag} is only on this machine.`);
134
+ console.log('to finish by hand:');
135
+ console.log(` git push origin ${tag}`);
136
+ return 1;
108
137
  }
109
138
 
110
139
  // 11. Create GitHub release via gh
@@ -210,7 +239,9 @@ function gitStdout(result) {
210
239
  * each check and exits nonzero if any fail. Skips the test suite when earlier
211
240
  * git checks already failed so a bad checkout does not burn a full run.
212
241
  */
213
- function releasePreflight({ cwd = process.cwd(), runTests = runFullTestSuite } = {}) {
242
+ // tagVersion: the version whose tag must be free. `atris release` passes the version it is about to
243
+ // make; the bare preflight checks the version package.json already carries.
244
+ function releasePreflight({ cwd = process.cwd(), runTests = runFullTestSuite, tagVersion = null } = {}) {
214
245
  let failed = false;
215
246
 
216
247
  const branchResult = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
@@ -245,7 +276,7 @@ function releasePreflight({ cwd = process.cwd(), runTests = runFullTestSuite } =
245
276
  console.log('pass: local master matches origin/master');
246
277
  }
247
278
 
248
- const version = readPackageVersion(cwd);
279
+ const version = tagVersion || readPackageVersion(cwd);
249
280
  if (!version) {
250
281
  failed = true;
251
282
  console.log('fail: package.json version is missing');
@@ -193,7 +193,7 @@ function statusAtris(isQuick = false, jsonMode = false, verbose = false) {
193
193
  .filter(Boolean);
194
194
  }
195
195
 
196
- const completedMatch = logContent.match(/## Completed ✅\n([\s\S]*?)(?=\n##|---|$)/);
196
+ const completedMatch = logContent.match(/## Completed(?: ✅)?[^\S\n]*\n([\s\S]*?)(?=\n##|---|$)/);
197
197
  if (completedMatch && completedMatch[1].trim()) {
198
198
  completions = completedMatch[1].trim().split('\n')
199
199
  .filter(l => l.match(/^- \*\*C\d+:/))
package/commands/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const { ensureProjectTasteFile } = require('../lib/taste-notes');
2
3
  const path = require('path');
3
4
  const { stateHome } = require('../lib/state-home');
4
5
  const { ensureWikiScaffold } = require('../lib/wiki');
@@ -178,11 +179,10 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
178
179
  fs.symlinkSync(relativePath, symlinkPath);
179
180
  if (verbose) console.log(`✓ Linked .claude/skills/${skill}`);
180
181
  } catch (e) {
182
+ // No symlinks here (Windows without the right): copy the whole skill,
183
+ // since a skill may link to notes beside its SKILL.md.
181
184
  ensureRealDirectory(symlinkPath);
182
- const skillFile = path.join(destSkillDir, 'SKILL.md');
183
- if (fs.existsSync(skillFile)) {
184
- fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
185
- }
185
+ fs.cpSync(destSkillDir, symlinkPath, { recursive: true });
186
186
  if (verbose) console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`);
187
187
  }
188
188
  }
@@ -568,6 +568,11 @@ function syncAtris(options = {}) {
568
568
  console.log(`${dryRun ? 'Would create' : '✓ Created'} atris/policies/ folder`);
569
569
  }
570
570
 
571
+ // Project taste is the project's own; create a starter only when missing.
572
+ if (ensureProjectTasteFile(targetDir, { dryRun })) {
573
+ console.log(`${dryRun ? 'Would create' : '✓ Created'} atris/taste/TASTE.md`);
574
+ }
575
+
571
576
  const filesToSync = [
572
577
  { source: 'atris.md', target: 'atris.md' },
573
578
  { source: 'PERSONA.md', target: 'PERSONA.md' },
@@ -885,11 +890,10 @@ function syncSkills({ silent = false } = {}) {
885
890
  }
886
891
  } catch (e) {
887
892
  // Fallback: copy instead of symlink
893
+ // No symlinks here (Windows without the right): copy the whole skill,
894
+ // since a skill may link to notes beside its SKILL.md.
888
895
  ensureRealDirectory(symlinkPath);
889
- const skillFile = path.join(destSkillDir, 'SKILL.md');
890
- if (fs.existsSync(skillFile)) {
891
- fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
892
- }
896
+ fs.cpSync(destSkillDir, symlinkPath, { recursive: true });
893
897
  }
894
898
  }
895
899
  }