@marcomigozzi/trailhead 0.16.1 → 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.
- package/README.it.md +82 -25
- package/README.md +82 -25
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +155 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +28 -19
- package/bin/lib/codex-projection.test.js +77 -20
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +15 -8
- package/bin/trailhead.test.js +71 -17
- package/bin/unblock-docs.test.js +56 -0
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +42 -17
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
- package/plugins/trailhead/skills/_shared/substrate.md +4 -3
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
- package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques.md +8 -6
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +419 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -4,35 +4,62 @@
|
|
|
4
4
|
// install channel). The panel cannot move the session's directory, so it hands the
|
|
5
5
|
// pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
|
|
6
6
|
// verb moves there first. This helper decodes the token, validates it and says
|
|
7
|
-
// whether the session is already there. It fails closed:
|
|
8
|
-
// another checkout
|
|
7
|
+
// whether the session is already there. It fails closed: never a blind search for
|
|
8
|
+
// another checkout, only a lookup by the repo's origin remote when the path is gone
|
|
9
|
+
// and the token names the repo (trailhead-checkout-find.js). #312, #329.
|
|
9
10
|
//
|
|
10
11
|
// The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
|
|
11
|
-
// with spaces, quotes or a leading `-` travels as one shell-safe word.
|
|
12
|
+
// with spaces, quotes or a leading `-` travels as one shell-safe word. It may carry
|
|
13
|
+
// the repo identity as a second part: base64url(path) + '.' + base64url(host/owner/name)
|
|
14
|
+
// (`.` is outside the alphabet, so an old helper rejects it and stays fail-closed).
|
|
12
15
|
//
|
|
13
16
|
// CLI:
|
|
14
|
-
// node trailhead-checkout-in.js --in <
|
|
17
|
+
// node trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]
|
|
18
|
+
// `--roots` replaces the default search roots of the remote lookup (tests).
|
|
15
19
|
//
|
|
16
20
|
// Prints exactly one JSON line on stdout:
|
|
17
|
-
// {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
|
|
21
|
+
// {"status":"here"|"move"|"choose"|"invalid","path":"...","reason":"...","cd":"..."}
|
|
18
22
|
// `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
|
|
19
|
-
//
|
|
23
|
+
// `relocatedFrom` (the old path) is added to `here`/`move` when the checkout was
|
|
24
|
+
// found by its remote. `choose` (path gone, several checkouts match) carries
|
|
25
|
+
// `candidates` ([{path, kind}]) and `reason`.
|
|
26
|
+
// Exit 0 for the four statuses, 2 on a usage error (stderr).
|
|
20
27
|
|
|
21
28
|
const fs = require('fs');
|
|
22
29
|
const path = require('path');
|
|
23
30
|
const { spawnSync } = require('child_process');
|
|
31
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
32
|
+
|
|
33
|
+
const { findCheckouts, parseRepoShape } = require('./trailhead-checkout-find.js');
|
|
24
34
|
|
|
25
35
|
const PREFIX = '--in=';
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
const b64 = (s) => Buffer.from(String(s), 'utf8').toString('base64url');
|
|
38
|
+
|
|
39
|
+
function encodeIn(p, repo) {
|
|
40
|
+
return PREFIX + b64(p) + (repo ? '.' + b64(repo) : '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One base64url part back to its string, or null when it is not canonical.
|
|
44
|
+
function decodePart(part) {
|
|
45
|
+
if (!/^[A-Za-z0-9_-]+$/.test(part)) return null;
|
|
46
|
+
const s = Buffer.from(part, 'base64url').toString('utf8');
|
|
47
|
+
// Round-trip guard: a part that does not re-encode to itself is not ours.
|
|
48
|
+
return s !== '' && b64(s) === part ? s : null;
|
|
29
49
|
}
|
|
30
50
|
|
|
51
|
+
// {path, repo} (repo null for a one-part token), or null for a malformed token.
|
|
31
52
|
function decodeIn(token) {
|
|
32
|
-
if (typeof token !== 'string'
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
if (typeof token !== 'string') return null;
|
|
54
|
+
const parts = token.split('.');
|
|
55
|
+
if (parts.length > 2) return null;
|
|
56
|
+
const p = decodePart(parts[0]);
|
|
57
|
+
if (p === null) return null;
|
|
58
|
+
if (parts.length === 1) return { path: p, repo: null };
|
|
59
|
+
const repo = decodePart(parts[1]);
|
|
60
|
+
// The repo part is `host/owner/name` by shape (the host may be dotless, e.g. `gitea`).
|
|
61
|
+
if (repo === null || parseRepoShape(repo) === null) return null;
|
|
62
|
+
return { path: p, repo };
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
function real(p) {
|
|
@@ -44,7 +71,7 @@ function real(p) {
|
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
function toplevelOf(dir) {
|
|
47
|
-
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
74
|
+
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: gitEnv() });
|
|
48
75
|
if (r.status !== 0) return null;
|
|
49
76
|
const top = r.stdout.replace(/\n$/, '');
|
|
50
77
|
return top === '' ? null : real(top);
|
|
@@ -55,12 +82,8 @@ function cdCommand(p) {
|
|
|
55
82
|
return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
|
|
56
83
|
}
|
|
57
84
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
|
|
61
|
-
if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
|
|
62
|
-
const canon = real(p);
|
|
63
|
-
if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
|
|
85
|
+
// here/move for a path that must be a checkout root.
|
|
86
|
+
function resolveRoot(p, canon, cwd) {
|
|
64
87
|
// The path must be a checkout root itself, never a folder inside one.
|
|
65
88
|
if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
|
|
66
89
|
const here = real(cwd) !== null ? toplevelOf(cwd) : null;
|
|
@@ -68,6 +91,38 @@ function check(token, cwd) {
|
|
|
68
91
|
return { status: 'move', path: p, cd: cdCommand(p) };
|
|
69
92
|
}
|
|
70
93
|
|
|
94
|
+
// The path is gone: look the repo up by its remote (only when the token names it).
|
|
95
|
+
function relocate(old, repo, cwd, opts) {
|
|
96
|
+
const found = findCheckouts(parseRepoShape(repo), opts.roots ? { roots: opts.roots } : { cwd });
|
|
97
|
+
if (found.status === 'found') {
|
|
98
|
+
const root = resolveRoot(found.path, found.path, cwd);
|
|
99
|
+
return { ...root, relocatedFrom: old };
|
|
100
|
+
}
|
|
101
|
+
if (found.status === 'ambiguous') {
|
|
102
|
+
const cut = found.truncated ? ' (the search was cut short)' : '';
|
|
103
|
+
return {
|
|
104
|
+
status: 'choose',
|
|
105
|
+
path: old,
|
|
106
|
+
candidates: found.candidates,
|
|
107
|
+
reason: `the path does not exist and ${found.candidates.length === 1 ? 'a checkout' : 'several checkouts'} of ${repo} may match${cut}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { status: 'invalid', path: old, reason: `the path does not exist and no checkout of ${repo} was found` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function check(token, cwd, opts = {}) {
|
|
114
|
+
const d = decodeIn(token);
|
|
115
|
+
if (d === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
|
|
116
|
+
const p = d.path;
|
|
117
|
+
if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
|
|
118
|
+
const canon = real(p);
|
|
119
|
+
if (canon === null) {
|
|
120
|
+
if (d.repo === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
|
|
121
|
+
return relocate(p, d.repo, cwd, opts);
|
|
122
|
+
}
|
|
123
|
+
return resolveRoot(p, canon, cwd);
|
|
124
|
+
}
|
|
125
|
+
|
|
71
126
|
function main(argv) {
|
|
72
127
|
const get = (flag) => {
|
|
73
128
|
const i = argv.indexOf(flag);
|
|
@@ -75,11 +130,13 @@ function main(argv) {
|
|
|
75
130
|
};
|
|
76
131
|
const token = get('--in');
|
|
77
132
|
const cwd = get('--cwd');
|
|
133
|
+
const roots = get('--roots');
|
|
78
134
|
if (token === null || cwd === null) {
|
|
79
|
-
process.stderr.write('usage: trailhead-checkout-in.js --in <
|
|
135
|
+
process.stderr.write('usage: trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]\n');
|
|
80
136
|
return 2;
|
|
81
137
|
}
|
|
82
|
-
|
|
138
|
+
const opts = roots === null ? {} : { roots: roots.split(':').filter((r) => r !== '') };
|
|
139
|
+
process.stdout.write(JSON.stringify(check(token, cwd, opts)) + '\n');
|
|
83
140
|
return 0;
|
|
84
141
|
}
|
|
85
142
|
|
|
@@ -23,6 +23,7 @@ const fs = require('fs');
|
|
|
23
23
|
const path = require('path');
|
|
24
24
|
const crypto = require('crypto');
|
|
25
25
|
const { execFileSync } = require('child_process');
|
|
26
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
26
27
|
|
|
27
28
|
// Nome del file di logica installato accanto al wrapper commit-msg.
|
|
28
29
|
const CJS_NAME = 'trailhead-commit-msg.cjs';
|
|
@@ -47,7 +48,7 @@ function normalize(text) {
|
|
|
47
48
|
// deve stampare rumore sul terminale dell'invocante, solo far fallire questa
|
|
48
49
|
// chiamata (il chiamante decide come reagire).
|
|
49
50
|
function defaultRunGit(args, cwd) {
|
|
50
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
51
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// Risolve la hooksDir effettiva del repo (rispetta core.hooksPath e i
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
// updatedAt check, post-write conflict check, secret scan). #253.
|
|
8
8
|
//
|
|
9
9
|
// CLI:
|
|
10
|
-
// node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
|
|
10
|
+
// node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run] [--update-only]
|
|
11
11
|
// --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
|
|
12
12
|
// audit's only writer path); without it the marker is copied verbatim from the
|
|
13
13
|
// existing body. --dry-run gathers and reads through gh, computes and scans, but
|
|
14
14
|
// writes, creates, pins and labels nothing; it prints the old and new byte sizes.
|
|
15
|
+
// --update-only refreshes an existing dashboard and never opens one: with no open
|
|
16
|
+
// dashboard issue it creates, pins and labels nothing (the label ensure is skipped
|
|
17
|
+
// too) and prints `skipped: no dashboard issue (update-only)`, exit 0. A capture
|
|
18
|
+
// uses it so it cannot open a second issue; the check lives here, in the helper,
|
|
19
|
+
// so there is no check-then-call race. That is all it forbids: it never creates the
|
|
20
|
+
// dashboard. With a dashboard present it is a normal refresh (the label ensure and
|
|
21
|
+
// the re-pin run as usual, both idempotent).
|
|
15
22
|
//
|
|
16
23
|
// Singleton labels (dashboard, codebase, conventions): more than one open issue
|
|
17
24
|
// aborts before any write, naming every duplicate by title and number.
|
|
@@ -23,6 +30,7 @@
|
|
|
23
30
|
//
|
|
24
31
|
// Prints exactly one line on stdout:
|
|
25
32
|
// dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
|
|
33
|
+
// skipped: no dashboard issue (update-only)
|
|
26
34
|
// aborted: <reason>
|
|
27
35
|
// Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
|
|
28
36
|
// A pin check or pin failure AFTER the body was written is not an abort: the line
|
|
@@ -266,10 +274,11 @@ function createDashboard(gh, repo, body, tmpdir) {
|
|
|
266
274
|
// --- CLI ---------------------------------------------------------------------
|
|
267
275
|
|
|
268
276
|
function parseArgs(argv) {
|
|
269
|
-
const o = { repo: null, setLastAudit: null, dryRun: false };
|
|
277
|
+
const o = { repo: null, setLastAudit: null, dryRun: false, updateOnly: false };
|
|
270
278
|
for (let i = 0; i < argv.length; i++) {
|
|
271
279
|
const flag = argv[i];
|
|
272
280
|
if (flag === '--dry-run') { o.dryRun = true; continue; }
|
|
281
|
+
if (flag === '--update-only') { o.updateOnly = true; continue; }
|
|
273
282
|
if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
|
|
274
283
|
i++;
|
|
275
284
|
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
@@ -304,6 +313,10 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
|
|
|
304
313
|
const repo = o.repo || defaultRepo(run);
|
|
305
314
|
if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
|
|
306
315
|
const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
|
|
316
|
+
if (o.updateOnly && singles.dashboard === null) {
|
|
317
|
+
out.write('skipped: no dashboard issue (update-only)\n');
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
307
320
|
if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
|
|
308
321
|
const snap = gatherSnapshot(run, repo, singles);
|
|
309
322
|
const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Repo-location environment for git spawns.
|
|
3
|
+
//
|
|
4
|
+
// Git gives these variables precedence over `-C` and the cwd, so an exported
|
|
5
|
+
// GIT_DIR (a hook, `git rebase --exec`, a parent shell) silently redirects every
|
|
6
|
+
// git call to another repository. Each git spawn in the plugin builds its env
|
|
7
|
+
// through gitEnv() so it always acts on the checkout it was pointed at.
|
|
8
|
+
//
|
|
9
|
+
// The names are `git rev-parse --local-env-vars` minus GIT_CONFIG*: those carry
|
|
10
|
+
// the parent's `-c` options and are meant to be inherited. A static list keeps
|
|
11
|
+
// this free of a git spawn; refresh it from that command when git adds a name.
|
|
12
|
+
// Names must be deleted: an empty string is not "unset" (`fatal: '' is not a git repository`).
|
|
13
|
+
//
|
|
14
|
+
// hooks/lib/trailhead-git-env.js is a byte-identical copy for the hooks (they
|
|
15
|
+
// install without the templates); a test keeps the two in sync.
|
|
16
|
+
|
|
17
|
+
const GIT_LOCATION_VARS = Object.freeze([
|
|
18
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
19
|
+
'GIT_OBJECT_DIRECTORY',
|
|
20
|
+
'GIT_DIR',
|
|
21
|
+
'GIT_WORK_TREE',
|
|
22
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
23
|
+
'GIT_GRAFT_FILE',
|
|
24
|
+
'GIT_INDEX_FILE',
|
|
25
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
26
|
+
'GIT_REPLACE_REF_BASE',
|
|
27
|
+
'GIT_PREFIX',
|
|
28
|
+
'GIT_SHALLOW_FILE',
|
|
29
|
+
'GIT_COMMON_DIR',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// A new env object: `base` without the repo-location names, then `extra` on top.
|
|
33
|
+
// Never mutates `base` (process.env by default).
|
|
34
|
+
function gitEnv(base = process.env, extra = {}) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, v] of Object.entries(base)) if (!GIT_LOCATION_VARS.includes(k)) out[k] = v;
|
|
37
|
+
return { ...out, ...extra };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { GIT_LOCATION_VARS, gitEnv };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead inbox needs-info classifier (self-contained, ships in templates/ so it
|
|
4
|
+
// reaches every install channel). Read-only: never writes to the tracker. #354.
|
|
5
|
+
//
|
|
6
|
+
// An inbox issue parked with `trailhead:needs-info` waits on its reporter. This
|
|
7
|
+
// helper sorts the parked issues so the inbox protocol can act on them:
|
|
8
|
+
// answered the reporter commented strictly after the latest label event
|
|
9
|
+
// timedOut no answer and at least N whole days since the label
|
|
10
|
+
// waiting no answer yet, inside the window
|
|
11
|
+
// unknown a read failed, no label event, or no usable reporter identity
|
|
12
|
+
//
|
|
13
|
+
// CLI:
|
|
14
|
+
// node trailhead-inbox-needs-info.js --repo <owner/name> --config-root <project root>
|
|
15
|
+
// N comes from `inbox.needs_info_days` (project config over the global one, default 14).
|
|
16
|
+
//
|
|
17
|
+
// Prints exactly one JSON line on stdout:
|
|
18
|
+
// {status, days, answered, timedOut, waiting, unknown, warnings}
|
|
19
|
+
// Exit 0 on ok, 1 on status error (the list read failed), 2 on a usage error (stderr).
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { spawnSync } = require('child_process');
|
|
25
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
26
|
+
|
|
27
|
+
const LABEL = 'trailhead:needs-info';
|
|
28
|
+
const KEY = 'inbox.needs_info_days';
|
|
29
|
+
const DEFAULT_DAYS = 14;
|
|
30
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
function defaultGh(args) {
|
|
33
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
34
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const o = { repo: null, configRoot: null };
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const flag = argv[i];
|
|
43
|
+
if (flag !== '--repo' && flag !== '--config-root') return { error: `usage: unknown argument: ${flag}` };
|
|
44
|
+
i++;
|
|
45
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
46
|
+
const v = argv[i];
|
|
47
|
+
if (flag === '--repo') {
|
|
48
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
49
|
+
o.repo = v;
|
|
50
|
+
} else {
|
|
51
|
+
o.configRoot = v;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!o.repo) return { error: 'usage: missing --repo <owner/name>' };
|
|
55
|
+
if (!o.configRoot) return { error: 'usage: missing --config-root <project root>' };
|
|
56
|
+
return o;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Lines of `gh api --paginate ... --jq '... | tojson'`: one JSON document per line.
|
|
60
|
+
// Any non-zero exit discards the whole read: a later page can fail after earlier ones printed.
|
|
61
|
+
function jsonLines(r) {
|
|
62
|
+
if (r.status !== 0) return null;
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const line of r.stdout.split('\n')) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try { out.push(JSON.parse(line)); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
72
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
73
|
+
|
|
74
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
75
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
76
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
77
|
+
|
|
78
|
+
function globalConfigPath(env) {
|
|
79
|
+
const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
80
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// { found, value } for the key in one parsed file: the flat key wins over the nested one.
|
|
84
|
+
function pick(cfg) {
|
|
85
|
+
if (has(cfg, KEY)) return { found: true, value: cfg[KEY] };
|
|
86
|
+
if (isObject(cfg.inbox) && has(cfg.inbox, 'needs_info_days')) return { found: true, value: cfg.inbox.needs_info_days };
|
|
87
|
+
return { found: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Days from the nearest valid layer. A bad layer warns and falls to the next one: a timeout only
|
|
91
|
+
// ever produces an offer the maintainer confirms.
|
|
92
|
+
function resolveDays(root, env, fs_, warnings) {
|
|
93
|
+
for (const file of [path.join(root, '.trailhead', 'config.json'), globalConfigPath(env)]) {
|
|
94
|
+
let cfg;
|
|
95
|
+
try {
|
|
96
|
+
cfg = JSON.parse(fs_.readFileSync(file, 'utf8'));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e && e.code === 'ENOENT') continue;
|
|
99
|
+
warnings.push(e instanceof SyntaxError ? `${file} is not valid JSON; ignored` : `cannot read ${file}; ignored`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!isObject(cfg)) { warnings.push(`${file} is not a JSON object; ignored`); continue; }
|
|
103
|
+
const hit = pick(cfg);
|
|
104
|
+
if (!hit.found) continue;
|
|
105
|
+
if (Number.isInteger(hit.value) && hit.value > 0) return hit.value;
|
|
106
|
+
warnings.push(`${KEY} in ${file} must be a positive integer; ignored`);
|
|
107
|
+
}
|
|
108
|
+
return DEFAULT_DAYS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const usable = (login) => typeof login === 'string' && login !== '' && login.toLowerCase() !== 'ghost';
|
|
112
|
+
|
|
113
|
+
function main(argv, { gh, now, stdout, stderr, env, fs: fs_ } = {}) {
|
|
114
|
+
const out = stdout || process.stdout;
|
|
115
|
+
const err = stderr || process.stderr;
|
|
116
|
+
const o = parseArgs(argv || []);
|
|
117
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
118
|
+
const run = gh || defaultGh;
|
|
119
|
+
const nowMs = (now ? now() : new Date()).getTime();
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const days = resolveDays(o.configRoot, env || process.env, fs_ || fs, warnings);
|
|
122
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
123
|
+
|
|
124
|
+
const list = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues?labels=${LABEL}&state=open&per_page=100`, '--jq', '.[] | select(.pull_request == null) | {number, title, author: .user.login} | tojson']));
|
|
125
|
+
if (!list) {
|
|
126
|
+
emit({ status: 'error', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings, reason: 'could not read the parked issues' });
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const res = { status: 'ok', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings };
|
|
131
|
+
for (const i of list) {
|
|
132
|
+
const { number, title } = i;
|
|
133
|
+
const unknown = (reason) => res.unknown.push({ number, title, reason });
|
|
134
|
+
const events = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/events`, '--jq', `.[] | select(.event == "labeled" and .label.name == "${LABEL}") | {at: .created_at} | tojson`]));
|
|
135
|
+
if (!events) { unknown('could not read the label events'); continue; }
|
|
136
|
+
const times = events.map((e) => Date.parse(e.at)).filter((t) => !Number.isNaN(t));
|
|
137
|
+
if (!times.length) { unknown('no label event found'); continue; }
|
|
138
|
+
if (!usable(i.author)) { unknown('the reporter has no usable identity'); continue; }
|
|
139
|
+
const labeledMs = Math.max(...times);
|
|
140
|
+
const labeledAt = new Date(labeledMs).toISOString().replace('.000Z', 'Z');
|
|
141
|
+
const comments = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/comments?since=${labeledAt}&per_page=100`, '--jq', '.[] | {author: .user.login, at: .created_at} | tojson']));
|
|
142
|
+
if (!comments) { unknown('could not read the comments'); continue; }
|
|
143
|
+
// `since` filters on update time, so the creation time is checked again.
|
|
144
|
+
const reporter = i.author.toLowerCase();
|
|
145
|
+
const answers = comments
|
|
146
|
+
.filter((c) => usable(c.author) && c.author.toLowerCase() === reporter && Date.parse(c.at) > labeledMs)
|
|
147
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
148
|
+
const ageDays = Math.max(0, Math.floor((nowMs - labeledMs) / DAY_MS));
|
|
149
|
+
if (answers.length) res.answered.push({ number, title, author: i.author, labeledAt, answeredAt: answers[0].at });
|
|
150
|
+
else if (ageDays >= days) res.timedOut.push({ number, title, author: i.author, labeledAt, ageDays });
|
|
151
|
+
else res.waiting.push({ number, title, labeledAt, ageDays });
|
|
152
|
+
}
|
|
153
|
+
emit(res);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { globalConfigPath, main, parseArgs, resolveDays, DEFAULT_DAYS };
|
|
158
|
+
|
|
159
|
+
if (require.main === module) {
|
|
160
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
161
|
+
}
|
|
@@ -32,6 +32,7 @@ const fs = require('fs');
|
|
|
32
32
|
const path = require('path');
|
|
33
33
|
const crypto = require('crypto');
|
|
34
34
|
const { execFileSync } = require('child_process');
|
|
35
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
35
36
|
|
|
36
37
|
const STATE_DIR = '.trailhead';
|
|
37
38
|
const STATE_FILE = 'isolation-answer.json';
|
|
@@ -124,7 +125,7 @@ function parseArgs(argv) {
|
|
|
124
125
|
function resolveProjectRoot(dir) {
|
|
125
126
|
try {
|
|
126
127
|
const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
127
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
128
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv() }).trim();
|
|
128
129
|
if (common && path.basename(common) === '.git') return path.dirname(common);
|
|
129
130
|
return dir;
|
|
130
131
|
} catch {
|
|
@@ -33,6 +33,7 @@ const fs = require('fs');
|
|
|
33
33
|
const os = require('os');
|
|
34
34
|
const path = require('path');
|
|
35
35
|
const { execFileSync } = require('child_process');
|
|
36
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
36
37
|
|
|
37
38
|
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
38
39
|
const SUFFIXES = ['.example', '.sample'];
|
|
@@ -98,8 +99,12 @@ function readCopyList(file, fs_) {
|
|
|
98
99
|
return { state: 'set', list: value, warnings };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
103
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
104
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
105
|
+
|
|
101
106
|
function globalConfigPath(env) {
|
|
102
|
-
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
107
|
+
const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
103
108
|
return path.join(base, 'trailhead', 'config.json');
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -131,7 +136,7 @@ const exists = (p, fs_) => {
|
|
|
131
136
|
function suggest(from, fs_) {
|
|
132
137
|
let tracked;
|
|
133
138
|
try {
|
|
134
|
-
tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
|
|
139
|
+
tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv(), maxBuffer: 64 * 1024 * 1024 })
|
|
135
140
|
.toString().split('\0').filter(Boolean);
|
|
136
141
|
} catch (err) {
|
|
137
142
|
return [];
|
|
@@ -143,7 +148,7 @@ function suggest(from, fs_) {
|
|
|
143
148
|
const sibling = file.slice(0, -suffix.length);
|
|
144
149
|
if (out.includes(sibling) || checkRelPath(sibling) !== null || !exists(path.join(from, sibling), fs_)) continue;
|
|
145
150
|
try {
|
|
146
|
-
execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore' });
|
|
151
|
+
execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore', env: gitEnv() });
|
|
147
152
|
out.push(sibling);
|
|
148
153
|
} catch (err) {
|
|
149
154
|
// not ignored (exit 1) or git error: not a suggestion
|
|
@@ -231,7 +236,7 @@ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
|
231
236
|
}
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
module.exports = { checkRelPath, readCopyList, parseArgs, main };
|
|
239
|
+
module.exports = { globalConfigPath, checkRelPath, readCopyList, parseArgs, main };
|
|
235
240
|
|
|
236
241
|
if (require.main === module) {
|
|
237
242
|
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|