sequant 2.2.0 → 2.3.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +73 -0
- package/dist/bin/cli.js +94 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +39 -0
- package/dist/src/commands/prompt.js +179 -0
- package/dist/src/commands/run-display.d.ts +11 -2
- package/dist/src/commands/run-display.js +62 -28
- package/dist/src/commands/run-progress.d.ts +32 -0
- package/dist/src/commands/run-progress.js +76 -0
- package/dist/src/commands/run.js +80 -18
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +12 -0
- package/dist/src/commands/watch.d.ts +16 -0
- package/dist/src/commands/watch.js +147 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +106 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +68 -0
- package/dist/src/lib/relay/types.js +76 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +248 -175
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
- package/dist/src/lib/workflow/phase-executor.js +157 -16
- package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
- package/dist/src/lib/workflow/phase-mapper.js +17 -20
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
- package/dist/src/lib/workflow/run-orchestrator.js +340 -15
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +12 -1
- package/dist/src/lib/workflow/state-manager.js +37 -0
- package/dist/src/lib/workflow/state-schema.d.ts +62 -0
- package/dist/src/lib/workflow/state-schema.js +35 -1
- package/dist/src/lib/workflow/types.d.ts +74 -1
- package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +15 -6
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +10 -3
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +11 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +103 -49
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -8
- package/templates/skills/fullsolve/SKILL.md +79 -5
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +727 -8
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +52 -0
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/testgen/SKILL.md +24 -17
|
@@ -194,6 +194,84 @@ const DEFAULT_LINT_PATTERNS = [
|
|
|
194
194
|
suggestion: "List all items explicitly or define boundaries",
|
|
195
195
|
},
|
|
196
196
|
];
|
|
197
|
+
/**
|
|
198
|
+
* Documentation-noun head words that suggest a doc-only verification bar
|
|
199
|
+
* when they appear in the AC title.
|
|
200
|
+
*/
|
|
201
|
+
const DOC_NOUNS = [
|
|
202
|
+
"note",
|
|
203
|
+
"comment",
|
|
204
|
+
"documentation",
|
|
205
|
+
"description",
|
|
206
|
+
"snippet",
|
|
207
|
+
"entry",
|
|
208
|
+
"mention",
|
|
209
|
+
];
|
|
210
|
+
/**
|
|
211
|
+
* Runtime-imperative phrases that suggest an execution/evidence bar
|
|
212
|
+
* when they appear in the AC body.
|
|
213
|
+
*/
|
|
214
|
+
const RUNTIME_IMPERATIVES = [
|
|
215
|
+
"execute",
|
|
216
|
+
"trigger",
|
|
217
|
+
"capture",
|
|
218
|
+
"verify by running",
|
|
219
|
+
"reproduce",
|
|
220
|
+
"confirm at runtime",
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Slash-command runtime pattern: "run /<command>" anywhere in the body.
|
|
224
|
+
*/
|
|
225
|
+
const RUN_SLASH_RE = /\brun\s+\/[a-z][a-z0-9_-]*/i;
|
|
226
|
+
/**
|
|
227
|
+
* Detect title/body verification-method tension.
|
|
228
|
+
*
|
|
229
|
+
* Splits the AC description on the first separator (`.`, `\n`, `:`, or `—`).
|
|
230
|
+
* Treats the head as the "title" and the rest as the "body". Warns when the
|
|
231
|
+
* title contains a documentation-noun (suggesting a doc bar) AND the body
|
|
232
|
+
* contains a runtime-imperative (incl. inflections like `triggered`,
|
|
233
|
+
* `captured`) or `run /<command>` (suggesting a runtime bar).
|
|
234
|
+
*
|
|
235
|
+
* Warning-only — same convention as the regex-based DEFAULT_LINT_PATTERNS.
|
|
236
|
+
*
|
|
237
|
+
* @param ac - The acceptance criterion to check
|
|
238
|
+
* @returns A lint issue if tension is detected, otherwise null
|
|
239
|
+
*/
|
|
240
|
+
function detectTitleBodyTension(ac) {
|
|
241
|
+
const description = ac.description;
|
|
242
|
+
const splitIdx = description.search(/[.\n:—]/);
|
|
243
|
+
if (splitIdx <= 0)
|
|
244
|
+
return null;
|
|
245
|
+
const title = description.slice(0, splitIdx);
|
|
246
|
+
const body = description.slice(splitIdx + 1);
|
|
247
|
+
if (title.length === 0 || body.length === 0)
|
|
248
|
+
return null;
|
|
249
|
+
const titleLower = title.toLowerCase();
|
|
250
|
+
const bodyLower = body.toLowerCase();
|
|
251
|
+
const docNoun = DOC_NOUNS.find((noun) => new RegExp(`\\b${noun}\\b`, "i").test(titleLower));
|
|
252
|
+
if (!docNoun)
|
|
253
|
+
return null;
|
|
254
|
+
const runtimeImperative = RUNTIME_IMPERATIVES.find((imp) => {
|
|
255
|
+
if (imp.includes(" ")) {
|
|
256
|
+
return new RegExp(`\\b${imp}\\b`, "i").test(bodyLower);
|
|
257
|
+
}
|
|
258
|
+
if (imp.endsWith("e")) {
|
|
259
|
+
const stem = imp.slice(0, -1);
|
|
260
|
+
return new RegExp(`\\b${stem}(?:e|es|ed|ing)\\b`, "i").test(bodyLower);
|
|
261
|
+
}
|
|
262
|
+
return new RegExp(`\\b${imp}(?:s|ed|ing)?\\b`, "i").test(bodyLower);
|
|
263
|
+
});
|
|
264
|
+
const slashRun = RUN_SLASH_RE.test(body);
|
|
265
|
+
if (!runtimeImperative && !slashRun)
|
|
266
|
+
return null;
|
|
267
|
+
const matched = runtimeImperative ?? "run /<command>";
|
|
268
|
+
return {
|
|
269
|
+
type: "title-body-tension",
|
|
270
|
+
matchedPattern: `title="${docNoun}" + body="${matched}"`,
|
|
271
|
+
problem: "Title/body tension: title implies a documentation bar, body specifies a runtime/execution bar",
|
|
272
|
+
suggestion: "Two verification bars detected. Either (a) tighten the title to match the runtime body (e.g., 'Smoke test execution — capture evidence'), or (b) split the runtime requirement into a separate AC.",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
197
275
|
/**
|
|
198
276
|
* Lint a single acceptance criterion against all patterns
|
|
199
277
|
*
|
|
@@ -215,6 +293,9 @@ export function lintAcceptanceCriterion(ac, patterns = DEFAULT_LINT_PATTERNS) {
|
|
|
215
293
|
});
|
|
216
294
|
}
|
|
217
295
|
}
|
|
296
|
+
const tension = detectTitleBodyTension(ac);
|
|
297
|
+
if (tension)
|
|
298
|
+
issues.push(tension);
|
|
218
299
|
return {
|
|
219
300
|
ac,
|
|
220
301
|
issues,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicted file-collision detection between PROCEED issues.
|
|
3
|
+
*
|
|
4
|
+
* Step 5 of `/assess` already inspects active worktrees for in-flight overlap.
|
|
5
|
+
* This module adds a complementary heuristic: read the bodies of unstarted
|
|
6
|
+
* PROCEED issues and predict which pairs will modify the same file once
|
|
7
|
+
* they're both run in parallel worktrees.
|
|
8
|
+
*
|
|
9
|
+
* The detector scans markdown bodies for file-path mentions outside fenced
|
|
10
|
+
* code blocks and HTML comments, then computes pairwise intersections. A
|
|
11
|
+
* small exclusion list filters paths that nearly every PROCEED issue tends
|
|
12
|
+
* to touch (CHANGELOG.md, lockfiles).
|
|
13
|
+
*
|
|
14
|
+
* Tunables — including the exclusion list, path regex, and the
|
|
15
|
+
* slash-command-skill derivation rule — are documented in
|
|
16
|
+
* `references/predicted-collision-detection.md` so they can change without
|
|
17
|
+
* skill-prose edits.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Files that virtually every PROCEED issue mentions. Including them in
|
|
21
|
+
* pairwise intersection would flag every batch as colliding, training
|
|
22
|
+
* users to ignore the warning.
|
|
23
|
+
*/
|
|
24
|
+
export declare const EXCLUDED_PATHS: ReadonlySet<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Extract the set of file paths an issue body identifies as
|
|
27
|
+
* targets-of-modification.
|
|
28
|
+
*
|
|
29
|
+
* Strategy:
|
|
30
|
+
* 1. Strip fenced code blocks and HTML comments (AC-5 guard).
|
|
31
|
+
* 2. Pull every backtick-quoted path matching the source-tree regex,
|
|
32
|
+
* normalizing skill-mirror paths to their canonical bare form.
|
|
33
|
+
* 3. If the body mentions "3-dir sync", also pull bare
|
|
34
|
+
* `<name>/SKILL.md` references and `/<skill>` slash-command
|
|
35
|
+
* mentions (where `<skill>` is in KNOWN_SKILL_NAMES). Both already
|
|
36
|
+
* live in the canonical bare form.
|
|
37
|
+
* 4. Remove globally excluded paths.
|
|
38
|
+
*
|
|
39
|
+
* The canonical bare form (`qa/SKILL.md`, not `.claude/skills/qa/SKILL.md`)
|
|
40
|
+
* is what the dashboard's `Order:` annotations render, and it makes
|
|
41
|
+
* mirrored collisions deduplicate without a separate post-processing
|
|
42
|
+
* pass.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractPathsFromIssueBody(body: string): Set<string>;
|
|
45
|
+
/**
|
|
46
|
+
* A predicted file collision: 2+ issues whose bodies both name the same
|
|
47
|
+
* file as a target-of-modification.
|
|
48
|
+
*/
|
|
49
|
+
export interface CollisionResult {
|
|
50
|
+
/** Issue numbers involved, in ascending order. */
|
|
51
|
+
issues: number[];
|
|
52
|
+
/** Path of the shared file (POSIX-style). */
|
|
53
|
+
file: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compute file-path overlaps across issue bodies.
|
|
57
|
+
*
|
|
58
|
+
* Each shared file emits one CollisionResult. When N issues all share a
|
|
59
|
+
* file, that's a single result with `issues.length === N` — the caller
|
|
60
|
+
* decides whether to chain-suggest based on count.
|
|
61
|
+
*
|
|
62
|
+
* Sort order: ascending file name, then ascending first-issue number.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectFileCollisions(issuePaths: Map<number, Set<string>>): CollisionResult[];
|
|
65
|
+
/**
|
|
66
|
+
* Rendered collision annotations ready for the dashboard output.
|
|
67
|
+
*
|
|
68
|
+
* - `orderLines` — one `Order:` line per pair (or per group).
|
|
69
|
+
* - `warnings` — `⚠ #N Modifies ... (overlaps #M); land sequentially`,
|
|
70
|
+
* one per affected issue per collision.
|
|
71
|
+
* - `chainSuggestion` — emitted only when ≥3 issues collide on the same
|
|
72
|
+
* file (AC-4); suggest-only, never auto-applied. Annotated with the
|
|
73
|
+
* historical chain-mode success rate at length≥3 (1/6 = 17%, per #604
|
|
74
|
+
* forensics) so users can weigh chain mode against the parallel default.
|
|
75
|
+
*/
|
|
76
|
+
export interface CollisionAnnotations {
|
|
77
|
+
orderLines: string[];
|
|
78
|
+
warnings: string[];
|
|
79
|
+
chainSuggestion?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format collision results as dashboard annotations.
|
|
83
|
+
*
|
|
84
|
+
* - 2-issue collision: `Order: A → B (path)` plus a warning per issue.
|
|
85
|
+
* - 3+-issue collision on the same file: `Order: A → B → C (path)`,
|
|
86
|
+
* warnings per issue, plus a single `Chain:` suggestion.
|
|
87
|
+
*
|
|
88
|
+
* Multiple shared files between the same pair render as multiple
|
|
89
|
+
* Order: lines (one per file). Callers decide whether to truncate.
|
|
90
|
+
*/
|
|
91
|
+
export declare function formatCollisionAnnotations(results: CollisionResult[]): CollisionAnnotations;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicted file-collision detection between PROCEED issues.
|
|
3
|
+
*
|
|
4
|
+
* Step 5 of `/assess` already inspects active worktrees for in-flight overlap.
|
|
5
|
+
* This module adds a complementary heuristic: read the bodies of unstarted
|
|
6
|
+
* PROCEED issues and predict which pairs will modify the same file once
|
|
7
|
+
* they're both run in parallel worktrees.
|
|
8
|
+
*
|
|
9
|
+
* The detector scans markdown bodies for file-path mentions outside fenced
|
|
10
|
+
* code blocks and HTML comments, then computes pairwise intersections. A
|
|
11
|
+
* small exclusion list filters paths that nearly every PROCEED issue tends
|
|
12
|
+
* to touch (CHANGELOG.md, lockfiles).
|
|
13
|
+
*
|
|
14
|
+
* Tunables — including the exclusion list, path regex, and the
|
|
15
|
+
* slash-command-skill derivation rule — are documented in
|
|
16
|
+
* `references/predicted-collision-detection.md` so they can change without
|
|
17
|
+
* skill-prose edits.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Files that virtually every PROCEED issue mentions. Including them in
|
|
21
|
+
* pairwise intersection would flag every batch as colliding, training
|
|
22
|
+
* users to ignore the warning.
|
|
23
|
+
*/
|
|
24
|
+
export const EXCLUDED_PATHS = new Set([
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"package-lock.json",
|
|
27
|
+
"yarn.lock",
|
|
28
|
+
"pnpm-lock.yaml",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Slash-command names recognized as references to a skill's SKILL.md.
|
|
32
|
+
* Used by the slash-command-skill derivation rule when an issue body
|
|
33
|
+
* also signals 3-dir sync — the skill name maps deterministically to the
|
|
34
|
+
* three mirrored SKILL.md files.
|
|
35
|
+
*/
|
|
36
|
+
const KNOWN_SKILL_NAMES = [
|
|
37
|
+
"assess",
|
|
38
|
+
"spec",
|
|
39
|
+
"exec",
|
|
40
|
+
"qa",
|
|
41
|
+
"test",
|
|
42
|
+
"testgen",
|
|
43
|
+
"verify",
|
|
44
|
+
"loop",
|
|
45
|
+
"merger",
|
|
46
|
+
"security-review",
|
|
47
|
+
"fullsolve",
|
|
48
|
+
"docs",
|
|
49
|
+
"release",
|
|
50
|
+
"clean",
|
|
51
|
+
"improve",
|
|
52
|
+
"reflect",
|
|
53
|
+
"setup",
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Regex matching backtick-quoted file paths under the project's tracked
|
|
57
|
+
* directories. The path component must start with a tracked directory
|
|
58
|
+
* root and end with a known source extension.
|
|
59
|
+
*/
|
|
60
|
+
const PATH_REGEX = /`((?:\.claude|templates|skills|src|bin|docs)\/[A-Za-z0-9_./@-]+\.(?:md|tsx?|json|sh))`/g;
|
|
61
|
+
/**
|
|
62
|
+
* Bare-filename match for skill files referenced alongside "3-dir sync"
|
|
63
|
+
* language. Captures e.g. `qa/SKILL.md` so we can expand it to the three
|
|
64
|
+
* skill roots.
|
|
65
|
+
*/
|
|
66
|
+
const SKILL_FILE_REGEX = /`((?:[a-z][a-z0-9_-]*\/)+SKILL\.md)`/g;
|
|
67
|
+
/**
|
|
68
|
+
* Slash-command mention regex (e.g. `/qa`, `/spec`). Captures the name
|
|
69
|
+
* for cross-reference against KNOWN_SKILL_NAMES. The non-word lookahead
|
|
70
|
+
* keeps `/qa-section` from matching as `/qa`.
|
|
71
|
+
*/
|
|
72
|
+
const SLASH_COMMAND_REGEX = /(?<![\w-])\/([a-z][a-z-]*)(?![\w-])/g;
|
|
73
|
+
/**
|
|
74
|
+
* Phrase that signals the cited skill file is mirrored to all three
|
|
75
|
+
* skill-root directories.
|
|
76
|
+
*/
|
|
77
|
+
const THREE_DIR_SYNC_PATTERN = /3[- ]dir(?:ectory)?\s+sync|across\s+all\s+three\s+skill\s+directories|across\s+(?:the\s+)?three\s+skill\s+directories/i;
|
|
78
|
+
/**
|
|
79
|
+
* Strip fenced code blocks and HTML comments from a markdown body so the
|
|
80
|
+
* path regex doesn't fire on quoted shell snippets or commented-out drafts.
|
|
81
|
+
*
|
|
82
|
+
* Inline backticks (single ` `) are preserved — the path regex requires a
|
|
83
|
+
* single-backtick wrapper, so this gives us the "paths quoted as code in
|
|
84
|
+
* prose count, paths inside a code block don't" behavior the AC-5 guard
|
|
85
|
+
* specifies.
|
|
86
|
+
*/
|
|
87
|
+
function stripCodeBlocksAndComments(body) {
|
|
88
|
+
return body.replace(/```[\s\S]*?```/g, "").replace(/<!--[\s\S]*?-->/g, "");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Collapse a fully-qualified skill-mirror path to its canonical bare form.
|
|
92
|
+
*
|
|
93
|
+
* The repo maintains three byte-identical mirrors of every skill file
|
|
94
|
+
* under `.claude/skills/`, `templates/skills/`, and `skills/`. Treating
|
|
95
|
+
* those mirrors as separate paths in collision detection produces 3× the
|
|
96
|
+
* Order: lines and 6× the warnings for one logical conflict, since both
|
|
97
|
+
* sides of the 3-dir-sync expansion match each mirror.
|
|
98
|
+
*
|
|
99
|
+
* Normalizing to the bare subpath (e.g. `qa/SKILL.md`) makes mirrored
|
|
100
|
+
* collisions deduplicate naturally and matches the issue-body shorthand
|
|
101
|
+
* the dashboard's `Order:` annotation already uses.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeSkillMirrorPath(path) {
|
|
104
|
+
const m = path.match(/^(?:\.claude\/skills\/|templates\/skills\/|skills\/)(.+)$/);
|
|
105
|
+
return m ? m[1] : path;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Extract the set of file paths an issue body identifies as
|
|
109
|
+
* targets-of-modification.
|
|
110
|
+
*
|
|
111
|
+
* Strategy:
|
|
112
|
+
* 1. Strip fenced code blocks and HTML comments (AC-5 guard).
|
|
113
|
+
* 2. Pull every backtick-quoted path matching the source-tree regex,
|
|
114
|
+
* normalizing skill-mirror paths to their canonical bare form.
|
|
115
|
+
* 3. If the body mentions "3-dir sync", also pull bare
|
|
116
|
+
* `<name>/SKILL.md` references and `/<skill>` slash-command
|
|
117
|
+
* mentions (where `<skill>` is in KNOWN_SKILL_NAMES). Both already
|
|
118
|
+
* live in the canonical bare form.
|
|
119
|
+
* 4. Remove globally excluded paths.
|
|
120
|
+
*
|
|
121
|
+
* The canonical bare form (`qa/SKILL.md`, not `.claude/skills/qa/SKILL.md`)
|
|
122
|
+
* is what the dashboard's `Order:` annotations render, and it makes
|
|
123
|
+
* mirrored collisions deduplicate without a separate post-processing
|
|
124
|
+
* pass.
|
|
125
|
+
*/
|
|
126
|
+
export function extractPathsFromIssueBody(body) {
|
|
127
|
+
const paths = new Set();
|
|
128
|
+
const cleaned = stripCodeBlocksAndComments(body);
|
|
129
|
+
for (const m of cleaned.matchAll(PATH_REGEX)) {
|
|
130
|
+
paths.add(normalizeSkillMirrorPath(m[1]));
|
|
131
|
+
}
|
|
132
|
+
const threeDir = THREE_DIR_SYNC_PATTERN.test(cleaned);
|
|
133
|
+
if (threeDir) {
|
|
134
|
+
for (const m of cleaned.matchAll(SKILL_FILE_REGEX)) {
|
|
135
|
+
paths.add(m[1]);
|
|
136
|
+
}
|
|
137
|
+
for (const m of cleaned.matchAll(SLASH_COMMAND_REGEX)) {
|
|
138
|
+
const name = m[1];
|
|
139
|
+
if (KNOWN_SKILL_NAMES.includes(name)) {
|
|
140
|
+
paths.add(`${name}/SKILL.md`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const excluded of EXCLUDED_PATHS) {
|
|
145
|
+
paths.delete(excluded);
|
|
146
|
+
}
|
|
147
|
+
return paths;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compute file-path overlaps across issue bodies.
|
|
151
|
+
*
|
|
152
|
+
* Each shared file emits one CollisionResult. When N issues all share a
|
|
153
|
+
* file, that's a single result with `issues.length === N` — the caller
|
|
154
|
+
* decides whether to chain-suggest based on count.
|
|
155
|
+
*
|
|
156
|
+
* Sort order: ascending file name, then ascending first-issue number.
|
|
157
|
+
*/
|
|
158
|
+
export function detectFileCollisions(issuePaths) {
|
|
159
|
+
const fileToIssues = new Map();
|
|
160
|
+
for (const [issueNumber, paths] of issuePaths) {
|
|
161
|
+
for (const file of paths) {
|
|
162
|
+
let bucket = fileToIssues.get(file);
|
|
163
|
+
if (!bucket) {
|
|
164
|
+
bucket = new Set();
|
|
165
|
+
fileToIssues.set(file, bucket);
|
|
166
|
+
}
|
|
167
|
+
bucket.add(issueNumber);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const results = [];
|
|
171
|
+
for (const [file, issues] of fileToIssues) {
|
|
172
|
+
if (issues.size < 2)
|
|
173
|
+
continue;
|
|
174
|
+
results.push({
|
|
175
|
+
file,
|
|
176
|
+
issues: [...issues].sort((a, b) => a - b),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
results.sort((a, b) => {
|
|
180
|
+
if (a.file !== b.file)
|
|
181
|
+
return a.file < b.file ? -1 : 1;
|
|
182
|
+
return a.issues[0] - b.issues[0];
|
|
183
|
+
});
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Format collision results as dashboard annotations.
|
|
188
|
+
*
|
|
189
|
+
* - 2-issue collision: `Order: A → B (path)` plus a warning per issue.
|
|
190
|
+
* - 3+-issue collision on the same file: `Order: A → B → C (path)`,
|
|
191
|
+
* warnings per issue, plus a single `Chain:` suggestion.
|
|
192
|
+
*
|
|
193
|
+
* Multiple shared files between the same pair render as multiple
|
|
194
|
+
* Order: lines (one per file). Callers decide whether to truncate.
|
|
195
|
+
*/
|
|
196
|
+
export function formatCollisionAnnotations(results) {
|
|
197
|
+
const orderLines = [];
|
|
198
|
+
const warnings = [];
|
|
199
|
+
let chainSuggestion;
|
|
200
|
+
for (const r of results) {
|
|
201
|
+
const arrow = r.issues.join(" → ");
|
|
202
|
+
orderLines.push(`Order: ${arrow} (${r.file})`);
|
|
203
|
+
for (const n of r.issues) {
|
|
204
|
+
const others = r.issues.filter((m) => m !== n);
|
|
205
|
+
const overlapStr = others.map((m) => `#${m}`).join(", ");
|
|
206
|
+
warnings.push(`⚠ #${n} Modifies ${r.file} (overlaps ${overlapStr}); land sequentially`);
|
|
207
|
+
}
|
|
208
|
+
if (r.issues.length >= 3 && !chainSuggestion) {
|
|
209
|
+
const ids = r.issues.join(" ");
|
|
210
|
+
chainSuggestion =
|
|
211
|
+
`Chain: npx sequant run ${ids} --chain --qa-gate -q ` +
|
|
212
|
+
`# alternative — ${r.issues.length} issues modify ${r.file} ` +
|
|
213
|
+
`(chain length≥3 historically 1/6 = 17%; see docs/reference/chain-mode-analysis-2026-05.md)`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { orderLines, warnings, chainSuggestion };
|
|
217
|
+
}
|
|
@@ -75,7 +75,12 @@ export interface AssessMarkers {
|
|
|
75
75
|
*/
|
|
76
76
|
export type SolveMarkers = AssessMarkers;
|
|
77
77
|
/**
|
|
78
|
-
* Check if a comment is an assess command output (new format)
|
|
78
|
+
* Check if a comment is an assess command output (new format).
|
|
79
|
+
*
|
|
80
|
+
* Matches either prose markers (e.g., "## Assess Analysis") or the durable
|
|
81
|
+
* HTML action marker `<!-- assess:action=... -->` written by every /assess
|
|
82
|
+
* comment regardless of prose format. The HTML marker is the contract;
|
|
83
|
+
* prose can change.
|
|
79
84
|
*/
|
|
80
85
|
export declare function isAssessComment(body: string): boolean;
|
|
81
86
|
/**
|
|
@@ -87,6 +92,14 @@ export declare function isSolveComment(body: string): boolean;
|
|
|
87
92
|
* Find the most recent assess/solve comment from a list of comments
|
|
88
93
|
*/
|
|
89
94
|
export declare function findAssessComment(comments: IssueComment[]): IssueComment | null;
|
|
95
|
+
/**
|
|
96
|
+
* Find every assess/solve comment in chronological order (oldest first).
|
|
97
|
+
*
|
|
98
|
+
* Preserves the input order of `comments`, which is the order GitHub returns
|
|
99
|
+
* them (chronological). Callers that need the most recent comment should
|
|
100
|
+
* read the last element.
|
|
101
|
+
*/
|
|
102
|
+
export declare function findAllAssessComments(comments: IssueComment[]): IssueComment[];
|
|
90
103
|
/**
|
|
91
104
|
* Find the most recent solve comment from a list of comments
|
|
92
105
|
* @deprecated Use findAssessComment instead
|
|
@@ -135,3 +148,48 @@ export declare function assessCoversIssue(workflow: AssessWorkflowResult, issueN
|
|
|
135
148
|
* @deprecated Use assessCoversIssue instead
|
|
136
149
|
*/
|
|
137
150
|
export declare function solveCoversIssue(workflow: AssessWorkflowResult, issueNumber: number): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Build a one-line supersession header for a new assess comment.
|
|
153
|
+
*
|
|
154
|
+
* Format:
|
|
155
|
+
* - 0 priors → null (caller omits the header entirely)
|
|
156
|
+
* - 1 prior → `Supersedes prior assess from <date> (<action>)`
|
|
157
|
+
* - ≥2 priors → `Supersedes N prior assessments (most recent: <date>)`
|
|
158
|
+
*
|
|
159
|
+
* Priors must be passed in chronological order (oldest first), matching
|
|
160
|
+
* the output of findAllAssessComments.
|
|
161
|
+
*/
|
|
162
|
+
export declare function buildSupersessionHeader(priors: IssueComment[]): string | null;
|
|
163
|
+
/**
|
|
164
|
+
* Result of churn detection: whether to fire the warning, the count
|
|
165
|
+
* of prior assessments, and the date of the first one.
|
|
166
|
+
*/
|
|
167
|
+
export interface ChurnResult {
|
|
168
|
+
isChurn: boolean;
|
|
169
|
+
count: number;
|
|
170
|
+
firstDate?: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Detect re-assessment churn — repeated /assess invocations without
|
|
174
|
+
* an intervening exec phase. Fires only when ≥3 priors exist and no
|
|
175
|
+
* exec phase marker appears in any comment dated after the first prior.
|
|
176
|
+
*
|
|
177
|
+
* `allComments` should be the full comment list from the issue
|
|
178
|
+
* (including the prior assess comments themselves) so we can search
|
|
179
|
+
* for SEQUANT_PHASE exec markers in non-assess comments.
|
|
180
|
+
*/
|
|
181
|
+
export declare function detectChurn(priors: IssueComment[], allComments: IssueComment[]): ChurnResult;
|
|
182
|
+
/**
|
|
183
|
+
* Decide whether to prompt the user before posting a new assess comment
|
|
184
|
+
* that conflicts with the most recent prior recommendation.
|
|
185
|
+
*
|
|
186
|
+
* Prompts only when:
|
|
187
|
+
* - A prior action exists, AND
|
|
188
|
+
* - The prior action is PROCEED or REWRITE (the "do work" actions
|
|
189
|
+
* where flipping to PARK/CLOSE/etc. needs explicit confirmation), AND
|
|
190
|
+
* - The new action differs from the prior.
|
|
191
|
+
*
|
|
192
|
+
* Skips the prompt for prior CLOSE/PARK/CLARIFY/MERGE — flipping
|
|
193
|
+
* away from those is rarely "wrong" and the prompt would just be noise.
|
|
194
|
+
*/
|
|
195
|
+
export declare function shouldPromptOnConflict(priorAction: AssessAction | undefined, newAction: AssessAction | undefined): boolean;
|
|
@@ -70,6 +70,11 @@ const HTML_MARKER_PATTERN = /<!--\s*(?:assess|solve):(\w[\w-]*)=([\w,.-]*)\s*-->
|
|
|
70
70
|
* Pattern to extract assess-specific HTML markers (takes precedence)
|
|
71
71
|
*/
|
|
72
72
|
const ASSESS_HTML_MARKER_PATTERN = /<!--\s*assess:(\w[\w-]*)=([\w,.-]*)\s*-->/g;
|
|
73
|
+
/**
|
|
74
|
+
* Non-global pattern for `.test()` checks. Matches any assess/solve action
|
|
75
|
+
* marker — the durable contract written by /assess regardless of prose format.
|
|
76
|
+
*/
|
|
77
|
+
const ASSESS_ACTION_MARKER_PATTERN = /<!--\s*(?:assess|solve):action=[\w-]+\s*-->/;
|
|
73
78
|
/**
|
|
74
79
|
* Pattern to extract issue numbers from header
|
|
75
80
|
* Matches: "## Solve Workflow for Issues: 123, 456" or "#123"
|
|
@@ -88,10 +93,17 @@ const VALID_ACTIONS = [
|
|
|
88
93
|
];
|
|
89
94
|
// ─── Detection Functions ────────────────────────────────────────────────────
|
|
90
95
|
/**
|
|
91
|
-
* Check if a comment is an assess command output (new format)
|
|
96
|
+
* Check if a comment is an assess command output (new format).
|
|
97
|
+
*
|
|
98
|
+
* Matches either prose markers (e.g., "## Assess Analysis") or the durable
|
|
99
|
+
* HTML action marker `<!-- assess:action=... -->` written by every /assess
|
|
100
|
+
* comment regardless of prose format. The HTML marker is the contract;
|
|
101
|
+
* prose can change.
|
|
92
102
|
*/
|
|
93
103
|
export function isAssessComment(body) {
|
|
94
|
-
|
|
104
|
+
if (ALL_MARKERS.some((marker) => body.includes(marker)))
|
|
105
|
+
return true;
|
|
106
|
+
return ASSESS_ACTION_MARKER_PATTERN.test(body);
|
|
95
107
|
}
|
|
96
108
|
/**
|
|
97
109
|
* Check if a comment is a solve command output (legacy format)
|
|
@@ -111,6 +123,16 @@ export function findAssessComment(comments) {
|
|
|
111
123
|
}
|
|
112
124
|
return null;
|
|
113
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Find every assess/solve comment in chronological order (oldest first).
|
|
128
|
+
*
|
|
129
|
+
* Preserves the input order of `comments`, which is the order GitHub returns
|
|
130
|
+
* them (chronological). Callers that need the most recent comment should
|
|
131
|
+
* read the last element.
|
|
132
|
+
*/
|
|
133
|
+
export function findAllAssessComments(comments) {
|
|
134
|
+
return comments.filter((c) => isAssessComment(c.body));
|
|
135
|
+
}
|
|
114
136
|
/**
|
|
115
137
|
* Find the most recent solve comment from a list of comments
|
|
116
138
|
* @deprecated Use findAssessComment instead
|
|
@@ -342,3 +364,103 @@ export function assessCoversIssue(workflow, issueNumber) {
|
|
|
342
364
|
export function solveCoversIssue(workflow, issueNumber) {
|
|
343
365
|
return assessCoversIssue(workflow, issueNumber);
|
|
344
366
|
}
|
|
367
|
+
// ─── Prior-Assessment Detection ─────────────────────────────────────────────
|
|
368
|
+
/**
|
|
369
|
+
* Pattern matching exec phase markers in any comment.
|
|
370
|
+
* Used by detectChurn to differentiate "no execution" churn from
|
|
371
|
+
* legitimate re-assessment after exec.
|
|
372
|
+
*/
|
|
373
|
+
const EXEC_PHASE_MARKER_PATTERN = /<!--\s*SEQUANT_PHASE:\s*\{[^}]*"phase"\s*:\s*"exec"[^}]*\}\s*-->/;
|
|
374
|
+
/**
|
|
375
|
+
* Format a comment's createdAt timestamp as YYYY-MM-DD.
|
|
376
|
+
* Falls back to the raw value when missing or unparseable so output
|
|
377
|
+
* still says something useful.
|
|
378
|
+
*/
|
|
379
|
+
function formatAssessDate(createdAt) {
|
|
380
|
+
if (!createdAt)
|
|
381
|
+
return "unknown date";
|
|
382
|
+
const parsed = new Date(createdAt);
|
|
383
|
+
if (isNaN(parsed.getTime()))
|
|
384
|
+
return createdAt;
|
|
385
|
+
return parsed.toISOString().slice(0, 10);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Build a one-line supersession header for a new assess comment.
|
|
389
|
+
*
|
|
390
|
+
* Format:
|
|
391
|
+
* - 0 priors → null (caller omits the header entirely)
|
|
392
|
+
* - 1 prior → `Supersedes prior assess from <date> (<action>)`
|
|
393
|
+
* - ≥2 priors → `Supersedes N prior assessments (most recent: <date>)`
|
|
394
|
+
*
|
|
395
|
+
* Priors must be passed in chronological order (oldest first), matching
|
|
396
|
+
* the output of findAllAssessComments.
|
|
397
|
+
*/
|
|
398
|
+
export function buildSupersessionHeader(priors) {
|
|
399
|
+
if (priors.length === 0)
|
|
400
|
+
return null;
|
|
401
|
+
const mostRecent = priors[priors.length - 1];
|
|
402
|
+
const date = formatAssessDate(mostRecent.createdAt);
|
|
403
|
+
if (priors.length === 1) {
|
|
404
|
+
const workflow = parseAssessWorkflow(mostRecent.body);
|
|
405
|
+
const action = workflow.action ?? "unknown";
|
|
406
|
+
return `Supersedes prior assess from ${date} (${action})`;
|
|
407
|
+
}
|
|
408
|
+
return `Supersedes ${priors.length} prior assessments (most recent: ${date})`;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Detect re-assessment churn — repeated /assess invocations without
|
|
412
|
+
* an intervening exec phase. Fires only when ≥3 priors exist and no
|
|
413
|
+
* exec phase marker appears in any comment dated after the first prior.
|
|
414
|
+
*
|
|
415
|
+
* `allComments` should be the full comment list from the issue
|
|
416
|
+
* (including the prior assess comments themselves) so we can search
|
|
417
|
+
* for SEQUANT_PHASE exec markers in non-assess comments.
|
|
418
|
+
*/
|
|
419
|
+
export function detectChurn(priors, allComments) {
|
|
420
|
+
const count = priors.length;
|
|
421
|
+
if (count < 3) {
|
|
422
|
+
return { isChurn: false, count };
|
|
423
|
+
}
|
|
424
|
+
const firstPrior = priors[0];
|
|
425
|
+
const firstDate = formatAssessDate(firstPrior.createdAt);
|
|
426
|
+
const firstTimestamp = firstPrior.createdAt
|
|
427
|
+
? new Date(firstPrior.createdAt).getTime()
|
|
428
|
+
: NaN;
|
|
429
|
+
const hasExecAfterFirst = allComments.some((c) => {
|
|
430
|
+
if (!EXEC_PHASE_MARKER_PATTERN.test(c.body))
|
|
431
|
+
return false;
|
|
432
|
+
if (!Number.isFinite(firstTimestamp))
|
|
433
|
+
return true;
|
|
434
|
+
if (!c.createdAt)
|
|
435
|
+
return true;
|
|
436
|
+
const ts = new Date(c.createdAt).getTime();
|
|
437
|
+
if (isNaN(ts))
|
|
438
|
+
return true;
|
|
439
|
+
return ts >= firstTimestamp;
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
isChurn: !hasExecAfterFirst,
|
|
443
|
+
count,
|
|
444
|
+
firstDate,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Decide whether to prompt the user before posting a new assess comment
|
|
449
|
+
* that conflicts with the most recent prior recommendation.
|
|
450
|
+
*
|
|
451
|
+
* Prompts only when:
|
|
452
|
+
* - A prior action exists, AND
|
|
453
|
+
* - The prior action is PROCEED or REWRITE (the "do work" actions
|
|
454
|
+
* where flipping to PARK/CLOSE/etc. needs explicit confirmation), AND
|
|
455
|
+
* - The new action differs from the prior.
|
|
456
|
+
*
|
|
457
|
+
* Skips the prompt for prior CLOSE/PARK/CLARIFY/MERGE — flipping
|
|
458
|
+
* away from those is rarely "wrong" and the prompt would just be noise.
|
|
459
|
+
*/
|
|
460
|
+
export function shouldPromptOnConflict(priorAction, newAction) {
|
|
461
|
+
if (!priorAction || !newAction)
|
|
462
|
+
return false;
|
|
463
|
+
if (priorAction === newAction)
|
|
464
|
+
return false;
|
|
465
|
+
return priorAction === "PROCEED" || priorAction === "REWRITE";
|
|
466
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting helpers used by run-renderer and friends.
|
|
3
|
+
*
|
|
4
|
+
* Lives in cli-ui/ so renderer and heartbeat can share without depending on
|
|
5
|
+
* the legacy phase-spinner module.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Format elapsed time in human-readable form.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* formatElapsedTime(5) // "5s"
|
|
12
|
+
* formatElapsedTime(75) // "1m 15s"
|
|
13
|
+
* formatElapsedTime(3725) // "1h 2m"
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatElapsedTime(seconds: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Format a wall-clock timestamp as `HH:MM:SS`. Used by the non-TTY renderer.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatTimestamp(date: Date): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting helpers used by run-renderer and friends.
|
|
3
|
+
*
|
|
4
|
+
* Lives in cli-ui/ so renderer and heartbeat can share without depending on
|
|
5
|
+
* the legacy phase-spinner module.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Format elapsed time in human-readable form.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* formatElapsedTime(5) // "5s"
|
|
12
|
+
* formatElapsedTime(75) // "1m 15s"
|
|
13
|
+
* formatElapsedTime(3725) // "1h 2m"
|
|
14
|
+
*/
|
|
15
|
+
export function formatElapsedTime(seconds) {
|
|
16
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
17
|
+
if (s < 60)
|
|
18
|
+
return `${s}s`;
|
|
19
|
+
if (s < 3600) {
|
|
20
|
+
const m = Math.floor(s / 60);
|
|
21
|
+
const r = s % 60;
|
|
22
|
+
return r > 0 ? `${m}m ${r}s` : `${m}m`;
|
|
23
|
+
}
|
|
24
|
+
const h = Math.floor(s / 3600);
|
|
25
|
+
const r = Math.floor((s % 3600) / 60);
|
|
26
|
+
return r > 0 ? `${h}h ${r}m` : `${h}h`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Format a wall-clock timestamp as `HH:MM:SS`. Used by the non-TTY renderer.
|
|
30
|
+
*/
|
|
31
|
+
export function formatTimestamp(date) {
|
|
32
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
33
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
34
|
+
}
|