minimal-vibe-coding-kit 0.4.2 → 0.5.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 (133) hide show
  1. package/.agents/skills/claim/SKILL.md +82 -0
  2. package/.agents/skills/tutien/SKILL.md +74 -0
  3. package/.agents/skills/tutien/references/classification.md +64 -0
  4. package/.agents/skills/tutien/references/lore-sources.md +15 -0
  5. package/.agents/skills/tutien/references/privacy.md +33 -0
  6. package/.agents/skills/tutien/references/schema.md +75 -0
  7. package/.agents/skills/tutien/references/scoring-and-realms.md +37 -0
  8. package/.agents/skills/tutien/references/voice-and-mode.md +44 -0
  9. package/.agents/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
  10. package/.agents/skills/tutien/scripts/adapters/git.mjs +48 -0
  11. package/.agents/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
  12. package/.agents/skills/tutien/scripts/analyze-history.mjs +84 -0
  13. package/.agents/skills/tutien/scripts/catalog.mjs +130 -0
  14. package/.agents/skills/tutien/scripts/classify.mjs +311 -0
  15. package/.agents/skills/tutien/scripts/command.mjs +107 -0
  16. package/.agents/skills/tutien/scripts/compare.mjs +41 -0
  17. package/.agents/skills/tutien/scripts/metrics.mjs +306 -0
  18. package/.agents/skills/tutien/scripts/normalize-events.mjs +68 -0
  19. package/.agents/skills/tutien/scripts/redact.mjs +30 -0
  20. package/.agents/skills/tutien/scripts/render-report.mjs +423 -0
  21. package/.agents/skills/tutien/scripts/run-tutien.mjs +272 -0
  22. package/.agents/skills/tutien/scripts/score.mjs +97 -0
  23. package/.agents/skills/tutien/scripts/snapshot.mjs +76 -0
  24. package/.agents/skills/tutien/scripts/villains.mjs +196 -0
  25. package/.claude/skills/claim/SKILL.md +82 -0
  26. package/.claude/skills/tutien/SKILL.md +74 -0
  27. package/.claude/skills/tutien/references/classification.md +64 -0
  28. package/.claude/skills/tutien/references/lore-sources.md +15 -0
  29. package/.claude/skills/tutien/references/privacy.md +33 -0
  30. package/.claude/skills/tutien/references/schema.md +75 -0
  31. package/.claude/skills/tutien/references/scoring-and-realms.md +37 -0
  32. package/.claude/skills/tutien/references/voice-and-mode.md +44 -0
  33. package/.claude/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
  34. package/.claude/skills/tutien/scripts/adapters/git.mjs +48 -0
  35. package/.claude/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
  36. package/.claude/skills/tutien/scripts/analyze-history.mjs +84 -0
  37. package/.claude/skills/tutien/scripts/catalog.mjs +130 -0
  38. package/.claude/skills/tutien/scripts/classify.mjs +311 -0
  39. package/.claude/skills/tutien/scripts/command.mjs +107 -0
  40. package/.claude/skills/tutien/scripts/compare.mjs +41 -0
  41. package/.claude/skills/tutien/scripts/metrics.mjs +306 -0
  42. package/.claude/skills/tutien/scripts/normalize-events.mjs +68 -0
  43. package/.claude/skills/tutien/scripts/redact.mjs +30 -0
  44. package/.claude/skills/tutien/scripts/render-report.mjs +423 -0
  45. package/.claude/skills/tutien/scripts/run-tutien.mjs +272 -0
  46. package/.claude/skills/tutien/scripts/score.mjs +97 -0
  47. package/.claude/skills/tutien/scripts/snapshot.mjs +76 -0
  48. package/.claude/skills/tutien/scripts/villains.mjs +196 -0
  49. package/.codex-plugin/plugin.json +1 -1
  50. package/.cursor/skills/claim/SKILL.md +82 -0
  51. package/.cursor/skills/tutien/SKILL.md +74 -0
  52. package/.cursor/skills/tutien/references/classification.md +64 -0
  53. package/.cursor/skills/tutien/references/lore-sources.md +15 -0
  54. package/.cursor/skills/tutien/references/privacy.md +33 -0
  55. package/.cursor/skills/tutien/references/schema.md +75 -0
  56. package/.cursor/skills/tutien/references/scoring-and-realms.md +37 -0
  57. package/.cursor/skills/tutien/references/voice-and-mode.md +44 -0
  58. package/.cursor/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
  59. package/.cursor/skills/tutien/scripts/adapters/git.mjs +48 -0
  60. package/.cursor/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
  61. package/.cursor/skills/tutien/scripts/analyze-history.mjs +84 -0
  62. package/.cursor/skills/tutien/scripts/catalog.mjs +130 -0
  63. package/.cursor/skills/tutien/scripts/classify.mjs +311 -0
  64. package/.cursor/skills/tutien/scripts/command.mjs +107 -0
  65. package/.cursor/skills/tutien/scripts/compare.mjs +41 -0
  66. package/.cursor/skills/tutien/scripts/metrics.mjs +306 -0
  67. package/.cursor/skills/tutien/scripts/normalize-events.mjs +68 -0
  68. package/.cursor/skills/tutien/scripts/redact.mjs +30 -0
  69. package/.cursor/skills/tutien/scripts/render-report.mjs +423 -0
  70. package/.cursor/skills/tutien/scripts/run-tutien.mjs +272 -0
  71. package/.cursor/skills/tutien/scripts/score.mjs +97 -0
  72. package/.cursor/skills/tutien/scripts/snapshot.mjs +76 -0
  73. package/.cursor/skills/tutien/scripts/villains.mjs +196 -0
  74. package/.grok/skills/claim/SKILL.md +82 -0
  75. package/.grok/skills/tutien/SKILL.md +74 -0
  76. package/.grok/skills/tutien/references/classification.md +64 -0
  77. package/.grok/skills/tutien/references/lore-sources.md +15 -0
  78. package/.grok/skills/tutien/references/privacy.md +33 -0
  79. package/.grok/skills/tutien/references/schema.md +75 -0
  80. package/.grok/skills/tutien/references/scoring-and-realms.md +37 -0
  81. package/.grok/skills/tutien/references/voice-and-mode.md +44 -0
  82. package/.grok/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
  83. package/.grok/skills/tutien/scripts/adapters/git.mjs +48 -0
  84. package/.grok/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
  85. package/.grok/skills/tutien/scripts/analyze-history.mjs +84 -0
  86. package/.grok/skills/tutien/scripts/catalog.mjs +130 -0
  87. package/.grok/skills/tutien/scripts/classify.mjs +311 -0
  88. package/.grok/skills/tutien/scripts/command.mjs +107 -0
  89. package/.grok/skills/tutien/scripts/compare.mjs +41 -0
  90. package/.grok/skills/tutien/scripts/metrics.mjs +306 -0
  91. package/.grok/skills/tutien/scripts/normalize-events.mjs +68 -0
  92. package/.grok/skills/tutien/scripts/redact.mjs +30 -0
  93. package/.grok/skills/tutien/scripts/render-report.mjs +423 -0
  94. package/.grok/skills/tutien/scripts/run-tutien.mjs +272 -0
  95. package/.grok/skills/tutien/scripts/score.mjs +97 -0
  96. package/.grok/skills/tutien/scripts/snapshot.mjs +76 -0
  97. package/.grok/skills/tutien/scripts/villains.mjs +196 -0
  98. package/.vibekit/docs/BACKBONE_REFERENCE.md +2 -0
  99. package/.vibekit/docs/INSTALL.md +3 -1
  100. package/.vibekit/init/CLAUDE-template.md +2 -0
  101. package/.vibekit/scripts/mvck.mjs +5 -5
  102. package/.vibekit/scripts/pack-dry-run.mjs +38 -3
  103. package/.vibekit/scripts/validate-kit.mjs +34 -24
  104. package/.vibekit/skills/claim/SKILL.md +82 -0
  105. package/.vibekit/skills/skills-manifest.json +27 -0
  106. package/.vibekit/skills/tutien/SKILL.md +74 -0
  107. package/.vibekit/skills/tutien/references/classification.md +64 -0
  108. package/.vibekit/skills/tutien/references/lore-sources.md +15 -0
  109. package/.vibekit/skills/tutien/references/privacy.md +33 -0
  110. package/.vibekit/skills/tutien/references/schema.md +75 -0
  111. package/.vibekit/skills/tutien/references/scoring-and-realms.md +37 -0
  112. package/.vibekit/skills/tutien/references/voice-and-mode.md +44 -0
  113. package/.vibekit/skills/tutien/scripts/adapters/generic-jsonl.mjs +82 -0
  114. package/.vibekit/skills/tutien/scripts/adapters/git.mjs +48 -0
  115. package/.vibekit/skills/tutien/scripts/adapters/plain-transcript.mjs +37 -0
  116. package/.vibekit/skills/tutien/scripts/analyze-history.mjs +84 -0
  117. package/.vibekit/skills/tutien/scripts/catalog.mjs +130 -0
  118. package/.vibekit/skills/tutien/scripts/classify.mjs +311 -0
  119. package/.vibekit/skills/tutien/scripts/command.mjs +107 -0
  120. package/.vibekit/skills/tutien/scripts/compare.mjs +41 -0
  121. package/.vibekit/skills/tutien/scripts/metrics.mjs +306 -0
  122. package/.vibekit/skills/tutien/scripts/normalize-events.mjs +68 -0
  123. package/.vibekit/skills/tutien/scripts/redact.mjs +30 -0
  124. package/.vibekit/skills/tutien/scripts/render-report.mjs +423 -0
  125. package/.vibekit/skills/tutien/scripts/run-tutien.mjs +272 -0
  126. package/.vibekit/skills/tutien/scripts/score.mjs +97 -0
  127. package/.vibekit/skills/tutien/scripts/snapshot.mjs +76 -0
  128. package/.vibekit/skills/tutien/scripts/villains.mjs +196 -0
  129. package/CHANGELOG.md +30 -0
  130. package/README.md +51 -47
  131. package/docs/README.vi.md +12 -8
  132. package/docs/README.zh-CN.md +277 -0
  133. package/package.json +9 -3
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { parseInvocation, TUTIEN_EXPERIENCE } from './command.mjs';
9
+ import { analyze } from './analyze-history.mjs';
10
+ import { renderReport, resolveLanguage, renderClassificationMarkdown } from './render-report.mjs';
11
+ import { buildSnapshot, snapshotsToPrune } from './snapshot.mjs';
12
+ import { compareSnapshots } from './compare.mjs';
13
+ import { classifyProject } from './classify.mjs';
14
+
15
+ // End-to-end executor for the advertised /tutien actions. All state lives
16
+ // under the git-ignored .vibekit/reports/tutien/ of the current repo; the
17
+ // runner never deletes anything (retention only prints a `trash` command).
18
+ //
19
+ // run-tutien.mjs [on|off|status|preview|analyze|compare|explain|classify] [k=v ...]
20
+ //
21
+ // Approval boundary: `preview` prints the exact scope and an approval token;
22
+ // `analyze` refuses to read anything until it receives approve=<that token>
23
+ // for the identical scope.
24
+
25
+ const sha16 = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
26
+
27
+ const root = process.cwd();
28
+ const reportDir = path.join(root, '.vibekit', 'reports', 'tutien');
29
+ const stateFile = path.join(reportDir, 'state.json');
30
+ const snapDir = path.join(reportDir, 'snapshots');
31
+
32
+ function loadState() {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
35
+ } catch {
36
+ return { mode: 'off' };
37
+ }
38
+ }
39
+ function saveState(state) {
40
+ fs.mkdirSync(reportDir, { recursive: true });
41
+ fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`);
42
+ }
43
+
44
+ function classifySources(sources) {
45
+ const spec = { jsonlFiles: [], transcriptFiles: [], gitRoot: null };
46
+ for (const s of sources) {
47
+ if (s === 'git') spec.gitRoot = root;
48
+ else if (s.endsWith('.jsonl')) spec.jsonlFiles.push(s);
49
+ else spec.transcriptFiles.push(s);
50
+ }
51
+ return spec;
52
+ }
53
+
54
+ const scopeToken = (sources, range) => sha16(JSON.stringify({ sources: [...sources].sort(), range }));
55
+
56
+ // Project profile for classification: repo metadata only (backbone.yml
57
+ // fields plus a distinct-commit-author COUNT — no names or emails are kept)
58
+ // merged with explicit declarations from the invocation.
59
+ function repoProfile(options) {
60
+ let description = null;
61
+ let primaryLanguage = null;
62
+ let projectType = null;
63
+ try {
64
+ const text = fs.readFileSync(path.join(root, 'backbone.yml'), 'utf8');
65
+ const grab = (re) => {
66
+ const m = text.match(re);
67
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
68
+ };
69
+ description = grab(/^\s*description:\s*(.+)$/m);
70
+ primaryLanguage = grab(/^\s*primary_language:\s*(.+)$/m);
71
+ projectType = grab(/^\s*type:\s*(.+)$/m);
72
+ } catch {}
73
+ // Author identifiers are read transiently (mailmap-collapsed) and reduced to
74
+ // a distinct COUNT immediately; no email or name is retained or returned.
75
+ let authorsCount = null;
76
+ const res = spawnSync('git', ['log', '--use-mailmap', '--format=%aE'], { cwd: root, encoding: 'utf8' });
77
+ if (res.status === 0) authorsCount = new Set(res.stdout.split('\n').filter(Boolean)).size;
78
+ return {
79
+ description,
80
+ primaryLanguage,
81
+ projectType,
82
+ domains: options.domains ?? [],
83
+ authorization: options.authorization ?? null,
84
+ authorsCount,
85
+ declared: { faction: options.faction, affiliation: options.affiliation, paths: options.paths }
86
+ };
87
+ }
88
+
89
+ // Preview inspects no content: file existence and size only, plus the git
90
+ // toplevel path. Nothing is parsed and nothing is written except the pending
91
+ // approval in the state file.
92
+ function previewLines(options) {
93
+ const lines = ['tutien preview — the observatory is open, but no scroll has been read; nothing is analyzed until this exact scope is approved.'];
94
+ if (options.sources.length === 0) lines.push('- (no sources; pass sources=git,/path/to/export.jsonl)');
95
+ for (const s of options.sources) {
96
+ if (s === 'git') {
97
+ lines.push(`- git: commit metadata of ${root} (read-only git log, current repository only)`);
98
+ } else {
99
+ let size = null;
100
+ try {
101
+ size = fs.statSync(s).size;
102
+ } catch {}
103
+ lines.push(size == null ? `- file: ${s} (NOT FOUND)` : `- file: ${s} (${size} bytes)`);
104
+ }
105
+ }
106
+ lines.push(`- range: ${options.range}`);
107
+ const token = scopeToken(options.sources, options.range);
108
+ lines.push(`To approve exactly this scope: analyze approve=${token} sources=${options.sources.join(',')}`);
109
+ return { lines, token };
110
+ }
111
+
112
+ const EXPLAIN = {
113
+ reportedTotal: 'Sum of provider-reported usable token totals; cumulative streaming records sharing a requestId are counted once (last value), independent chunks are summed. Records without a computable positive total never contribute.',
114
+ estimatedTotal: 'Sum of locally estimated usable token totals; never mixed into reportedTotal.',
115
+ unknownTurns: 'Assistant message turns whose usage is absent or has no usable total; claimed accuracy without numbers counts as unknown.',
116
+ retryLoops: 'Runs of 3+ exact/near-identical user prompts within one task with intervening assistant/tool events; confidence-scored candidates.',
117
+ conflicts: 'A "never/do not X" instruction followed in the same task by a later prompt requesting X; paired event IDs, confidence 0.6.',
118
+ realm: 'Weighted dimension score (delivery 30%, validation 25%, clarity 15%, recovery 15%, safety 10%, automation 5%) mapped to the realm ladder; requires >= 60% known-token coverage, otherwise "Chưa đủ thiên cơ".'
119
+ };
120
+
121
+ export function run(argsString = '') {
122
+ const { action, explicitAction, options } = parseInvocation(argsString);
123
+ const state = loadState();
124
+ const refuse = (msg) => ({ code: 2, out: [msg] });
125
+
126
+ if (action === 'off') {
127
+ state.mode = 'off';
128
+ delete state.pendingApproval;
129
+ saveState(state);
130
+ return { code: 0, out: ['Tutien mode is off. Normal kit writing style restored.'] };
131
+ }
132
+ if (action === 'on') {
133
+ state.mode = 'on';
134
+ saveState(state);
135
+ return { code: 0, out: ['Tutien mode is on — the mountain gate is open. Run preview to see the exact scope before any analysis.'] };
136
+ }
137
+ if (action === 'status') {
138
+ const snaps = fs.existsSync(snapDir) ? fs.readdirSync(snapDir).length : 0;
139
+ return {
140
+ code: 0,
141
+ out: [
142
+ `mode: ${state.mode ?? 'off'}`,
143
+ `experience: ${TUTIEN_EXPERIENCE.kind}`,
144
+ `voice: ${TUTIEN_EXPERIENCE.narrativeStyle}`,
145
+ `semantic namespace: ${TUTIEN_EXPERIENCE.semanticNamespace}`,
146
+ `pending approval: ${state.pendingApproval ?? 'none'}`,
147
+ `snapshots: ${snaps}`
148
+ ]
149
+ };
150
+ }
151
+
152
+ // Report actions. A bare `/tutien` re-activates the mode; an explicit
153
+ // report action while off is refused (off-mode suppression).
154
+ if ((state.mode ?? 'off') !== 'on') {
155
+ if (explicitAction) return refuse("tutien mode is off; run 'on' (or bare /tutien) first.");
156
+ state.mode = 'on';
157
+ }
158
+
159
+ if (action === 'preview') {
160
+ const { lines, token } = previewLines(options);
161
+ state.pendingApproval = token;
162
+ saveState(state);
163
+ return { code: 0, out: lines };
164
+ }
165
+
166
+ if (action === 'classify') {
167
+ // classify is a metadata-only action (no history content): it reads
168
+ // backbone.yml fields and a transient distinct-author count of the current
169
+ // repo. It states that scope before producing the classification.
170
+ let classification;
171
+ try {
172
+ classification = classifyProject(repoProfile(options));
173
+ } catch (err) {
174
+ return refuse(err.message);
175
+ }
176
+ saveState(state);
177
+ const language = resolveLanguage(options.language, { invocationText: argsString });
178
+ const scope = language === 'vi'
179
+ ? 'Phạm vi classify: chỉ đọc metadata (backbone.yml + số lượng tác giả commit của repo hiện tại) — không đọc nội dung lịch sử, không tính tiến cảnh (tiến cảnh cần `analyze`).'
180
+ : 'classify scope: metadata only (backbone.yml + a distinct commit-author count of the current repo) — no history content is read, and no progression is computed (progression needs `analyze`).';
181
+ return { code: 0, out: [scope, '', renderClassificationMarkdown(classification, language)] };
182
+ }
183
+
184
+ if (action === 'explain') {
185
+ if (options.metric && EXPLAIN[options.metric]) {
186
+ return { code: 0, out: [`A note from the Dao ledger — ${options.metric}: ${EXPLAIN[options.metric]}`] };
187
+ }
188
+ return { code: 0, out: [`The Dao ledger can explain: ${Object.keys(EXPLAIN).join(', ')} (pass metric=<name>).`] };
189
+ }
190
+
191
+ if (action === 'compare') {
192
+ const files = fs.existsSync(snapDir) ? fs.readdirSync(snapDir).filter((f) => f.endsWith('.json')).sort() : [];
193
+ if (files.length < 2) return refuse('need at least two snapshots to compare; run analyze snapshot=true first.');
194
+ const load = (f) => JSON.parse(fs.readFileSync(path.join(snapDir, f), 'utf8'));
195
+ const result = compareSnapshots(load(files[files.length - 2]), load(files[files.length - 1]));
196
+ return {
197
+ code: 0,
198
+ out: ['Two cultivation records rest beneath the same lamp; the figures below show only what changed.', JSON.stringify(result, null, 2)]
199
+ };
200
+ }
201
+
202
+ if (action === 'analyze') {
203
+ const token = scopeToken(options.sources, options.range);
204
+ if (!options.approve) {
205
+ return refuse('analysis requires explicit approval: run preview, then pass the approve=<token> it printed.');
206
+ }
207
+ if (options.approve !== token || state.pendingApproval !== token) {
208
+ return refuse('approval token does not match the previewed scope; run preview again and approve that exact scope.');
209
+ }
210
+ delete state.pendingApproval;
211
+
212
+ const analysis = analyze(classifySources(options.sources));
213
+ let prior = null;
214
+ try {
215
+ const files = fs.readdirSync(snapDir).filter((f) => f.endsWith('.json')).sort();
216
+ if (files.length) prior = JSON.parse(fs.readFileSync(path.join(snapDir, files[files.length - 1]), 'utf8'));
217
+ } catch {}
218
+
219
+ const language = resolveLanguage(options.language, { invocationText: argsString });
220
+ let profile = null;
221
+ try {
222
+ profile = repoProfile(options);
223
+ } catch {}
224
+ const { model, markdown } = renderReport(analysis, {
225
+ ...options,
226
+ language,
227
+ profile,
228
+ progressionSalt: path.basename(root),
229
+ priorProgression: prior?.progression ?? null,
230
+ priorVillains: prior?.villainState ?? []
231
+ });
232
+
233
+ const out = [];
234
+ if (options.snapshot) {
235
+ fs.mkdirSync(snapDir, { recursive: true });
236
+ const createdAt = new Date().toISOString();
237
+ const snap = buildSnapshot(analysis, {
238
+ projectId: path.basename(root),
239
+ window: options.range,
240
+ createdAt,
241
+ prevVillainState: prior?.villainState ?? [],
242
+ shownVillains: model.villainCards,
243
+ canGamify: !model.suppressGamification,
244
+ policyState: model.policyState,
245
+ progression: model.cultivation?.progression ?? null,
246
+ classification: model.cultivation
247
+ ? { faction: model.cultivation.classification.faction.id, paths: model.cultivation.classification.paths.map((p) => p.id) }
248
+ : null
249
+ });
250
+ const file = path.join(snapDir, `${createdAt.replace(/[:.]/g, '-')}.json`);
251
+ fs.writeFileSync(file, `${JSON.stringify(snap, null, 2)}\n`);
252
+ out.push(`snapshot written: ${path.relative(root, file)}`);
253
+ const prune = snapshotsToPrune(fs.readdirSync(snapDir).filter((f) => f.endsWith('.json')), 20);
254
+ if (prune.length) {
255
+ out.push(`retention: remove old snapshots with: trash ${prune.map((f) => path.relative(root, path.join(snapDir, f))).join(' ')}`);
256
+ }
257
+ }
258
+ fs.mkdirSync(reportDir, { recursive: true });
259
+ fs.writeFileSync(path.join(reportDir, 'latest.md'), `${markdown}\n`);
260
+ saveState(state);
261
+ return { code: 0, out: [...out, markdown] };
262
+ }
263
+
264
+ return refuse(`unknown action "${action}"`);
265
+ }
266
+
267
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
268
+ if (isMain) {
269
+ const res = run(process.argv.slice(2).join(' '));
270
+ for (const line of res.out) console.log(line);
271
+ process.exit(res.code);
272
+ }
@@ -0,0 +1,97 @@
1
+ // Deterministic scoring: analysis JSON -> dimension scores -> realm.
2
+ // No randomness, no wall clock. High token usage never raises the score;
3
+ // necessary iteration is not punished; penalties are capped so one hard task
4
+ // cannot erase a long history.
5
+
6
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
7
+ const round3 = (x) => Math.round(x * 1000) / 1000;
8
+
9
+ export const DIMENSIONS = [
10
+ { key: 'delivery', weight: 0.3 },
11
+ { key: 'validation', weight: 0.25 },
12
+ { key: 'clarity', weight: 0.15 },
13
+ { key: 'recovery', weight: 0.15 },
14
+ { key: 'safety', weight: 0.1 },
15
+ { key: 'automation', weight: 0.05 }
16
+ ];
17
+
18
+ export const REALMS = [
19
+ { min: 0, max: 14, name: 'Phàm Nhân Nhập Môn', gloss: 'Mortal Initiate' },
20
+ { min: 15, max: 29, name: 'Luyện Khí', gloss: 'Qi Refining' },
21
+ { min: 30, max: 44, name: 'Trúc Cơ', gloss: 'Foundation Establishment' },
22
+ { min: 45, max: 59, name: 'Kết Đan', gloss: 'Core Formation' },
23
+ { min: 60, max: 69, name: 'Nguyên Anh', gloss: 'Nascent Soul' },
24
+ { min: 70, max: 79, name: 'Hóa Thần', gloss: 'Spirit Transformation' },
25
+ { min: 80, max: 87, name: 'Luyện Hư', gloss: 'Void Refinement' },
26
+ { min: 88, max: 93, name: 'Hợp Thể', gloss: 'Body Integration' },
27
+ { min: 94, max: 97, name: 'Đại Thừa', gloss: 'Great Vehicle' },
28
+ { min: 98, max: 100, name: 'Độ Kiếp / Phi Thăng', gloss: 'Tribulation / Ascension' }
29
+ ];
30
+
31
+ // Fraction of assistant turns with known (reported or estimated) usage.
32
+ // Below 0.60 the run does not have enough evidence ("thiên cơ") to score.
33
+ export function coverageFraction(analysis) {
34
+ const tc = analysis.coverage?.tokenCoverage ?? {};
35
+ return clamp01((tc.reportedTurnsPct ?? 0) + (tc.estimatedTurnsPct ?? 0));
36
+ }
37
+
38
+ export function scoreDimensions(analysis) {
39
+ const issues = analysis.issues ?? {};
40
+ const passes = issues.passes ?? 0;
41
+ const failures = (issues.failures ?? []).length;
42
+ const recoveries = (issues.recoveries ?? []).length;
43
+ const commits = analysis.coverage?.commits ?? 0;
44
+ const userPrompts = analysis.coverage?.userPrompts ?? 0;
45
+ const validationEvents = passes + failures;
46
+ const rep = analysis.repetition ?? {};
47
+ const retryPrompts = (rep.retryLoopCandidates ?? []).reduce((n, l) => n + l.count, 0);
48
+ const conflicts = (analysis.conflicts ?? []).length;
49
+
50
+ // delivery: verified completion vs failed attempts, plus committed output.
51
+ const delivery = validationEvents + commits === 0
52
+ ? 0.3
53
+ : clamp01((passes + recoveries + Math.min(commits, 5) * 0.3) / (passes + failures + 1));
54
+
55
+ // validation discipline: presence and density of validation evidence.
56
+ const validation = validationEvents === 0
57
+ ? 0.2
58
+ : clamp01(0.55 + 0.1 * recoveries + 0.05 * passes);
59
+
60
+ // clarity: 1 - avoidable-repeat rate (capped penalty).
61
+ const clarity = clamp01(1 - Math.min(0.6, userPrompts ? retryPrompts / userPrompts : 0));
62
+
63
+ // recovery from failure.
64
+ const recovery = failures === 0 ? 0.7 : clamp01(recoveries / failures);
65
+
66
+ // safety / rule alignment: high baseline, capped penalty per conflict.
67
+ const safety = clamp01(0.9 - Math.min(0.4, conflicts * 0.15));
68
+
69
+ // reusable automation: not inferable from history alone in V1 (neutral).
70
+ const automation = 0.4;
71
+
72
+ const scores = { delivery, validation, clarity, recovery, safety, automation };
73
+ for (const k of Object.keys(scores)) scores[k] = round3(scores[k]);
74
+ return scores;
75
+ }
76
+
77
+ export function realmFor(score) {
78
+ return REALMS.find((r) => score >= r.min && score <= r.max) ?? REALMS[0];
79
+ }
80
+
81
+ export function scoreReport(analysis, { coverageGate = 0.6 } = {}) {
82
+ const coverage = round3(coverageFraction(analysis));
83
+ const dimensions = scoreDimensions(analysis);
84
+ if (coverage < coverageGate) {
85
+ return {
86
+ enoughEvidence: false,
87
+ coverage,
88
+ dimensions,
89
+ score: null,
90
+ realm: { name: 'Chưa đủ thiên cơ', gloss: 'Not enough evidence yet' },
91
+ missing: ['token usage coverage below 60%; supply provider usage metadata to compute a realm']
92
+ };
93
+ }
94
+ const weighted = DIMENSIONS.reduce((sum, d) => sum + dimensions[d.key] * d.weight, 0);
95
+ const score = Math.round(weighted * 100);
96
+ return { enoughEvidence: true, coverage, dimensions, score, realm: realmFor(score), missing: [] };
97
+ }
@@ -0,0 +1,76 @@
1
+ // Phase 4 persistence. A snapshot is an AGGREGATE only: metric counts,
2
+ // coverage, token totals, score/realm, thresholds, adapter versions, villain
3
+ // cooldown state, and SALTED event-id digests. It never stores raw prompts,
4
+ // URLs, secrets, or file contents. Snapshots live under the git-ignored
5
+ // .vibekit/reports/tutien/ tree; deletion follows the repo safe-delete policy
6
+ // (prefer `trash`, never silent `rm`).
7
+
8
+ import crypto from 'node:crypto';
9
+ import { scoreReport } from './score.mjs';
10
+ import { detectProblems } from './catalog.mjs';
11
+ import { advanceVillainState } from './villains.mjs';
12
+
13
+ export const SNAPSHOT_SCHEMA = 1;
14
+ const PEPPER = 'tutien-snapshot-v1';
15
+
16
+ const sha16 = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
17
+ // Salt is stable per project (so trends line up) but not reversible to raw ids.
18
+ const saltFor = (projectId) => sha16(`${PEPPER}:${projectId}`);
19
+
20
+ export function buildSnapshot(analysis, options = {}) {
21
+ const { projectId = 'repo', window = 'all', createdAt = null, thresholds = {}, adapterVersions = {}, prevVillainState = [] } = options;
22
+ const salt = saltFor(projectId);
23
+ const score = scoreReport(analysis);
24
+ const problems = detectProblems(analysis);
25
+
26
+ // Provenance without content: only salted digests of evidence event ids.
27
+ const provenance = [
28
+ ...new Set(problems.flatMap((p) => p.evidence.eventIds ?? []))
29
+ ].sort().map((id) => sha16(`${salt}:${id}`));
30
+
31
+ const rep = analysis.repetition ?? {};
32
+ const issues = analysis.issues ?? {};
33
+
34
+ const shown = options.shownVillains ?? [];
35
+ // A suppressed policy (declared-stop / needs-review / authorization-required)
36
+ // must not persist a score, realm, or dimensions — the same fail-closed
37
+ // projection the renderer uses. Only Nghiệp Lực survives in progression.
38
+ const suppressed = options.canGamify === false;
39
+ return {
40
+ snapshotSchema: SNAPSHOT_SCHEMA,
41
+ createdAt,
42
+ projectId,
43
+ window,
44
+ coverage: analysis.coverage ?? {},
45
+ tokens: analysis.tokens ?? {},
46
+ counts: {
47
+ exactRepeats: (rep.exactRepeats ?? []).length,
48
+ nearRepeats: (rep.nearRepeats ?? []).length,
49
+ retryLoops: (rep.retryLoopCandidates ?? []).length,
50
+ conflicts: (analysis.conflicts ?? []).length,
51
+ failures: (issues.failures ?? []).length,
52
+ recoveries: (issues.recoveries ?? []).length,
53
+ passes: issues.passes ?? 0
54
+ },
55
+ score: suppressed ? null : score.score,
56
+ realm: suppressed ? null : score.realm,
57
+ dimensions: suppressed ? null : score.dimensions,
58
+ thresholds,
59
+ adapterVersions,
60
+ villainState: advanceVillainState(prevVillainState, shown),
61
+ // Cultivation accumulators and classification are aggregate slugs only:
62
+ // metric counters, the salted seen-ledger, and faction/path ids — never
63
+ // rationale text, raw prompts, or authorization values.
64
+ progression: options.progression ?? null,
65
+ classification: options.classification ?? null,
66
+ policyState: options.policyState ?? null,
67
+ provenance
68
+ };
69
+ }
70
+
71
+ // Retention is a pure decision; the caller performs the actual removal with
72
+ // `trash` per safe-delete rules. Returns the oldest files beyond `keep`.
73
+ export function snapshotsToPrune(files, keep = 20) {
74
+ const sorted = [...files].sort();
75
+ return sorted.slice(0, Math.max(0, sorted.length - keep));
76
+ }
@@ -0,0 +1,196 @@
1
+ // Phase 3 villain engine. Evidence chooses the problem and the
2
+ // counter-technique; a deterministic seed chooses only the villain's name and
3
+ // wording. Nothing here can change a count, severity, evidence, or advice.
4
+ //
5
+ // Safety rails (all enforced, not optional):
6
+ // - a villain appears only when its problem confidence >= threshold (0.80);
7
+ // - at most one boss and two minor villains per report;
8
+ // - villains=off, tone=neutral, or any emergency signal -> no villains;
9
+ // - challenge lines are authored safe and re-checked against banned
10
+ // categories before emission;
11
+ // - origin stories use only an allowlist of sanitized context slugs — never
12
+ // raw prompts, secrets, names, emails, or identity-bearing paths.
13
+
14
+ import crypto from 'node:crypto';
15
+ import { normalizeTone } from './command.mjs';
16
+
17
+ export const VILLAIN_THRESHOLD = 0.8;
18
+ export const MAX_BOSS = 1;
19
+ export const MAX_MINOR = 2;
20
+ export const DEFAULT_COOLDOWN_WINDOWS = 2;
21
+
22
+ // Defense-in-depth: challenge lines are written to avoid these, but every
23
+ // emitted line is re-scanned so a future edit cannot slip an attack through.
24
+ const BANNED_CATEGORY = [
25
+ /\b(stupid|idiot|idiotic|incompetent|dumb|moron|useless|worthless|pathetic|loser|failure of a)\b/i,
26
+ /\b(insane|crazy|psycho|mental|deranged|unhinged|tẩu hỏa nhập ma)\b/i,
27
+ /\b(retard|cripple|disabled|handicapped)\b/i,
28
+ /\b(sin|heretic against god|infidel|kafir|damned to hell)\b/i,
29
+ /\b(man up|like a girl|woman driver|too emotional for a)\b/i,
30
+ /\b(race|racial|ethnic slur|your people)\b/i,
31
+ /\b(broke|poor|can't afford|worthless salary|financial failure)\b/i,
32
+ /\b(kill|die|hurt you|destroy you|i will end)\b/i,
33
+ /\b(f[u*]ck|sh[i*]t|damn you|bastard)\b/i,
34
+ /\b(humiliat|shame on you|you should be ashamed|embarrass yourself)\b/i
35
+ ];
36
+
37
+ export function containsBannedCategory(text) {
38
+ return BANNED_CATEGORY.some((re) => re.test(String(text)));
39
+ }
40
+
41
+ // Only these context fields may shape an origin story, and each must be a
42
+ // strict slug: no slashes, dots, at-signs, spaces, or traversal — so neither
43
+ // free text nor path-like or identity-bearing values can ride along.
44
+ const CONTEXT_ALLOWLIST = ['projectType', 'primaryLanguage', 'fileCategory', 'validationLabel', 'metricCategory'];
45
+ const SLUG = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
46
+
47
+ export function sanitizeContext(ctx = {}) {
48
+ const out = {};
49
+ for (const key of CONTEXT_ALLOWLIST) {
50
+ const v = ctx[key];
51
+ if (typeof v === 'string' && SLUG.test(v)) out[key] = v;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Two authored variants per tone per archetype. The seed picks the index, so
57
+ // changing the seed changes wording only. The serene/spirited vocabulary is
58
+ // local to this coding-reflection game and always targets the workflow pattern,
59
+ // never the person or any unrelated support/companion persona.
60
+ const LINES = {
61
+ 'repeated-failure': {
62
+ serene: [
63
+ { vi: 'Giữa làn sương mỏng, Luân Hồi Lỗi Ma hiện bóng khi lỗi cũ quay thêm một vòng.', en: 'In the thin mountain mist, the Error-Cycle Wraith appears as the old error completes another circle.' },
64
+ { vi: 'Một lần thử giống hệt khẽ lay chuông, gọi Luân Hồi Lỗi Ma trở lại đạo lộ.', en: 'One unchanged retry stirs the bell that calls the Error-Cycle Wraith back to the path.' }
65
+ ],
66
+ spirited: [
67
+ { vi: 'Luân Hồi Lỗi Ma lại gõ chuông: “Không đổi giả thuyết, sao đổi được kết cục?”', en: 'The Error-Cycle Wraith rings its bell again: “Without a new hypothesis, what new ending could appear?”' },
68
+ { vi: 'Vòng lặp cũ vừa khép, Luân Hồi Lỗi Ma đã bày sẵn bàn trà chờ vòng kế tiếp.', en: 'The old loop has barely closed, and the Error-Cycle Wraith has already set tea for the next.' }
69
+ ]
70
+ },
71
+ 'too-many-prompts': {
72
+ serene: [
73
+ { vi: 'Cửu Hoàn Tâm Ma dệt một task thành nhiều vòng sương khi cột mốc chưa hiện rõ.', en: 'The Ninefold Loop Heart-Shadow weaves one task into rings of mist when its checkpoints remain unclear.' },
74
+ { vi: 'Nơi ranh giới task nhạt dần, Cửu Hoàn Tâm Ma lặng lẽ nối thêm một vòng.', en: 'Where a task boundary fades, the Ninefold Loop Heart-Shadow quietly adds another circle.' }
75
+ ],
76
+ spirited: [
77
+ { vi: 'Cửu Hoàn Tâm Ma đếm prompt thay cột mốc; đã đến lúc đổi chiếc vòng kế tiếp thành một tiêu chí “xong”.', en: 'The Ninefold Loop Heart-Shadow is counting prompts instead of checkpoints; turn the next circle into a done-criterion.' },
78
+ { vi: 'Prompt càng tản như lá thu, Cửu Hoàn Tâm Ma càng dễ giấu mất lối ra.', en: 'The more prompts scatter like autumn leaves, the easier the Ninefold Loop Heart-Shadow hides the exit.' }
79
+ ]
80
+ },
81
+ 'conflicting-instructions': {
82
+ serene: [
83
+ { vi: 'Nghịch Lệnh Ma Quân hiện giữa hai đạo lệnh không thể cùng đứng vững.', en: 'The Lord of Clashing Edicts appears between two instructions that cannot stand together.' },
84
+ { vi: 'Hai đạo lệnh ngược chiều làm mặt hồ dậy sóng, và Nghịch Lệnh Ma Quân bước ra.', en: 'Two opposing edicts ripple the still lake, and the Lord of Clashing Edicts steps forth.' }
85
+ ],
86
+ spirited: [
87
+ { vi: 'Nghịch Lệnh Ma Quân giơ hai cuộn lệnh và hỏi: “Cuộn nào mới thật sự dẫn đường?”', en: 'The Lord of Clashing Edicts raises two scrolls and asks: “Which one truly leads the way?”' },
88
+ { vi: 'Hai lệnh cùng tranh ngôi; Nghịch Lệnh Ma Quân chỉ cần ta chưa lập thứ tự ưu tiên.', en: 'Two edicts contest the throne; the Lord of Clashing Edicts needs only an absent precedence rule.' }
89
+ ]
90
+ },
91
+ 'unrecovered-failure': {
92
+ serene: [
93
+ { vi: 'Vô Kiểm Ảnh Quân ẩn sau một thất bại chưa được soi lại bằng lần kiểm chứng mới.', en: 'The Unverified Trial Wraith rests behind a failure that has not yet met a fresh validation.' },
94
+ { vi: 'Một lỗi bị bỏ lại làm bóng Vô Kiểm Ảnh Quân dài thêm trên bậc đá.', en: 'A failure left behind lengthens the Unverified Trial Wraith’s shadow across the stone steps.' }
95
+ ],
96
+ spirited: [
97
+ { vi: 'Vô Kiểm Ảnh Quân phủi bụi trên ghế đá: một lần kiểm chứng thành công là đủ để mời nó rời đi.', en: 'The Unverified Trial Wraith dusts its stone seat; one passing validation is enough to send it on its way.' },
98
+ { vi: 'Chưa có lần chạy lại, Vô Kiểm Ảnh Quân vẫn ung dung giữ chỗ bên cổng hoàn thành.', en: 'Without a rerun, the Unverified Trial Wraith calmly keeps its seat beside the completion gate.' }
99
+ ]
100
+ },
101
+ 'work-without-proof': {
102
+ serene: [
103
+ { vi: 'Vô Chứng Đan Ảnh lảng vảng bên lò luyện khi lời tuyên bố hoàn thành chưa có bằng chứng đi cùng.', en: 'The Proofless Elixir Shade lingers by the furnace when a claim of completion carries no evidence.' },
104
+ { vi: 'Một commit thiếu kết quả kiểm chứng để lại làn khói mỏng cho Vô Chứng Đan Ảnh nương thân.', en: 'A commit without validation leaves a thread of smoke where the Proofless Elixir Shade can dwell.' }
105
+ ],
106
+ spirited: [
107
+ { vi: 'Vô Chứng Đan Ảnh nâng chén trà bên viên đan chưa thử; một dòng validation sẽ làm màn khói tan ngay.', en: 'The Proofless Elixir Shade raises tea beside an untested pill; one validation line will clear the smoke.' },
108
+ { vi: 'Không có biên nhận kiểm chứng, Vô Chứng Đan Ảnh cứ ngỡ lò luyện thuộc về mình.', en: 'Without a validation receipt, the Proofless Elixir Shade starts to think the furnace is its own.' }
109
+ ]
110
+ }
111
+ };
112
+
113
+ // Roll the villain cooldown forward one reporting window. Villains shown this
114
+ // run reset to a full cooldown; villains from prior runs that were not shown
115
+ // decrement and drop at zero. Feeds the next run's `priorVillains`.
116
+ export function advanceVillainState(prevState = [], shownVillains = []) {
117
+ const next = new Map();
118
+ for (const p of prevState) {
119
+ const remaining = Math.max(0, (p.windowsRemaining ?? 0) - 1);
120
+ if (remaining > 0) next.set(p.problemId, { problemId: p.problemId, windowsRemaining: remaining, priority: p.priority });
121
+ }
122
+ for (const v of shownVillains) {
123
+ next.set(v.problemId, { problemId: v.problemId, windowsRemaining: v.cooldownWindows ?? DEFAULT_COOLDOWN_WINDOWS, priority: v.priorityAtShow ?? v.priority ?? 0 });
124
+ }
125
+ return [...next.values()].sort((a, b) => a.problemId.localeCompare(b.problemId));
126
+ }
127
+
128
+ export function villainSeed({ projectId = 'repo', window = 'all', schemaVersion = 1 } = {}) {
129
+ return crypto.createHash('sha256').update(`${projectId}${window}${schemaVersion}`).digest('hex');
130
+ }
131
+
132
+ // Deterministic non-negative index from the seed and a per-villain salt.
133
+ function seedIndex(seed, salt, n) {
134
+ if (n <= 1) return 0;
135
+ const h = crypto.createHash('sha256').update(`${seed}${salt}`).digest();
136
+ return h[0] % n;
137
+ }
138
+
139
+ // problems: the raw output of detectProblems(analysis).
140
+ // options: { tone, villains, threshold, emergency, seedInputs, context,
141
+ // priorVillains: [{ problemId, windowsRemaining, priority }] }.
142
+ export function buildVillains(problems, options = {}) {
143
+ const tone = normalizeTone(options.tone);
144
+ if (options.villains === 'off') return { villains: [], suppressed: 'villains-off' };
145
+ if (tone === 'neutral' || options.emergency) return { villains: [], suppressed: 'neutral-context' };
146
+
147
+ const threshold = options.threshold ?? VILLAIN_THRESHOLD;
148
+ const seed = villainSeed(options.seedInputs);
149
+ const context = sanitizeContext(options.context);
150
+ const prior = new Map((options.priorVillains ?? []).map((p) => [p.problemId, p]));
151
+
152
+ const eligible = problems
153
+ .filter((p) => p.confidence >= threshold && LINES[p.problemId])
154
+ .sort((a, b) => b.priority - a.priority || a.problemId.localeCompare(b.problemId));
155
+
156
+ const cards = [];
157
+ let bosses = 0;
158
+ let minors = 0;
159
+ for (const p of eligible) {
160
+ // Cooldown: skip a villain still cooling down unless its problem worsened.
161
+ const was = prior.get(p.problemId);
162
+ if (was && was.windowsRemaining > 0 && p.priority <= was.priority) continue;
163
+
164
+ let role;
165
+ if (bosses < MAX_BOSS) { role = 'boss'; bosses += 1; }
166
+ else if (minors < MAX_MINOR) { role = 'minor'; minors += 1; }
167
+ else break;
168
+
169
+ const toneLines = LINES[p.problemId][tone] ?? LINES[p.problemId].serene;
170
+ const idx = seedIndex(seed, p.problemId, toneLines.length);
171
+ const challenge = toneLines[idx];
172
+
173
+ if (containsBannedCategory(challenge.vi) || containsBannedCategory(challenge.en)) {
174
+ // Fail safe: drop the theatrics, keep the finding (rendered plainly).
175
+ continue;
176
+ }
177
+
178
+ cards.push({
179
+ problemId: p.problemId,
180
+ role,
181
+ priority: p.priority,
182
+ archetype: p.meta.villain,
183
+ tone,
184
+ challenge,
185
+ originSignals: [`metric:${p.meta.problemType}`, ...Object.values(context)],
186
+ evidenceRefs: p.evidence.eventIds ?? [],
187
+ confidence: p.confidence,
188
+ rebuttal: p.meta.counterTechnique,
189
+ microQuest: p.meta.microQuest,
190
+ victory: p.meta.victory,
191
+ cooldownWindows: DEFAULT_COOLDOWN_WINDOWS,
192
+ safetyFlags: ['failure-mode-not-person', 'evidence-bound']
193
+ });
194
+ }
195
+ return { villains: cards, suppressed: null };
196
+ }
@@ -40,6 +40,8 @@ If no command is known, leave `validate` as a safe echo and ask the user to fill
40
40
 
41
41
  The `conventions` section is created during first-time init and must be reviewed before writing. It should capture repo-specific rules with evidence, not a fixed framework template.
42
42
 
43
+ `conventions.review_required_before_write: true` means: before writing to agent surfaces or instruction files — rules, skills, workflows, commands, root instruction files, and `backbone.yml` itself — the agent must show the proposed diff and wait for explicit approval. It does not gate ordinary code edits inside `policy.editable_paths`. Skills that offer a "clean, proceed in the same turn" path (for example `claim`) must downgrade to propose-and-wait for writes this flag covers.
44
+
43
45
  Include:
44
46
 
45
47
  - Naming style for files, directories, and symbols.
@@ -127,8 +127,10 @@ The installer also includes `visual-design-loop` for Claude, Codex, and Grok sur
127
127
 
128
128
  ## User-invoked utility skills
129
129
 
130
- Three user-invoked skills install across Claude, Codex, Cursor, and Grok surfaces:
130
+ Five user-invoked skills install across Claude, Codex, Cursor, and Grok surfaces:
131
131
 
132
132
  - `memento`: write a `MEMENTO.md` working note before closing a multi-day task (`/memento`), then resume from it in the next session (`/memento resume`).
133
133
  - `coding-level`: set the explanation register from 0 (ELI5) to 5 (expert peer) with `/coding-level N`; stays active until reinvoked.
134
134
  - `prompt-sharpener`: sharpen a rough prompt into a precise one with `/prompt-sharpener <rough prompt>`, then execute the sharpened version immediately in the same turn.
135
+ - `claim`: vet a request to bring something new into the repo with `/claim <request>` — validate URLs and references against official sources, check fit with existing rules and skills, confirm anything unclear, then integrate and document it.
136
+ - `tutien`: turn Git history and explicitly supplied AI-chat exports into a private, funny xianxia "cultivation" report with `/tutien` (realm, token use, workflow habits, evidence-bound suggestions); read-only, aggregate-only by default, and `/tutien off` disables the mode.