throughline 0.3.23 → 0.3.24

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/README.md CHANGED
@@ -509,6 +509,23 @@ Two things to know:
509
509
  `~/.npm-global/bin` is **before** the Windows npm path in `PATH` (the
510
510
  `.bashrc` snippet above prepends, so it does this naturally).
511
511
 
512
+ **`.vscode/tasks.json` should not be committed to git (recommended)**
513
+
514
+ The `Throughline Monitor` task that gets auto-generated contains absolute
515
+ paths specific to your machine (`process.execPath` and the install location
516
+ of `throughline.mjs`). For shared repositories, add one of these to
517
+ `.gitignore`:
518
+
519
+ ```gitignore
520
+ .vscode/tasks.json # only ignore tasks.json (settings.json etc. stay shared)
521
+ .vscode/ # ignore the whole .vscode directory
522
+ ```
523
+
524
+ Throughline detects this and prints a one-time recommendation when it first
525
+ creates / merges / repairs the file. (You don't have to follow the advice —
526
+ v0.3.23+ auto-repairs stale absolute paths anyway, so committing is not
527
+ catastrophic.)
528
+
512
529
  **Cross-environment `.vscode/tasks.json` errors after switching machines**
513
530
 
514
531
  If you commit `.vscode/tasks.json` to git and pull it on a different machine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -19,6 +19,7 @@ import { join, dirname, isAbsolute } from 'node:path';
19
19
 
20
20
  const MONITOR_LABEL = 'Throughline Monitor';
21
21
  const JSONC_MARKER_FILENAME = '.throughline-jsonc-noted';
22
+ const GITIGNORE_MARKER_FILENAME = '.throughline-gitignore-noted';
22
23
 
23
24
  /**
24
25
  * VSCode 系エディタが動いているかを env から推定する。
@@ -243,6 +244,67 @@ function emitSetupNotice(action) {
243
244
  if (text) process.stdout.write(text);
244
245
  }
245
246
 
247
+ /**
248
+ * `.vscode/tasks.json` が gitignore 推奨対象か判定する。
249
+ *
250
+ * 推奨条件:
251
+ * - cwd が git リポジトリ (`.git/` がある) かつ
252
+ * - `.gitignore` に `.vscode/tasks.json` 相当のエントリが無い
253
+ *
254
+ * 単独プロジェクト (.git なし) は判定対象外 (false を返す)。共有しないので
255
+ * gitignore の必要性そのものが無いため。
256
+ *
257
+ * @param {string} cwd
258
+ * @returns {boolean}
259
+ */
260
+ export function shouldRecommendGitignore(cwd) {
261
+ const gitDir = join(cwd, '.git');
262
+ if (!existsSync(gitDir)) return false;
263
+
264
+ const gitignorePath = join(cwd, '.gitignore');
265
+ if (!existsSync(gitignorePath)) return true;
266
+
267
+ const content = readFileSync(gitignorePath, 'utf8');
268
+ const lines = content.split(/\r?\n/);
269
+ for (const raw of lines) {
270
+ const line = raw.trim();
271
+ if (!line || line.startsWith('#')) continue;
272
+ // 否定 (!) は除外しない: "!.vscode/tasks.json" は明示的に追跡したい意図なので推奨を出すべき
273
+ if (line.startsWith('!')) continue;
274
+ // .vscode 全体除外 / tasks.json 単独除外 / .vscode/* どれかを含むか
275
+ if (line === '.vscode' || line === '.vscode/' || line === '/.vscode' || line === '/.vscode/') return false;
276
+ if (line === '.vscode/*' || line === '.vscode/**') return false;
277
+ if (line === '.vscode/tasks.json' || line === '/.vscode/tasks.json') return false;
278
+ }
279
+ return true;
280
+ }
281
+
282
+ function emitGitignoreRecommendationOnce(vscodeDir) {
283
+ const markerPath = join(vscodeDir, GITIGNORE_MARKER_FILENAME);
284
+ if (existsSync(markerPath)) return;
285
+ process.stdout.write(
286
+ '<system-reminder>\n' +
287
+ 'Throughline ヒント: .vscode/tasks.json には現環境の絶対パス (node 実行ファイル / throughline.mjs)\n' +
288
+ 'が書き込まれます。共有リポジトリで commit すると別 OS / 別 install 場所のメンバーで起動失敗の\n' +
289
+ '原因になるため、.gitignore に以下のいずれかを追加することを推奨します:\n' +
290
+ '\n' +
291
+ ' .vscode/tasks.json # tasks.json だけ除外 (settings.json 等は共有可)\n' +
292
+ ' .vscode/ # .vscode 配下を全部除外\n' +
293
+ '\n' +
294
+ 'なお Throughline v0.3.23+ は別環境の絶対パスが焼き込まれた tasks.json を\n' +
295
+ '自動修復するため、commit してしまっても致命的ではありません。あくまで推奨です。\n' +
296
+ '\n' +
297
+ 'この通知はプロジェクトごとに 1 度だけ流れます。必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
298
+ '</system-reminder>\n'
299
+ );
300
+ try {
301
+ writeFileSync(markerPath, `${new Date().toISOString()}\n`);
302
+ } catch (err) {
303
+ const msg = err instanceof Error ? err.message : 'unknown';
304
+ process.stderr.write(`[throughline] failed to write gitignore marker: ${msg}\n`);
305
+ }
306
+ }
307
+
246
308
  function emitJsoncGuidanceOnce(vscodeDir) {
247
309
  const markerPath = join(vscodeDir, JSONC_MARKER_FILENAME);
248
310
  if (existsSync(markerPath)) return;
@@ -302,6 +364,7 @@ export function ensureMonitorTaskFile(opts = {}) {
302
364
  const obj = { version: '2.0.0', tasks: [buildMonitorTask(bin)] };
303
365
  atomicWrite(tasksPath, JSON.stringify(obj, null, 2) + '\n');
304
366
  emitSetupNotice('created');
367
+ if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
305
368
  return { action: 'created', path: tasksPath };
306
369
  }
307
370
 
@@ -336,6 +399,7 @@ export function ensureMonitorTaskFile(opts = {}) {
336
399
  const nextObj = { ...obj, version: obj.version ?? '2.0.0', tasks: nextTasks };
337
400
  atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
338
401
  emitSetupNotice('repaired');
402
+ if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
339
403
  return { action: 'repaired', path: tasksPath };
340
404
  }
341
405
  return { action: 'already_present', path: tasksPath };
@@ -349,5 +413,6 @@ export function ensureMonitorTaskFile(opts = {}) {
349
413
  };
350
414
  atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
351
415
  emitSetupNotice('merged');
416
+ if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
352
417
  return { action: 'merged', path: tasksPath };
353
418
  }
@@ -13,6 +13,7 @@ import {
13
13
  isMonitorTaskBroken,
14
14
  buildMonitorTask,
15
15
  buildSetupNotice,
16
+ shouldRecommendGitignore,
16
17
  } from './vscode-task.mjs';
17
18
 
18
19
  const VSCODE_ENV = { TERM_PROGRAM: 'vscode' };
@@ -481,6 +482,175 @@ test('ensureMonitorTaskFile: second call is idempotent (already_present after cr
481
482
  }
482
483
  });
483
484
 
485
+ // --- shouldRecommendGitignore ---
486
+
487
+ test('shouldRecommendGitignore: false when not a git repo (.git missing)', () => {
488
+ const { dir, cleanup } = mkTmpCwd();
489
+ try {
490
+ assert.equal(shouldRecommendGitignore(dir), false);
491
+ } finally {
492
+ cleanup();
493
+ }
494
+ });
495
+
496
+ test('shouldRecommendGitignore: true when git repo has no .gitignore', () => {
497
+ const { dir, cleanup } = mkTmpCwd();
498
+ try {
499
+ mkdirSync(join(dir, '.git'));
500
+ assert.equal(shouldRecommendGitignore(dir), true);
501
+ } finally {
502
+ cleanup();
503
+ }
504
+ });
505
+
506
+ test('shouldRecommendGitignore: true when .gitignore does not list .vscode/tasks.json', () => {
507
+ const { dir, cleanup } = mkTmpCwd();
508
+ try {
509
+ mkdirSync(join(dir, '.git'));
510
+ writeFileSync(join(dir, '.gitignore'), 'node_modules/\n*.log\n');
511
+ assert.equal(shouldRecommendGitignore(dir), true);
512
+ } finally {
513
+ cleanup();
514
+ }
515
+ });
516
+
517
+ test('shouldRecommendGitignore: false when .gitignore has .vscode/tasks.json', () => {
518
+ const { dir, cleanup } = mkTmpCwd();
519
+ try {
520
+ mkdirSync(join(dir, '.git'));
521
+ writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.vscode/tasks.json\n');
522
+ assert.equal(shouldRecommendGitignore(dir), false);
523
+ } finally {
524
+ cleanup();
525
+ }
526
+ });
527
+
528
+ test('shouldRecommendGitignore: false when .gitignore has .vscode/ (whole dir)', () => {
529
+ const { dir, cleanup } = mkTmpCwd();
530
+ try {
531
+ mkdirSync(join(dir, '.git'));
532
+ writeFileSync(join(dir, '.gitignore'), '.vscode/\n');
533
+ assert.equal(shouldRecommendGitignore(dir), false);
534
+ } finally {
535
+ cleanup();
536
+ }
537
+ });
538
+
539
+ test('shouldRecommendGitignore: false when .gitignore has .vscode (no slash)', () => {
540
+ const { dir, cleanup } = mkTmpCwd();
541
+ try {
542
+ mkdirSync(join(dir, '.git'));
543
+ writeFileSync(join(dir, '.gitignore'), '.vscode\n');
544
+ assert.equal(shouldRecommendGitignore(dir), false);
545
+ } finally {
546
+ cleanup();
547
+ }
548
+ });
549
+
550
+ test('shouldRecommendGitignore: ignores comments and negation lines', () => {
551
+ const { dir, cleanup } = mkTmpCwd();
552
+ try {
553
+ mkdirSync(join(dir, '.git'));
554
+ // 否定パターンは「除外しない」意図なので、推奨は引き続き出す
555
+ writeFileSync(join(dir, '.gitignore'), '# comment\n!.vscode/tasks.json\n');
556
+ assert.equal(shouldRecommendGitignore(dir), true);
557
+ } finally {
558
+ cleanup();
559
+ }
560
+ });
561
+
562
+ // --- ensureMonitorTaskFile: gitignore recommendation notice ---
563
+
564
+ test('ensureMonitorTaskFile: created emits gitignore recommendation when .git exists and no .gitignore entry', () => {
565
+ const { dir, cleanup } = mkTmpCwd();
566
+ const captured = [];
567
+ const origWrite = process.stdout.write.bind(process.stdout);
568
+ process.stdout.write = (chunk) => {
569
+ captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
570
+ return true;
571
+ };
572
+ try {
573
+ mkdirSync(join(dir, '.git'));
574
+ const result = ensureMonitorTaskFile({
575
+ cwd: dir,
576
+ env: VSCODE_ENV,
577
+ throughlineBin: FAKE_BIN,
578
+ });
579
+ assert.equal(result.action, 'created');
580
+ } finally {
581
+ process.stdout.write = origWrite;
582
+ cleanup();
583
+ }
584
+ const joined = captured.join('');
585
+ assert.ok(joined.includes('.gitignore'), 'should emit gitignore recommendation');
586
+ assert.ok(joined.includes('Reload Window'), 'should still emit setup notice');
587
+ });
588
+
589
+ test('ensureMonitorTaskFile: created does NOT emit gitignore recommendation when not a git repo', () => {
590
+ const { dir, cleanup } = mkTmpCwd();
591
+ const captured = [];
592
+ const origWrite = process.stdout.write.bind(process.stdout);
593
+ process.stdout.write = (chunk) => {
594
+ captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
595
+ return true;
596
+ };
597
+ try {
598
+ const result = ensureMonitorTaskFile({
599
+ cwd: dir,
600
+ env: VSCODE_ENV,
601
+ throughlineBin: FAKE_BIN,
602
+ });
603
+ assert.equal(result.action, 'created');
604
+ } finally {
605
+ process.stdout.write = origWrite;
606
+ cleanup();
607
+ }
608
+ const joined = captured.join('');
609
+ assert.ok(!joined.includes('gitignore'), 'should not mention gitignore for non-git dirs');
610
+ });
611
+
612
+ test('ensureMonitorTaskFile: gitignore recommendation is emitted only once per project', () => {
613
+ const { dir, cleanup } = mkTmpCwd();
614
+ try {
615
+ mkdirSync(join(dir, '.git'));
616
+
617
+ const captured = [];
618
+ const origWrite = process.stdout.write.bind(process.stdout);
619
+ process.stdout.write = (chunk) => {
620
+ captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
621
+ return true;
622
+ };
623
+ try {
624
+ // 1 回目: created → gitignore 推奨が出る
625
+ const r1 = ensureMonitorTaskFile({
626
+ cwd: dir,
627
+ env: VSCODE_ENV,
628
+ throughlineBin: FAKE_BIN,
629
+ });
630
+ assert.equal(r1.action, 'created');
631
+ const firstCount = captured.filter((s) => s.includes('gitignore')).length;
632
+ assert.equal(firstCount, 1);
633
+
634
+ // tasks.json を一度消して再 created 状況を作る
635
+ // (実運用では already_present になるので現実的ではないが、marker の効きを見る)
636
+ const tasksPath = join(dir, '.vscode', 'tasks.json');
637
+ rmSync(tasksPath);
638
+ const r2 = ensureMonitorTaskFile({
639
+ cwd: dir,
640
+ env: VSCODE_ENV,
641
+ throughlineBin: FAKE_BIN,
642
+ });
643
+ assert.equal(r2.action, 'created');
644
+ const secondCount = captured.filter((s) => s.includes('gitignore')).length;
645
+ assert.equal(secondCount, 1, 'marker file should suppress 2nd recommendation');
646
+ } finally {
647
+ process.stdout.write = origWrite;
648
+ }
649
+ } finally {
650
+ cleanup();
651
+ }
652
+ });
653
+
484
654
  // --- ensureMonitorTaskFile: cross-environment repair (地雷 4) ---
485
655
 
486
656
  test('ensureMonitorTaskFile: repaired when existing task points to non-existent absolute paths', () => {