@marcomigozzi/trailhead 0.17.0 → 0.18.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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -18,6 +18,7 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { spawnSync } = require('child_process');
21
+ const { gitEnv } = require('./trailhead-git-env.js');
21
22
 
22
23
  const LABEL = 'trailhead:uat';
23
24
  const COLOR = 'BFD4F2';
@@ -27,7 +28,7 @@ const LIMIT = 1000;
27
28
  const UAT_LINE_RE = /^UAT of:/m;
28
29
 
29
30
  function defaultGh(args) {
30
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
31
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
31
32
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
32
33
  }
33
34
 
@@ -25,13 +25,14 @@
25
25
  // Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
26
26
 
27
27
  const { spawnSync } = require('child_process');
28
+ const { gitEnv } = require('./trailhead-git-env.js');
28
29
 
29
30
  const MAX_PAGES = 100; // 100 pages x 100 issues
30
31
  const QUERY = 'query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:100,after:$after,labels:["trailhead:uat"],states:[OPEN,CLOSED],orderBy:{field:CREATED_AT,direction:ASC}){nodes{number title state body} pageInfo{hasNextPage endCursor}}}}';
31
32
  const URL_RE = /https:\/\/github\.com\/([^/\s)]+)\/([^/\s)]+)\/issues\/(\d+)(?![\w])/gi;
32
33
 
33
34
  function defaultGh(args) {
34
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
35
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
35
36
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
36
37
  }
37
38
 
@@ -46,6 +46,7 @@ const fs = require('fs');
46
46
  const os = require('os');
47
47
  const path = require('path');
48
48
  const { spawnSync } = require('child_process');
49
+ const { gitEnv } = require('./trailhead-git-env.js');
49
50
 
50
51
  const CONFIG_REL = path.join('.trailhead', 'config.json');
51
52
  const OUTPUT_TAIL = 2000;
@@ -118,8 +119,12 @@ function pick(cfg, key, file) {
118
119
  return out;
119
120
  }
120
121
 
122
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
123
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
124
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
125
+
121
126
  function globalConfigPath(env) {
122
- const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
127
+ const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
123
128
  return path.join(base, 'trailhead', 'config.json');
124
129
  }
125
130
 
@@ -257,7 +262,7 @@ function redactor(names, env) {
257
262
  function sh(command, repo, env, timeoutMs, wantOutput) {
258
263
  const timeout = Math.max(1, Math.ceil(timeoutMs));
259
264
  if (!wantOutput) {
260
- const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
265
+ const r = spawnSync('sh', ['-c', command], { cwd: repo, env: gitEnv(env), stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
261
266
  const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
262
267
  return { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
263
268
  }
@@ -265,7 +270,7 @@ function sh(command, repo, env, timeoutMs, wantOutput) {
265
270
  const file = path.join(dir, 'out');
266
271
  const fd = fs.openSync(file, 'w');
267
272
  try {
268
- const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
273
+ const r = spawnSync('sh', ['-c', command], { cwd: repo, env: gitEnv(env), stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
269
274
  const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
270
275
  const res = { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
271
276
  if (wantOutput) {
@@ -377,7 +382,7 @@ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
377
382
  return 0;
378
383
  }
379
384
 
380
- module.exports = { parseArgs, readConfig, main };
385
+ module.exports = { globalConfigPath, parseArgs, readConfig, main };
381
386
 
382
387
  if (require.main === module) {
383
388
  const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
@@ -21,11 +21,12 @@
21
21
  // Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
22
22
 
23
23
  const { spawnSync } = require('child_process');
24
+ const { gitEnv } = require('./trailhead-git-env.js');
24
25
 
25
26
  const LABEL = 'trailhead:blocked';
26
27
 
27
28
  function defaultGh(args) {
28
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
29
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
29
30
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
30
31
  }
31
32
 
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  import { resetAutoRun } from '../hooks/mod/auto-run'
5
6
  import { resetLocate } from '../hooks/mod/work-state-locate'
@@ -47,7 +48,7 @@ const rig = (on: On) => {
47
48
  on('session.cwd', () => ({ value: r.cwd }))
48
49
  on('session.id', () => ({ value: SESSION }))
49
50
  on('process.run', (_$, e) => {
50
- const line = e.argv.join(' ')
51
+ const line = unscrub(e.argv).join(' ')
51
52
  r.runs.push(line)
52
53
  r.cwds.push(e.cwd ?? '')
53
54
  const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
@@ -0,0 +1,20 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { GIT_LOCATION_VARS, gitArgv, scrubGitEnv } from '../hooks/mod/git-env'
4
+ import { unscrub } from './unscrub'
5
+
6
+ test('gitArgv wraps git in env -u for every repo-location variable', () => {
7
+ const argv = gitArgv('-C', '/repo', 'status')
8
+ expect(argv.slice(0, 3)).toEqual(['env', '-u', GIT_LOCATION_VARS[0]])
9
+ for (const name of GIT_LOCATION_VARS) expect(argv).toContain(name)
10
+ expect(argv.slice(-4)).toEqual(['git', '-C', '/repo', 'status'])
11
+ expect(unscrub(argv)).toEqual(['git', '-C', '/repo', 'status'])
12
+ })
13
+
14
+ test('GIT_DIR and GIT_WORK_TREE are scrubbed, GIT_CONFIG* is not', () => {
15
+ const argv = scrubGitEnv(['gh', 'api', 'graphql'])
16
+ expect(argv).toContain('GIT_DIR')
17
+ expect(argv).toContain('GIT_WORK_TREE')
18
+ expect(argv.some(a => a.startsWith('GIT_CONFIG'))).toBe(false)
19
+ expect(unscrub(argv)).toEqual(['gh', 'api', 'graphql'])
20
+ })
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, mock, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  const SURFACES = ['terminal', 'desktop'] as const
5
6
  const SESSION = 'sess-A'
@@ -383,15 +384,18 @@ const rig = (on: On, engine?: any) => {
383
384
  return { value: undefined }
384
385
  })
385
386
  on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
387
+ // A checkout in `gone` no longer exists on disk; nothing else is listed beside it.
388
+ on('fs.exists', (_$, e) => ({ value: !r.gone.includes(e.path) }))
389
+ on('fs.list', () => ({ value: [] }))
386
390
  on('session.root', () => ({ value: r.sessionRoot }))
387
391
  on('session.cwd', () => ({ value: r.cwd }))
388
392
  on('process.run', async (_$, e) => {
389
393
  // The pin's own `git rev-parse --show-toplevel`: answered apart, never counted as a `gh` call.
390
- if (e.argv[0] === 'git') {
391
- r.gitCalls.push({ argv: [...e.argv], cwd: e.init?.cwd })
394
+ if (unscrub(e.argv)[0] === 'git') {
395
+ r.gitCalls.push({ argv: [...unscrub(e.argv)], cwd: e.init?.cwd })
392
396
  // Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
393
397
  const where = e.init?.cwd
394
- if (e.argv[1] === 'remote') {
398
+ if (unscrub(e.argv)[1] === 'remote') {
395
399
  const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
396
400
  return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
397
401
  }
@@ -400,29 +404,31 @@ const rig = (on: On, engine?: any) => {
400
404
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
401
405
  }
402
406
  // The pin's host check: answered apart, never counted as a read of the pane.
403
- if (e.argv[1] === 'auth') {
404
- const host = e.argv[4] ?? ''
407
+ if (unscrub(e.argv)[1] === 'auth') {
408
+ const host = unscrub(e.argv)[4] ?? ''
405
409
  r.authCalls.push(host)
406
410
  const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
407
411
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
408
412
  }
409
- if (isLoginRead(e.argv) || isMineRead(e.argv)) {
410
- r.mineCalls.push([...e.argv])
411
- const body = isLoginRead(e.argv)
413
+ if (isLoginRead(unscrub(e.argv)) || isMineRead(unscrub(e.argv))) {
414
+ r.mineCalls.push([...unscrub(e.argv)])
415
+ const body = isLoginRead(unscrub(e.argv))
412
416
  ? { data: { viewer: { login: 'me' } } }
413
417
  : { data: { repository: { issues: { nodes: (r.mine ?? []).map(flatNode), pageInfo: { hasNextPage: false, endCursor: null } } } } }
414
- const out = r.mine === null && isMineRead(e.argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
418
+ const out = r.mine === null && isMineRead(unscrub(e.argv)) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
415
419
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
416
420
  }
417
- r.calls.push([...e.argv])
421
+ r.calls.push([...unscrub(e.argv)])
418
422
  r.cwds.push(e.init?.cwd)
419
423
  // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
420
424
  const where = e.init?.cwd ?? r.cwd
425
+ // A spawn in a deleted directory fails the way a missing binary does.
426
+ if (r.gone.includes(where)) return { deny: 'spawn gh ENOENT' }
421
427
  if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
422
428
  return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
423
429
  }
424
430
  if (r.deny !== null) return { deny: r.deny }
425
- const out = await (r.gh ?? defaultGh)(e.argv)
431
+ const out = await (r.gh ?? defaultGh)(unscrub(e.argv))
426
432
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
427
433
  })
428
434
  on('ui.open', (_$, e) => {
@@ -444,6 +450,7 @@ const rig = (on: On, engine?: any) => {
444
450
  on('turn.start', (_$, e) => ({ turnId: e.turnId }))
445
451
  on('turn.complete', () => ({ text: '' }))
446
452
  on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
453
+ on('session.attach', (_$, e) => ({ clientId: e.clientId }))
447
454
  on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
448
455
  return r
449
456
  }
@@ -457,6 +464,8 @@ const BAND_PROPS = {
457
464
  scroll: { offset: 0, bodyRows: 10 },
458
465
  view: {},
459
466
  } as const
467
+ // What the command answers for an undrawn pane (never the host's raw reason).
468
+ const NOT_SHOWN = 'Map panel is open but not shown yet: it appears once the screen can place it.'
460
469
  const FULLSCREEN = { columns: 200, rows: 50, isFullscreen: true } as const
461
470
 
462
471
  const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
@@ -513,13 +522,24 @@ for (const surface of SURFACES) {
513
522
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
514
523
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
515
524
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
516
- expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
525
+ expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#9ece6a' } })
526
+ // ogni sezione ha il suo glifo e un filetto colorato davanti a ogni riga
527
+ expect(await textOf(ui, 'glyph-frontier')).toBe('◆ ')
528
+ expect(await textOf(ui, 'glyph-blocked')).toBe('■ ')
529
+ if (surface === 'terminal') {
530
+ expect(await textOf(ui, 'gutter-209')).toBe('│ ')
531
+ expect((await ui.find({ key: 'gutter-211' }))?.children[0]).toMatchObject({ props: { color: '#f7768e' } })
532
+ } else {
533
+ // sul desktop il filetto è una barra continua, alta quanto il blocco
534
+ expect((await ui.find({ key: 'gutter-211-bar' }))?.props).toMatchObject({ backgroundColor: '#f7768e' })
535
+ }
517
536
  expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
518
537
 
519
538
  expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
520
539
  expect(await textOf(ui, 'type-209')).toBe(' build')
521
- expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
540
+ expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { color: '#7dcfff' } })
522
541
  expect(await textOf(ui, 'type-214')).toBe(' decision')
542
+ expect((await ui.find({ key: 'type-214' }))?.children[0]).toMatchObject({ props: { color: '#ff9e64' } })
523
543
  const links = (await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)
524
544
  expect(links).toContain(`${URL}/209`)
525
545
 
@@ -545,6 +565,7 @@ for (const surface of SURFACES) {
545
565
  // the quarantined ticket is counted, never listed
546
566
  expect(await ui.find({ key: 'row-215' })).toBeUndefined()
547
567
  expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
568
+ expect((await ui.find({ key: 'unverified' }))?.children[0]).toMatchObject({ props: { color: '#e0af68' } })
548
569
  expect(await textOf(ui, 'age')).toBe('updated just now')
549
570
  })
550
571
 
@@ -578,6 +599,10 @@ for (const surface of SURFACES) {
578
599
  // the parent line, dim, under the row; none without a parent
579
600
  expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
580
601
  expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
602
+ // la riga del genitore sta nel blocco del ticket, dopo il filetto che copre entrambe le righe
603
+ expect(await keysOf(ui, 'entry-lines-240')).toEqual(['entry-lines-240'])
604
+ expect(await textOf(ui, 'entry-lines-240')).toContain('tests Burn-down view of the map #209')
605
+ if (surface === 'terminal') expect(await textOf(ui, 'gutter-240')).toBe('│ │ ')
581
606
  expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
582
607
  expect(await hasKey(ui, 'uat-of-242')).toBe(false)
583
608
  expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
@@ -1125,6 +1150,17 @@ for (const surface of SURFACES) {
1125
1150
  expect(await reasonFor(() => (r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })))).toMatch(/^⚠ gh failed \(exit 1\) · list from /)
1126
1151
  })
1127
1152
 
1153
+ test(`a pinned checkout that is gone is named, not blamed on gh [${surface}]`, async ($, on) => {
1154
+ const r = rig(on, $)
1155
+ await $.session.start(START)
1156
+ const ui = await openAndMount($, r)
1157
+ r.gone = [ROOT]
1158
+ await r.clock.advance(FLOOR)
1159
+ await completeTurn($)
1160
+ await settle(r)
1161
+ expect(await textOf(ui, 'age')).toMatch(/^⚠ checkout not found: \/repo · list from /)
1162
+ })
1163
+
1128
1164
  test(`a first fetch that fails shows the reason without a list [${surface}]`, async ($, on) => {
1129
1165
  const r = rig(on, $)
1130
1166
  r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })
@@ -1298,6 +1334,22 @@ for (const surface of SURFACES) {
1298
1334
  expect(listReads(r)).toEqual([])
1299
1335
  })
1300
1336
 
1337
+ // A heredoc body is data: a write-looking line inside one starts no read, a real write after it still does (#369).
1338
+ test(`a heredoc body that names a tracker write starts no pane read [${surface}]`, async ($, on) => {
1339
+ const r = rig(on, $)
1340
+ await $.session.start(START)
1341
+ await openAndMount($, r)
1342
+ await r.clock.advance(FLOOR)
1343
+ const before = fetches(r)
1344
+ await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\ngh issue close 209\nEOF" } as never)
1345
+ await settle(r)
1346
+ expect(fetches(r)).toBe(before)
1347
+
1348
+ await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\nbody\nEOF\ngh issue close 209" } as never)
1349
+ await settle(r)
1350
+ expect(fetches(r)).toBe(before + 1)
1351
+ })
1352
+
1301
1353
  // The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
1302
1354
  // list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
1303
1355
  test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
@@ -1480,6 +1532,34 @@ for (const surface of SURFACES) {
1480
1532
  }
1481
1533
  })
1482
1534
 
1535
+ test(`a multi-line Bash write refreshes, a multi-line read does not [${surface}]`, async ($, on) => {
1536
+ const r = rig(on, $)
1537
+ await $.session.start(START)
1538
+ await openAndMount($, r)
1539
+ const heredoc = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
1540
+ const base = fetches(r)
1541
+ for (const command of [`${heredoc}\ngh issue view 5`, `${heredoc}\ngh issue list --state open`]) {
1542
+ await r.clock.advance(FLOOR)
1543
+ await $.tool.call({ tool: 'Bash', command } as never)
1544
+ await settle(r)
1545
+ }
1546
+ expect(fetches(r)).toBe(base)
1547
+
1548
+ for (const command of [`${heredoc}\ncd /repo && gh issue edit 325 --title "t"`, `${heredoc}\ngh issue create --body-file /tmp/x.md`]) {
1549
+ // until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
1550
+ let seen = -1
1551
+ while (seen !== fetches(r)) {
1552
+ seen = fetches(r)
1553
+ await r.clock.advance(FLOOR)
1554
+ await settle(r)
1555
+ }
1556
+ const before = fetches(r)
1557
+ await $.tool.call({ tool: 'Bash', command } as never)
1558
+ await settle(r)
1559
+ expect([command, fetches(r)]).toEqual([command, before + 1])
1560
+ }
1561
+ })
1562
+
1483
1563
  test(`closing the pane stops refreshing [${surface}]`, async ($, on) => {
1484
1564
  const r = rig(on, $)
1485
1565
  await $.session.start(START)
@@ -2387,14 +2467,43 @@ for (const surface of SURFACES) {
2387
2467
  expect(r.logs).toEqual([])
2388
2468
  })
2389
2469
 
2390
- test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
2470
+ test(`/trailhead:panel says plainly that a pane cannot be placed yet [${surface}]`, async ($, on) => {
2391
2471
  const r = rig(on, $)
2392
2472
  r.isPlaced = false
2393
2473
  await $.session.start(START)
2394
- expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
2474
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2395
2475
  await settle(r)
2396
2476
  expect(fetches(r)).toBe(0)
2397
- // a pane waiting undrawn can still be closed
2477
+ // a pane the person never saw is not closed by a repeat: it stays open, undrawn, until it can be placed
2478
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2479
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2480
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2481
+ // placed at last, the command sees it drawn and closes it
2482
+ r.isPlaced = true
2483
+ expect((await panel($)).text).toBe('Map panel opened.')
2484
+ expect((await panel($)).text).toBe('Map panel closed.')
2485
+ expect(r.panes).toEqual([])
2486
+ })
2487
+
2488
+ // #348: l'auto-open su una superficie che non ha ancora comunicato il layout resta non disegnato; il comando
2489
+ // non deve chiudere un pannello che la persona non ha mai visto.
2490
+ test(`/trailhead:panel never closes a pane the auto-open left undrawn on a surface without layout [${surface}]`, async ($, on) => {
2491
+ const r = rig(on, $)
2492
+ r.isPlaced = false
2493
+ r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
2494
+ await $.session.start(START)
2495
+ await mountBand($, FULLSCREEN)
2496
+ await settle(r)
2497
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2498
+
2499
+ const shown = NOT_SHOWN
2500
+ expect((await panel($)).text).toBe(shown)
2501
+ expect((await panel($)).text).toBe(shown)
2502
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2503
+
2504
+ // the layout arrives: the command draws it, and only then does a repeat close it
2505
+ r.isPlaced = true
2506
+ expect((await panel($)).text).toBe('Map panel opened.')
2398
2507
  expect((await panel($)).text).toBe('Map panel closed.')
2399
2508
  })
2400
2509
 
@@ -2433,6 +2542,148 @@ for (const surface of SURFACES) {
2433
2542
  expect(r.panes).toEqual([])
2434
2543
  })
2435
2544
 
2545
+ // #348: un auto-open che resta non disegnato non conta come fatto, così un trigger successivo può riprovare.
2546
+ test(`an auto-open left undrawn does not end the auto-open, a drawn pane does [${surface}]`, async ($, on) => {
2547
+ const r = rig(on, $)
2548
+ r.isPlaced = false
2549
+ r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
2550
+ await $.session.start(START)
2551
+ await mountBand($, FULLSCREEN)
2552
+ await settle(r)
2553
+ expect(r.opens).toHaveLength(1)
2554
+
2555
+ // listed but undrawn: further renders do not open it again (the host places it when it can)
2556
+ await mountBand($, FULLSCREEN)
2557
+ await settle(r)
2558
+ expect(r.opens).toHaveLength(1)
2559
+
2560
+ // the host dropped it while still undrawn: the next render tries again
2561
+ r.panes = []
2562
+ await mountBand($, FULLSCREEN)
2563
+ await settle(r)
2564
+ expect(r.opens).toHaveLength(2)
2565
+
2566
+ // drawn at last: its own render completes the auto-open for good
2567
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
2568
+ await mountPane($)
2569
+ await settle(r)
2570
+ r.panes = []
2571
+ await mountBand($, FULLSCREEN)
2572
+ await settle(r)
2573
+ expect(r.opens).toHaveLength(2)
2574
+ })
2575
+
2576
+ test(`an auto-open that finds the pane listed and drawn ends the auto-open [${surface}]`, async ($, on) => {
2577
+ const r = rig(on, $)
2578
+ r.isPlaced = false
2579
+ await $.session.start(START)
2580
+ await mountBand($, FULLSCREEN)
2581
+ await settle(r)
2582
+ expect(r.opens).toHaveLength(1)
2583
+
2584
+ // placed by the host with no pane render yet (the pane's render needs the dock): the next band render sees it
2585
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
2586
+ await mountBand($, FULLSCREEN)
2587
+ await settle(r)
2588
+ r.panes = []
2589
+ await mountBand($, FULLSCREEN)
2590
+ await settle(r)
2591
+ expect(r.opens).toHaveLength(1)
2592
+ })
2593
+
2594
+ test(`the person's own request on an undrawn pane ends the auto-open, a close of it stays closed [${surface}]`, async ($, on) => {
2595
+ const r = rig(on, $)
2596
+ r.isPlaced = false
2597
+ await $.session.start(START)
2598
+ await mountBand($, FULLSCREEN)
2599
+ await settle(r)
2600
+ expect(r.opens).toHaveLength(1)
2601
+
2602
+ // the command is the person acting on it: the auto-open is done even though the pane is still undrawn
2603
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2604
+ expect(r.opens).toHaveLength(2)
2605
+ r.panes = []
2606
+ await mountBand($, FULLSCREEN)
2607
+ await settle(r)
2608
+ expect(r.opens).toHaveLength(2)
2609
+ })
2610
+
2611
+ // Il desktop avvia la sessione al primo invio: il comando che l'ha avviata arriva subito dopo l'auto-open.
2612
+ test(`/trailhead:panel right after the auto-open keeps the pane the person never saw [${surface}]`, async ($, on) => {
2613
+ const r = rig(on, $)
2614
+ await $.session.start(START)
2615
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2616
+ await settle(r)
2617
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2618
+
2619
+ const opened = await panel($)
2620
+ expect(opened.text).toBe('Map panel opened.')
2621
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2622
+
2623
+ // the next press acts on a pane the person asked for: it closes, however soon
2624
+ const closed = await panel($)
2625
+ expect(closed.text).toBe('Map panel closed.')
2626
+ expect(r.panes).toEqual([])
2627
+ })
2628
+
2629
+ test(`/trailhead:panel closes an auto-opened pane once it has been on screen [${surface}]`, async ($, on) => {
2630
+ const r = rig(on, $)
2631
+ await $.session.start(START)
2632
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2633
+ await settle(r)
2634
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2635
+
2636
+ await r.clock.advance(2_000)
2637
+ const closed = await panel($)
2638
+ expect(closed.text).toBe('Map panel closed.')
2639
+ expect(r.panes).toEqual([])
2640
+ })
2641
+
2642
+ test(`a surface attaching in fullscreen retries the auto-open [${surface}]`, async ($, on) => {
2643
+ const r = rig(on, $)
2644
+ r.isPlaced = false
2645
+ r.reason = 'no-surface: no surface is attached ($.session.surfaces() is empty)'
2646
+ await $.session.start(START)
2647
+ await settle(r)
2648
+ expect(r.opens).toEqual([])
2649
+
2650
+ // not fullscreen, or no layout said: the render trigger decides
2651
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: { columns: 200, rows: 50, isFullscreen: false } } as never)
2652
+ await $.session.attach({ surface, clientId: `${surface}:default` } as never)
2653
+ await settle(r)
2654
+ expect(r.opens).toEqual([])
2655
+
2656
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2657
+ await settle(r)
2658
+ expect(r.opens).toHaveLength(1)
2659
+
2660
+ // still undrawn and listed: another attach does not open again
2661
+ await $.session.attach({ surface, clientId: `${surface}:other`, viewport: FULLSCREEN } as never)
2662
+ await settle(r)
2663
+ expect(r.opens).toHaveLength(1)
2664
+ })
2665
+
2666
+ test(`an attach never opens the pane after the person closed it [${surface}]`, async ($, on) => {
2667
+ const r = rig(on, $)
2668
+ await $.session.start(START)
2669
+ await panel($)
2670
+ await panel($)
2671
+ expect(r.panes).toEqual([])
2672
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2673
+ await settle(r)
2674
+ expect(r.opens).toHaveLength(1)
2675
+
2676
+ })
2677
+
2678
+ test(`an attach without an active map does not open the pane [${surface}]`, async ($, on) => {
2679
+ const r = rig(on, $)
2680
+ r.active = null
2681
+ await $.session.start(START)
2682
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2683
+ await settle(r)
2684
+ expect(r.opens).toEqual([])
2685
+ })
2686
+
2436
2687
  for (const withBand of [false, true]) {
2437
2688
  test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
2438
2689
  const r = rig(on, $)
@@ -2553,7 +2804,7 @@ for (const surface of SURFACES) {
2553
2804
  expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2554
2805
  expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
2555
2806
  expect(await textOf(ui, 'map-mark-198')).toBe('● ')
2556
- expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
2807
+ expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#bb9af7' } })
2557
2808
  expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2558
2809
  expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
2559
2810
  expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
@@ -278,6 +278,12 @@ test('groupTickets lists a needs-you ticket once, under Needs you, ascending, wh
278
278
  expect(groupTickets([t(1, 'frontier', { isNeedsYou: false })]).needsYou).toEqual([])
279
279
  })
280
280
 
281
+ test('failureReason blames the folder, not gh, when the checkout is gone', () => {
282
+ const error = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })
283
+ expect(failureReason({ error, missingRoot: '/work/r-t5' })).toBe('checkout not found: /work/r-t5')
284
+ expect(failureReason({ error })).toBe('gh not installed')
285
+ })
286
+
281
287
  test('failureReason names one short cause', () => {
282
288
  expect(failureReason({ error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) })).toBe('gh not installed')
283
289
  expect(failureReason({ error: new Error('spawn gh ENOENT') })).toBe('gh not installed')
@@ -1,6 +1,9 @@
1
1
  import { expect, test } from 'claude-code/testing'
2
2
 
3
- import { GH_ISSUE_WRITE } from '../hooks/mod/map-pane'
3
+ import { GH_ISSUE_WRITE, isTrackerWrite } from '../hooks/mod/map-pane'
4
+
5
+ // A heredoc that writes a body file, as the model usually runs before a tracker write.
6
+ const HEREDOC = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
4
7
 
5
8
  // Bash commands the pane refreshes on (a write to the tracker) and ones it must ignore.
6
9
  const WRITES = [
@@ -25,6 +28,13 @@ const WRITES = [
25
28
  "gh api -X PUT 'repos/o/r/issues/5/lock'",
26
29
  'gh api repos/o/r/issues/5 --input body.json',
27
30
  'node /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x',
31
+ // multi-line: a heredoc before the write, or a line on either side of it
32
+ `${HEREDOC}\ncd /repo && gh issue edit 325 --title "t"`,
33
+ `${HEREDOC}\ngh issue create --body-file /tmp/x.md`,
34
+ `${HEREDOC}\ngh issue comment 5 --body-file /tmp/x.md`,
35
+ 'echo hi\ngh issue close 5',
36
+ 'gh issue close 5\necho hi',
37
+ `${HEREDOC}\nnode /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x`,
28
38
  ]
29
39
  const READS = [
30
40
  'gh issue list --state open',
@@ -40,6 +50,10 @@ const READS = [
40
50
  'gh api repos/o/r/pulls -f state=open',
41
51
  'git log && echo gh issue',
42
52
  'gh api repos/o/r/issues/5 | jq . ; echo -f x',
53
+ // multi-line reads
54
+ `${HEREDOC}\ngh issue view 5`,
55
+ `${HEREDOC}\ngh issue list --state open`,
56
+ 'cd /repo\ngh api repos/o/r/issues/5',
43
57
  ]
44
58
 
45
59
  test('the write matcher takes the tracker writes of a Bash call', () => {
@@ -49,3 +63,45 @@ test('the write matcher takes the tracker writes of a Bash call', () => {
49
63
  test('the write matcher leaves reads alone', () => {
50
64
  for (const command of READS) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, false])
51
65
  })
66
+
67
+ // A heredoc body is data, not commands: [command, is it a tracker write]. `\t` is a tab.
68
+ const HEREDOCS: Array<[string, boolean]> = [
69
+ ['cat >f <<EOF\ngh issue close 5\nEOF', false],
70
+ ["cat >f <<'EOF'\ngh issue close 5\nEOF", false],
71
+ ['cat >f <<"EOF"\ngh issue close 5\nEOF', false],
72
+ ['cat >f <<-EOF\n\tgh issue close 5\n\tEOF', false],
73
+ ['cat <<A >a; cat <<B >b\ngh issue close 1\nA\ngh issue edit 2\nB', false],
74
+ ['cat <<A >a; cat <<B >b\nx\nA\ny\nB\ngh issue close 3', true],
75
+ ['gh issue create --body-file - <<EOF\nbody\nEOF', true],
76
+ ['gh issue comment 5 -F - <<EOF\ngh issue close 9\nEOF', true],
77
+ ["cat <<'EOF' >x\nb\nEOF\ngh issue close 5", true],
78
+ ["cat <<'EOF' >x\nEOF2\ngh issue close 5\n EOF\nEOF", false],
79
+ ["cat <<'EOF' >x\ngh issue close 5", false],
80
+ ['cat <<-EOF >x\nb\n EOF\ngh issue close 5\nEOF', false],
81
+ ["cat <<< 'x'\ngh issue close 5", true],
82
+ ['echo $((1<<2))\ngh issue close 5', true],
83
+ ['echo $((1<<2)); gh issue view 5', false],
84
+ ["cat <<'EOF' >x\nnode trailhead-issue-body.js append\nEOF", false],
85
+ ['echo "a <<EOF"\ngh issue close 5', true],
86
+ // a `<<` in a comment opens nothing, so the write after it stays
87
+ ['# build body with <<EOF\ngh issue close 5', true],
88
+ ['git commit -m "x" # see <<EOF\ngh issue close 5', true],
89
+ ['# build body with <<EOF\ngh issue view 5', false],
90
+ ['cat a#b <<EOF\ngh issue close 5\nEOF', false],
91
+ // an opener inside a multi-line quoted string opens nothing
92
+ ['echo "a\ncat <<EOF\nb"\ngh issue close 5\nEOF', true],
93
+ ['echo "a\ncat <<EOF\nb"\ngh issue view 5\nEOF', false],
94
+ // a `<<` inside a parameter expansion opens nothing
95
+ ['echo ${x:-<<EOF}\ngh issue close 5\nEOF', true],
96
+ // an apostrophe in a heredoc body leaks no quote state into later lines
97
+ ["cat <<EOF\nit's\nEOF\ncat <<B\ngh issue close 5\nB", false],
98
+ ]
99
+
100
+ test('isTrackerWrite takes the writes and leaves the reads', () => {
101
+ for (const command of WRITES) expect([command, isTrackerWrite(command)]).toEqual([command, true])
102
+ for (const command of READS) expect([command, isTrackerWrite(command)]).toEqual([command, false])
103
+ })
104
+
105
+ test('isTrackerWrite ignores a heredoc body and keeps a write around it', () => {
106
+ for (const [command, verdict] of HEREDOCS) expect([command, isTrackerWrite(command)]).toEqual([command, verdict])
107
+ })