throughline 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/sc-detail.md +22 -0
- package/.claude/commands/tl.md +30 -0
- package/README.md +15 -8
- package/package.json +2 -1
- package/src/cli/install.mjs +57 -2
- package/src/cli/install.test.mjs +150 -0
- package/src/token-monitor.mjs +2 -2
- package/src/token-monitor.test.mjs +2 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Throughline の退避済みターン(L2 本文 + L3 ツール入出力)を時刻で復元する
|
|
3
|
+
argument-hint: <HH:MM:SS> または <HH:MM:SS>-<HH:MM:SS>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
以下の Bash コマンドを実行し、結果を全文そのまま表示してください(要約せず、一字一句)。
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
throughline detail "$ARGUMENTS"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 使い方
|
|
13
|
+
|
|
14
|
+
- `/sc-detail 14:23:05` — 指定時刻のターンを復元
|
|
15
|
+
- `/sc-detail 14:23-14:30` — 時刻範囲のターンをまとめて復元
|
|
16
|
+
|
|
17
|
+
## 何が返るか
|
|
18
|
+
|
|
19
|
+
- **L2**: そのターンの会話本文(ユーザー発言 + Claude 返答)
|
|
20
|
+
- **L3**: そのターンで実行されたツールの入出力
|
|
21
|
+
|
|
22
|
+
これは Throughline のセッション記憶 `~/.throughline/throughline.db` から読み出されます。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: 次の新規セッションに現セッションの記憶を引き継ぐためのバトンを置く (Throughline)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Throughline の UserPromptSubmit hook が `/tl` を検出し、バトン(現セッション ID)を保存しました。
|
|
6
|
+
|
|
7
|
+
次に必ずやること(スキップ厳禁):
|
|
8
|
+
|
|
9
|
+
1. 今この時点の「中断地点の状態」を自分の文脈から思い出し、以下 4 項目を Markdown で整理する:
|
|
10
|
+
- **次の一手**: 今まさに何をやろうとしていたか(1-3 文、具体的に)
|
|
11
|
+
- **現在の方針 / 仮説**: 追っているバグの原因、設計の方向性、調査中の観点など
|
|
12
|
+
- **未解決の疑問**: 判断保留中の論点
|
|
13
|
+
- **進行中 TODO**: 完了済みを除いた現行 TODO(TodoWrite の最新状態や Plan の内容を踏まえて、意味のあるものだけ)
|
|
14
|
+
|
|
15
|
+
2. Bash ツールで次のコマンドを実行し、stdin で上記 Markdown を流し込む:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
throughline save-inflight <<'EOF'
|
|
19
|
+
**次の一手**: ...
|
|
20
|
+
**現在の方針**: ...
|
|
21
|
+
**未解決の疑問**: ...
|
|
22
|
+
**進行中 TODO**:
|
|
23
|
+
- [ ] ...
|
|
24
|
+
- [ ] ...
|
|
25
|
+
EOF
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Bash の出力 (`[throughline] in-flight memo saved (N chars) for next session`) がそのまま完了確認になります。追加のユーザーへの報告テキストは不要です。ツール呼び出し後は何も返さずターンを終えて構いません(必要なら一言だけ)。
|
|
29
|
+
|
|
30
|
+
バトン(および in-flight メモ)は 1 時間で失効します。失効後に新セッションを開いた場合は引き継ぎは発火しません。
|
package/README.md
CHANGED
|
@@ -205,24 +205,31 @@ Example output (real values from a running 1M-context Opus session):
|
|
|
205
205
|
### VS Code auto-start (automatic)
|
|
206
206
|
|
|
207
207
|
After `throughline install`, any VS Code / Cursor / VSCodium project you work in
|
|
208
|
-
gets `.vscode/tasks.json` provisioned automatically on the
|
|
208
|
+
gets `.vscode/tasks.json` provisioned automatically on the first session event.
|
|
209
209
|
The file configures `runOn: folderOpen` so the monitor appears in a dedicated
|
|
210
210
|
terminal panel the next time you open that folder.
|
|
211
211
|
|
|
212
|
-
**How it works.**
|
|
212
|
+
**How it works.** `ensureMonitorTaskFile` is called from **all three hooks
|
|
213
|
+
(SessionStart, UserPromptSubmit, Stop)** as of v0.3.18. Whichever one fires
|
|
214
|
+
first in your environment creates the file; the rest are idempotent no-ops.
|
|
213
215
|
Once per project it inspects `.vscode/tasks.json`:
|
|
214
216
|
|
|
215
|
-
- **No file yet** → creates one with a single `Throughline Monitor` task
|
|
217
|
+
- **No file yet** → creates one with a single `Throughline Monitor` task, and
|
|
218
|
+
emits a one-time `<system-reminder>` to stdout so Claude tells you a
|
|
219
|
+
**Developer: Reload Window** is needed to activate the `folderOpen` task once
|
|
220
|
+
(v0.3.19+).
|
|
216
221
|
- **Plain JSON with other tasks** → appends the monitor task, preserves your
|
|
217
|
-
existing entries, `version`, and indentation.
|
|
222
|
+
existing entries, `version`, and indentation (same notice fires once).
|
|
218
223
|
- **JSONC (comments or trailing commas)** → does not touch the file. Prints a
|
|
219
224
|
one-time notice to stderr asking you to paste the snippet below.
|
|
220
225
|
- **Already contains a Throughline Monitor task** → does nothing (idempotent;
|
|
221
|
-
this is the common path on every subsequent turn).
|
|
226
|
+
this is the common path on every subsequent turn; notice is silent).
|
|
222
227
|
|
|
223
|
-
The generated task uses `type: '
|
|
224
|
-
`bin/throughline.mjs
|
|
225
|
-
|
|
228
|
+
The generated task uses `type: 'shell'` with the absolute path to Node and
|
|
229
|
+
`bin/throughline.mjs`. VS Code wraps shell tasks in a PTY (xterm.js) so the
|
|
230
|
+
monitor sees `isTTY=true`, real `columns`, and resize events. Windows `.cmd`
|
|
231
|
+
shims and missing PATH entries cannot break it because the command is already
|
|
232
|
+
an absolute Node binary path.
|
|
226
233
|
|
|
227
234
|
**Opt out:** set `THROUGHLINE_NO_VSCODE=1` in the environment used by Claude
|
|
228
235
|
Code. Delete `.vscode/tasks.json` (or just the monitor entry) if you want to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
|
|
6
6
|
"keywords": [
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"bin/",
|
|
23
23
|
"src/",
|
|
24
|
+
".claude/commands/",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|
package/src/cli/install.mjs
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* node のインストール先や OS が変わっても PATH さえ通れば動く。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
-
import { join, dirname } from 'node:path';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { join, dirname, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
16
17
|
|
|
18
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
19
|
+
const SLASH_COMMANDS_SRC = join(PACKAGE_ROOT, '.claude', 'commands');
|
|
20
|
+
const SC_SLASH_COMMAND_FILES = ['tl.md', 'sc-detail.md'];
|
|
21
|
+
|
|
17
22
|
// Throughline が管理する hook コマンド一覧
|
|
18
23
|
// schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
|
|
19
24
|
const SC_COMMANDS = [
|
|
@@ -48,6 +53,42 @@ function resolveSettingsPath(args) {
|
|
|
48
53
|
return join(homedir(), '.claude', 'settings.json');
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function resolveCommandsDir(args) {
|
|
57
|
+
if (args.includes('--project')) {
|
|
58
|
+
return join(process.cwd(), '.claude', 'commands');
|
|
59
|
+
}
|
|
60
|
+
return join(homedir(), '.claude', 'commands');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function installSlashCommands(commandsDir) {
|
|
64
|
+
if (!existsSync(SLASH_COMMANDS_SRC)) {
|
|
65
|
+
return { installed: [], skipped: 'source-missing' };
|
|
66
|
+
}
|
|
67
|
+
if (!existsSync(commandsDir)) mkdirSync(commandsDir, { recursive: true });
|
|
68
|
+
const installed = [];
|
|
69
|
+
for (const name of SC_SLASH_COMMAND_FILES) {
|
|
70
|
+
const src = join(SLASH_COMMANDS_SRC, name);
|
|
71
|
+
if (!existsSync(src)) continue;
|
|
72
|
+
const dest = join(commandsDir, name);
|
|
73
|
+
copyFileSync(src, dest);
|
|
74
|
+
installed.push(name);
|
|
75
|
+
}
|
|
76
|
+
return { installed, skipped: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function uninstallSlashCommands(commandsDir) {
|
|
80
|
+
const removed = [];
|
|
81
|
+
if (!existsSync(commandsDir)) return removed;
|
|
82
|
+
for (const name of SC_SLASH_COMMAND_FILES) {
|
|
83
|
+
const dest = join(commandsDir, name);
|
|
84
|
+
if (existsSync(dest)) {
|
|
85
|
+
unlinkSync(dest);
|
|
86
|
+
removed.push(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
function readSettings(settingsPath) {
|
|
52
93
|
if (!existsSync(settingsPath)) return {};
|
|
53
94
|
return JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
@@ -62,6 +103,7 @@ function writeSettings(settingsPath, obj) {
|
|
|
62
103
|
export async function run(args = []) {
|
|
63
104
|
const uninstall = args.includes('--uninstall');
|
|
64
105
|
const settingsPath = resolveSettingsPath(args);
|
|
106
|
+
const commandsDir = resolveCommandsDir(args);
|
|
65
107
|
const current = readSettings(settingsPath);
|
|
66
108
|
const existingHooks = current.hooks ?? {};
|
|
67
109
|
const scSet = new Set(SC_COMMANDS);
|
|
@@ -81,8 +123,12 @@ export async function run(args = []) {
|
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
writeSettings(settingsPath, current);
|
|
126
|
+
const removedCommands = uninstallSlashCommands(commandsDir);
|
|
84
127
|
console.log('Throughline hooks を削除しました。');
|
|
85
128
|
console.log(` ${settingsPath}`);
|
|
129
|
+
if (removedCommands.length > 0) {
|
|
130
|
+
console.log(` slash commands 削除: ${removedCommands.join(', ')} (${commandsDir})`);
|
|
131
|
+
}
|
|
86
132
|
return;
|
|
87
133
|
}
|
|
88
134
|
|
|
@@ -100,6 +146,7 @@ export async function run(args = []) {
|
|
|
100
146
|
|
|
101
147
|
current.hooks = existingHooks;
|
|
102
148
|
writeSettings(settingsPath, current);
|
|
149
|
+
const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
|
|
103
150
|
|
|
104
151
|
const scope = args.includes('--project') ? 'プロジェクトローカル' : 'グローバル(全プロジェクト)';
|
|
105
152
|
console.log(`Throughline hooks をインストールしました [${scope}]`);
|
|
@@ -110,5 +157,13 @@ export async function run(args = []) {
|
|
|
110
157
|
console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
|
|
111
158
|
console.log(' UserPromptSubmit → throughline prompt-submit (/tl バトン書き込み)');
|
|
112
159
|
console.log('');
|
|
160
|
+
if (installedCommands.length > 0) {
|
|
161
|
+
console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
|
|
162
|
+
console.log(` ${commandsDir}`);
|
|
163
|
+
console.log('');
|
|
164
|
+
} else if (skipped === 'source-missing') {
|
|
165
|
+
console.log('注意: パッケージ内に slash commands のソースが見つからないためスキップしました。');
|
|
166
|
+
console.log('');
|
|
167
|
+
}
|
|
113
168
|
console.log(' アンインストール: throughline uninstall');
|
|
114
169
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir, homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { run } from './install.mjs';
|
|
8
|
+
|
|
9
|
+
function makeTempHome() {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-install-test-'));
|
|
11
|
+
const origUserprofile = process.env.USERPROFILE;
|
|
12
|
+
const origHome = process.env.HOME;
|
|
13
|
+
process.env.USERPROFILE = dir;
|
|
14
|
+
process.env.HOME = dir;
|
|
15
|
+
const resolved = homedir();
|
|
16
|
+
return {
|
|
17
|
+
dir,
|
|
18
|
+
resolved,
|
|
19
|
+
restore() {
|
|
20
|
+
if (origUserprofile === undefined) delete process.env.USERPROFILE;
|
|
21
|
+
else process.env.USERPROFILE = origUserprofile;
|
|
22
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
23
|
+
else process.env.HOME = origHome;
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function silence() {
|
|
30
|
+
const origLog = console.log;
|
|
31
|
+
const origErr = console.error;
|
|
32
|
+
console.log = () => {};
|
|
33
|
+
console.error = () => {};
|
|
34
|
+
return () => {
|
|
35
|
+
console.log = origLog;
|
|
36
|
+
console.error = origErr;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('global install copies /tl and /sc-detail to ~/.claude/commands/', async () => {
|
|
41
|
+
const home = makeTempHome();
|
|
42
|
+
if (home.resolved !== home.dir) {
|
|
43
|
+
home.restore();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const unsilence = silence();
|
|
47
|
+
try {
|
|
48
|
+
await run([]);
|
|
49
|
+
const tl = join(home.dir, '.claude', 'commands', 'tl.md');
|
|
50
|
+
const sc = join(home.dir, '.claude', 'commands', 'sc-detail.md');
|
|
51
|
+
assert.ok(existsSync(tl), 'tl.md should be installed globally');
|
|
52
|
+
assert.ok(existsSync(sc), 'sc-detail.md should be installed globally');
|
|
53
|
+
const tlBody = readFileSync(tl, 'utf8');
|
|
54
|
+
assert.match(tlBody, /Throughline/, 'tl.md content should be real');
|
|
55
|
+
const settings = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
|
|
56
|
+
assert.ok(settings.hooks?.UserPromptSubmit, 'UserPromptSubmit hook should be registered');
|
|
57
|
+
} finally {
|
|
58
|
+
unsilence();
|
|
59
|
+
home.restore();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('project install copies commands to cwd/.claude/commands/', async () => {
|
|
64
|
+
const home = makeTempHome();
|
|
65
|
+
if (home.resolved !== home.dir) {
|
|
66
|
+
home.restore();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const projectDir = mkdtempSync(join(tmpdir(), 'tl-install-proj-'));
|
|
70
|
+
const origCwd = process.cwd();
|
|
71
|
+
process.chdir(projectDir);
|
|
72
|
+
const unsilence = silence();
|
|
73
|
+
try {
|
|
74
|
+
await run(['--project']);
|
|
75
|
+
const tl = join(projectDir, '.claude', 'commands', 'tl.md');
|
|
76
|
+
assert.ok(existsSync(tl), 'tl.md should be installed in project');
|
|
77
|
+
const globalTl = join(home.dir, '.claude', 'commands', 'tl.md');
|
|
78
|
+
assert.ok(!existsSync(globalTl), '--project should NOT touch global dir');
|
|
79
|
+
} finally {
|
|
80
|
+
unsilence();
|
|
81
|
+
process.chdir(origCwd);
|
|
82
|
+
home.restore();
|
|
83
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('uninstall removes slash command files', async () => {
|
|
88
|
+
const home = makeTempHome();
|
|
89
|
+
if (home.resolved !== home.dir) {
|
|
90
|
+
home.restore();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const unsilence = silence();
|
|
94
|
+
try {
|
|
95
|
+
await run([]);
|
|
96
|
+
const tl = join(home.dir, '.claude', 'commands', 'tl.md');
|
|
97
|
+
assert.ok(existsSync(tl), 'install should have placed tl.md');
|
|
98
|
+
await run(['--uninstall']);
|
|
99
|
+
assert.ok(!existsSync(tl), 'uninstall should remove tl.md');
|
|
100
|
+
const sc = join(home.dir, '.claude', 'commands', 'sc-detail.md');
|
|
101
|
+
assert.ok(!existsSync(sc), 'uninstall should remove sc-detail.md');
|
|
102
|
+
} finally {
|
|
103
|
+
unsilence();
|
|
104
|
+
home.restore();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('uninstall preserves unrelated slash commands in the same dir', async () => {
|
|
109
|
+
const home = makeTempHome();
|
|
110
|
+
if (home.resolved !== home.dir) {
|
|
111
|
+
home.restore();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const unsilence = silence();
|
|
115
|
+
try {
|
|
116
|
+
await run([]);
|
|
117
|
+
const otherCmd = join(home.dir, '.claude', 'commands', 'unrelated.md');
|
|
118
|
+
writeFileSync(otherCmd, '# unrelated slash command\n');
|
|
119
|
+
await run(['--uninstall']);
|
|
120
|
+
assert.ok(existsSync(otherCmd), 'uninstall must not touch unrelated files');
|
|
121
|
+
} finally {
|
|
122
|
+
unsilence();
|
|
123
|
+
home.restore();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
|
|
128
|
+
const home = makeTempHome();
|
|
129
|
+
if (home.resolved !== home.dir) {
|
|
130
|
+
home.restore();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const unsilence = silence();
|
|
134
|
+
try {
|
|
135
|
+
await run([]);
|
|
136
|
+
await run([]);
|
|
137
|
+
const tl = join(home.dir, '.claude', 'commands', 'tl.md');
|
|
138
|
+
assert.ok(existsSync(tl));
|
|
139
|
+
const settings = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
|
|
140
|
+
const stopGroups = settings.hooks.Stop;
|
|
141
|
+
const processTurnCount = stopGroups
|
|
142
|
+
.flatMap(g => g.hooks ?? [])
|
|
143
|
+
.filter(h => h.command === 'throughline process-turn')
|
|
144
|
+
.length;
|
|
145
|
+
assert.equal(processTurnCount, 1, 'double-install must not duplicate hook entries');
|
|
146
|
+
} finally {
|
|
147
|
+
unsilence();
|
|
148
|
+
home.restore();
|
|
149
|
+
}
|
|
150
|
+
});
|
package/src/token-monitor.mjs
CHANGED
|
@@ -304,8 +304,8 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
|
|
|
304
304
|
// 90%超: !! + 強い文言 (赤)
|
|
305
305
|
// 70%超: ! + 弱い文言 (黄)
|
|
306
306
|
const warn =
|
|
307
|
-
ratio >= 0.9 ? color(ANSI.red + ANSI.bold, ' !! /
|
|
308
|
-
ratio >= 0.7 ? color(ANSI.yellow, ' ! そろそろ /
|
|
307
|
+
ratio >= 0.9 ? color(ANSI.red + ANSI.bold, ' !! /tl 強く推奨') :
|
|
308
|
+
ratio >= 0.7 ? color(ANSI.yellow, ' ! そろそろ /tl') :
|
|
309
309
|
'';
|
|
310
310
|
|
|
311
311
|
const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
|
|
@@ -305,13 +305,13 @@ test('formatLine: 70% 未満は警告テキストなし', () => {
|
|
|
305
305
|
const out = stripColors(formatLine(makeLineArgs(0.5)));
|
|
306
306
|
assert.ok(!out.includes('!!'));
|
|
307
307
|
assert.ok(!out.includes('! '));
|
|
308
|
-
assert.ok(!out.includes('/
|
|
308
|
+
assert.ok(!out.includes('/tl'));
|
|
309
309
|
});
|
|
310
310
|
|
|
311
311
|
test('formatLine: 70% 以上で "!" マーカーと弱めの文言', () => {
|
|
312
312
|
const out = stripColors(formatLine(makeLineArgs(0.75)));
|
|
313
313
|
assert.ok(out.includes('!'), 'should include ! marker');
|
|
314
|
-
assert.ok(out.includes('そろそろ /
|
|
314
|
+
assert.ok(out.includes('そろそろ /tl'), 'should show soft warning');
|
|
315
315
|
assert.ok(!out.includes('!!'), 'should not include critical marker yet');
|
|
316
316
|
});
|
|
317
317
|
|