ronin-skillforce 0.1.0 → 0.2.0-beta.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 (155) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +202 -21
  3. package/NOTICE +19 -0
  4. package/README.md +38 -5
  5. package/bin/cli.js +15 -3
  6. package/bin/commands/archive.js +192 -0
  7. package/bin/commands/capstone-status.js +82 -0
  8. package/bin/commands/create.js +69 -12
  9. package/bin/commands/import.js +5 -5
  10. package/bin/commands/status.js +1 -1
  11. package/bin/commands/studio-autostart.js +414 -0
  12. package/bin/commands/studio-state.js +159 -0
  13. package/bin/commands/studio.js +351 -17
  14. package/bin/commands/trail.js +153 -3
  15. package/bin/commands/web.js +8 -10
  16. package/bin/course-reader.js +6 -15
  17. package/bin/platforms/_shared.js +154 -28
  18. package/bin/platforms/claude.js +23 -42
  19. package/bin/platforms/cursor.js +23 -20
  20. package/bin/platforms/gemini.js +18 -19
  21. package/bin/platforms/opencode.js +47 -28
  22. package/package.json +5 -4
  23. package/plugin.json +1 -1
  24. package/shared/SKILL.md +102 -72
  25. package/shared/agents/coach.md +19 -16
  26. package/shared/agents/navigator.md +27 -34
  27. package/shared/agents/professor.md +16 -34
  28. package/shared/agents/researcher.md +1 -1
  29. package/shared/agents/spotter.md +3 -4
  30. package/shared/commands/professor/archive.md +65 -72
  31. package/shared/commands/professor/capstone-review.md +5 -5
  32. package/shared/commands/professor/capstone.md +3 -3
  33. package/shared/commands/professor/done.md +59 -31
  34. package/shared/commands/professor/hint.md +14 -2
  35. package/shared/commands/professor/navigator.md +27 -1
  36. package/shared/commands/professor/new-topic.md +3 -3
  37. package/shared/commands/professor/next.md +9 -5
  38. package/shared/commands/professor/note.md +6 -6
  39. package/shared/commands/professor/progress.md +1 -1
  40. package/shared/commands/professor/publish.md +5 -5
  41. package/shared/commands/professor/quiz.md +2 -2
  42. package/shared/commands/professor/recall.md +1 -1
  43. package/shared/commands/professor/review.md +14 -0
  44. package/shared/commands/professor/skill-export.md +3 -4
  45. package/shared/commands/professor/spotter.md +21 -4
  46. package/shared/commands/professor/stuck.md +24 -0
  47. package/shared/commands/professor/switch.md +1 -1
  48. package/shared/commands/professor/syllabus.md +1 -1
  49. package/shared/commands/professor/template-export.md +2 -2
  50. package/shared/commands/professor/worktrees.md +3 -7
  51. package/shared/hooks/pre-compact.js +4 -15
  52. package/studio/dist/assets/BetaReportPanel-BJ9HIAnz.js +1 -0
  53. package/studio/dist/assets/{Capstone-Cv9kH6LY.js → Capstone-CoUU-jP7.js} +1 -1
  54. package/studio/dist/assets/{CodeWorkspace-BwCEINWo.js → CodeWorkspace-BNrL4qvR.js} +2 -2
  55. package/studio/dist/assets/{Course-BJ_hafUP.js → Course-BMGTXus_.js} +1 -1
  56. package/studio/dist/assets/{Editor-CV0mNhlz.js → Editor-Cw9j3cnp.js} +1 -1
  57. package/studio/dist/assets/{Home-C1ry89ak.js → Home-D2woITq5.js} +1 -1
  58. package/studio/dist/assets/{Lesson-2e9TD9yL.js → Lesson-CYQhrDCZ.js} +2 -2
  59. package/studio/dist/assets/{NewCourse-BZeOg2xn.js → NewCourse-CMpwVRQb.js} +1 -1
  60. package/studio/dist/assets/{Progress-CRc_xEWC.js → Progress-2rS19Fur.js} +1 -1
  61. package/studio/dist/assets/{Review-6QEbHEfk.js → Review-SlqK-hjp.js} +1 -1
  62. package/studio/dist/assets/{Settings-NFnqVQui.js → Settings-t42u7n9H.js} +1 -1
  63. package/studio/dist/assets/{TutorPanel-DXQ6Z3BY.js → TutorPanel-DQDWIU7f.js} +3 -3
  64. package/studio/dist/assets/{abnfDiagram-WEYOIA3C-qeSjKlGM.js → abnfDiagram-WEYOIA3C-jTUmGBuB.js} +1 -1
  65. package/studio/dist/assets/{anthropic-D7NiQCUH.js → anthropic-iIm2l85r.js} +3 -3
  66. package/studio/dist/assets/{architecture-WOLXFQ4H-COG-m35l.js → architecture-WOLXFQ4H-Ch9Ld_vv.js} +1 -1
  67. package/studio/dist/assets/{architectureDiagram-ZHVYKT53-VO7uKoeD.js → architectureDiagram-ZHVYKT53-Dy-z6wsy.js} +1 -1
  68. package/studio/dist/assets/{blockDiagram-RJAE7QA3-BGJF_qiL.js → blockDiagram-RJAE7QA3-2fHVrc6N.js} +1 -1
  69. package/studio/dist/assets/{bridge-BTh0AeNX.js → bridge-Cr0q4rLp.js} +1 -1
  70. package/studio/dist/assets/{c4Diagram-XDPPPE54-C-S10RgU.js → c4Diagram-XDPPPE54-BuuKlG4V.js} +1 -1
  71. package/studio/dist/assets/{capstone-Amn6Hztq.js → capstone-DhwBeNqk.js} +1 -1
  72. package/studio/dist/assets/{card-store-BrbrtX9w.js → card-store-CVMBc-Pv.js} +1 -1
  73. package/studio/dist/assets/channel-M_JkdHrL.js +1 -0
  74. package/studio/dist/assets/{chunk-2E7WHVMD-fv-iqTyC.js → chunk-2E7WHVMD-lZdQWttk.js} +1 -1
  75. package/studio/dist/assets/{chunk-4EMNKR2T-CY9IZrgv.js → chunk-4EMNKR2T-C6kbMwke.js} +1 -1
  76. package/studio/dist/assets/{chunk-55XCU76K-DyTZz7v_.js → chunk-55XCU76K-rkBzkZog.js} +1 -1
  77. package/studio/dist/assets/{chunk-7S6XSTFO-BNMzTLRW.js → chunk-7S6XSTFO-Skg347QF.js} +1 -1
  78. package/studio/dist/assets/{chunk-AQDTSKZB-CEYANohH.js → chunk-AQDTSKZB-0pSDBJ23.js} +1 -1
  79. package/studio/dist/assets/{chunk-DSNLSHJZ-CDk5EXjH.js → chunk-DSNLSHJZ-BaeQDl2i.js} +1 -1
  80. package/studio/dist/assets/{chunk-HAGFOTED-hNAi1zh_.js → chunk-HAGFOTED-BEm00juw.js} +1 -1
  81. package/studio/dist/assets/{chunk-HNE4Q36P-COJnSYoZ.js → chunk-HNE4Q36P-DyCR-LvS.js} +1 -1
  82. package/studio/dist/assets/{chunk-NQOKRVIF-BU6KVxRr.js → chunk-NQOKRVIF-BNJ9uMmB.js} +1 -1
  83. package/studio/dist/assets/{chunk-UHW7PFBP-DYvxL9JG.js → chunk-UHW7PFBP-DqJczBLs.js} +1 -1
  84. package/studio/dist/assets/{chunk-VQ5W62QI-CuRfk65j.js → chunk-VQ5W62QI-CVWYSP5Q.js} +1 -1
  85. package/studio/dist/assets/{chunk-WQQL4CDK-KaG7x-Mb.js → chunk-WQQL4CDK-DJf0Dx05.js} +1 -1
  86. package/studio/dist/assets/{chunk-YZO67HFG-DbuqIZmk.js → chunk-YZO67HFG-BFUm_JHA.js} +1 -1
  87. package/studio/dist/assets/{chunk-ZAUX2LTX-bk4ivUmc.js → chunk-ZAUX2LTX-C5ETkkSE.js} +1 -1
  88. package/studio/dist/assets/{chunk-ZCHMQSOR-B_LElfwh.js → chunk-ZCHMQSOR-WVX9r9PZ.js} +1 -1
  89. package/studio/dist/assets/{classDiagram-v2-4MYWR3UA-DtCJ6H_X.js → classDiagram-v2-4MYWR3UA-CI77SpuR.js} +1 -1
  90. package/studio/dist/assets/{course-export-DpaOTT3a.js → course-export-C8jSPuda.js} +1 -1
  91. package/studio/dist/assets/{cynefin-EF2NZ3EQ-D9btHJvU.js → cynefin-EF2NZ3EQ-CLffmM25.js} +1 -1
  92. package/studio/dist/assets/{cynefinDiagram-WF24UDRP-CsFJuv27.js → cynefinDiagram-WF24UDRP-BvLwocOb.js} +1 -1
  93. package/studio/dist/assets/{dagre-NK4GIZVX-Cg1Duec7.js → dagre-NK4GIZVX-C2nMAVdh.js} +1 -1
  94. package/studio/dist/assets/{diagram-CBK3MWTC-_KU2m5gd.js → diagram-CBK3MWTC-Bid-pdax.js} +1 -1
  95. package/studio/dist/assets/{diagram-I5XIIV2D-5RaBrwUA.js → diagram-I5XIIV2D-DYJeAvzl.js} +1 -1
  96. package/studio/dist/assets/{diagram-ODIL2UCW-C0VKxoD3.js → diagram-ODIL2UCW-B06X2r_F.js} +1 -1
  97. package/studio/dist/assets/{diagram-PFBEJUXE-BaHcZEP2.js → diagram-PFBEJUXE-CwY--5Rz.js} +1 -1
  98. package/studio/dist/assets/{diagram-RJA3I4VV-cwSAZ90p.js → diagram-RJA3I4VV-4SqhH8zw.js} +1 -1
  99. package/studio/dist/assets/{diagram-TYUNFSNR-BJImxCEN.js → diagram-TYUNFSNR-CrW4AIM9.js} +1 -1
  100. package/studio/dist/assets/{ebnfDiagram-V4P3TTPT-OQQLRXcm.js → ebnfDiagram-V4P3TTPT-xeoCmH-d.js} +1 -1
  101. package/studio/dist/assets/{elk-QONTBCTQ-BJGhQHxQ.js → elk-QONTBCTQ-Dadmz6iS.js} +1 -1
  102. package/studio/dist/assets/{erDiagram-KYQIGQAA-D91EKSep.js → erDiagram-KYQIGQAA-CSuH0bbF.js} +1 -1
  103. package/studio/dist/assets/{eventmodeling-K75KTNOO-DgdTRgnw.js → eventmodeling-K75KTNOO-CciVfx3I.js} +1 -1
  104. package/studio/dist/assets/flowDiagram-IUGLLWBU-DgNR54qK.js +1 -0
  105. package/studio/dist/assets/{ganttDiagram-R7GPNTJY-CSYLPWEI.js → ganttDiagram-R7GPNTJY-C0wtA-2y.js} +1 -1
  106. package/studio/dist/assets/{gitGraph-VSP46ZUC-D6GWaV9y.js → gitGraph-VSP46ZUC-BXoDzaA3.js} +1 -1
  107. package/studio/dist/assets/{gitGraphDiagram-PDYL7XHY-CaV31bvT.js → gitGraphDiagram-PDYL7XHY-vZBlNRPe.js} +1 -1
  108. package/studio/dist/assets/{index-DioXTZVc.js → index-CS8XPYbB.js} +2 -2
  109. package/studio/dist/assets/{info-OHQRW6UA-BN-x8GoN.js → info-OHQRW6UA-bs2Qpf7P.js} +1 -1
  110. package/studio/dist/assets/{infoDiagram-MVDRKAX5-CIose_cy.js → infoDiagram-MVDRKAX5-rPJio6-1.js} +1 -1
  111. package/studio/dist/assets/{ishikawaDiagram-HPEHV2GE-DCihSNBw.js → ishikawaDiagram-HPEHV2GE-CXf9XFff.js} +1 -1
  112. package/studio/dist/assets/{journeyDiagram-BOSHW7O7-Bz3o3RIX.js → journeyDiagram-BOSHW7O7-C3MSH79o.js} +1 -1
  113. package/studio/dist/assets/{kanban-definition-FNSA4HC5-BUyqvlFE.js → kanban-definition-FNSA4HC5-Bdh7SB4n.js} +1 -1
  114. package/studio/dist/assets/{line-B08T8KlK.js → line-CHkoQXJF.js} +1 -1
  115. package/studio/dist/assets/{mermaid-parser.core-BYwreSUy.js → mermaid-parser.core-Do1oxD1S.js} +3 -3
  116. package/studio/dist/assets/{mermaid.core-DTO1oDmq.js → mermaid.core-DgZUD0sU.js} +4 -4
  117. package/studio/dist/assets/{mindmap-definition-6OY57UG3-BGLwKheY.js → mindmap-definition-6OY57UG3-PUaK6oE_.js} +1 -1
  118. package/studio/dist/assets/{node.browser-BISniYn8.js → node.browser-DkpMze_r.js} +1 -1
  119. package/studio/dist/assets/{ollama-De5w84tG.js → ollama-bJNp3jaD.js} +1 -1
  120. package/studio/dist/assets/{openai-compatible-Joy1oK3L.js → openai-compatible-0tmAyMlN.js} +1 -1
  121. package/studio/dist/assets/{packet-JDAUHWVQ-CDIMjHS_.js → packet-JDAUHWVQ-vf2FtqFr.js} +1 -1
  122. package/studio/dist/assets/{pegDiagram-ECIUJNBX-DWXm4mwS.js → pegDiagram-ECIUJNBX-NEVrgPTY.js} +1 -1
  123. package/studio/dist/assets/{pie-XZMESJXO-CY3QQiKZ.js → pie-XZMESJXO-BIWydVNZ.js} +1 -1
  124. package/studio/dist/assets/{pieDiagram-GNMPY74T-M1aCnI7W.js → pieDiagram-GNMPY74T-BI9_u6rT.js} +1 -1
  125. package/studio/dist/assets/{quadrantDiagram-D57K4MC3-BFFtO7U5.js → quadrantDiagram-D57K4MC3-ryNpoBC5.js} +1 -1
  126. package/studio/dist/assets/{radar-ABXABTNO-DT0W4_Xn.js → radar-ABXABTNO-D8TjnvBB.js} +1 -1
  127. package/studio/dist/assets/{railroad-I3PHUGI6-_VCpgZR5.js → railroad-I3PHUGI6-BmEDV5kf.js} +1 -1
  128. package/studio/dist/assets/{railroad-abnf-LNEFI6M7-DkSpQ5YY.js → railroad-abnf-LNEFI6M7-Cs_sArMc.js} +1 -1
  129. package/studio/dist/assets/{railroad-ebnf-DWYD2UWJ-BqWILdGb.js → railroad-ebnf-DWYD2UWJ-DYuCq46d.js} +1 -1
  130. package/studio/dist/assets/{railroad-peg-JR7BVNR7-BB0bQL-Z.js → railroad-peg-JR7BVNR7-BcXSgdMy.js} +1 -1
  131. package/studio/dist/assets/{railroadDiagram-Y72NRBL7-CjLKjIdJ.js → railroadDiagram-Y72NRBL7-DBcc8b2c.js} +1 -1
  132. package/studio/dist/assets/{rehype-katex-C-sKddsH.js → rehype-katex-BfUIq5As.js} +1 -1
  133. package/studio/dist/assets/{remark-math-C-ox67hE.js → remark-math-XvZOjmDO.js} +1 -1
  134. package/studio/dist/assets/{requirementDiagram-EQYSW5RA-CnwzZqR8.js → requirementDiagram-EQYSW5RA-CVcBgM1L.js} +1 -1
  135. package/studio/dist/assets/{sankeyDiagram-SY24JHO5-CPDugTUl.js → sankeyDiagram-SY24JHO5-DjZ81hDs.js} +1 -1
  136. package/studio/dist/assets/{sequenceDiagram-BLEHNHQU-1dLfeU9Y.js → sequenceDiagram-BLEHNHQU-BNwOa2S7.js} +1 -1
  137. package/studio/dist/assets/{stateDiagram-v2-A6IESKWZ-DR7CmEjj.js → stateDiagram-v2-A6IESKWZ-BFWa5lEx.js} +1 -1
  138. package/studio/dist/assets/{swimlanes-B3ENRKFQ-BAWB9omQ.js → swimlanes-B3ENRKFQ-LfM_Jg6Z.js} +1 -1
  139. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-BDmpoiGp.js +8 -0
  140. package/studio/dist/assets/{timeline-definition-JXYKGLGB-DNkj8z77.js → timeline-definition-JXYKGLGB-BwGzkWgZ.js} +1 -1
  141. package/studio/dist/assets/{treeView-D4JQ5SDB-BA-ouhMb.js → treeView-D4JQ5SDB-MLk3VuRt.js} +1 -1
  142. package/studio/dist/assets/{treemap-SAJKECNS-BrrGxsAb.js → treemap-SAJKECNS-D8iSjp7L.js} +1 -1
  143. package/studio/dist/assets/{usecaseDiagram-WPOLBNNX-BB3D0Dt_.js → usecaseDiagram-WPOLBNNX-BqxpaNlT.js} +1 -1
  144. package/studio/dist/assets/{vennDiagram-Q3Y3IDCW-BrtqtKTE.js → vennDiagram-Q3Y3IDCW-CpeUDMhQ.js} +1 -1
  145. package/studio/dist/assets/{wardley-7MLQ67FV-BKkemDql.js → wardley-7MLQ67FV-tAMbCZAj.js} +1 -1
  146. package/studio/dist/assets/{wardleyDiagram-IVAIDKWA-DFnI3P5z.js → wardleyDiagram-IVAIDKWA-CDJViWzt.js} +1 -1
  147. package/studio/dist/assets/{xychartDiagram-4URNHDU4-Da0DZG_q.js → xychartDiagram-4URNHDU4-CwaQrd3_.js} +1 -1
  148. package/studio/dist/index.html +1 -1
  149. package/templates/claude/plugin.json +1 -1
  150. package/templates/gemini/GEMINI.md +18 -16
  151. package/web/server.js +3 -3
  152. package/studio/dist/assets/BetaReportPanel-8NrDLiPm.js +0 -1
  153. package/studio/dist/assets/channel-SJiUQu_N.js +0 -1
  154. package/studio/dist/assets/flowDiagram-IUGLLWBU-CVnJYMum.js +0 -1
  155. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-qeuHru16.js +0 -8
@@ -16,6 +16,7 @@ import { style } from '../registry-helpers.js';
16
16
 
17
17
  export const MAX_ROUNDS = 5;
18
18
  const TRAIL_RE = /^##\s+.*reasoning trail/i;
19
+ const ENTRIES_RE = /^##\s+.*section entries/i;
19
20
  const ROUND_RE = /^Round\s+\d+\b/;
20
21
 
21
22
  /** Pick the course by slug; with no slug only a single course is accepted — never guess. */
@@ -24,10 +25,10 @@ export function resolveCourse(cwd, slug) {
24
25
  if (slug) {
25
26
  const hit = courses.find((c) => c.slug === slug);
26
27
  if (hit) return hit;
27
- throw new Error(`No course "${slug}" under learning/ or courses/. Found: ${courses.map((c) => c.slug).join(', ') || 'none'}.`);
28
+ throw new Error(`No course "${slug}" under learning/. Found: ${courses.map((c) => c.slug).join(', ') || 'none'}.`);
28
29
  }
29
30
  if (courses.length === 1) return courses[0];
30
- if (!courses.length) throw new Error('No course found under learning/ or courses/. Run professor:new-topic first.');
31
+ if (!courses.length) throw new Error('No course found under learning/. Run professor:new-topic first.');
31
32
  throw new Error(`Several courses found — pass the slug: ${courses.map((c) => c.slug).join(', ')}.`);
32
33
  }
33
34
 
@@ -161,12 +162,148 @@ export function recordRound({ cwd, slug, section, learner, asked, concept, evide
161
162
  return { recorded: true, path, section: target, rounds, saved };
162
163
  }
163
164
 
165
+ // ─── Section Entries (done gate verdict) ─────────────────────────────────────
166
+
167
+ /** Accepts `solid` or `watch-this: <concept>` (leading ⚠️ tolerated). */
168
+ export function normalizeVerdict(raw) {
169
+ const value = oneLine(raw);
170
+ if (/^solid$/i.test(value)) return { verdict: 'solid', watch: null };
171
+ const m = value.match(/^(?:⚠️\s*)?watch-this\s*:\s*(.+)$/i);
172
+ if (m && oneLine(m[1])) return { verdict: `⚠️ watch-this: ${oneLine(m[1])}`, watch: oneLine(m[1]) };
173
+ throw new Error('Invalid verdict — use "solid" or "watch-this: <concept>". Nothing was recorded.');
174
+ }
175
+
176
+ /** Index of the first `##` heading after `start`, or lines.length. */
177
+ function regionEnd(lines, start) {
178
+ let i = start + 1;
179
+ while (i < lines.length && !/^##\s/.test(lines[i])) i++;
180
+ return i;
181
+ }
182
+
183
+ /** Find or create `## 📝 Section Entries`; returns its line index. */
184
+ function ensureEntriesHeading(lines) {
185
+ let e = lines.findIndex((l) => ENTRIES_RE.test(l));
186
+ if (e !== -1) return e;
187
+ const attempt = lines.findIndex((l) => /^##\s+.*attempt log/i.test(l));
188
+ let at = attempt === -1 ? lines.length : regionEnd(lines, attempt);
189
+ while (at > 0 && !lines[at - 1].trim()) at--;
190
+ lines.splice(at, 0, '', '## 📝 Section Entries', '');
191
+ return lines.findIndex((l) => ENTRIES_RE.test(l));
192
+ }
193
+
194
+ function verdictEntryLines(entry) {
195
+ const out = [`### Section ${entry.section}`, '', `- Date: ${entry.date}`];
196
+ if (entry.hardest) out.push(`- Hardest: ${oneLine(entry.hardest)}`);
197
+ if (entry.duration) out.push(`- Duration: ${oneLine(entry.duration)}`);
198
+ for (const { q, a } of entry.answers) out.push(`- Q: "${oneLine(q)}" → A: "${oneLine(a)}"`);
199
+ out.push(`- Verdict: ${entry.verdict}`);
200
+ if (entry.lecture) out.push(`- Archived lecture: ${oneLine(entry.lecture)}`);
201
+ return out;
202
+ }
203
+
204
+ /** Put the watch-this flag under the section's reasoning trail, replacing an older one. */
205
+ function addTrailWatch(lines, section, watch) {
206
+ let t = lines.findIndex((l) => TRAIL_RE.test(l));
207
+ if (t === -1) {
208
+ const e = lines.findIndex((l) => ENTRIES_RE.test(l));
209
+ let at = e === -1 ? lines.length : e;
210
+ while (at > 0 && !lines[at - 1].trim()) at--;
211
+ lines.splice(at, 0, '', '## 🗣️ Reasoning Trail', '');
212
+ t = at + 1;
213
+ }
214
+ let end = regionEnd(lines, t);
215
+ const key = sectionKey(section);
216
+ let h = -1;
217
+ for (let i = t + 1; i < end; i++) if (sectionHeadRe(key).test(lines[i])) { h = i; break; }
218
+ const flag = `⚠️ watch-this: ${oneLine(watch)}`;
219
+ if (h === -1) {
220
+ while (end > t + 1 && !lines[end - 1].trim()) end--;
221
+ lines.splice(end, 0, '', `### Section ${section}`, '', flag, '');
222
+ return;
223
+ }
224
+ let blockEnd = h + 1;
225
+ while (blockEnd < lines.length && !/^#{2,3}\s/.test(lines[blockEnd])) blockEnd++;
226
+ const block = lines.slice(h + 1, blockEnd);
227
+ const { preface, rounds } = splitRounds(block);
228
+ const keep = preface.filter((l) => !/watch-this/i.test(l));
229
+ keep.push(flag);
230
+ const rebuilt = [
231
+ '', ...keep, '',
232
+ ...rounds.flatMap((r) => [...r, '']).slice(0, -1),
233
+ '',
234
+ ];
235
+ lines.splice(h + 1, block.length, ...rebuilt);
236
+ }
237
+
238
+ /** Pure: return LEARNING-LOG.md text with the section entry for `entry`. */
239
+ export function appendVerdict(text, entry) {
240
+ let src = text && text.trim()
241
+ ? text
242
+ : `---\ncourse: ${entry.slug}\nupdated: ${entry.date}\n---\n\n# Learning Log\n`;
243
+ src = src.replace(/^(---\n[\s\S]*?^updated:\s*).*$/m, `$1${entry.date}`);
244
+ const lines = src.replace(/\n+$/, '').split('\n');
245
+
246
+ const e = ensureEntriesHeading(lines);
247
+ const headRe = sectionHeadRe(sectionKey(entry.section));
248
+ let h = -1;
249
+ for (let i = e + 1; i < lines.length && !/^##\s/.test(lines[i]); i++) {
250
+ if (headRe.test(lines[i])) { h = i; break; }
251
+ }
252
+ const body = verdictEntryLines(entry);
253
+ if (h === -1) {
254
+ let at = regionEnd(lines, e);
255
+ while (at > e + 1 && !lines[at - 1].trim()) at--;
256
+ lines.splice(at, 0, '', ...body, '');
257
+ } else {
258
+ let end = h + 1;
259
+ while (end < lines.length && !/^#{2,3}\s/.test(lines[end])) end++;
260
+ while (end > h + 1 && !lines[end - 1].trim()) end--;
261
+ lines.splice(h, end - h, ...body, '');
262
+ }
263
+
264
+ if (entry.watch || /watch-this/i.test(entry.verdict)) {
265
+ const watch = entry.watch || normalizeVerdict(entry.verdict).watch;
266
+ if (watch) addTrailWatch(lines, entry.section, watch);
267
+ }
268
+
269
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
270
+ return { text: `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n` };
271
+ }
272
+
273
+ /** Record the done-gate verdict; Professor is the only writer of LEARNING-LOG.md. */
274
+ export function recordVerdict({
275
+ cwd, slug, section, hardest, answers, verdict, duration, lecture, date = todayISO(),
276
+ }) {
277
+ if (!slug) throw new Error('Pass the course slug — verdict never guesses which course to write.');
278
+ const normalized = normalizeVerdict(verdict);
279
+ if (!oneLine(hardest)) throw new Error('Missing hardest — nothing was recorded.');
280
+ const cleaned = (Array.isArray(answers) ? answers : [])
281
+ .map((a) => (typeof a === 'string' ? { q: 'What was the hardest concept?', a } : a || {}))
282
+ .map((a) => ({ q: oneLine(a.q), a: oneLine(a.a) }))
283
+ .filter((a) => a.q && a.a);
284
+ if (!cleaned.length) throw new Error('Missing answers — nothing was recorded.');
285
+ const ctx = buildContext({ cwd, slug });
286
+ const target = section || ctx.section;
287
+ if (!target) throw new Error('No current section in COURSE.md — pass --section "N — Title".');
288
+ const path = ctx.learningLog;
289
+ const before = existsSync(path) ? readFileSync(path, 'utf-8') : '';
290
+ const { text } = appendVerdict(before, {
291
+ slug, section: target, date, duration, hardest, answers: cleaned,
292
+ verdict: normalized.verdict, watch: normalized.watch, lecture,
293
+ });
294
+ writeFileSync(path, text);
295
+ return { recorded: true, path, section: target, verdict: normalized.verdict, watch: normalized.watch };
296
+ }
297
+
164
298
  function flagValue(args, name) {
165
299
  const i = args.indexOf(name);
166
300
  return i !== -1 && args[i + 1] !== undefined && !args[i + 1].startsWith('--') ? args[i + 1] : null;
167
301
  }
168
302
 
169
- const VALUE_FLAGS = ['--section', '--learner', '--asked', '--concept', '--evidence', '--watch', '--date'];
303
+ const VALUE_FLAGS = [
304
+ '--section', '--learner', '--asked', '--concept', '--evidence', '--watch', '--date',
305
+ '--hardest', '--answers', '--verdict', '--duration', '--lecture',
306
+ ];
170
307
 
171
308
  function positionals(args) {
172
309
  const out = [];
@@ -197,6 +334,19 @@ export async function run({ args = [], cwd = process.cwd(), command = 'context'
197
334
  }
198
335
  return;
199
336
  }
337
+ if (command === 'verdict') {
338
+ // Same stdin-only rule as trail: the learner's words never appear inside a shell flag.
339
+ const fromStdin = args.includes('--stdin') ? JSON.parse(readFileSync(0, 'utf-8')) : {};
340
+ const f = (n) => fromStdin[n.slice(2)] ?? flagValue(args, n);
341
+ const r = recordVerdict({
342
+ cwd, slug, section: f('--section'), hardest: f('--hardest'), answers: f('--answers'),
343
+ verdict: f('--verdict'), duration: f('--duration'), lecture: f('--lecture'),
344
+ date: f('--date') || undefined,
345
+ });
346
+ if (json) console.log(JSON.stringify(r));
347
+ else console.log(style('success', `Recorded verdict "${r.verdict}" for Section ${r.section} in ${relative(cwd, r.path)}`));
348
+ return;
349
+ }
200
350
  const ctx = buildContext({ cwd, slug });
201
351
  if (json) { console.log(JSON.stringify(ctx, null, 2)); return; }
202
352
  console.log(`${ctx.title} (${ctx.slug}, ${ctx.kind}) — ${ctx.dir}`);
@@ -5,13 +5,13 @@
5
5
  * Legacy mode is the explicit opt-in (`--legacy` or `PROFESSOR_LEGACY=1`) and keeps the old
6
6
  * Anthropic/OpenAI fallback prompt. The server itself owns legacy-dev Vite startup (D-08).
7
7
  */
8
- import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
9
  import { join, dirname } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { createRequire } from 'module';
12
12
  import inquirer from 'inquirer';
13
13
  import ora from 'ora';
14
- import { buildPairingUrl } from './studio.js';
14
+ import { buildPairingUrl, loopbackUrl, toLoopbackUrl } from './studio.js';
15
15
  import * as security from '../../web/security.js';
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -297,13 +297,7 @@ export async function run({ args = [], style, startServerImpl } = {}) {
297
297
 
298
298
  process.env.PORT = positionalPort;
299
299
 
300
- let defaultCourses = './courses';
301
- if (existsSync('./courses')) {
302
- if (readdirSync('./courses').length === 0) defaultCourses = './learning';
303
- } else if (existsSync('./learning')) {
304
- defaultCourses = './learning';
305
- }
306
- process.env.COURSES_DIR = process.env.COURSES_DIR || defaultCourses;
300
+ process.env.COURSES_DIR = process.env.COURSES_DIR || './learning';
307
301
 
308
302
  const token =
309
303
  rotatedToken ||
@@ -330,7 +324,11 @@ export async function run({ args = [], style, startServerImpl } = {}) {
330
324
  host: process.env.HOST,
331
325
  logBanner: false,
332
326
  });
333
- const uiUrl = started?.uiUrl || `http://localhost:${expectedUiPort}`;
327
+ // Printed links use the IPv4 literal: `localhost` can resolve to `::1`, where another dev server
328
+ // may answer instead of this one (the server itself binds 127.0.0.1). Legacy dev is left alone
329
+ // because Vite owns that port.
330
+ const uiBase = started?.uiUrl || loopbackUrl(expectedUiPort);
331
+ const uiUrl = legacy && !isProduction ? uiBase : toLoopbackUrl(uiBase);
334
332
  const uiPort = (() => {
335
333
  try {
336
334
  return new URL(uiUrl).port || expectedUiPort;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Course reader — pure parsers for course files on disk.
3
3
  *
4
- * Reads `learning/{slug}/` (primary) and `courses/{slug}/` (legacy). Every
4
+ * Reads `learning/{slug}/`, the single course location. Every
5
5
  * function here is side-effect free apart from reading files, so Phase 31
6
6
  * (`studio`, `review`, `import`) can reuse the same shapes without going
7
7
  * through the CLI.
@@ -252,7 +252,7 @@ export function parseSections(content) {
252
252
  return parseListSections(region);
253
253
  }
254
254
 
255
- function parseCapstoneStatus(content, rowStatus) {
255
+ export function parseCapstoneStatus(content, rowStatus) {
256
256
  const raw = metaValue(content, 'Capstone status');
257
257
  if (raw) {
258
258
  const lower = raw.toLowerCase();
@@ -488,10 +488,10 @@ export function resolveExercise(course, { lecture = null } = {}) {
488
488
 
489
489
  /**
490
490
  * Find every course directory on disk.
491
- * Scans `learning/*​/` first, then legacy `courses/*​/` (dedup by slug).
491
+ * Scans `learning/*​/` only — it is the single course location.
492
492
  * @param {string} cwd
493
- * @param {{ dir?: string }} [opts] `dir` overrides the roots (single course or parent)
494
- * @returns {{ slug: string, dir: string, location: 'learning'|'courses'|'custom' }[]}
493
+ * @param {{ dir?: string }} [opts] `dir` overrides the root (single course or parent)
494
+ * @returns {{ slug: string, dir: string, location: 'learning'|'custom' }[]}
495
495
  */
496
496
  export function findCourses(cwd, { dir } = {}) {
497
497
  if (dir) {
@@ -501,16 +501,7 @@ export function findCourses(cwd, { dir } = {}) {
501
501
  }
502
502
  return scanRoot(abs, 'custom');
503
503
  }
504
- const results = [];
505
- const seen = new Set();
506
- for (const [name, location] of [['learning', 'learning'], ['courses', 'courses']]) {
507
- for (const course of scanRoot(join(cwd, name), location)) {
508
- if (seen.has(course.slug)) continue;
509
- seen.add(course.slug);
510
- results.push(course);
511
- }
512
- }
513
- return results;
504
+ return scanRoot(join(cwd, 'learning'), 'learning');
514
505
  }
515
506
 
516
507
  function scanRoot(root, location) {
@@ -1,9 +1,139 @@
1
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs';
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ statSync,
7
+ existsSync,
8
+ copyFileSync,
9
+ } from 'fs';
2
10
  import { join, dirname } from 'path';
3
11
  import { fileURLToPath } from 'url';
4
12
 
5
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
14
 
15
+ /** Markers that delimit a plugin-managed region inside a learner-editable file. */
16
+ export const BLOCK_START = '<!-- professor:start -->';
17
+ export const BLOCK_END = '<!-- professor:end -->';
18
+
19
+ /**
20
+ * Counter for one setup run: what was created or updated, what was already current, and how
21
+ * many files were backed up.
22
+ * @returns {{ updated: number, unchanged: number, backedUp: number, backedUpPaths: Set<string> }}
23
+ */
24
+ export function createInstallRun() {
25
+ return { updated: 0, unchanged: 0, backedUp: 0, backedUpPaths: new Set() };
26
+ }
27
+
28
+ /**
29
+ * Save the content a write is about to replace, at most once per run per file.
30
+ * @param {string} dest
31
+ * @param {{ backedUp: number, backedUpPaths: Set<string> }} run
32
+ */
33
+ function _backupOnce(dest, run) {
34
+ if (run.backedUpPaths.has(dest)) return;
35
+ copyFileSync(dest, `${dest}.bak`);
36
+ run.backedUpPaths.add(dest);
37
+ run.backedUp++;
38
+ }
39
+
40
+ /**
41
+ * Write a file the plugin owns: create it, leave it alone when it matches, otherwise back it up and
42
+ * replace it. This is what makes re-running setup refresh an upgrade.
43
+ * @param {string} dest
44
+ * @param {string} content
45
+ * @param {ReturnType<typeof createInstallRun>} run
46
+ * @param {string} [label] path shown in the log
47
+ * @returns {'created'|'updated'|'unchanged'}
48
+ */
49
+ export function writeManagedFile(dest, content, run, label = dest) {
50
+ mkdirSync(dirname(dest), { recursive: true });
51
+
52
+ if (!existsSync(dest)) {
53
+ writeFileSync(dest, content);
54
+ run.updated++;
55
+ console.log(` + ${label}`);
56
+ return 'created';
57
+ }
58
+
59
+ if (readFileSync(dest, 'utf-8') === content) {
60
+ run.unchanged++;
61
+ console.log(` = ${label} (unchanged)`);
62
+ return 'unchanged';
63
+ }
64
+
65
+ _backupOnce(dest, run);
66
+ writeFileSync(dest, content);
67
+ run.updated++;
68
+ console.log(` ~ ${label} (updated, backup saved)`);
69
+ return 'updated';
70
+ }
71
+
72
+ /**
73
+ * Refresh the marker-delimited Professor block inside a file the learner may also edit.
74
+ * Text outside the markers is preserved; a file without markers gets the block appended.
75
+ * @param {string} dest
76
+ * @param {string} blockContent
77
+ * @param {ReturnType<typeof createInstallRun>} run
78
+ * @param {string} [label]
79
+ * @returns {'created'|'updated'|'unchanged'}
80
+ */
81
+ export function writeManagedBlock(dest, blockContent, run, label = dest) {
82
+ const block = `${BLOCK_START}\n${blockContent}\n${BLOCK_END}`;
83
+ mkdirSync(dirname(dest), { recursive: true });
84
+
85
+ if (!existsSync(dest)) {
86
+ writeFileSync(dest, `${block}\n`);
87
+ run.updated++;
88
+ console.log(` + ${label}`);
89
+ return 'created';
90
+ }
91
+
92
+ const current = readFileSync(dest, 'utf-8');
93
+ const start = current.indexOf(BLOCK_START);
94
+ const end = start === -1 ? -1 : current.indexOf(BLOCK_END, start);
95
+ let next;
96
+ if (start !== -1 && end !== -1) {
97
+ next = current.slice(0, start) + block + current.slice(end + BLOCK_END.length);
98
+ } else {
99
+ next = `${current}${current.endsWith('\n') ? '\n' : '\n\n'}${block}\n`;
100
+ }
101
+
102
+ if (next === current) {
103
+ run.unchanged++;
104
+ console.log(` = ${label} (unchanged)`);
105
+ return 'unchanged';
106
+ }
107
+
108
+ _backupOnce(dest, run);
109
+ writeFileSync(dest, next);
110
+ run.updated++;
111
+ console.log(` ~ ${label} (updated, backup saved)`);
112
+ return 'updated';
113
+ }
114
+
115
+ /**
116
+ * True when the content is the skill document an older release wrote wholesale into a file.
117
+ * @param {string} content
118
+ * @returns {boolean}
119
+ */
120
+ export function isLegacySkillDocument(content) {
121
+ const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
122
+ return !!frontmatter && /^name:\s*professor\s*$/m.test(frontmatter[1]);
123
+ }
124
+
125
+ /**
126
+ * One summary line per platform run.
127
+ * @param {string} platform
128
+ * @param {ReturnType<typeof createInstallRun>} run
129
+ */
130
+ export function printInstallSummary(platform, run) {
131
+ console.log(
132
+ `✎ Setup summary (${platform}): ${run.updated} updated, ` +
133
+ `${run.unchanged} unchanged, ${run.backedUp} backed up.`
134
+ );
135
+ }
136
+
7
137
  // Maps platform name → tool substitutions.
8
138
  // null means: strip the AskUserQuestion call and replace with plain inline text.
9
139
  export const TOOL_SUBSTITUTIONS = {
@@ -46,7 +176,9 @@ export function substituteTokens(content, platform) {
46
176
  */
47
177
  export function copySharedFiles(targetDir, platform) {
48
178
  const sharedDir = join(__dirname, '..', '..', 'shared');
49
- _copyRecursive(sharedDir, targetDir, platform, true);
179
+ const run = createInstallRun();
180
+ _copyRecursive(sharedDir, targetDir, platform, true, run);
181
+ return run;
50
182
  }
51
183
 
52
184
  /**
@@ -54,23 +186,24 @@ export function copySharedFiles(targetDir, platform) {
54
186
  * By default, applies token substitution to .md/.js files.
55
187
  */
56
188
  export function copyDirectoryFiles(sourceDir, targetDir, platform, { substitute = true } = {}) {
57
- _copyRecursive(sourceDir, targetDir, platform, substitute);
189
+ const run = createInstallRun();
190
+ _copyRecursive(sourceDir, targetDir, platform, substitute, run);
191
+ return run;
58
192
  }
59
193
 
60
- function _copyRecursive(src, dest, platform, substitute) {
194
+ function _copyRecursive(src, dest, platform, substitute, run) {
61
195
  if (!existsSync(src)) return;
62
196
  const stat = statSync(src);
63
197
  if (stat.isDirectory()) {
64
- if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
65
198
  for (const file of readdirSync(src)) {
66
- _copyRecursive(join(src, file), join(dest, file), platform, substitute);
199
+ _copyRecursive(join(src, file), join(dest, file), platform, substitute, run);
67
200
  }
68
- } else if (!existsSync(dest)) {
201
+ } else {
69
202
  let content = readFileSync(src, 'utf-8');
70
203
  if (substitute && (src.endsWith('.md') || src.endsWith('.js'))) {
71
204
  content = substituteTokens(content, platform);
72
205
  }
73
- writeFileSync(dest, content);
206
+ writeManagedFile(dest, content, run);
74
207
  }
75
208
  }
76
209
 
@@ -167,16 +300,18 @@ function _parseFrontmatter(content) {
167
300
 
168
301
  /**
169
302
  * Read every .md file from shared/commands/professor/, apply substituteTokens(),
170
- * then write per-platform command files into targetDir. Does NOT overwrite existing files.
303
+ * then write per-platform command files into targetDir.
171
304
  *
172
- * platform = 'gemini' → writes targetDir/<slug>.toml
305
+ * platform = 'gemini' → writes targetDir/<slug>.toml (arguments become {{args}})
173
306
  * platform = 'opencode' → writes targetDir/professor-<slug>.md
307
+ *
308
+ * @param {string} targetDir
309
+ * @param {'gemini'|'opencode'} platform
310
+ * @param {ReturnType<typeof createInstallRun>} [run]
174
311
  */
175
- export function generateCommandFiles(targetDir, platform) {
312
+ export function generateCommandFiles(targetDir, platform, run = createInstallRun()) {
176
313
  const sharedDir = join(__dirname, '..', '..', 'shared', 'commands', 'professor');
177
- if (!existsSync(sharedDir)) return;
178
-
179
- mkdirSync(targetDir, { recursive: true });
314
+ if (!existsSync(sharedDir)) return run;
180
315
 
181
316
  for (const file of readdirSync(sharedDir)) {
182
317
  if (!file.endsWith('.md')) continue;
@@ -184,28 +319,19 @@ export function generateCommandFiles(targetDir, platform) {
184
319
 
185
320
  let raw = readFileSync(join(sharedDir, file), 'utf-8');
186
321
  raw = substituteTokens(raw, platform);
322
+ if (platform === 'gemini') raw = raw.replaceAll('$ARGUMENTS', '{{args}}');
187
323
  const { description, body } = _parseFrontmatter(raw);
188
324
 
189
325
  if (platform === 'gemini') {
190
326
  const filename = `${stem}.toml`;
191
- const destPath = join(targetDir, filename);
192
- if (existsSync(destPath)) {
193
- console.log(` ↔ ${filename} (already exists)`);
194
- continue;
195
- }
196
327
  const output = `description = "${description}"\n\nprompt = """\n${body}"""\n`;
197
- writeFileSync(destPath, output);
198
- console.log(` + ${filename}`);
328
+ writeManagedFile(join(targetDir, filename), output, run, filename);
199
329
  } else if (platform === 'opencode') {
200
330
  const filename = `professor-${stem}.md`;
201
- const destPath = join(targetDir, filename);
202
- if (existsSync(destPath)) {
203
- console.log(` ↔ ${filename} (already exists)`);
204
- continue;
205
- }
206
331
  const output = `---\ndescription: ${description}\nsubtask: true\n---\n\n${body}`;
207
- writeFileSync(destPath, output);
208
- console.log(` + ${filename}`);
332
+ writeManagedFile(join(targetDir, filename), output, run, filename);
209
333
  }
210
334
  }
335
+
336
+ return run;
211
337
  }
@@ -1,7 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { substituteTokens } from './_shared.js';
4
+ import {
5
+ substituteTokens,
6
+ createInstallRun,
7
+ writeManagedFile,
8
+ printInstallSummary,
9
+ } from './_shared.js';
5
10
 
6
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
12
  const PLUGIN_DIR = join(__dirname, '..', '..');
@@ -25,6 +30,7 @@ export async function install(scope = 'local') {
25
30
  : join(process.cwd(), '.claude');
26
31
 
27
32
  const label = scope === 'global' ? '~/.claude/' : '.claude/';
33
+ const run = createInstallRun();
28
34
 
29
35
  if (!existsSync(base)) {
30
36
  mkdirSync(base, { recursive: true });
@@ -32,67 +38,42 @@ export async function install(scope = 'local') {
32
38
  }
33
39
 
34
40
  // 1. agents/ — one .md file per agent
35
- const agentsDestDir = join(base, 'agents');
36
- mkdirSync(agentsDestDir, { recursive: true });
37
- const agentsSrcDir = join(PLUGIN_DIR, 'shared', 'agents');
38
41
  console.log('✓ Installing agents...');
42
+ const agentsSrcDir = join(PLUGIN_DIR, 'shared', 'agents');
43
+ const agentsDestDir = join(base, 'agents');
39
44
  for (const file of readdirSync(agentsSrcDir)) {
40
45
  if (!file.endsWith('.md')) continue;
41
- const dest = join(agentsDestDir, file);
42
- if (existsSync(dest)) {
43
- console.log(` ↔ agents/${file} (already exists)`);
44
- } else {
45
- const content = substituteTokens(readFileSync(join(agentsSrcDir, file), 'utf-8'), 'claude');
46
- writeFileSync(dest, content);
47
- console.log(` + agents/${file}`);
48
- }
46
+ const content = substituteTokens(readFileSync(join(agentsSrcDir, file), 'utf-8'), 'claude');
47
+ writeManagedFile(join(agentsDestDir, file), content, run, `agents/${file}`);
49
48
  }
50
49
 
51
50
  // 2. commands/professor/ — one .md file per command
52
- const commandsDestDir = join(base, 'commands', 'professor');
53
- mkdirSync(commandsDestDir, { recursive: true });
54
- const commandsSrcDir = join(PLUGIN_DIR, 'shared', 'commands', 'professor');
55
51
  console.log('✓ Installing commands...');
52
+ const commandsSrcDir = join(PLUGIN_DIR, 'shared', 'commands', 'professor');
53
+ const commandsDestDir = join(base, 'commands', 'professor');
56
54
  for (const file of readdirSync(commandsSrcDir)) {
57
55
  if (!file.endsWith('.md')) continue;
58
- const dest = join(commandsDestDir, file);
59
- if (existsSync(dest)) {
60
- console.log(` ↔ commands/professor/${file} (already exists)`);
61
- } else {
62
- const content = substituteTokens(readFileSync(join(commandsSrcDir, file), 'utf-8'), 'claude');
63
- writeFileSync(dest, content);
64
- console.log(` + commands/professor/${file}`);
65
- }
56
+ const content = substituteTokens(readFileSync(join(commandsSrcDir, file), 'utf-8'), 'claude');
57
+ writeManagedFile(join(commandsDestDir, file), content, run, `commands/professor/${file}`);
66
58
  }
67
59
 
68
60
  // 3. hooks/ — pre-compact.js
69
- const hooksDestDir = join(base, 'hooks');
70
- mkdirSync(hooksDestDir, { recursive: true });
71
61
  const hooksSrcDir = join(PLUGIN_DIR, 'shared', 'hooks');
72
62
  if (existsSync(hooksSrcDir)) {
73
63
  console.log('✓ Installing hooks...');
64
+ const hooksDestDir = join(base, 'hooks');
74
65
  for (const file of readdirSync(hooksSrcDir)) {
75
- const dest = join(hooksDestDir, file);
76
- if (existsSync(dest)) {
77
- console.log(` ↔ hooks/${file} (already exists)`);
78
- } else {
79
- writeFileSync(dest, readFileSync(join(hooksSrcDir, file), 'utf-8'));
80
- console.log(` + hooks/${file}`);
81
- }
66
+ const content = readFileSync(join(hooksSrcDir, file), 'utf-8');
67
+ writeManagedFile(join(hooksDestDir, file), content, run, `hooks/${file}`);
82
68
  }
83
69
  }
84
70
 
85
71
  // 4. skills/professor/ — SKILL.md
86
- const skillsDestDir = join(base, 'skills', 'professor');
87
- mkdirSync(skillsDestDir, { recursive: true });
88
72
  const skillSrc = join(PLUGIN_DIR, 'shared', 'SKILL.md');
89
- const skillDest = join(skillsDestDir, 'SKILL.md');
90
- if (existsSync(skillDest)) {
91
- console.log(' ↔ skills/professor/SKILL.md (already exists)');
92
- } else {
93
- writeFileSync(skillDest, readFileSync(skillSrc, 'utf-8'));
94
- console.log(' + skills/professor/SKILL.md');
95
- }
73
+ const skillDest = join(base, 'skills', 'professor', 'SKILL.md');
74
+ writeManagedFile(skillDest, readFileSync(skillSrc, 'utf-8'), run, 'skills/professor/SKILL.md');
75
+
76
+ printInstallSummary('Claude Code', run);
96
77
 
97
78
  console.log(`
98
79
  ✅ Setup complete for Claude Code!