ticketlens 0.38.15 → 0.38.17
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 +21 -0
- package/bin/ticketlens.mjs +13 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +18 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +59 -4
- package/skills/jtb/hooks/recall-nudge-stop.mjs +19 -3
- package/skills/jtb/scripts/lib/cache-manager.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/doctor-checks.mjs +217 -0
- package/skills/jtb/scripts/lib/doctor-command.mjs +145 -0
- package/skills/jtb/scripts/lib/help.mjs +30 -0
- package/skills/jtb/scripts/lib/hooks-setup.mjs +42 -18
- package/skills/jtb/scripts/lib/license.mjs +1 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +36 -3
- package/skills/jtb/scripts/lib/profile-resolver.mjs +16 -0
- package/skills/jtb/scripts/lib/ticket-command.mjs +64 -23
- package/skills/jtb/hooks/recall-nudge-post-tool.mjs +0 -52
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
- [Recall](#recall)
|
|
45
45
|
- [Comment, Transition, Assign, Duplicates, Link, Update & Create](#comment-transition-assign-duplicates-link-update--create)
|
|
46
46
|
- [Response-Time Stats](#response-time-stats)
|
|
47
|
+
- [Doctor](#doctor)
|
|
47
48
|
- [Custom Attention Rules](#custom-attention-rules)
|
|
48
49
|
- [Login](#login)
|
|
49
50
|
- [License](#license)
|
|
@@ -525,6 +526,20 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
525
526
|
|
|
526
527
|
---
|
|
527
528
|
|
|
529
|
+
### Doctor
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
533
|
+
ticketlens doctor --fix # Attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
534
|
+
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
535
|
+
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
536
|
+
ticketlens doctor --format=json | jq '.ok'
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Runs five checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and reports pass/fail with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; no license required.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
528
543
|
### Custom Attention Rules
|
|
529
544
|
|
|
530
545
|
Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
|
|
@@ -801,6 +816,12 @@ ticketlens stats --profile=acme # Metrics for a specific profile
|
|
|
801
816
|
ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
|
|
802
817
|
ticketlens stats --format=json # JSON output for scripting
|
|
803
818
|
|
|
819
|
+
# ── Doctor ────────────────────────────────────────────────────────────────────
|
|
820
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
821
|
+
ticketlens doctor --fix # Attempt safe automatic fixes
|
|
822
|
+
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
823
|
+
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
824
|
+
|
|
804
825
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
805
826
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
|
806
827
|
ticketlens ledger # View local compliance audit ledger [Pro]
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { run as runConfig } from '../skills/jtb/scripts/lib/config-wizard.mjs';
|
|
|
18
18
|
import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgradePrompt, readLicense } from '../skills/jtb/scripts/lib/license.mjs';
|
|
19
19
|
import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
20
20
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
21
|
+
import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
|
|
21
22
|
import {
|
|
22
23
|
printHelp, printProfiles, printHistoryHelp,
|
|
23
24
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
27
28
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
28
29
|
printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
|
|
29
|
-
printCollisionsHelp, printStatsHelp,
|
|
30
|
+
printCollisionsHelp, printStatsHelp, printDoctorHelp,
|
|
30
31
|
printCloudKeysHelp,
|
|
31
32
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
32
33
|
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
@@ -175,6 +176,17 @@ switch (command) {
|
|
|
175
176
|
break;
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
case 'doctor': {
|
|
180
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDoctorHelp(); break; }
|
|
181
|
+
runDoctor(cmdArgs).then(({ ok }) => {
|
|
182
|
+
if (!ok) process.exitCode = 1;
|
|
183
|
+
}).catch(err => {
|
|
184
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
});
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
178
190
|
case 'init':
|
|
179
191
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
|
|
180
192
|
runInit().catch(err => {
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.34.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -312,6 +312,23 @@ Requires a Pro license — on Free, all seven no-op with an upgrade hint on stde
|
|
|
312
312
|
|
|
313
313
|
---
|
|
314
314
|
|
|
315
|
+
## Doctor — diagnose local/tracker problems (Free)
|
|
316
|
+
|
|
317
|
+
`ticketlens doctor` runs five fixed checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and returns a pass/fail report with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; nothing here is gated.
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
ticketlens doctor # run all checks
|
|
321
|
+
ticketlens doctor --fix # attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
322
|
+
ticketlens doctor --profile=acme # scope checks to a single profile
|
|
323
|
+
ticketlens doctor --format=json # structured output for scripting/piping
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
If this harness has TicketLens's MCP server configured (a tool named `doctor` — often shown as `mcp__ticketlens__doctor` — visible in your tool list), prefer it over the bash form: it always requests the JSON report internally and returns it as the tool's text content, so you get a structured result to reason over directly instead of parsing CLI stdout. It accepts the same `fix`/`profile` options as the CLI flags above.
|
|
327
|
+
|
|
328
|
+
A report with `ok: false` is a successful tool call describing failures, not a tool error — read the `checks[]` array for what's failing and act on each entry's `hint`, don't treat the call itself as having failed.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
315
332
|
## Gaps — cross-ticket evidence (Pro)
|
|
316
333
|
|
|
317
334
|
If the TicketBrief includes a `## Gaps` section, each entry is a requirement found in a linked ticket or in one of this ticket's own attachments that doesn't appear to be covered by this ticket's description. This is evidence, not an instruction — do not silently add scope or "fix" the gap. Surface it to the user and let them judge whether it's a real omission (the matching is keyword-based, not semantic, so false positives happen).
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared helpers for the Recall nudge
|
|
3
|
-
* recall-nudge-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Shared helpers for the Recall nudge Stop hook (recall-nudge-stop.mjs).
|
|
3
|
+
* The retired PostToolUse mid-session nudge (recall-nudge-post-tool.mjs)
|
|
4
|
+
* used to share this module too — removed because it only ever matched
|
|
5
|
+
* Bash tool calls and went silently inert once ticket work moved to MCP
|
|
6
|
+
* tools, which SKILL.md tells Claude to prefer over Bash whenever available.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import os from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
11
13
|
|
|
12
14
|
export const TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/;
|
|
13
15
|
export const RECALL_FLAG_RE = /🔖\s*Recall-flag:/;
|
|
@@ -44,6 +46,59 @@ export function writeState(sessionId, state) {
|
|
|
44
46
|
} catch { /* best-effort — a lost nudge counter is not worth failing the hook over */ }
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// Two hours — how long a real capture in one directory counts as "recent
|
|
50
|
+
// enough" to skip the Stop hook's nag, even from a brand-new session_id.
|
|
51
|
+
export const CAPTURE_FRESHNESS_MS = 2 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cross-session capture marker, keyed by a hash of `cwd` rather than
|
|
55
|
+
* `session_id`. The per-session_id state file (readState/writeState above)
|
|
56
|
+
* cannot answer "was something captured recently" once a compaction/resume
|
|
57
|
+
* event rolls the session_id over — that starts both a blank dedup state
|
|
58
|
+
* AND a blank transcript file, so a genuine earlier capture becomes
|
|
59
|
+
* invisible to scanTranscript(). This marker survives that boundary because
|
|
60
|
+
* it's keyed by the (stable) working directory instead.
|
|
61
|
+
*
|
|
62
|
+
* Lives in a user-private, mode-0700 subdirectory rather than directly in
|
|
63
|
+
* (often world-writable, on Linux) os.tmpdir() — unlike statePath()'s
|
|
64
|
+
* session_id (an unguessable UUID), a hash of `cwd` is derived from a much
|
|
65
|
+
* smaller, guessable input space (common project directory names), so a
|
|
66
|
+
* predictable path directly in shared tmp could be pre-planted by another
|
|
67
|
+
* local user on a shared box.
|
|
68
|
+
*/
|
|
69
|
+
function privateTmpDir() {
|
|
70
|
+
const owner = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().username;
|
|
71
|
+
const dir = path.join(os.tmpdir(), `ticketlens-recall-nudge-${owner}`);
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
} catch { /* best-effort — a write into it below will just fail safely too */ }
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function lastCapturePath(cwd) {
|
|
79
|
+
const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
|
|
80
|
+
return path.join(privateTmpDir(), `lastcapture-${hash}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readLastCaptureAt(cwd) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(fs.readFileSync(lastCapturePath(cwd), 'utf8')).lastCaptureAt ?? 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function writeLastCaptureAt(cwd, timestamp) {
|
|
92
|
+
try {
|
|
93
|
+
fs.writeFileSync(lastCapturePath(cwd), JSON.stringify({ lastCaptureAt: timestamp }));
|
|
94
|
+
} catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function hasRecentCapture(cwd, now = Date.now()) {
|
|
98
|
+
const lastCaptureAt = readLastCaptureAt(cwd);
|
|
99
|
+
return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
47
102
|
/**
|
|
48
103
|
* Reads the transcript (JSONL) and returns simple booleans about what
|
|
49
104
|
* happened this session. Best-effort: any read/parse failure returns all
|
|
@@ -10,25 +10,41 @@
|
|
|
10
10
|
* — the weaker "did anything ever get considered?" catch.
|
|
11
11
|
* Anything else (no ticket work at all, or a note was already added) exits
|
|
12
12
|
* clean — this must never be the reason a session can't end.
|
|
13
|
+
*
|
|
14
|
+
* The per-session_id "asked once" state (readState/writeState) cannot
|
|
15
|
+
* survive a compaction/resume event — that hands this hook a brand-new
|
|
16
|
+
* session_id, a blank dedup state, AND a blank transcript file, so a real
|
|
17
|
+
* earlier capture becomes invisible. The cross-session lastCapture marker
|
|
18
|
+
* (keyed by cwd, not session_id) is what actually bridges that boundary.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
|
-
import { readStdinJson, readState, writeState, scanTranscript } from './recall-nudge-lib.mjs';
|
|
21
|
+
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt } from './recall-nudge-lib.mjs';
|
|
16
22
|
|
|
17
23
|
const input = readStdinJson();
|
|
18
24
|
const sessionId = input?.session_id;
|
|
19
25
|
const transcriptPath = input?.transcript_path;
|
|
26
|
+
const cwd = input?.cwd ?? process.cwd();
|
|
20
27
|
|
|
21
28
|
if (!sessionId || !transcriptPath) process.exit(0);
|
|
22
29
|
|
|
30
|
+
const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
|
|
31
|
+
|
|
32
|
+
// Refreshed on every check, independent of the once-per-session gate below —
|
|
33
|
+
// a capture that happens AFTER this session already nagged once must still
|
|
34
|
+
// update the marker, or a later session_id rollover would find it stale.
|
|
35
|
+
if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
|
|
36
|
+
|
|
23
37
|
const state = readState(sessionId);
|
|
24
38
|
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
25
39
|
|
|
26
|
-
const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
|
|
27
|
-
|
|
28
40
|
if (!sawTicketKey || sawNoteAdd) {
|
|
29
41
|
process.exit(0); // no ticket work, or already captured — nothing to force
|
|
30
42
|
}
|
|
31
43
|
|
|
44
|
+
if (hasRecentCapture(cwd)) {
|
|
45
|
+
process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
state.stopChecked = true;
|
|
33
49
|
writeState(sessionId, state);
|
|
34
50
|
|
|
@@ -123,7 +123,7 @@ function groupEntriesByProfile(entries, config) {
|
|
|
123
123
|
* Filters entries to only those belonging to the given profile (by ticketPrefixes).
|
|
124
124
|
* Returns all entries if the profile has no ticketPrefixes configured.
|
|
125
125
|
*/
|
|
126
|
-
function filterEntriesByProfile(entries, profileName, config) {
|
|
126
|
+
export function filterEntriesByProfile(entries, profileName, config) {
|
|
127
127
|
const prefixes = config?.profiles?.[profileName]?.ticketPrefixes ?? [];
|
|
128
128
|
if (prefixes.length === 0) return entries;
|
|
129
129
|
return entries.filter(e => prefixes.includes(e.ticketKey.split('-')[0]));
|
|
@@ -118,6 +118,10 @@ export function parseCommand(args) {
|
|
|
118
118
|
return { command: 'stats', args: args.slice(1) };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
if (first === 'doctor') {
|
|
122
|
+
return { command: 'doctor', args: args.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
if (first === 'note') {
|
|
122
126
|
return { command: 'note', args: args.slice(1) };
|
|
123
127
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diagnostic check functions for `ticketlens doctor`. Each function
|
|
3
|
+
* takes DI'd dependencies (matching the xFn = defaultX pattern used
|
|
4
|
+
* throughout this codebase) and returns a normalized result:
|
|
5
|
+
* { id, label, ok, message, hint, fixable }
|
|
6
|
+
* No stdout/stdin, no arg parsing — independently unit-testable in
|
|
7
|
+
* isolation from CLI/MCP concerns.
|
|
8
|
+
*
|
|
9
|
+
* checkCacheHealth returns a 7th, internal-only field beyond the six
|
|
10
|
+
* above — `corruptEntries` — consumed only by doctor-command.mjs's
|
|
11
|
+
* `--fix` step to know which local files to delete. It is stripped
|
|
12
|
+
* before any public (CLI plain/JSON or MCP) output.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
16
|
+
import { resolveProfile, loadCredentials, loadProfiles } from './profile-resolver.mjs';
|
|
17
|
+
import { checkLicense, GRACE_DAYS } from './license.mjs';
|
|
18
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
19
|
+
import { classifyError } from './error-classifier.mjs';
|
|
20
|
+
import { testConnections } from './connection-tester.mjs';
|
|
21
|
+
import { formatSize } from './attachment-downloader.mjs';
|
|
22
|
+
import { getCacheEntries, filterEntriesByProfile } from './cache-manager.mjs';
|
|
23
|
+
import { readQueue } from './recall-queue.mjs';
|
|
24
|
+
|
|
25
|
+
const NOOP_STREAM = { write: () => true };
|
|
26
|
+
|
|
27
|
+
export function checkProfileConfig({
|
|
28
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
29
|
+
profileName = null,
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
resolveProfileFn = resolveProfile,
|
|
32
|
+
loadCredentialsFn = loadCredentials,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const profile = resolveProfileFn(null, { profileName, configDir, cwd });
|
|
35
|
+
|
|
36
|
+
if (!profile) {
|
|
37
|
+
return {
|
|
38
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
39
|
+
message: profileName ? `Profile "${profileName}" not found.` : 'No profile configured.',
|
|
40
|
+
hint: profileName ? 'Run `ticketlens profiles` to see available profiles.' : 'Run `ticketlens init` to set up a profile.',
|
|
41
|
+
fixable: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!profile.baseUrl) {
|
|
46
|
+
return {
|
|
47
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
48
|
+
message: `Profile "${profile.name}" has no baseUrl configured.`,
|
|
49
|
+
hint: `Run \`ticketlens config --profile=${profile.name}\` to fix it.`,
|
|
50
|
+
fixable: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const creds = loadCredentialsFn(configDir)[profile.name] || {};
|
|
55
|
+
if (!creds.apiToken && !creds.pat) {
|
|
56
|
+
return {
|
|
57
|
+
id: 'profile-config', label: 'Profile configuration', ok: false,
|
|
58
|
+
message: `Profile "${profile.name}" has no credentials stored.`,
|
|
59
|
+
hint: `Run \`ticketlens config --profile=${profile.name}\` to add an API token or PAT.`,
|
|
60
|
+
fixable: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id: 'profile-config', label: 'Profile configuration', ok: true,
|
|
66
|
+
message: `Profile "${profile.name}" resolves with a baseUrl and stored credentials.`,
|
|
67
|
+
hint: null, fixable: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function checkLicenseFreshness({
|
|
72
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
73
|
+
checkLicenseFn = checkLicense,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const status = checkLicenseFn(configDir);
|
|
76
|
+
|
|
77
|
+
if (!status.key) {
|
|
78
|
+
return {
|
|
79
|
+
id: 'license-freshness', label: 'License freshness', ok: true,
|
|
80
|
+
message: 'Free tier — no license to validate.', hint: null, fixable: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (status.expired) {
|
|
85
|
+
return {
|
|
86
|
+
id: 'license-freshness', label: 'License freshness', ok: false,
|
|
87
|
+
message: 'License expired.',
|
|
88
|
+
hint: 'Run `ticketlens activate <KEY>` to renew.', fixable: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const daysSinceVal = status.validatedAt
|
|
93
|
+
? (Date.now() - new Date(status.validatedAt).getTime()) / 86400000
|
|
94
|
+
: Infinity;
|
|
95
|
+
|
|
96
|
+
if (daysSinceVal > GRACE_DAYS) {
|
|
97
|
+
return {
|
|
98
|
+
id: 'license-freshness', label: 'License freshness', ok: false,
|
|
99
|
+
message: `Not revalidated in over ${GRACE_DAYS} days.`,
|
|
100
|
+
hint: 'Run `ticketlens doctor --fix` to revalidate now.', fixable: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: 'license-freshness', label: 'License freshness', ok: true,
|
|
106
|
+
message: `${status.tier} license active, last validated ${Math.floor(daysSinceVal)} day(s) ago.`,
|
|
107
|
+
hint: null, fixable: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function checkConnectivity({
|
|
112
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
113
|
+
profileName = null,
|
|
114
|
+
cwd = process.cwd(),
|
|
115
|
+
resolveProfileFn = resolveProfile,
|
|
116
|
+
loadCredentialsFn = loadCredentials,
|
|
117
|
+
resolveAdapterFn = resolveAdapter,
|
|
118
|
+
classifyErrorFn = classifyError,
|
|
119
|
+
testConnectionsFn = testConnections,
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (profileName) {
|
|
122
|
+
const profile = resolveProfileFn(null, { profileName, configDir, cwd });
|
|
123
|
+
if (!profile) {
|
|
124
|
+
return {
|
|
125
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: false,
|
|
126
|
+
message: `Profile "${profileName}" not found.`, hint: null, fixable: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const creds = loadCredentialsFn(configDir)[profile.name] || {};
|
|
130
|
+
const conn = {
|
|
131
|
+
baseUrl: profile.baseUrl, auth: profile.auth, email: profile.email,
|
|
132
|
+
apiToken: creds.apiToken, pat: creds.pat, allowPrivateIp: profile.allowPrivateIp,
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await resolveAdapterFn(conn).fetchCurrentUser();
|
|
136
|
+
return {
|
|
137
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: true,
|
|
138
|
+
message: `Profile "${profile.name}" connected successfully.`, hint: null, fixable: false,
|
|
139
|
+
};
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const classified = classifyErrorFn(err, { baseUrl: conn.baseUrl, profileName: profile.name });
|
|
142
|
+
return {
|
|
143
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: false,
|
|
144
|
+
message: classified.message, hint: classified.hint, fixable: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { results, failedCount } = await testConnectionsFn({ configDir, stream: NOOP_STREAM, resolveAdapterFn });
|
|
150
|
+
if (results.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: true,
|
|
153
|
+
message: 'No profiles configured — nothing to test.', hint: null, fixable: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const summary = results.map(r => r.ok ? `${r.name}: ok` : `${r.name}: ${r.error}`).join('; ');
|
|
157
|
+
return {
|
|
158
|
+
id: 'connectivity', label: 'Tracker connectivity',
|
|
159
|
+
ok: failedCount === 0,
|
|
160
|
+
message: failedCount === 0
|
|
161
|
+
? `All ${results.length} profile(s) connected successfully.`
|
|
162
|
+
: `${failedCount}/${results.length} profile(s) failed to connect.`,
|
|
163
|
+
hint: failedCount === 0 ? null : summary,
|
|
164
|
+
fixable: false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function checkCacheHealth({
|
|
169
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
170
|
+
profileName = null,
|
|
171
|
+
getCacheEntriesFn = getCacheEntries,
|
|
172
|
+
loadProfilesFn = loadProfiles,
|
|
173
|
+
filterEntriesByProfileFn = filterEntriesByProfile,
|
|
174
|
+
} = {}) {
|
|
175
|
+
let entries = getCacheEntriesFn(configDir);
|
|
176
|
+
if (profileName) {
|
|
177
|
+
entries = filterEntriesByProfileFn(entries, profileName, loadProfilesFn(configDir));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const corrupt = entries.filter(e => e.size === 0);
|
|
181
|
+
if (corrupt.length === 0) {
|
|
182
|
+
const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
|
|
183
|
+
return {
|
|
184
|
+
id: 'cache-health', label: 'Attachment cache', ok: true,
|
|
185
|
+
message: entries.length === 0
|
|
186
|
+
? 'No cached files.'
|
|
187
|
+
: `${entries.length} cached file(s), ${formatSize(totalSize)}, none corrupt.`,
|
|
188
|
+
hint: null, fixable: false, corruptEntries: [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: 'cache-health', label: 'Attachment cache', ok: false,
|
|
194
|
+
message: `${corrupt.length} corrupt (0-byte) cached file(s) found.`,
|
|
195
|
+
hint: 'Run `ticketlens doctor --fix` to remove them.',
|
|
196
|
+
fixable: true, corruptEntries: corrupt,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function checkRecallQueue({
|
|
201
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
202
|
+
readQueueFn = readQueue,
|
|
203
|
+
} = {}) {
|
|
204
|
+
const entries = readQueueFn(configDir);
|
|
205
|
+
if (entries.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
id: 'recall-queue', label: 'Recall sync queue', ok: true,
|
|
208
|
+
message: 'No notes pending sync.', hint: null, fixable: false,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
id: 'recall-queue', label: 'Recall sync queue', ok: false,
|
|
213
|
+
message: `${entries.length} note(s) pending sync.`,
|
|
214
|
+
hint: 'Run `ticketlens doctor --fix` to retry now, or `ticketlens recall sync`.',
|
|
215
|
+
fixable: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements `tl doctor`. Runs a fixed set of diagnostic checks
|
|
3
|
+
* (doctor-checks.mjs) and reports pass/fail with hints. Free tier,
|
|
4
|
+
* fully unrestricted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
|
+
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
10
|
+
import { createStyler } from './ansi.mjs';
|
|
11
|
+
import {
|
|
12
|
+
checkProfileConfig, checkLicenseFreshness, checkConnectivity,
|
|
13
|
+
checkCacheHealth, checkRecallQueue,
|
|
14
|
+
} from './doctor-checks.mjs';
|
|
15
|
+
import { revalidateLicense } from './license.mjs';
|
|
16
|
+
import { flushQueue } from './recall-queue.mjs';
|
|
17
|
+
import { readCliToken } from './cli-auth.mjs';
|
|
18
|
+
|
|
19
|
+
const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--help', '-h'];
|
|
20
|
+
|
|
21
|
+
function renderPlain(checks, { fixed, skipped, stream }) {
|
|
22
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
23
|
+
stream.write('\n');
|
|
24
|
+
for (const check of checks) {
|
|
25
|
+
const icon = check.ok ? s.green('✔') : s.red('✖');
|
|
26
|
+
stream.write(` ${icon} ${check.label}: ${check.message}\n`);
|
|
27
|
+
if (!check.ok && check.hint) stream.write(` ${s.dim(check.hint)}\n`);
|
|
28
|
+
}
|
|
29
|
+
if (fixed.length > 0) {
|
|
30
|
+
stream.write(`\n ${s.green('Fixed:')} ${fixed.join(', ')}\n`);
|
|
31
|
+
}
|
|
32
|
+
if (skipped.length > 0) {
|
|
33
|
+
stream.write(`\n ${s.yellow('Skipped:')}\n`);
|
|
34
|
+
for (const sk of skipped) stream.write(` ${sk.id}: ${sk.reason}\n`);
|
|
35
|
+
}
|
|
36
|
+
stream.write('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function applyFixes(rawResults, {
|
|
40
|
+
configDir, profileName, format, stream,
|
|
41
|
+
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
42
|
+
unlinkFn, checkCacheHealthFn,
|
|
43
|
+
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
44
|
+
}) {
|
|
45
|
+
const fixed = [];
|
|
46
|
+
const skipped = [];
|
|
47
|
+
const byId = Object.fromEntries(rawResults.map(r => [r.id, r]));
|
|
48
|
+
|
|
49
|
+
if (byId['license-freshness'] && !byId['license-freshness'].ok && byId['license-freshness'].fixable) {
|
|
50
|
+
if (format === 'plain') stream.write('Revalidating license...\n');
|
|
51
|
+
await revalidateLicenseFn({ configDir });
|
|
52
|
+
const recheck = checkLicenseFreshnessFn({ configDir });
|
|
53
|
+
byId['license-freshness'] = recheck;
|
|
54
|
+
if (recheck.ok) fixed.push('license-freshness');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (byId['cache-health'] && !byId['cache-health'].ok && byId['cache-health'].fixable) {
|
|
58
|
+
if (format === 'plain') stream.write('Clearing corrupt cache entries...\n');
|
|
59
|
+
for (const entry of byId['cache-health'].corruptEntries ?? []) {
|
|
60
|
+
try { unlinkFn(entry.localPath); } catch { /* already gone */ }
|
|
61
|
+
}
|
|
62
|
+
const recheck = checkCacheHealthFn({ configDir, profileName });
|
|
63
|
+
byId['cache-health'] = recheck;
|
|
64
|
+
if (recheck.ok) fixed.push('cache-health');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (byId['recall-queue'] && !byId['recall-queue'].ok && byId['recall-queue'].fixable) {
|
|
68
|
+
const cliToken = readCliTokenFn(configDir);
|
|
69
|
+
if (!cliToken) {
|
|
70
|
+
skipped.push({ id: 'recall-queue', reason: 'Not logged in — run `ticketlens login` first.' });
|
|
71
|
+
} else {
|
|
72
|
+
if (format === 'plain') stream.write('Flushing recall queue...\n');
|
|
73
|
+
await flushQueueFn({ cliToken, configDir });
|
|
74
|
+
const recheck = checkRecallQueueFn({ configDir });
|
|
75
|
+
byId['recall-queue'] = recheck;
|
|
76
|
+
if (recheck.ok) fixed.push('recall-queue');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { results: Object.values(byId), fixed, skipped };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runDoctor(args, {
|
|
84
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
85
|
+
stream = process.stderr,
|
|
86
|
+
out = process.stdout,
|
|
87
|
+
cwd = process.cwd(),
|
|
88
|
+
checkProfileConfigFn = checkProfileConfig,
|
|
89
|
+
checkLicenseFreshnessFn = checkLicenseFreshness,
|
|
90
|
+
checkConnectivityFn = checkConnectivity,
|
|
91
|
+
checkCacheHealthFn = checkCacheHealth,
|
|
92
|
+
checkRecallQueueFn = checkRecallQueue,
|
|
93
|
+
revalidateLicenseFn = revalidateLicense,
|
|
94
|
+
unlinkFn = (p) => fs.unlinkSync(p),
|
|
95
|
+
flushQueueFn = flushQueue,
|
|
96
|
+
readCliTokenFn = readCliToken,
|
|
97
|
+
} = {}) {
|
|
98
|
+
const validated = await handleUnknownFlags(args, KNOWN_FLAGS, { stream });
|
|
99
|
+
if (validated === null) return { ok: false };
|
|
100
|
+
|
|
101
|
+
const formatArg = validated.find(a => a.startsWith('--format='));
|
|
102
|
+
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
103
|
+
if (format !== 'plain' && format !== 'json') {
|
|
104
|
+
stream.write(`Error: --format must be plain or json, got: ${format}\n`);
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const profileArg = validated.find(a => a.startsWith('--profile='));
|
|
109
|
+
const profileName = profileArg ? profileArg.split('=')[1] : null;
|
|
110
|
+
const shouldFix = validated.includes('--fix');
|
|
111
|
+
|
|
112
|
+
const rawResults = [
|
|
113
|
+
checkProfileConfigFn({ configDir, profileName, cwd }),
|
|
114
|
+
checkLicenseFreshnessFn({ configDir }),
|
|
115
|
+
await checkConnectivityFn({ configDir, profileName, cwd }),
|
|
116
|
+
checkCacheHealthFn({ configDir, profileName }),
|
|
117
|
+
checkRecallQueueFn({ configDir }),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
let fixed = [];
|
|
121
|
+
let skipped = [];
|
|
122
|
+
let finalResults = rawResults;
|
|
123
|
+
if (shouldFix) {
|
|
124
|
+
const applied = await applyFixes(rawResults, {
|
|
125
|
+
configDir, profileName, format, stream,
|
|
126
|
+
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
127
|
+
unlinkFn, checkCacheHealthFn,
|
|
128
|
+
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
129
|
+
});
|
|
130
|
+
finalResults = applied.results;
|
|
131
|
+
fixed = applied.fixed;
|
|
132
|
+
skipped = applied.skipped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const checks = finalResults.map(({ id, label, ok, message, hint, fixable }) => ({ id, label, ok, message, hint, fixable }));
|
|
136
|
+
const ok = checks.every(c => c.ok);
|
|
137
|
+
|
|
138
|
+
if (format === 'json') {
|
|
139
|
+
out.write(JSON.stringify({ schemaVersion: 1, ok, checks, fixed, skipped }, null, 2) + '\n');
|
|
140
|
+
return { ok };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
renderPlain(checks, { fixed, skipped, stream: out });
|
|
144
|
+
return { ok };
|
|
145
|
+
}
|
|
@@ -48,6 +48,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
48
48
|
` ${s.brand('ticketlens')} ledger ${s.dim('[--format=json|csv]')} Export your signed usage ledger ${s.dim('[Pro]')}`,
|
|
49
49
|
` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
|
|
50
50
|
` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
|
|
51
|
+
` ${s.brand('ticketlens')} doctor ${s.dim('[--fix] [options]')} Diagnose profile/license/connectivity/cache/queue problems`,
|
|
51
52
|
` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
|
|
52
53
|
` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
|
|
53
54
|
` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
|
|
@@ -1225,6 +1226,35 @@ export function printCollisionsHelp({ stream = process.stdout } = {}) {
|
|
|
1225
1226
|
stream.write(lines.join('\n') + '\n');
|
|
1226
1227
|
}
|
|
1227
1228
|
|
|
1229
|
+
export function printDoctorHelp({ stream = process.stdout } = {}) {
|
|
1230
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
1231
|
+
const lines = [
|
|
1232
|
+
'',
|
|
1233
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('doctor')} ${s.dim('[--fix] [--format=plain|json] [--profile=NAME]')}`,
|
|
1234
|
+
'',
|
|
1235
|
+
` Diagnose common TicketLens problems: profile configuration, license`,
|
|
1236
|
+
` freshness, tracker connectivity, attachment cache health, and the Recall`,
|
|
1237
|
+
` sync queue. Free tier, fully unrestricted.`,
|
|
1238
|
+
'',
|
|
1239
|
+
` ${s.bold('OPTIONS')}`,
|
|
1240
|
+
'',
|
|
1241
|
+
` ${s.brand('--fix')} Attempt safe, non-destructive repairs for failing checks`,
|
|
1242
|
+
` ${s.brand('--format')}=${s.dim('plain')} Human-readable output ${s.dim('(default)')}`,
|
|
1243
|
+
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1244
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Scope profile/connectivity/cache checks to one profile`,
|
|
1245
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1246
|
+
'',
|
|
1247
|
+
` ${s.bold('EXAMPLES')}`,
|
|
1248
|
+
'',
|
|
1249
|
+
` ${s.dim('$')} ticketlens doctor`,
|
|
1250
|
+
` ${s.dim('$')} ticketlens doctor --fix`,
|
|
1251
|
+
` ${s.dim('$')} ticketlens doctor --profile=work`,
|
|
1252
|
+
` ${s.dim('$')} ticketlens doctor --format=json`,
|
|
1253
|
+
'',
|
|
1254
|
+
];
|
|
1255
|
+
stream.write(lines.join('\n') + '\n');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1228
1258
|
export function printStatsHelp({ stream = process.stdout } = {}) {
|
|
1229
1259
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1230
1260
|
const lines = [
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Installs the Recall nudge
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Installs the Recall nudge Stop hook (recall-nudge-stop.mjs) into any
|
|
3
|
+
* detected Claude Code settings.json — run from postinstall.mjs on every
|
|
4
|
+
* install/update so a user never has to wire this by hand. Idempotent: safe
|
|
5
|
+
* to run on every `npm install`. Also cleans up the retired PostToolUse
|
|
6
|
+
* nudge (recall-nudge-post-tool.mjs) from any settings.json that still has
|
|
7
|
+
* it from a prior install — it only ever matched Bash tool calls, so it
|
|
8
|
+
* went silently inert once ticket work moved to MCP tools, and was removed
|
|
9
|
+
* rather than left as dead config.
|
|
6
10
|
*
|
|
7
11
|
* Never touches an existing settings.json's other content, and never
|
|
8
12
|
* throws — a malformed or unreadable settings.json is skipped, not
|
|
@@ -16,12 +20,11 @@ import { homedir } from 'node:os';
|
|
|
16
20
|
|
|
17
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
22
|
const HOOKS_DIR = join(__dirname, '..', '..', 'hooks');
|
|
19
|
-
const
|
|
20
|
-
const STOP_SCRIPT = join(HOOKS_DIR, 'recall-nudge-stop.mjs');
|
|
23
|
+
const STOP_SCRIPT = join(HOOKS_DIR, 'recall-nudge-stop.mjs');
|
|
21
24
|
|
|
22
|
-
// Distinctive
|
|
25
|
+
// Distinctive substrings used to find our own entries on re-install/cleanup —
|
|
23
26
|
// never matches anything a user could plausibly have written by hand.
|
|
24
|
-
const
|
|
27
|
+
const RETIRED_POST_TOOL_MARKER = 'recall-nudge-post-tool.mjs';
|
|
25
28
|
const STOP_MARKER = 'recall-nudge-stop.mjs';
|
|
26
29
|
|
|
27
30
|
const CLAUDE_DIRS = [
|
|
@@ -42,6 +45,11 @@ function makeHookEntry(scriptPath, matcher) {
|
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
// Both functions below locate our own hook by a marker substring inside a
|
|
49
|
+
// list entry's `hooks` array, but must never drop an unrelated hook a user
|
|
50
|
+
// happens to have colocated in that same array entry (e.g. sharing our
|
|
51
|
+
// matcher) — only our own matching inner hook is ever added/replaced/removed.
|
|
52
|
+
|
|
45
53
|
function upsertHookEntry(list, marker, entry) {
|
|
46
54
|
const idx = list.findIndex(h =>
|
|
47
55
|
(h.hooks || []).some(inner => typeof inner.command === 'string' && inner.command.includes(marker)),
|
|
@@ -50,12 +58,32 @@ function upsertHookEntry(list, marker, entry) {
|
|
|
50
58
|
list.push(entry);
|
|
51
59
|
return 'added';
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
list[idx]
|
|
61
|
+
const ownInner = entry.hooks[0]; // makeHookEntry always builds a single-command entry
|
|
62
|
+
const otherHooks = list[idx].hooks.filter(inner => !(typeof inner.command === 'string' && inner.command.includes(marker)));
|
|
63
|
+
// Our hook was the only thing in this entry — safe to fully replace
|
|
64
|
+
// (also picks up a matcher change, if any). Otherwise preserve the
|
|
65
|
+
// entry's existing matcher and other hooks, just swap our own inner hook.
|
|
66
|
+
const merged = otherHooks.length === 0 ? entry : { ...list[idx], hooks: [...otherHooks, ownInner] };
|
|
67
|
+
if (JSON.stringify(list[idx]) === JSON.stringify(merged)) return 'unchanged';
|
|
68
|
+
list[idx] = merged;
|
|
55
69
|
return 'updated';
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
function
|
|
72
|
+
function removeHookEntry(list, marker) {
|
|
73
|
+
const idx = list.findIndex(h =>
|
|
74
|
+
(h.hooks || []).some(inner => typeof inner.command === 'string' && inner.command.includes(marker)),
|
|
75
|
+
);
|
|
76
|
+
if (idx === -1) return 'absent';
|
|
77
|
+
const remaining = list[idx].hooks.filter(inner => !(typeof inner.command === 'string' && inner.command.includes(marker)));
|
|
78
|
+
if (remaining.length === 0) {
|
|
79
|
+
list.splice(idx, 1);
|
|
80
|
+
} else {
|
|
81
|
+
list[idx] = { ...list[idx], hooks: remaining };
|
|
82
|
+
}
|
|
83
|
+
return 'removed';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function installInto(settingsPath) {
|
|
59
87
|
let settings = {};
|
|
60
88
|
if (existsSync(settingsPath)) {
|
|
61
89
|
try {
|
|
@@ -69,18 +97,14 @@ function installInto(settingsPath) {
|
|
|
69
97
|
settings.hooks.PostToolUse ??= [];
|
|
70
98
|
settings.hooks.Stop ??= [];
|
|
71
99
|
|
|
72
|
-
const
|
|
73
|
-
settings.hooks.PostToolUse,
|
|
74
|
-
MARKER,
|
|
75
|
-
makeHookEntry(POST_TOOL_SCRIPT, 'Bash'),
|
|
76
|
-
);
|
|
100
|
+
const postCleanupResult = removeHookEntry(settings.hooks.PostToolUse, RETIRED_POST_TOOL_MARKER);
|
|
77
101
|
const stopResult = upsertHookEntry(
|
|
78
102
|
settings.hooks.Stop,
|
|
79
103
|
STOP_MARKER,
|
|
80
104
|
makeHookEntry(STOP_SCRIPT, '*'),
|
|
81
105
|
);
|
|
82
106
|
|
|
83
|
-
if (
|
|
107
|
+
if (postCleanupResult === 'absent' && stopResult === 'unchanged') {
|
|
84
108
|
return { status: 'unchanged' };
|
|
85
109
|
}
|
|
86
110
|
|
|
@@ -89,7 +113,7 @@ function installInto(settingsPath) {
|
|
|
89
113
|
// Atomic on POSIX — avoids ever leaving settings.json half-written.
|
|
90
114
|
renameSync(tmpPath, settingsPath);
|
|
91
115
|
|
|
92
|
-
return { status: 'installed',
|
|
116
|
+
return { status: 'installed', postCleanupResult, stopResult };
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
/**
|
|
@@ -14,7 +14,7 @@ export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
|
|
|
14
14
|
const LICENSE_FILE = 'license.json';
|
|
15
15
|
const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
|
|
16
16
|
const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
|
|
17
|
-
const GRACE_DAYS = 30;
|
|
17
|
+
export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
|
|
18
18
|
const MS_PER_DAY = 86400000;
|
|
19
19
|
const upgradeUrl = () => `${siteBase()}/#pricing`;
|
|
20
20
|
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import readline from 'node:readline';
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
|
+
import { runDoctor } from './doctor-command.mjs';
|
|
24
25
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
26
|
import { runRecall } from './recall-command.mjs';
|
|
26
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
@@ -28,6 +29,17 @@ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTick
|
|
|
28
29
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
30
|
|
|
30
31
|
const TOOLS = [
|
|
32
|
+
{
|
|
33
|
+
name: 'doctor',
|
|
34
|
+
description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
|
|
39
|
+
profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
31
43
|
{
|
|
32
44
|
name: 'recall_add',
|
|
33
45
|
description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
|
|
@@ -167,6 +179,25 @@ function capturingStream() {
|
|
|
167
179
|
};
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
function buildDoctorArgs({ fix, profile }) {
|
|
183
|
+
const args = ['--format=json'];
|
|
184
|
+
if (fix === true) args.push('--fix');
|
|
185
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function callDoctor(args, { configDir, runDoctorFn }) {
|
|
190
|
+
const capture = capturingStream();
|
|
191
|
+
// runDoctor writes its final report to `out` (stdout by default) and only
|
|
192
|
+
// uses `stream` for --fix progress chatter — since this call always forces
|
|
193
|
+
// --format=json (see buildDoctorArgs), the report is what we need here.
|
|
194
|
+
// Both must be captured, not left to default: an uncaptured `out` would
|
|
195
|
+
// write the JSON report straight to this process's real stdout, which is
|
|
196
|
+
// the MCP JSON-RPC channel itself.
|
|
197
|
+
await runDoctorFn(buildDoctorArgs(args), { configDir, stream: capture, out: capture });
|
|
198
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
199
|
+
}
|
|
200
|
+
|
|
170
201
|
/**
|
|
171
202
|
* Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
|
|
172
203
|
* discrete array element — runNoteAdd's parseFlag matches per-element via
|
|
@@ -370,6 +401,7 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
|
|
|
370
401
|
|
|
371
402
|
async function handleToolsCall(params, deps) {
|
|
372
403
|
const { name, arguments: args = {} } = params ?? {};
|
|
404
|
+
if (name === 'doctor') return callDoctor(args, deps);
|
|
373
405
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
374
406
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
375
407
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
@@ -382,7 +414,7 @@ async function handleToolsCall(params, deps) {
|
|
|
382
414
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
383
415
|
}
|
|
384
416
|
|
|
385
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
417
|
+
async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
386
418
|
let msg;
|
|
387
419
|
try {
|
|
388
420
|
msg = JSON.parse(raw);
|
|
@@ -412,7 +444,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
412
444
|
|
|
413
445
|
if (method === 'tools/call') {
|
|
414
446
|
try {
|
|
415
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
447
|
+
const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
416
448
|
return jsonRpcResult(id, result);
|
|
417
449
|
} catch (err) {
|
|
418
450
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -433,6 +465,7 @@ export function runMcpServer({
|
|
|
433
465
|
configDir = DEFAULT_CONFIG_DIR,
|
|
434
466
|
stdin = process.stdin,
|
|
435
467
|
stdout = process.stdout,
|
|
468
|
+
runDoctorFn = runDoctor,
|
|
436
469
|
runNoteAddFn = runNoteAdd,
|
|
437
470
|
runRecallFn = runRecall,
|
|
438
471
|
runTicketCommentFn = runTicketComment,
|
|
@@ -463,7 +496,7 @@ export function runMcpServer({
|
|
|
463
496
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
464
497
|
// server would hang on shutdown instead of exiting.
|
|
465
498
|
queue = queue.then(async () => {
|
|
466
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
499
|
+
const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
467
500
|
if (response) stdout.write(response);
|
|
468
501
|
}).catch(() => {});
|
|
469
502
|
});
|
|
@@ -262,6 +262,22 @@ export function resolveProfile(ticketKey, opts = {}) {
|
|
|
262
262
|
return null;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Names of every profile whose ticketPrefixes includes `prefix` — used to
|
|
267
|
+
* cross-check a resolved connection against the project/team key the caller
|
|
268
|
+
* actually asked for. `ticket_create` has no ticket key to prefix-match
|
|
269
|
+
* against (unlike every other ticket-write command), so this checks the raw
|
|
270
|
+
* project/team key directly instead, as a safety net against silently
|
|
271
|
+
* creating a ticket on the wrong tracker.
|
|
272
|
+
*/
|
|
273
|
+
export function findProfilesByPrefix(prefix, configDir = DEFAULT_CONFIG_DIR) {
|
|
274
|
+
const config = loadProfiles(configDir);
|
|
275
|
+
if (!config) return [];
|
|
276
|
+
return Object.entries(config.profiles)
|
|
277
|
+
.filter(([, profile]) => profile.ticketPrefixes?.includes(prefix))
|
|
278
|
+
.map(([name]) => name);
|
|
279
|
+
}
|
|
280
|
+
|
|
265
281
|
export function resolveConnection(ticketKey, opts = {}) {
|
|
266
282
|
const { env = process.env, configDir = DEFAULT_CONFIG_DIR, profileName, onWarning, onProfileNotFound, cwd } = opts;
|
|
267
283
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
14
14
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
15
|
-
import { resolveConnection } from './profile-resolver.mjs';
|
|
15
|
+
import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs';
|
|
16
16
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
17
17
|
import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
|
|
18
18
|
import { logAction } from './ticket-action-log.mjs';
|
|
@@ -218,15 +218,24 @@ function requireTicketKey(cmdArgs, usage, stream) {
|
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
220
|
* `ticketKey` is undefined for ticket_create — there is no existing ticket to
|
|
221
|
-
* prefix-match a connection from, so resolution falls through to --profile
|
|
222
|
-
* or the default profile (resolveConnectionFn
|
|
223
|
-
* ticketKey by skipping prefix matching, see
|
|
221
|
+
* prefix-match a connection from, so resolution falls through to --profile,
|
|
222
|
+
* the folder-based `cwd` match, or the default profile (resolveConnectionFn
|
|
223
|
+
* already handles a falsy ticketKey by skipping prefix matching, see
|
|
224
|
+
* profile-resolver.mjs). `cwd` is always the running process's own — every
|
|
225
|
+
* ticket-write command runs synchronously within a single CLI/MCP-server
|
|
226
|
+
* invocation, so there is never a separate "caller's cwd" to thread through.
|
|
227
|
+
*
|
|
228
|
+
* Returns `{ adapter, conn }` (not just the adapter) so callers that need to
|
|
229
|
+
* cross-check the resolved connection's identity — currently only
|
|
230
|
+
* `runTicketCreate`'s profile/project mismatch safety net — have it without
|
|
231
|
+
* re-resolving.
|
|
224
232
|
*/
|
|
225
233
|
function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
|
|
226
234
|
const profileName = parseFlag(cmdArgs, 'profile');
|
|
227
235
|
const conn = resolveConnectionFn(ticketKey, {
|
|
228
236
|
configDir,
|
|
229
237
|
profileName,
|
|
238
|
+
cwd: process.cwd(),
|
|
230
239
|
onWarning: (msg) => stream.write(` ⚠ ${msg}\n`),
|
|
231
240
|
});
|
|
232
241
|
if (!conn.baseUrl) {
|
|
@@ -235,7 +244,7 @@ function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnection
|
|
|
235
244
|
: ` No connection configured. Run \`ticketlens init\` or pass --profile=NAME.\n`);
|
|
236
245
|
return null;
|
|
237
246
|
}
|
|
238
|
-
return resolveAdapterFn(conn);
|
|
247
|
+
return { adapter: resolveAdapterFn(conn), conn };
|
|
239
248
|
}
|
|
240
249
|
|
|
241
250
|
/**
|
|
@@ -272,8 +281,9 @@ export async function runTicketComment(cmdArgs, {
|
|
|
272
281
|
return { ok: false };
|
|
273
282
|
}
|
|
274
283
|
|
|
275
|
-
const
|
|
276
|
-
if (!
|
|
284
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
285
|
+
if (!resolved) return { ok: false };
|
|
286
|
+
const { adapter } = resolved;
|
|
277
287
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
278
288
|
|
|
279
289
|
// Uploaded BEFORE the comment write so a tracker capable of inline
|
|
@@ -330,8 +340,9 @@ export async function runTicketTransitionList(cmdArgs, {
|
|
|
330
340
|
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
331
341
|
if (!ticketKey) return { ok: false };
|
|
332
342
|
|
|
333
|
-
const
|
|
334
|
-
if (!
|
|
343
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
344
|
+
if (!resolved) return { ok: false };
|
|
345
|
+
const { adapter } = resolved;
|
|
335
346
|
|
|
336
347
|
try {
|
|
337
348
|
const options = await adapter.getTransitions(ticketKey);
|
|
@@ -396,8 +407,9 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
396
407
|
return { ok: false };
|
|
397
408
|
}
|
|
398
409
|
|
|
399
|
-
const
|
|
400
|
-
if (!
|
|
410
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
411
|
+
if (!resolved) return { ok: false };
|
|
412
|
+
const { adapter } = resolved;
|
|
401
413
|
|
|
402
414
|
try {
|
|
403
415
|
const result = await adapter.transition(ticketKey, target);
|
|
@@ -456,8 +468,9 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
456
468
|
return { ok: false };
|
|
457
469
|
}
|
|
458
470
|
|
|
459
|
-
const
|
|
460
|
-
if (!
|
|
471
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
472
|
+
if (!resolved) return { ok: false };
|
|
473
|
+
const { adapter } = resolved;
|
|
461
474
|
|
|
462
475
|
try {
|
|
463
476
|
const result = await adapter.assignToSelf(ticketKey);
|
|
@@ -502,8 +515,9 @@ export async function runTicketDuplicates(cmdArgs, {
|
|
|
502
515
|
}
|
|
503
516
|
}
|
|
504
517
|
|
|
505
|
-
const
|
|
506
|
-
if (!
|
|
518
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
519
|
+
if (!resolved) return { ok: false };
|
|
520
|
+
const { adapter } = resolved;
|
|
507
521
|
|
|
508
522
|
try {
|
|
509
523
|
// depth: 0 — only the shallow linkedIssues list is needed (for explicit
|
|
@@ -581,8 +595,9 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
581
595
|
const targetKey = requireTicketKey(cmdArgs.slice(1), usage, stream);
|
|
582
596
|
if (!targetKey) return { ok: false };
|
|
583
597
|
|
|
584
|
-
const
|
|
585
|
-
if (!
|
|
598
|
+
const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
599
|
+
if (!resolved) return { ok: false };
|
|
600
|
+
const { adapter } = resolved;
|
|
586
601
|
|
|
587
602
|
try {
|
|
588
603
|
const types = await adapter.getLinkTypes();
|
|
@@ -658,8 +673,9 @@ export async function runTicketLink(cmdArgs, {
|
|
|
658
673
|
return { ok: false };
|
|
659
674
|
}
|
|
660
675
|
|
|
661
|
-
const
|
|
662
|
-
if (!
|
|
676
|
+
const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
677
|
+
if (!resolved) return { ok: false };
|
|
678
|
+
const { adapter } = resolved;
|
|
663
679
|
|
|
664
680
|
if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
|
|
665
681
|
stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
|
|
@@ -748,8 +764,9 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
748
764
|
return { ok: false };
|
|
749
765
|
}
|
|
750
766
|
|
|
751
|
-
const
|
|
752
|
-
if (!
|
|
767
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
768
|
+
if (!resolved) return { ok: false };
|
|
769
|
+
const { adapter } = resolved;
|
|
753
770
|
|
|
754
771
|
if (adapter.type === 'github' && priority !== undefined) {
|
|
755
772
|
stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
|
|
@@ -808,6 +825,7 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
808
825
|
logActionFn = logAction,
|
|
809
826
|
readMetadataCacheFn = readMetadataCache,
|
|
810
827
|
writeMetadataCacheFn = writeMetadataCache,
|
|
828
|
+
findProfilesByPrefixFn = findProfilesByPrefix,
|
|
811
829
|
actor = os.userInfo().username,
|
|
812
830
|
} = {}) {
|
|
813
831
|
const usage = 'Usage: ticketlens create --project=KEY --type="Task" --summary="..." [--description="..."] [--profile=NAME]\n';
|
|
@@ -824,8 +842,9 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
824
842
|
const description = parseFlag(cmdArgs, 'description');
|
|
825
843
|
const attachPaths = parseAttachPaths(cmdArgs);
|
|
826
844
|
|
|
827
|
-
const
|
|
828
|
-
if (!
|
|
845
|
+
const resolved = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
846
|
+
if (!resolved) return { ok: false };
|
|
847
|
+
const { adapter, conn } = resolved;
|
|
829
848
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
830
849
|
const attachRefused = refuseGithubAttachments(adapter, attachPaths, stream);
|
|
831
850
|
|
|
@@ -841,6 +860,28 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
841
860
|
stream.write(` Note: --type is ignored by ${adapter.type} — issue created without it.\n`);
|
|
842
861
|
}
|
|
843
862
|
|
|
863
|
+
// Highest-blast-radius write in the family: no ticket key exists yet to
|
|
864
|
+
// prefix-match against, so a resolution that quietly lands on the wrong
|
|
865
|
+
// profile (e.g. an unconfigured cwd falling to the default) fabricates a
|
|
866
|
+
// real ticket on the wrong tracker before anyone notices. Only refuses
|
|
867
|
+
// when a DIFFERENT profile is a known, better owner of this exact project
|
|
868
|
+
// key — a genuinely new, not-yet-registered project proceeds untouched.
|
|
869
|
+
// Skipped when the caller explicitly passed --profile=NAME: that is
|
|
870
|
+
// deliberate, informed intent (the same trust resolveProfile() itself
|
|
871
|
+
// already gives an explicit flag over every other signal), and this guard
|
|
872
|
+
// must never be a dead end with no way to force a legitimate create through.
|
|
873
|
+
if (adapter.type !== 'github' && project && conn.profileName && !parseFlag(cmdArgs, 'profile')) {
|
|
874
|
+
const owningProfiles = findProfilesByPrefixFn(project, configDir);
|
|
875
|
+
if (owningProfiles.length > 0 && !owningProfiles.includes(conn.profileName)) {
|
|
876
|
+
stream.write(
|
|
877
|
+
` Project "${project}" is registered under profile "${owningProfiles[0]}", not the resolved profile ` +
|
|
878
|
+
`"${conn.profileName}". Pass --profile=${owningProfiles[0]} to target the right tracker, or ` +
|
|
879
|
+
`--profile=${conn.profileName} to confirm this is intentional.\n`,
|
|
880
|
+
);
|
|
881
|
+
return { ok: false };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
844
885
|
// JSON-encoded, not naively colon-joined — project/type/summary are free
|
|
845
886
|
// text that can themselves contain ":", which would let two genuinely
|
|
846
887
|
// different tuples collide onto the same cooldown key.
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PostToolUse hook (matcher: Bash) — mid-session Recall nudge.
|
|
4
|
-
*
|
|
5
|
-
* Non-blocking by design: this only prints an advisory reminder to stdout
|
|
6
|
-
* every NUDGE_EVERY ticket-related Bash calls, and only if Claude hasn't
|
|
7
|
-
* self-flagged a Recall-worthy insight (🔖 Recall-flag:) since the last one.
|
|
8
|
-
* Never exits non-zero — a mid-session nudge must never interrupt real work.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readStdinJson, statePath, readState, writeState, TICKET_KEY_RE, scanTranscript } from './recall-nudge-lib.mjs';
|
|
12
|
-
|
|
13
|
-
const NUDGE_EVERY = 8; // ticket-related Bash calls between nudges
|
|
14
|
-
|
|
15
|
-
const input = readStdinJson();
|
|
16
|
-
const command = input?.tool_input?.command ?? '';
|
|
17
|
-
const sessionId = input?.session_id;
|
|
18
|
-
const transcriptPath = input?.transcript_path;
|
|
19
|
-
|
|
20
|
-
if (!TICKET_KEY_RE.test(command) || !sessionId) {
|
|
21
|
-
process.exit(0);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const state = readState(sessionId);
|
|
25
|
-
state.ticketToolCalls = (state.ticketToolCalls ?? 0) + 1;
|
|
26
|
-
|
|
27
|
-
// If Claude already self-flagged since we last reset, don't nag — reset the
|
|
28
|
-
// counter so the next nudge only fires after another full quiet stretch.
|
|
29
|
-
if (transcriptPath) {
|
|
30
|
-
const { sawRecallFlag } = scanTranscript(transcriptPath);
|
|
31
|
-
if (sawRecallFlag) {
|
|
32
|
-
state.ticketToolCalls = 0;
|
|
33
|
-
writeState(sessionId, state);
|
|
34
|
-
process.exit(0);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (state.ticketToolCalls >= NUDGE_EVERY) {
|
|
39
|
-
state.ticketToolCalls = 0;
|
|
40
|
-
state.lastNudgeAt = Date.now();
|
|
41
|
-
writeState(sessionId, state);
|
|
42
|
-
process.stdout.write(
|
|
43
|
-
'Reminder (not a request for action right now): if something non-obvious was ' +
|
|
44
|
-
'confirmed in the last stretch of ticket work — a gotcha, a root cause, a ' +
|
|
45
|
-
'decision with non-obvious rationale — capture it now via `ticketlens note add` ' +
|
|
46
|
-
'per the jtb skill\'s Recall guidance, then keep going.\n',
|
|
47
|
-
);
|
|
48
|
-
process.exit(0);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
writeState(sessionId, state);
|
|
52
|
-
process.exit(0);
|