@marcomigozzi/trailhead 0.16.0 → 0.16.1
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 +10 -8
- package/README.md +10 -8
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/lib/codex-projection.js +8 -1
- package/bin/lib/codex-projection.test.js +23 -0
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/report-status-docs.test.js +1 -1
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +8 -3
- package/plugins/trailhead/hooks/mod/map-pane.tsx +52 -28
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -0
- package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/tests/map-pane-ui.test.ts +179 -2
|
@@ -12,7 +12,7 @@ argument-hint: "[work|quick|pause|resume|split|auto] [ticket | \"text\" | note]"
|
|
|
12
12
|
The panel starts `work` and `resume` from wherever the session stands and cannot move it, so it appends the pinned checkout to the args as a last word `--in=<token>` (base64url of the checkout root). When the args of `work` or `resume` end with such a word, this section runs first, before the load, the Preflight and anything else; with no such word it does not apply. `quick` never gets one.
|
|
13
13
|
|
|
14
14
|
1. Drop the `--in=<token>` word from the args: the verb, and its ticket number if any, are what is left.
|
|
15
|
-
2. Run `node <templates>/trailhead-checkout-in.js --in <token> --cwd "$PWD"`. `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`,
|
|
15
|
+
2. Run `node <templates>/trailhead-checkout-in.js --in <token> --cwd "$PWD"`. `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains the script (the same lookup as `trailhead-work-state.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty). It prints one JSON line with a `status`. If the script is missing (none of the three has it: an outdated install), stop and report that the checkout could not be reached and the install needs an update: nothing is claimed, and never move by hand.
|
|
16
16
|
3. `invalid` -> stop and report its `reason` in one line; never move, never look for another checkout.
|
|
17
17
|
4. `here` -> the session is already in the checkout: no move, carry on with the args from step 1.
|
|
18
18
|
5. `move` -> run the helper's `cd` field verbatim as its own Bash call (a lone `cd` is what moves the session; never join it to a read or another command, and never edit the quoting). A failed `cd` stops the verb with a report. Then re-run the helper with the new `$PWD` and require `here`; anything else means the session could not move: stop and report that, with nothing claimed.
|
|
@@ -60,8 +60,8 @@ The user invokes with a map (URL or number). A ticket is optional: without one,
|
|
|
60
60
|
3. **Brief the ticket and confirm scope, before the engine runs.** With the ticket claimed, read it and give the user a **brief, result-oriented summary of what resolving it will do** (its goal and the shape of the work, not a plan yet), then **ask whether to proceed or change something as an `AskUserQuestion` menu, never as free text**: **Proceed** (Recommended) first, **Change something** second (the user types the change through the menu's built-in "Other" or in a follow-up message), plus the **delegate** option (this gate is a process choice, see `../_shared/choices.md`). On a host with no structured question tool (Codex), the same options are a plain-text numbered list, per its adapter. This is a HITL confirm gate: on **Proceed**, proceed to the engine. **If the user asks for a change, hold it against the ticket.** A change **consistent** with the ticket is folded in and the engine proceeds. A change that **diverges** from the ticket is surfaced as a mismatch, and the user is **asked, never forced**, whether to (a) work the ticket **as-is**, or (b) open a **separate new ticket** for the divergent request, as an `AskUserQuestion` menu **without the delegate option** (routing a scope change is the human's, per the one-line test in `../_shared/choices.md`). Offer both and decide neither: open the separate ticket **only if the user chooses it** (via **Scope that surfaces while working a ticket**, served by the capture cluster, the same capture-or-split path used for scope that surfaces mid-work), never automatically. **If the user defers the choice rather than settling it here, mark the divergent follow-up pending for the Pending follow-up guard (`../_shared/session-handoff.md`), so it is re-posed at the handoff rather than lost.** **Under an autonomous run (`auto`) this is a process confirm gate**, so it follows the advisory boundary in `references/auto.md`: `auto` proceeds (takes the delegate option) without stopping to brief.
|
|
61
61
|
- **Skip the confirm on a ticket this session already shaped.** Skip the brief menu when, in this same session (before any `/clear`), (a) the agent created this ticket from the user's own request for it, one ticket at a time (a capture `todo`/`bug`/`idea`/`seed`, `ticket`, `quick "<text>"`, or a `split` child the user approved), or (b) a Grilling round on this ticket concluded (`grill <ticket>`): the user just stated or settled its scope, so asking again is redundant. Not a trigger: tickets created in bulk as a side effect (`new`/`adopt` charting, `inbox` triage, follow-ons spawned by a grill on another topic), where no one confirmed this ticket's own scope. Skipping replaces the menu with a **one-line**, result-oriented note (e.g. "working <name> (#n), scope as just agreed"), so the user can still interrupt with a change; a change typed then follows the same consistent/divergent handling as the menu's **Change something**. Still asked: a ticket created or grilled in an earlier session, by someone else, or before a `/clear` (a fresh session has no memory of it), a `resume`d ticket, a ticket only read or mentioned this session. `auto` is unchanged (it already takes the confirm as delegate). The evidence is the session's own context, never inferred from tracker metadata (an issue's author or creation time does not prove this session made it).
|
|
62
62
|
4. Resolve it with its type's engine in **`references/ticket-engines.md`**, **zoom as needed**: fetch the full body of related/closed tickets on demand. If in doubt on a `decision` ticket, run **Grilling** + **Domain vocabulary**. If in doubt on a `build`, **stop and ask** (see the Discuss step): never auto-grill.
|
|
63
|
-
5. Record the resolution: a comment with the answer, `gh issue close`, add the pointer to *Decisions so far* (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, which does the fail-safe read-modify-write, the concurrent-edit checks and the secret scan in one call; when the helper is missing, do it by hand per the Fail-safe body rewrites rule in `../_shared/substrate.md`). Then **unblock dependents**: for every ticket this one was blocking, if it was the last open blocker, remove its `trailhead:blocked` label so it graduates onto the frontier.
|
|
64
|
-
6. Add newly-surfaced tickets (create-then-wire, labelling blocked ones `trailhead:blocked`); graduate the fog that became specifiable,
|
|
63
|
+
5. Record the resolution: a comment with the answer (for a `decision`, carrying the `Builds:` line of its engine's **Carry it into build** step, with the build tickets created before the close), `gh issue close`, add the pointer to *Decisions so far* (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, which does the fail-safe read-modify-write, the concurrent-edit checks and the secret scan in one call; when the helper is missing, do it by hand per the Fail-safe body rewrites rule in `../_shared/substrate.md`). Then **unblock dependents**: for every ticket this one was blocking, if it was the last open blocker, remove its `trailhead:blocked` label so it graduates onto the frontier.
|
|
64
|
+
6. Add newly-surfaced tickets (create-then-wire, labelling blocked ones `trailhead:blocked`); graduate the fog that became specifiable: re-read **every entry** of *Not yet specified* (not just the ones this ticket names) against the answer, apply the **Ticket or fog** test of charting (`../trailhead-chart/references/charting.md`, Mode 1 step 4) to each, and propose all the entries that pass as **one batch** (title, type, blockers per ticket), created on a yes, so the user never has to dissolve ripe fog one entry per session. For each graduated entry, clear the patch from *Not yet specified* (default path: `node <templates>/trailhead-issue-body.js remove --issue <map> --section "Not yet specified" --match "<unique text of the entry>"`, which drops exactly one list entry (no match is a reported no-op, several matches abort); when the helper is missing, do the fail-safe read-modify-write by hand). If the answer reveals a ticket sits beyond the destination, apply the **out-of-scope vs deferred** test (`../_shared/out-of-scope.md`): if it's really deferred on something outside the map, ask the user to route it to a `seed`/`idea`/`todo`; only if it's truly beyond the destination rule it **out of scope** (label, then **drop its native sub-issue edge** to reclaim a slot, then close: the eager drop in `../_shared/substrate-commands.md`, 404-tolerant, references intact) instead of resolving it. If the decision invalidates other parts of the map, update or delete them.
|
|
65
65
|
7. **Hand off.** Close the session with the **Session handoff** ritual (`../_shared/session-handoff.md`): confirm the ticket is resolved by name, then the scannable **next-step block with `/clear` first** (never the next command without it), naming the next frontier ticket and giving its number (or, when several mutually independent tickets sit on the frontier, presenting them as a **set to choose from** rather than crowning the first-listed), or `/trailhead:map` if the frontier is empty/ambiguous. This holds in any later follow-up too, not only at the moment of resolution. **If this resolution leaves the map exhausted** (no open tickets and no fog left: the destination is reached), say so; but **before closing it out, if *Out of scope* holds any deferred line** (apply the out-of-scope-vs-deferred tells in `../_shared/out-of-scope.md`), **stop and ask the user what to do with each** (a `seed` gated on its trigger, an `idea`, or a `todo`, in this or another map), so nothing wanted-later is lost when the map goes quiet. Only once the deferred items are routed (or the user says leave them), **ask the user whether to close the map issue** now that the destination is reached: `gh issue close` it on a yes (it stays the record), leave it open on a no; never close a map unprompted. Either way **refresh the dashboard** so an exhausted/closed map drops off (a structural event): `node <templates>/trailhead-dashboard.js` regenerates the whole body from the live tracker in one call (`--dry-run` previews it); when the helper is missing, regenerate it by hand per `../_shared/substrate-commands.md`. The `clear` of the work state (`node <templates>/trailhead-work-state.js clear --repo <working-root>`) also removes the session-ticket marker, so the handoff needs just that one call. Maps aren't pinned, so there's no pin to free; the dashboard, codebase, and conventions stay pinned regardless.
|
|
66
66
|
|
|
67
67
|
## Work one ticket whole, without splitting: `quick`
|
|
@@ -16,6 +16,7 @@ Close a choice, but widen before you narrow:
|
|
|
16
16
|
|
|
17
17
|
1. **Diverge (only if the option space isn't already clear).** Mapping the frontier usually framed the options already; when it did, check that the framed set is still current and complete before grilling: re-read it against the ticket and against what has landed since (the map's `Decisions so far`, tickets closed after it was framed), add any missing candidate (the status quo, "leave it as it is", included when it is a real option), post the additions as an `OPTIONS` comment, then grill. When it didn't (the ticket names a question but the candidate answers aren't laid out) open the space *first*: brainstorm the plausible options, name each concretely, and post them as an `OPTIONS` comment. Divergence has no judgement yet: surface possibilities, don't rank them. If a missing **fact** is what's blocking clarity, spin a `research` ticket and block on it; if the question is "how could it look/behave", spin a `prototype`. Don't grill a space you haven't opened.
|
|
18
18
|
2. **Converge.** Run the **Grilling** + **Domain vocabulary** techniques over the options to pick one. Before the final confirmation, run the **Holistic review before the final confirmation** (Grilling technique). Record the decision and the why. The delegate option (agent picks) is never offered here, the choice stays the human's, but **defer is offered** (in prose, per the recipe in `../../_shared/choices.md`) when the choice is genuinely blocked on a prerequisite.
|
|
19
|
+
3. **Carry it into build.** A decision that only gets recorded builds nothing: when a fog area graduated into this ticket, its entry left *Not yet specified*, so once the ticket closes the work it implies lives nowhere unless a ticket carries it. So, before the close, list the work the answer makes buildable (the code, the content, the setup it commits the project to) and give every piece a home: an **existing** open ticket that already covers it (a `build`, `bug` or `task` on the map, its body extended with a `Decided in: <name+link>` line when it lacks one), or a **new `build` ticket** on the same map (on the whiteboard for a whiteboard decision), create-then-wire per `../../_shared/substrate.md`, its `## Question` naming the deliverable and its body a `Decided in: <name+link>` line back to this ticket, labelled `trailhead:blocked` and wired to the open tickets it really waits on (another unresolved decision, the build it extends). Size each one to a session, as at charting; a sprawling answer gets several builds, not one. Put the proposed split (titles, one line of scope each, blockers) in front of the human inside the **final confirmation** of step 2, so the scope of what gets built is theirs too, then create the tickets. The resolution comment carries one `Builds: <name+link>, <name+link>` line listing every ticket (new or existing) that carries the work. Only a decision whose answer builds nothing (a pure policy, a naming, a ruling that a feature is dropped) writes `Builds: none (<why>)` instead, with the reason. **Never `gh issue close` a decision whose resolution comment lacks the `Builds:` line**: the line is what lets the map, and `/trailhead:audit`, tell a carried decision from a stranded one.
|
|
19
20
|
|
|
20
21
|
The two phases are distinct on purpose: divergence generates, grilling chooses. Never let the agent invent the options *and* pick for the human: the choice stays theirs.
|
|
21
22
|
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
#
|
|
21
21
|
# Usage: % REALE del piano via l'API OAuth di Anthropic (come ccstatusline):
|
|
22
22
|
# GET https://api.anthropic.com/api/oauth/usage con il Bearer preso da
|
|
23
|
-
#
|
|
23
|
+
# ${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json (claudeAiOauth.accessToken). Mostra la finestra
|
|
24
24
|
# 5h e la settimana (7 giorni) piu' il reset. Cache ~60s, chiamata in background
|
|
25
25
|
# (il render non aspetta la rete). Disabilita con TRAILHEAD_STATUSLINE_USAGE=0.
|
|
26
26
|
#
|
|
27
27
|
# Uso:
|
|
28
|
-
# - standalone: settings.json -> statusLine.command = "bash
|
|
28
|
+
# - standalone: settings.json -> statusLine.command = "bash <config dir>/statusline-trailhead.sh" (config dir = $CLAUDE_CONFIG_DIR, default ~/.claude)
|
|
29
29
|
# - widget singolo (ccstatusline custom command): "--ticket-only" | "--context-only" | "--usage-only"
|
|
30
30
|
|
|
31
31
|
set -uo pipefail
|
|
@@ -277,7 +277,7 @@ print(f"[{bar}] {round(tok/1000)}k/{round(limit/1000)}k ({pct}%)")
|
|
|
277
277
|
# Retry-After (default 300s); altri errori 30s. Timeout 5s (USAGE_API_TIMEOUT lo cambia).
|
|
278
278
|
# Chiamata in background: il render non attende mai. Off con TRAILHEAD_STATUSLINE_USAGE=0.
|
|
279
279
|
USAGE_RAW=""
|
|
280
|
-
CREDS="$HOME/.claude/.credentials.json"
|
|
280
|
+
CREDS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
|
|
281
281
|
if [ "${TRAILHEAD_STATUSLINE_USAGE:-1}" != "0" ] && [ -f "$CREDS" ]; then
|
|
282
282
|
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/trailhead-statusline"
|
|
283
283
|
mkdir -p "$CACHE" 2>/dev/null
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
// family, matched case-insensitively; an empty value or `none` means none. A required
|
|
37
37
|
// field missing, a label given twice or a broken invariant is the `fields` problem.
|
|
38
38
|
// Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
|
|
39
|
+
// The verdict must also agree with the report's content, and only ever moves to the
|
|
40
|
+
// stricter one (fail closed, action normalise): a code-review `clean` whose counts line has
|
|
41
|
+
// critical N > 0 stands only if the ledger closes (`fixed`/`deferred`/`accepted`, first
|
|
42
|
+
// disposition word after the `C<n>` id) at least N distinct Critical ids and none is `open`,
|
|
43
|
+
// else it becomes `issues_found`; a verify tag below the highest `class: <c>` claim line
|
|
44
|
+
// (unmet/partial > inconclusive > met) is raised to it. Warnings and Info never gate.
|
|
39
45
|
|
|
40
46
|
const fs = require('fs');
|
|
41
47
|
|
|
@@ -48,6 +54,10 @@ const REVIEW_TOKENS = Object.freeze({
|
|
|
48
54
|
'code-review': frozen(['clean', 'issues_found']),
|
|
49
55
|
});
|
|
50
56
|
|
|
57
|
+
// The class a verify claim line carries (`class: <c>`); the contract is pinned against
|
|
58
|
+
// scripts/agent-blocks.md and verify.md by report-status.test.js.
|
|
59
|
+
const VERIFY_CLASSES = frozen(['met', 'unmet', 'partial', 'inconclusive']);
|
|
60
|
+
|
|
51
61
|
// The return contract of each field family. Hard-coded here (the checker ships
|
|
52
62
|
// self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
|
|
53
63
|
// states. An invariant says: for these statuses the field (or `body`, the report without
|
|
@@ -144,6 +154,58 @@ function countsOf(line) {
|
|
|
144
154
|
return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
// A ledger line: `C<n>`, an optional separator (colon, hyphen, en dash, em dash, `)`), then
|
|
158
|
+
// the disposition word right away (a bare `open` must end the line or be followed by
|
|
159
|
+
// punctuation: `C1 open redirect` is prose).
|
|
160
|
+
const LEDGER_RE = /^C(\d+)\s*[:\-\u2013\u2014)]?\s*(fixed|deferred|accepted|reclassified|open(?=\s*(?:$|[:.,;(\u2192])))(?![-\w])/i;
|
|
161
|
+
const CRITICAL_ID_RE = /^C(\d+)\b/i;
|
|
162
|
+
const CLOSED = frozen(['fixed', 'deferred', 'accepted']);
|
|
163
|
+
// Verify verdicts by strictness; unmet and partial claims both mean gaps_found.
|
|
164
|
+
const VERIFY_RANK = Object.freeze({ met: 0, inconclusive: 1, gaps_found: 2 });
|
|
165
|
+
const CLASS_VERDICT = Object.freeze({ met: 'met', unmet: 'gaps_found', partial: 'gaps_found', inconclusive: 'inconclusive' });
|
|
166
|
+
|
|
167
|
+
// Critical ids at the start of any candidate line, as { seen, ledger }: `seen` is every id
|
|
168
|
+
// (parsed as a ledger line or not), `ledger` the parsed lines [{ id, disposition }].
|
|
169
|
+
function criticalLines(lines, cands) {
|
|
170
|
+
const stripped = cands.map((i) => strip(lines[i]));
|
|
171
|
+
const seen = stripped.flatMap((s) => { const m = CRITICAL_ID_RE.exec(s); return m ? [`C${m[1]}`] : []; });
|
|
172
|
+
const ledger = stripped.flatMap((s) => {
|
|
173
|
+
const m = LEDGER_RE.exec(s);
|
|
174
|
+
return m ? [{ id: `C${m[1]}`, disposition: m[2].toLowerCase() }] : [];
|
|
175
|
+
});
|
|
176
|
+
return { seen: new Set(seen), ledger };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Does a `clean` verdict with `critical` Criticals stand against its ledger? Every id seen
|
|
180
|
+
// must be closed (or reclassified away), none parsed as open, and the closed count reach N.
|
|
181
|
+
function cleanStands(critical, { seen, ledger }) {
|
|
182
|
+
if (critical === 0) return true;
|
|
183
|
+
if (ledger.some((e) => e.disposition === 'open')) return false;
|
|
184
|
+
const closed = new Set(ledger.filter((e) => CLOSED.includes(e.disposition)).map((e) => e.id));
|
|
185
|
+
const moved = new Set(ledger.filter((e) => e.disposition === 'reclassified').map((e) => e.id));
|
|
186
|
+
if ([...seen].some((id) => !closed.has(id) && !moved.has(id))) return false;
|
|
187
|
+
return closed.size >= critical;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The verify verdict raised to the highest class among the claim lines; never lowered.
|
|
191
|
+
function raisedVerify(verdict, lines, cands, statusIdx) {
|
|
192
|
+
const classes = cands.filter((i) => !statusIdx.has(i)).flatMap((i) => {
|
|
193
|
+
const m = new RegExp(`(?:^|[\\s,;(])class\\s*:\\s*(${VERIFY_CLASSES.join('|')})\\b(?![-\\w])`, 'i').exec(strip(lines[i]));
|
|
194
|
+
return m ? [CLASS_VERDICT[m[1].toLowerCase()]] : [];
|
|
195
|
+
});
|
|
196
|
+
return classes.reduce((v, c) => (VERIFY_RANK[c] > VERIFY_RANK[v] ? c : v), verdict);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The verdict after the content check: the declared one, or the stricter one it contradicts.
|
|
200
|
+
function checkedVerdict(technique, verdict, lines, cands, statusIdx, counts) {
|
|
201
|
+
if (technique === 'verify') return raisedVerify(verdict, lines, cands, statusIdx);
|
|
202
|
+
if (technique === 'code-review' && verdict === 'clean') {
|
|
203
|
+
const critical = Number(/^critical: (\d+)/.exec(counts[0].text)[1]);
|
|
204
|
+
return cleanStands(critical, criticalLines(lines, cands)) ? verdict : 'issues_found';
|
|
205
|
+
}
|
|
206
|
+
return verdict;
|
|
207
|
+
}
|
|
208
|
+
|
|
147
209
|
const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
|
|
148
210
|
|
|
149
211
|
const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
|
|
@@ -198,7 +260,7 @@ function checkReport(text, technique) {
|
|
|
198
260
|
const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
|
|
199
261
|
if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
|
|
200
262
|
if (valid.size === 0) return reask(original, 'unknown-token');
|
|
201
|
-
const
|
|
263
|
+
const declared = [...valid][0];
|
|
202
264
|
|
|
203
265
|
let counts = [];
|
|
204
266
|
if (technique === 'code-review') {
|
|
@@ -206,6 +268,8 @@ function checkReport(text, technique) {
|
|
|
206
268
|
if (counts.length !== 1) return reask(original, 'counts');
|
|
207
269
|
}
|
|
208
270
|
|
|
271
|
+
const verdict = checkedVerdict(technique, declared, lines, cands, new Set(statusLines.map((c) => c.i)), counts);
|
|
272
|
+
|
|
209
273
|
// Blank lines that led the original are not content: drop them with the status lines.
|
|
210
274
|
const lead = lines.findIndex((l) => l.trim() !== '');
|
|
211
275
|
const drop = new Set([...statusLines, ...counts].map((c) => c.i));
|
|
@@ -224,7 +288,7 @@ function checkReport(text, technique) {
|
|
|
224
288
|
if (family === 'plan' && verdict === 'ready') body = text;
|
|
225
289
|
}
|
|
226
290
|
|
|
227
|
-
const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
|
|
291
|
+
const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}` && verdict === declared
|
|
228
292
|
&& (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
|
|
229
293
|
if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
|
|
230
294
|
|
|
@@ -271,4 +335,4 @@ function main(argv, io = {}) {
|
|
|
271
335
|
// exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
|
|
272
336
|
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
273
337
|
|
|
274
|
-
module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
|
|
338
|
+
module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };
|
|
@@ -192,6 +192,8 @@ const rig = (on: On, engine?: any) => {
|
|
|
192
192
|
repo: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } as { root: string; remote: string | null; internal: boolean } | null,
|
|
193
193
|
// The active-map marker of the second checkout (ROOT_OTHER); null: it has none.
|
|
194
194
|
otherActive: null as string | null,
|
|
195
|
+
// Runs once before the next read of the active-map marker, to land an event inside a check.
|
|
196
|
+
beforeMarkerRead: null as (() => Promise<void>) | null,
|
|
195
197
|
// The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
|
|
196
198
|
// session's project root; every path read and every `git` run is recorded.
|
|
197
199
|
treeActive: null as string | null,
|
|
@@ -288,13 +290,18 @@ const rig = (on: On, engine?: any) => {
|
|
|
288
290
|
if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
|
|
289
291
|
return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
|
|
290
292
|
}
|
|
291
|
-
on('fs.read', (_$, e) => {
|
|
293
|
+
on('fs.read', async (_$, e) => {
|
|
292
294
|
// A relative path reaches the hook already resolved against the host's directory, not the repo's: put
|
|
293
295
|
// it under the session's working directory, which is what the engine resolves it against. A path under
|
|
294
296
|
// one of the repos is absolute (a pinned read) and stays.
|
|
295
297
|
const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
|
|
296
298
|
const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
|
|
297
299
|
r.reads.push(path)
|
|
300
|
+
if (path.endsWith('/.trailhead/active-map') && r.beforeMarkerRead !== null) {
|
|
301
|
+
const hook = r.beforeMarkerRead
|
|
302
|
+
r.beforeMarkerRead = null
|
|
303
|
+
await hook()
|
|
304
|
+
}
|
|
298
305
|
const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
|
|
299
306
|
const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
|
|
300
307
|
const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
|
|
@@ -1119,9 +1126,9 @@ for (const surface of SURFACES) {
|
|
|
1119
1126
|
await openAndMount($, r)
|
|
1120
1127
|
expect(fetches(r)).toBe(1)
|
|
1121
1128
|
|
|
1129
|
+
// two turn ends with no write between: no flush, the trailing refresh is the only extra read
|
|
1122
1130
|
await r.clock.advance(2_000)
|
|
1123
1131
|
await completeTurn($)
|
|
1124
|
-
await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
|
|
1125
1132
|
await completeTurn($)
|
|
1126
1133
|
await settle(r)
|
|
1127
1134
|
expect(fetches(r)).toBe(1)
|
|
@@ -1138,6 +1145,37 @@ for (const surface of SURFACES) {
|
|
|
1138
1145
|
expect(fetches(r)).toBe(2)
|
|
1139
1146
|
})
|
|
1140
1147
|
|
|
1148
|
+
// A write followed by a turn end flushes: the turn end reads at once, inside the floor. The armed trailing
|
|
1149
|
+
// refresh is kept, so the read after the search index caught up still follows, one floor after the flush.
|
|
1150
|
+
test(`a write then a turn end inside the floor reads at once and keeps the trailing [${surface}]`, async ($, on) => {
|
|
1151
|
+
const r = rig(on, $)
|
|
1152
|
+
await $.session.start(START)
|
|
1153
|
+
await openAndMount($, r)
|
|
1154
|
+
expect(fetches(r)).toBe(1)
|
|
1155
|
+
|
|
1156
|
+
await r.clock.advance(2_000)
|
|
1157
|
+
await completeTurn($)
|
|
1158
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
|
|
1159
|
+
await completeTurn($)
|
|
1160
|
+
await settle(r)
|
|
1161
|
+
expect(fetches(r)).toBe(2)
|
|
1162
|
+
// a second turn end with no write since the flush adds nothing
|
|
1163
|
+
await completeTurn($)
|
|
1164
|
+
await settle(r)
|
|
1165
|
+
expect(fetches(r)).toBe(2)
|
|
1166
|
+
|
|
1167
|
+
// the trailing one fires at the first floor's end, lands inside the flush's floor and waits for its end
|
|
1168
|
+
await r.clock.advance(FLOOR - 2_000)
|
|
1169
|
+
await settle(r)
|
|
1170
|
+
expect(fetches(r)).toBe(2)
|
|
1171
|
+
await r.clock.advance(2_000)
|
|
1172
|
+
await settle(r)
|
|
1173
|
+
expect(fetches(r)).toBe(3)
|
|
1174
|
+
await r.clock.advance(FLOOR * 2)
|
|
1175
|
+
await settle(r)
|
|
1176
|
+
expect(fetches(r)).toBe(3)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1141
1179
|
// The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
|
|
1142
1180
|
// wait on a dead handle.
|
|
1143
1181
|
test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
|
|
@@ -1166,6 +1204,51 @@ for (const surface of SURFACES) {
|
|
|
1166
1204
|
expect(fetches(r)).toBe(3)
|
|
1167
1205
|
})
|
|
1168
1206
|
|
|
1207
|
+
// The trailing refresh is dropped like the slow timer (a closed ticket stayed on the pane as held): the turn
|
|
1208
|
+
// end after a write reads at once, floor or not, so the final tracker state shows with no timer involved.
|
|
1209
|
+
test(`a turn end after a write shows the close although the trailing refresh was dropped [${surface}]`, async ($, on) => {
|
|
1210
|
+
on('clock.after', { ms: FLOOR }, async () => ({ deny: 'dropped' }))
|
|
1211
|
+
const r = rig(on, $)
|
|
1212
|
+
await $.session.start(START)
|
|
1213
|
+
const ui = await openAndMount($, r)
|
|
1214
|
+
await r.clock.advance(FLOOR)
|
|
1215
|
+
// the resolution comment reads at once, before the close
|
|
1216
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
|
|
1217
|
+
await settle(r)
|
|
1218
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1219
|
+
await r.clock.advance(2_000)
|
|
1220
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1221
|
+
await completeTurn($)
|
|
1222
|
+
await settle(r)
|
|
1223
|
+
expect(await hasKey(ui, 'row-209')).toBe(false)
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
// Without a turn end, a pane drawn again past the dead trailing's due time and grace reads at once.
|
|
1227
|
+
test(`a pane drawn again after a dropped trailing refresh reads at once [${surface}]`, async ($, on) => {
|
|
1228
|
+
let isDropping = false
|
|
1229
|
+
on('clock.after', { ms: FLOOR }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
|
|
1230
|
+
const r = rig(on, $)
|
|
1231
|
+
await $.session.start(START)
|
|
1232
|
+
const ui = await openAndMount($, r)
|
|
1233
|
+
await r.clock.advance(FLOOR)
|
|
1234
|
+
isDropping = true
|
|
1235
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
|
|
1236
|
+
await settle(r)
|
|
1237
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1238
|
+
await r.clock.advance(2_000)
|
|
1239
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1240
|
+
await settle(r)
|
|
1241
|
+
isDropping = false
|
|
1242
|
+
// nothing fired for the close: the pane still shows the closed ticket
|
|
1243
|
+
await r.clock.advance(FLOOR * 4)
|
|
1244
|
+
await settle(r)
|
|
1245
|
+
expect(await hasKey(ui, 'row-209')).toBe(true)
|
|
1246
|
+
await ui.unmount()
|
|
1247
|
+
const again = await mountPane($)
|
|
1248
|
+
await settle(r)
|
|
1249
|
+
expect(await hasKey(again, 'row-209')).toBe(false)
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1169
1252
|
// The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
|
|
1170
1253
|
const listReads = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('--label') && argv.includes('trailhead:map-198') && argv.includes('open') && !argv.includes('trailhead:uat'))
|
|
1171
1254
|
|
|
@@ -3289,6 +3372,100 @@ for (const surface of SURFACES) {
|
|
|
3289
3372
|
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3290
3373
|
})
|
|
3291
3374
|
|
|
3375
|
+
test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
|
|
3376
|
+
const r = rig(on, $)
|
|
3377
|
+
await $.session.start(START)
|
|
3378
|
+
let release: () => void = () => {}
|
|
3379
|
+
const gate = new Promise<void>(resolve => {
|
|
3380
|
+
release = resolve
|
|
3381
|
+
})
|
|
3382
|
+
// The answer is fixed when the call starts, so the reads begun in /repo keep its tracker state.
|
|
3383
|
+
r.gh = async argv => {
|
|
3384
|
+
const answer = r.defaultGh(argv)
|
|
3385
|
+
await gate
|
|
3386
|
+
return answer
|
|
3387
|
+
}
|
|
3388
|
+
r.open()
|
|
3389
|
+
const ui = await mountPane($)
|
|
3390
|
+
await settle(r)
|
|
3391
|
+
// Another checkout of the SAME repo (same remote), with map 198 active there; the tracker changed after the
|
|
3392
|
+
// read started. A turn start refreshes the band, which moves the pin while the read is still in flight.
|
|
3393
|
+
r.cwd = ROOT_OTHER
|
|
3394
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3395
|
+
r.otherActive = '198\n'
|
|
3396
|
+
r.tickets = OTHER_TICKETS
|
|
3397
|
+
r.history = OTHER_HISTORY
|
|
3398
|
+
await $.turn.start({ text: 'go', turnId: 'race-same-repo' })
|
|
3399
|
+
release()
|
|
3400
|
+
await settle(r)
|
|
3401
|
+
await r.clock.advance(FLOOR)
|
|
3402
|
+
await settle(r)
|
|
3403
|
+
// The dropped read is repeated: the pane shows the new tracker state, not the old list.
|
|
3404
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3405
|
+
})
|
|
3406
|
+
|
|
3407
|
+
test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
|
|
3408
|
+
const r = rig(on, $)
|
|
3409
|
+
await $.session.start(START)
|
|
3410
|
+
let release: () => void = () => {}
|
|
3411
|
+
const gate = new Promise<void>(resolve => {
|
|
3412
|
+
release = resolve
|
|
3413
|
+
})
|
|
3414
|
+
r.gh = async argv => {
|
|
3415
|
+
const answer = r.defaultGh(argv)
|
|
3416
|
+
await gate
|
|
3417
|
+
return answer
|
|
3418
|
+
}
|
|
3419
|
+
r.open()
|
|
3420
|
+
const ui = await mountPane($)
|
|
3421
|
+
await settle(r)
|
|
3422
|
+
// The first marker read once the gate opens is the stale check's last await: the move lands there,
|
|
3423
|
+
// after the check read the old pin and before it decides.
|
|
3424
|
+
r.beforeMarkerRead = async () => {
|
|
3425
|
+
r.cwd = ROOT_OTHER
|
|
3426
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3427
|
+
r.otherActive = '198\n'
|
|
3428
|
+
r.tickets = OTHER_TICKETS
|
|
3429
|
+
r.history = OTHER_HISTORY
|
|
3430
|
+
await $.turn.start({ text: 'go', turnId: 'race-in-check' })
|
|
3431
|
+
}
|
|
3432
|
+
release()
|
|
3433
|
+
await settle(r)
|
|
3434
|
+
await r.clock.advance(FLOOR)
|
|
3435
|
+
await settle(r)
|
|
3436
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3437
|
+
})
|
|
3438
|
+
|
|
3439
|
+
test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
|
|
3440
|
+
const r = rig(on, $)
|
|
3441
|
+
r.maps = [MAPS[0]]
|
|
3442
|
+
await $.session.start(START)
|
|
3443
|
+
const ui = await openAndMount($, r)
|
|
3444
|
+
let release: () => void = () => {}
|
|
3445
|
+
const gate = new Promise<void>(resolve => {
|
|
3446
|
+
release = resolve
|
|
3447
|
+
})
|
|
3448
|
+
r.gh = async argv => {
|
|
3449
|
+
const answer = r.defaultGh(argv)
|
|
3450
|
+
await gate
|
|
3451
|
+
return answer
|
|
3452
|
+
}
|
|
3453
|
+
// The dashboard read starts under /repo and waits on the gate.
|
|
3454
|
+
await ui.press({ key: 'open-whiteboard' })
|
|
3455
|
+
await settle(r)
|
|
3456
|
+
r.cwd = ROOT_OTHER
|
|
3457
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3458
|
+
r.otherActive = '198\n'
|
|
3459
|
+
r.whiteboard = [issue(320, 'New loose one', [...WB, 'trailhead:bug'])]
|
|
3460
|
+
await $.turn.start({ text: 'go', turnId: 'race-same-repo-dash' })
|
|
3461
|
+
release()
|
|
3462
|
+
await settle(r)
|
|
3463
|
+
await r.clock.advance(FLOOR)
|
|
3464
|
+
await settle(r)
|
|
3465
|
+
expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
|
|
3466
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-320'])
|
|
3467
|
+
})
|
|
3468
|
+
|
|
3292
3469
|
test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
|
|
3293
3470
|
const r = rig(on, $)
|
|
3294
3471
|
r.work = stateText()
|