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.
Files changed (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +94 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/locks.d.ts +67 -0
  8. package/dist/src/commands/locks.js +290 -0
  9. package/dist/src/commands/merge.js +11 -0
  10. package/dist/src/commands/prompt.d.ts +39 -0
  11. package/dist/src/commands/prompt.js +179 -0
  12. package/dist/src/commands/run-display.d.ts +11 -2
  13. package/dist/src/commands/run-display.js +62 -28
  14. package/dist/src/commands/run-progress.d.ts +32 -0
  15. package/dist/src/commands/run-progress.js +76 -0
  16. package/dist/src/commands/run.js +80 -18
  17. package/dist/src/commands/stats.d.ts +2 -0
  18. package/dist/src/commands/stats.js +94 -8
  19. package/dist/src/commands/status.js +12 -0
  20. package/dist/src/commands/watch.d.ts +16 -0
  21. package/dist/src/commands/watch.js +147 -0
  22. package/dist/src/lib/ac-linter.d.ts +1 -1
  23. package/dist/src/lib/ac-linter.js +81 -0
  24. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  25. package/dist/src/lib/assess-collision-detect.js +217 -0
  26. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  27. package/dist/src/lib/assess-comment-parser.js +124 -2
  28. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  29. package/dist/src/lib/cli-ui/format.js +34 -0
  30. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  31. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  32. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  33. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  34. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  35. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  36. package/dist/src/lib/locks/index.d.ts +7 -0
  37. package/dist/src/lib/locks/index.js +5 -0
  38. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  39. package/dist/src/lib/locks/lock-manager.js +433 -0
  40. package/dist/src/lib/locks/types.d.ts +59 -0
  41. package/dist/src/lib/locks/types.js +31 -0
  42. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  43. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  44. package/dist/src/lib/relay/activation.d.ts +60 -0
  45. package/dist/src/lib/relay/activation.js +122 -0
  46. package/dist/src/lib/relay/archive.d.ts +34 -0
  47. package/dist/src/lib/relay/archive.js +106 -0
  48. package/dist/src/lib/relay/frame.d.ts +20 -0
  49. package/dist/src/lib/relay/frame.js +76 -0
  50. package/dist/src/lib/relay/index.d.ts +13 -0
  51. package/dist/src/lib/relay/index.js +13 -0
  52. package/dist/src/lib/relay/paths.d.ts +43 -0
  53. package/dist/src/lib/relay/paths.js +59 -0
  54. package/dist/src/lib/relay/pid.d.ts +34 -0
  55. package/dist/src/lib/relay/pid.js +72 -0
  56. package/dist/src/lib/relay/reader.d.ts +35 -0
  57. package/dist/src/lib/relay/reader.js +115 -0
  58. package/dist/src/lib/relay/types.d.ts +68 -0
  59. package/dist/src/lib/relay/types.js +76 -0
  60. package/dist/src/lib/relay/writer.d.ts +48 -0
  61. package/dist/src/lib/relay/writer.js +113 -0
  62. package/dist/src/lib/settings.d.ts +31 -1
  63. package/dist/src/lib/settings.js +18 -3
  64. package/dist/src/lib/version-check.d.ts +60 -5
  65. package/dist/src/lib/version-check.js +97 -9
  66. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  67. package/dist/src/lib/workflow/batch-executor.js +248 -175
  68. package/dist/src/lib/workflow/config-resolver.js +4 -0
  69. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  70. package/dist/src/lib/workflow/heartbeat.js +194 -0
  71. package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
  72. package/dist/src/lib/workflow/phase-executor.js +157 -16
  73. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  74. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  75. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  76. package/dist/src/lib/workflow/platforms/github.js +20 -3
  77. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  78. package/dist/src/lib/workflow/pr-status.js +41 -9
  79. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  80. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  81. package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
  82. package/dist/src/lib/workflow/run-orchestrator.js +340 -15
  83. package/dist/src/lib/workflow/run-reflect.js +1 -1
  84. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  85. package/dist/src/lib/workflow/run-state.js +14 -0
  86. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  87. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  88. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  89. package/dist/src/lib/workflow/state-manager.js +37 -0
  90. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  91. package/dist/src/lib/workflow/state-schema.js +35 -1
  92. package/dist/src/lib/workflow/types.d.ts +74 -1
  93. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  94. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  95. package/dist/src/mcp/tools/run.d.ts +44 -0
  96. package/dist/src/mcp/tools/run.js +104 -13
  97. package/dist/src/ui/tui/App.d.ts +14 -0
  98. package/dist/src/ui/tui/App.js +41 -0
  99. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  100. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  101. package/dist/src/ui/tui/Header.d.ts +6 -0
  102. package/dist/src/ui/tui/Header.js +15 -0
  103. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  104. package/dist/src/ui/tui/IssueBox.js +68 -0
  105. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  106. package/dist/src/ui/tui/Spinner.js +18 -0
  107. package/dist/src/ui/tui/index.d.ts +15 -0
  108. package/dist/src/ui/tui/index.js +29 -0
  109. package/dist/src/ui/tui/theme.d.ts +29 -0
  110. package/dist/src/ui/tui/theme.js +52 -0
  111. package/dist/src/ui/tui/truncate.d.ts +11 -0
  112. package/dist/src/ui/tui/truncate.js +31 -0
  113. package/package.json +10 -3
  114. package/templates/agents/sequant-explorer.md +1 -0
  115. package/templates/agents/sequant-qa-checker.md +2 -1
  116. package/templates/agents/sequant-testgen.md +1 -0
  117. package/templates/hooks/post-tool.sh +11 -0
  118. package/templates/hooks/pre-tool.sh +18 -9
  119. package/templates/hooks/relay-check.sh +107 -0
  120. package/templates/relay/frame.txt +11 -0
  121. package/templates/scripts/cleanup-worktree.sh +25 -3
  122. package/templates/scripts/new-feature.sh +6 -0
  123. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  124. package/templates/skills/_shared/references/subagent-types.md +21 -8
  125. package/templates/skills/assess/SKILL.md +103 -49
  126. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  127. package/templates/skills/docs/SKILL.md +141 -22
  128. package/templates/skills/exec/SKILL.md +10 -8
  129. package/templates/skills/fullsolve/SKILL.md +79 -5
  130. package/templates/skills/loop/SKILL.md +28 -0
  131. package/templates/skills/merger/SKILL.md +621 -0
  132. package/templates/skills/qa/SKILL.md +727 -8
  133. package/templates/skills/setup/SKILL.md +6 -0
  134. package/templates/skills/spec/SKILL.md +52 -0
  135. package/templates/skills/spec/references/parallel-groups.md +7 -0
  136. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  137. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { DIVIDER_COLOR } from "./theme.js";
4
+ /** Top-of-dashboard summary: count, concurrency, base, quality-loop. */
5
+ export function Header({ snapshot }) {
6
+ const { config, issues } = snapshot;
7
+ const concurrency = config.concurrency > 1
8
+ ? `parallel (${config.concurrency} concurrent)`
9
+ : "sequential";
10
+ const loop = config.qualityLoop ? "on" : "off";
11
+ const base = config.baseSha
12
+ ? `${config.baseBranch} @${config.baseSha.slice(0, 7)}`
13
+ : config.baseBranch;
14
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "sequant run" }), _jsxs(Text, { color: DIVIDER_COLOR, children: [" ─ ", issues.length, " issue", issues.length === 1 ? "" : "s", " • ", concurrency, " • ", "quality loop ", loop] })] }), _jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "base " }), _jsx(Text, { children: base })] })] }));
15
+ }
@@ -0,0 +1,16 @@
1
+ import type { JSX } from "react";
2
+ import type { IssueRuntimeState } from "../../lib/workflow/run-state.js";
3
+ /**
4
+ * Three-cell rendering of a single issue's runtime state.
5
+ *
6
+ * Cells:
7
+ * 1. header — id, title, phase N/total, elapsed
8
+ * 2. context — branch, phase progression, log path
9
+ * 3. activity — current `now` line, last-activity stamp
10
+ */
11
+ export declare function IssueBox({ state, slot, width, now, }: {
12
+ state: IssueRuntimeState;
13
+ slot: number;
14
+ width: number;
15
+ now: number;
16
+ }): JSX.Element;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { DIVIDER_COLOR, PHASE_GLYPHS, borderColorForIssue, phaseStatusColor, } from "./theme.js";
4
+ import { Spinner } from "./Spinner.js";
5
+ import { ElapsedTimer, formatSinceActivity } from "./ElapsedTimer.js";
6
+ import { truncateToWidth } from "./truncate.js";
7
+ /**
8
+ * Three-cell rendering of a single issue's runtime state.
9
+ *
10
+ * Cells:
11
+ * 1. header — id, title, phase N/total, elapsed
12
+ * 2. context — branch, phase progression, log path
13
+ * 3. activity — current `now` line, last-activity stamp
14
+ */
15
+ export function IssueBox({ state, slot, width, now, }) {
16
+ const border = borderColorForIssue(state.status, slot);
17
+ const innerWidth = Math.max(10, width - 4);
18
+ const doneCount = state.phases.filter((p) => p.status === "done" || p.status === "failed").length;
19
+ const activePhaseIndex = state.phases.findIndex((p) => p.status === "running");
20
+ const displayPhaseN = activePhaseIndex >= 0 ? activePhaseIndex + 1 : doneCount;
21
+ const total = state.phases.length;
22
+ const headerTitle = truncateToWidth(`#${state.number} ${state.title}`, Math.max(10, innerWidth - 20));
23
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: border, children: headerTitle }), _jsxs(Text, { color: DIVIDER_COLOR, children: ["phase ", displayPhaseN, "/", total, " \u2022", " ", _jsx(ElapsedTimer, { startedAt: state.startedAt })] })] }), _jsx(Divider, { width: innerWidth, borderColor: border }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "branch " }), _jsx(Text, { children: truncateToWidth(state.branch, innerWidth - 8) })] }), _jsx(PhaseProgression, { phases: state.phases, borderColor: border }), state.currentPhase?.logPath ? (_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "log " }), _jsx(Text, { children: truncateToWidth(state.currentPhase.logPath, innerWidth - 8) })] })) : null] }), _jsx(Divider, { width: innerWidth, borderColor: border }), _jsx(Box, { flexDirection: "column", children: state.currentPhase ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "now " }), _jsx(Spinner, { color: border }), _jsxs(Text, { children: [" ", truncateToWidth(state.currentPhase.nowLine, innerWidth - 12)] })] }), _jsx(Box, { children: _jsxs(Text, { color: DIVIDER_COLOR, children: [" └ last activity ", formatSinceActivity(now, state.currentPhase.lastActivityAt)] }) })] })) : (_jsx(Text, { color: DIVIDER_COLOR, children: statusLine(state) })) })] }));
24
+ }
25
+ function statusLine(state) {
26
+ switch (state.status) {
27
+ case "queued":
28
+ return "queued";
29
+ case "running":
30
+ return "working…";
31
+ case "passed":
32
+ return "completed";
33
+ case "failed":
34
+ return "failed";
35
+ }
36
+ }
37
+ function Divider({ width, borderColor, }) {
38
+ const mid = "─".repeat(Math.max(0, width - 2));
39
+ return (_jsxs(Text, { children: [_jsx(Text, { color: borderColor, children: "\u251C" }), _jsx(Text, { color: DIVIDER_COLOR, children: mid }), _jsx(Text, { color: borderColor, children: "\u2524" })] }));
40
+ }
41
+ function PhaseProgression({ phases, borderColor, }) {
42
+ return (_jsxs(Box, { flexWrap: "wrap", children: [_jsx(Text, { color: DIVIDER_COLOR, children: "phases " }), phases.map((p, i) => {
43
+ const isLast = i === phases.length - 1;
44
+ return (_jsxs(Box, { children: [_jsx(PhaseGlyph, { status: p.status, label: p.name, activeColor: borderColor, elapsedMs: p.elapsedMs }), !isLast ? (_jsxs(Text, { color: DIVIDER_COLOR, children: [" ", PHASE_GLYPHS.separator, " "] })) : null] }, `${p.name}-${i}`));
45
+ })] }));
46
+ }
47
+ function PhaseGlyph({ status, label, activeColor, elapsedMs, }) {
48
+ if (status === "running") {
49
+ return (_jsxs(Box, { children: [_jsx(Spinner, { color: activeColor }), _jsxs(Text, { children: [" ", label] })] }));
50
+ }
51
+ const glyph = status === "done"
52
+ ? PHASE_GLYPHS.done
53
+ : status === "failed"
54
+ ? PHASE_GLYPHS.failed
55
+ : PHASE_GLYPHS.pending;
56
+ const glyphColor = phaseStatusColor(status);
57
+ return (_jsxs(Box, { children: [_jsx(Text, { color: glyphColor, children: glyph }), _jsxs(Text, { color: DIVIDER_COLOR, children: [" ", label, elapsedMs != null ? ` ${formatShortDuration(elapsedMs)}` : ""] })] }));
58
+ }
59
+ function formatShortDuration(ms) {
60
+ const secs = Math.max(0, Math.floor(ms / 1000));
61
+ if (secs < 60)
62
+ return `${secs.toString().padStart(2, "0")}s`;
63
+ const mm = Math.floor(secs / 60)
64
+ .toString()
65
+ .padStart(2, "0");
66
+ const ss = (secs % 60).toString().padStart(2, "0");
67
+ return `${mm}:${ss}`;
68
+ }
@@ -0,0 +1,9 @@
1
+ import { type JSX } from "react";
2
+ import { type BorderColor } from "./theme.js";
3
+ /**
4
+ * Braille spinner. Ticks at 10 Hz in its own component so the parent
5
+ * issue box is not forced to re-render on each frame.
6
+ */
7
+ export declare function Spinner({ color }: {
8
+ color?: BorderColor;
9
+ }): JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ import { SPINNER_FRAMES } from "./theme.js";
5
+ /**
6
+ * Braille spinner. Ticks at 10 Hz in its own component so the parent
7
+ * issue box is not forced to re-render on each frame.
8
+ */
9
+ export function Spinner({ color }) {
10
+ const [frame, setFrame] = useState(0);
11
+ useEffect(() => {
12
+ const id = setInterval(() => {
13
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
14
+ }, 100);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+ return _jsx(Text, { color: color, children: SPINNER_FRAMES[frame] });
18
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Experimental multi-issue TUI entry point.
3
+ *
4
+ * Mounts an `ink` app that polls `RunOrchestrator.getSnapshot()` at 10 Hz.
5
+ * Unmounts when the orchestrator reports `done` so the shell returns
6
+ * cleanly. Only safe to call when `process.stdout.isTTY` is true.
7
+ */
8
+ import type { RunOrchestrator } from "../../lib/workflow/run-orchestrator.js";
9
+ export interface TuiHandle {
10
+ /** Promise that resolves when the TUI unmounts. */
11
+ done: Promise<void>;
12
+ /** Force-unmount (e.g., on SIGINT fallback). */
13
+ unmount: () => void;
14
+ }
15
+ export declare function renderTui(orchestrator: RunOrchestrator): TuiHandle;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Experimental multi-issue TUI entry point.
3
+ *
4
+ * Mounts an `ink` app that polls `RunOrchestrator.getSnapshot()` at 10 Hz.
5
+ * Unmounts when the orchestrator reports `done` so the shell returns
6
+ * cleanly. Only safe to call when `process.stdout.isTTY` is true.
7
+ */
8
+ import { createElement } from "react";
9
+ import { render } from "ink";
10
+ import { App } from "./App.js";
11
+ export function renderTui(orchestrator) {
12
+ let resolveDone;
13
+ const done = new Promise((resolve) => {
14
+ resolveDone = resolve;
15
+ });
16
+ const instance = render(createElement(App, {
17
+ getSnapshot: () => orchestrator.getSnapshot(),
18
+ onDone: () => {
19
+ instance.unmount();
20
+ },
21
+ }), { exitOnCtrlC: false });
22
+ instance.waitUntilExit().then(() => resolveDone());
23
+ return {
24
+ done,
25
+ unmount: () => {
26
+ instance.unmount();
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Theme tokens for the experimental multi-issue dashboard TUI.
3
+ *
4
+ * Palette rotates by start order (cyan → magenta → blue → yellow); issue
5
+ * status (failed / passed) overrides the rotation color where applicable.
6
+ * Respects `NO_COLOR` automatically via `ink`/`chalk`.
7
+ */
8
+ import type { IssueStatus, PhaseStatus } from "../../lib/workflow/run-state.js";
9
+ /** Border-color palette rotated by issue start order. */
10
+ export declare const BORDER_ROTATION: readonly ["cyan", "magenta", "blue", "yellow"];
11
+ export type BorderColor = (typeof BORDER_ROTATION)[number] | "green" | "red" | "gray";
12
+ /** Gray used for horizontal dividers inside each box. */
13
+ export declare const DIVIDER_COLOR: "gray";
14
+ /**
15
+ * Pick the border color for an issue.
16
+ * Failed / passed states win over rotation; otherwise rotate by slot.
17
+ */
18
+ export declare function borderColorForIssue(status: IssueStatus, slot: number): BorderColor;
19
+ /** Glyphs for the phase progression row. */
20
+ export declare const PHASE_GLYPHS: {
21
+ readonly pending: "○";
22
+ readonly done: "✓";
23
+ readonly failed: "✗";
24
+ readonly separator: "▸";
25
+ };
26
+ /** Braille spinner frames — 10 Hz rotation looks smooth at ~100ms tick. */
27
+ export declare const SPINNER_FRAMES: readonly ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
+ /** Color for a phase glyph based on its status. Active phase uses border color. */
29
+ export declare function phaseStatusColor(status: PhaseStatus): BorderColor;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Theme tokens for the experimental multi-issue dashboard TUI.
3
+ *
4
+ * Palette rotates by start order (cyan → magenta → blue → yellow); issue
5
+ * status (failed / passed) overrides the rotation color where applicable.
6
+ * Respects `NO_COLOR` automatically via `ink`/`chalk`.
7
+ */
8
+ /** Border-color palette rotated by issue start order. */
9
+ export const BORDER_ROTATION = ["cyan", "magenta", "blue", "yellow"];
10
+ /** Gray used for horizontal dividers inside each box. */
11
+ export const DIVIDER_COLOR = "gray";
12
+ /**
13
+ * Pick the border color for an issue.
14
+ * Failed / passed states win over rotation; otherwise rotate by slot.
15
+ */
16
+ export function borderColorForIssue(status, slot) {
17
+ if (status === "failed")
18
+ return "red";
19
+ if (status === "passed")
20
+ return "green";
21
+ const idx = ((slot % BORDER_ROTATION.length) + BORDER_ROTATION.length) %
22
+ BORDER_ROTATION.length;
23
+ return BORDER_ROTATION[idx];
24
+ }
25
+ /** Glyphs for the phase progression row. */
26
+ export const PHASE_GLYPHS = {
27
+ pending: "○",
28
+ done: "✓",
29
+ failed: "✗",
30
+ separator: "▸",
31
+ };
32
+ /** Braille spinner frames — 10 Hz rotation looks smooth at ~100ms tick. */
33
+ export const SPINNER_FRAMES = [
34
+ "⠋",
35
+ "⠙",
36
+ "⠹",
37
+ "⠸",
38
+ "⠼",
39
+ "⠴",
40
+ "⠦",
41
+ "⠧",
42
+ "⠇",
43
+ "⠏",
44
+ ];
45
+ /** Color for a phase glyph based on its status. Active phase uses border color. */
46
+ export function phaseStatusColor(status) {
47
+ if (status === "done")
48
+ return "green";
49
+ if (status === "failed")
50
+ return "red";
51
+ return "gray";
52
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Display-width-aware truncation for terminal output.
3
+ *
4
+ * Uses `string-width` so wide glyphs (CJK, emoji) and ANSI escapes are
5
+ * counted correctly. Cheaper than ink's own truncation in hot paths.
6
+ */
7
+ /**
8
+ * Truncate `text` so its visible width does not exceed `max` columns.
9
+ * If truncation happens, appends a single `…` (which itself counts as 1 col).
10
+ */
11
+ export declare function truncateToWidth(text: string, max: number): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Display-width-aware truncation for terminal output.
3
+ *
4
+ * Uses `string-width` so wide glyphs (CJK, emoji) and ANSI escapes are
5
+ * counted correctly. Cheaper than ink's own truncation in hot paths.
6
+ */
7
+ import stringWidth from "string-width";
8
+ /**
9
+ * Truncate `text` so its visible width does not exceed `max` columns.
10
+ * If truncation happens, appends a single `…` (which itself counts as 1 col).
11
+ */
12
+ export function truncateToWidth(text, max) {
13
+ if (max <= 0)
14
+ return "";
15
+ const width = stringWidth(text);
16
+ if (width <= max)
17
+ return text;
18
+ if (max === 1)
19
+ return "…";
20
+ let acc = "";
21
+ let accWidth = 0;
22
+ const budget = max - 1; // reserve one column for the ellipsis
23
+ for (const ch of text) {
24
+ const w = stringWidth(ch);
25
+ if (accWidth + w > budget)
26
+ break;
27
+ acc += ch;
28
+ accWidth += w;
29
+ }
30
+ return `${acc}…`;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "lint": "eslint src/ bin/ --max-warnings 0",
28
28
  "sync:skills": "cp -r templates/skills/* .claude/skills/",
29
29
  "validate:skills": "for skill in templates/skills/*/; do npx skills-ref validate \"$skill\"; done",
30
+ "lint:skill-calls": "npx tsx scripts/lint-skill-calls.ts",
30
31
  "prepare:marketplace": "npx tsx scripts/prepare-marketplace.ts",
31
32
  "validate:marketplace": "npx tsx scripts/prepare-marketplace.ts --validate-only",
32
33
  "prepublishOnly": "npm run build"
@@ -76,19 +77,23 @@
76
77
  },
77
78
  "dependencies": {
78
79
  "@anthropic-ai/claude-agent-sdk": "^0.2.11",
79
- "@hono/node-server": "^1.19.9",
80
+ "@hono/node-server": "^2.0.0",
80
81
  "boxen": "^8.0.1",
81
82
  "chalk": "^5.3.0",
82
83
  "chokidar": "^5.0.0",
83
84
  "cli-table3": "^0.6.5",
84
85
  "commander": "^14.0.3",
85
- "diff": "^8.0.3",
86
+ "diff": "^9.0.0",
86
87
  "gradient-string": "^3.0.0",
87
88
  "hono": "^4.12.1",
89
+ "ink": "^7.0.1",
88
90
  "inquirer": "^13.3.0",
91
+ "log-update": "^7.0.1",
89
92
  "open": "^11.0.0",
90
93
  "ora": "^9.3.0",
91
94
  "p-limit": "^7.3.0",
95
+ "react": "^19.2.5",
96
+ "string-width": "^8.2.0",
92
97
  "yaml": "^2.7.0",
93
98
  "zod": "^4.3.5"
94
99
  },
@@ -97,10 +102,12 @@
97
102
  "@types/gradient-string": "^1.1.6",
98
103
  "@types/inquirer": "^9.0.7",
99
104
  "@types/node": "^25.4.0",
105
+ "@types/react": "^19.2.14",
100
106
  "@typescript-eslint/eslint-plugin": "^8.58.0",
101
107
  "@typescript-eslint/parser": "^8.58.0",
102
108
  "eslint": "^10.1.0",
103
109
  "globals": "^17.0.0",
110
+ "ink-testing-library": "^4.0.0",
104
111
  "tsx": "^4.19.2",
105
112
  "typescript": "^6.0.2",
106
113
  "typescript-eslint": "^8.58.0",
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sequant-explorer
3
3
  description: Codebase exploration agent for sequant /spec phase. Searches for existing patterns, components, database schemas, and file structures. Use when gathering context before planning a feature implementation.
4
+ # Note: per anthropics/claude-code#43869 this is currently a no-op; agent runs on parent's model
4
5
  model: haiku
5
6
  maxTurns: 15
6
7
  tools:
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: sequant-qa-checker
3
3
  description: Quality check agent for sequant /qa phase. Runs type safety, scope/size, security, and documentation checks on diffs. Use when spawned by the /qa skill to perform parallel or sequential quality checks.
4
- model: haiku
4
+ # Note: per anthropics/claude-code#43869 this is currently a no-op; agent runs on parent's model
5
+ model: sonnet
5
6
  permissionMode: bypassPermissions
6
7
  effort: low
7
8
  maxTurns: 15
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sequant-testgen
3
3
  description: Test stub generator for sequant /testgen phase. Parses verification criteria from /spec comments and generates Jest/Vitest test stubs with Given/When/Then structure. Use when spawned by the /testgen skill.
4
+ # Note: per anthropics/claude-code#43869 this is currently a no-op; agent runs on parent's model
4
5
  model: haiku
5
6
  maxTurns: 25
6
7
  tools:
@@ -13,6 +13,17 @@ if [[ "${CLAUDE_HOOKS_DISABLED:-}" == "true" ]]; then
13
13
  exit 0
14
14
  fi
15
15
 
16
+ # === RELAY CHECK (#383) ===
17
+ # Sourced only when SEQUANT_RELAY=true. The check itself is a single env-var
18
+ # comparison when relay is disabled (default), so non-relay runs incur no cost.
19
+ if [[ "${SEQUANT_RELAY:-}" == "true" ]]; then
20
+ _RELAY_CHECK="$(dirname "${BASH_SOURCE[0]:-$0}")/relay-check.sh"
21
+ if [[ -f "${_RELAY_CHECK}" ]]; then
22
+ # shellcheck source=relay-check.sh disable=SC1091
23
+ source "${_RELAY_CHECK}" || true
24
+ fi
25
+ fi
26
+
16
27
  # === READ INPUT FROM STDIN ===
17
28
  # Claude Code passes tool data as JSON via stdin, not environment variables
18
29
  INPUT_JSON=$(cat)
@@ -104,20 +104,23 @@ if echo "$TOOL_INPUT" | grep -qE '^(env|printenv|export)$'; then
104
104
  fi
105
105
 
106
106
  # Destructive system commands
107
- if echo "$TOOL_INPUT" | grep -qE 'sudo|rm -rf /|rm -rf ~|rm -rf \$HOME'; then
107
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#570)
108
+ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'sudo|rm -rf /|rm -rf ~|rm -rf \$HOME'; then
108
109
  echo "HOOK_BLOCKED: Destructive system command" | tee -a "$HOOK_LOG" >&2
109
110
  exit 2
110
111
  fi
111
112
 
112
113
  # Deployment (should never happen in issue automation)
113
- if echo "$TOOL_INPUT" | grep -qE 'vercel (deploy|--prod)|terraform (apply|destroy)|kubectl (apply|delete)'; then
114
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#570)
115
+ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'vercel (deploy|--prod)|terraform (apply|destroy)|kubectl (apply|delete)'; then
114
116
  echo "HOOK_BLOCKED: Deployment command" | tee -a "$HOOK_LOG" >&2
115
117
  exit 2
116
118
  fi
117
119
 
118
120
  # Force push
119
121
  # Pattern requires -f to be a standalone flag (not part of branch name like -fix)
120
- if echo "$TOOL_INPUT" | grep -qE 'git push.*(--force| -f($| ))'; then
122
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
123
+ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git push.*(--force| -f($| ))'; then
121
124
  echo "HOOK_BLOCKED: Force push" | tee -a "$HOOK_LOG" >&2
122
125
  exit 2
123
126
  fi
@@ -127,7 +130,8 @@ fi
127
130
  # - Unpushed commits on main/master
128
131
  # - Uncommitted changes (staged or unstaged)
129
132
  # - Unfinished merge in progress
130
- if echo "$TOOL_INPUT" | grep -qE 'git reset.*(--hard|origin)'; then
133
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#570)
134
+ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git reset.*(--hard|origin)'; then
131
135
  CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
132
136
  BLOCK_REASONS=""
133
137
 
@@ -167,7 +171,8 @@ if echo "$TOOL_INPUT" | grep -qE 'git reset.*(--hard|origin)'; then
167
171
  fi
168
172
 
169
173
  # CI/CD triggers (automation shouldn't trigger more automation)
170
- if echo "$TOOL_INPUT" | grep -qE 'gh workflow run'; then
174
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#570)
175
+ if ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'gh workflow run'; then
171
176
  echo "HOOK_BLOCKED: Workflow trigger" | tee -a "$HOOK_LOG" >&2
172
177
  exit 2
173
178
  fi
@@ -225,7 +230,8 @@ check_sensitive_files() {
225
230
 
226
231
  if [[ "${CLAUDE_HOOKS_SECURITY:-true}" != "false" ]]; then
227
232
  # Security checks for git commit
228
- if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
233
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
234
+ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
229
235
  # Skip security checks if --no-verify is used
230
236
  if ! echo "$TOOL_INPUT" | grep -qE -- '--no-verify'; then
231
237
  # Check staged files for secrets
@@ -257,7 +263,8 @@ fi
257
263
  # --- No-Changes Guard (AC-7) ---
258
264
  # Block commits when there are no staged or unstaged changes (prevents empty commits)
259
265
  # Skips for --amend since amending doesn't require new changes
260
- if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
266
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
267
+ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
261
268
  if ! echo "$TOOL_INPUT" | grep -qE -- '--amend|--allow-empty'; then
262
269
  # Extract target directory from cd command if present (for worktree commits)
263
270
  # Handles: "cd /path && git commit" or "cd /path; git commit"
@@ -284,7 +291,8 @@ fi
284
291
  # Warn (but don't block) when committing outside a feature worktree
285
292
  # This catches accidental commits to main repo during feature work
286
293
  QUALITY_LOG="${_LOG_DIR}/claude-quality.log"
287
- if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
294
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
295
+ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
288
296
  CWD=$(pwd)
289
297
  if ! echo "$CWD" | grep -qE 'worktrees/feature/'; then
290
298
  echo "$(date +%H:%M:%S) WORKTREE_WARNING: Committing outside feature worktree ($CWD)" >> "$QUALITY_LOG"
@@ -295,7 +303,8 @@ fi
295
303
  # --- Commit Message Validation (AC-3) ---
296
304
  # Enforce conventional commits format: type(scope): description
297
305
  # Types: feat|fix|docs|style|refactor|test|chore|ci|build|perf
298
- if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
306
+ # Skip for gh issue/pr commands body text may legitimately reference these tokens (#564)
307
+ if [[ "$TOOL_NAME" == "Bash" ]] && ! echo "$TOOL_INPUT" | grep -qE '^gh (issue|pr) ' && echo "$TOOL_INPUT" | grep -qE 'git commit'; then
299
308
  # Extract message from -m flag
300
309
  MSG=""
301
310
 
@@ -0,0 +1,107 @@
1
+ #!/bin/bash
2
+ # Relay check (#383): sourced from post-tool.sh on every PostToolUse when
3
+ # SEQUANT_RELAY=true. Reads unread user messages from inbox.jsonl, renders the
4
+ # framing prompt to stdout (which Claude Code surfaces as additional context),
5
+ # and advances the per-issue read cursor.
6
+ #
7
+ # Fast path (no pending messages): exits silently in well under 5 ms.
8
+ # Slow path (messages pending): renders one framing block per invocation.
9
+
10
+ # Opt-in guard. Fast path #1: env var unset / not exactly "true".
11
+ # Bash precedence makes `cmd1 && cmd2 || cmd3` equivalent to `(cmd1 && cmd2) || cmd3`,
12
+ # so we must use an explicit `if` block to avoid falling through to `exit 0`
13
+ # when the test is FALSE (which is the relay-enabled case).
14
+ if [[ "${SEQUANT_RELAY:-}" != "true" ]]; then
15
+ return 0 2>/dev/null || exit 0
16
+ fi
17
+
18
+ # Resolve relay directory. Inside an isolated worktree, the phase-executor sets
19
+ # SEQUANT_WORKTREE. During the spec phase (main repo) we fall back to the
20
+ # per-issue layout under .sequant/relay/<issue>/.
21
+ if [[ -n "${SEQUANT_WORKTREE:-}" ]]; then
22
+ _RELAY_DIR="${SEQUANT_WORKTREE}/.sequant/relay"
23
+ elif [[ -n "${SEQUANT_ISSUE:-}" ]]; then
24
+ _RELAY_DIR="${PWD}/.sequant/relay/${SEQUANT_ISSUE}"
25
+ else
26
+ # Nothing to do without a target issue or worktree.
27
+ return 0 2>/dev/null || exit 0
28
+ fi
29
+
30
+ _INBOX="${_RELAY_DIR}/inbox.jsonl"
31
+ _CURSOR="${_RELAY_DIR}/.cursor"
32
+
33
+ # Fast path #2: AC-8 — `test -s` is sub-millisecond. Empty/missing inbox skips.
34
+ [[ -s "${_INBOX}" ]] || return 0 2>/dev/null || exit 0
35
+
36
+ # Cursor read (missing → 0). Compare against current inbox line count.
37
+ _CURSOR_VAL=0
38
+ if [[ -f "${_CURSOR}" ]]; then
39
+ _CURSOR_VAL=$(cat "${_CURSOR}" 2>/dev/null || echo 0)
40
+ [[ "${_CURSOR_VAL}" =~ ^[0-9]+$ ]] || _CURSOR_VAL=0
41
+ fi
42
+
43
+ _INBOX_LINES=$(wc -l < "${_INBOX}" 2>/dev/null || echo 0)
44
+ _INBOX_LINES=${_INBOX_LINES// /} # trim whitespace from wc output on macOS
45
+
46
+ # Fast path #3: cursor caught up.
47
+ if [[ "${_INBOX_LINES}" -le "${_CURSOR_VAL}" ]]; then
48
+ return 0 2>/dev/null || exit 0
49
+ fi
50
+
51
+ # Slow path: render frame.
52
+
53
+ # Resolve frame template. Phase-executor sets SEQUANT_RELAY_FRAME to the
54
+ # absolute path within the sequant installation. Falls back to ./templates/
55
+ # (when running from the sequant repo itself).
56
+ _FRAME_PATH="${SEQUANT_RELAY_FRAME:-}"
57
+ if [[ -z "${_FRAME_PATH}" || ! -f "${_FRAME_PATH}" ]]; then
58
+ for _candidate in \
59
+ "${PWD}/.claude/relay/frame.txt" \
60
+ "${PWD}/templates/relay/frame.txt"; do
61
+ if [[ -f "${_candidate}" ]]; then
62
+ _FRAME_PATH="${_candidate}"
63
+ break
64
+ fi
65
+ done
66
+ fi
67
+ if [[ -z "${_FRAME_PATH}" || ! -f "${_FRAME_PATH}" ]]; then
68
+ # Missing template — emit a minimal frame so the user still gets through.
69
+ _FRAME_PATH=""
70
+ fi
71
+
72
+ # Skip the lines we've already shown the model. `tail -n +N` is 1-indexed.
73
+ _START=$((_CURSOR_VAL + 1))
74
+
75
+ # Render messages. Sort by timestamp ascending (AC-9). jq handles JSON parsing.
76
+ # Messages are separated by `---` lines; a single message has no separator.
77
+ _render_messages() {
78
+ if command -v jq &>/dev/null; then
79
+ tail -n "+${_START}" "${_INBOX}" \
80
+ | jq -s -r 'sort_by(.timestamp) | map("Type: \(.type)\nMessage: \((.message // "") | tojson)") | join("\n---\n")'
81
+ else
82
+ # Fallback without jq: dump raw lines.
83
+ tail -n "+${_START}" "${_INBOX}"
84
+ fi
85
+ }
86
+
87
+ if [[ -n "${_FRAME_PATH}" ]]; then
88
+ # Split frame template at {{MESSAGES}} placeholder.
89
+ _PREFIX=$(awk '/\{\{MESSAGES\}\}/ {exit} {print}' "${_FRAME_PATH}")
90
+ _SUFFIX=$(awk '/\{\{MESSAGES\}\}/ {found=1; next} found {print}' "${_FRAME_PATH}")
91
+ printf '%s\n' "${_PREFIX}"
92
+ _render_messages
93
+ if [[ -n "${_SUFFIX}" ]]; then
94
+ printf '%s\n' "${_SUFFIX}"
95
+ fi
96
+ else
97
+ printf '[SEQUANT RELAY — message from user]\n'
98
+ _render_messages
99
+ fi
100
+
101
+ # Advance cursor atomically (temp + rename on same fs).
102
+ _TMP="${_CURSOR}.tmp.$$"
103
+ if printf '%s' "${_INBOX_LINES}" > "${_TMP}" 2>/dev/null; then
104
+ mv -f "${_TMP}" "${_CURSOR}" 2>/dev/null || rm -f "${_TMP}" 2>/dev/null || true
105
+ fi
106
+
107
+ return 0 2>/dev/null || exit 0
@@ -0,0 +1,11 @@
1
+ [SEQUANT RELAY — message from user]
2
+ Respond briefly in .sequant/relay/outbox.jsonl, then continue your current task unchanged.
3
+ Rules:
4
+ - Do NOT modify acceptance criteria
5
+ - Do NOT change your current objective or phase
6
+ - Do NOT treat this as a new requirement
7
+ - For "query" type: provide a brief status update only
8
+ - For "directive" type: acknowledge and adjust approach if reasonable, but do not abandon current work
9
+ - For "abort" type: stop gracefully, commit progress, and exit
10
+
11
+ {{MESSAGES}}