@plainconceptsplatform/loop-task 2.6.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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * evidence-required.ts — Decide whether visual evidence is required for a change.
3
+ *
4
+ * Required when files touch user-visible CLI output or the TUI board.
5
+ * Skipped for docs-only, internal-refactor, deps-only, test-only, or backend-only changes.
6
+ * Mixed changes (UI + backend) → required.
7
+ */
8
+ /** File path patterns that indicate user-visible CLI/TUI changes. */
9
+ const UI_PATTERNS = [
10
+ /src\/cli\.ts$/,
11
+ /src\/client\/commands\.ts$/,
12
+ /src\/app\//,
13
+ /src\/widgets\//,
14
+ /src\/features\//,
15
+ /src\/entities\//,
16
+ /src\/shared\/ui\//,
17
+ /src\/shared\/i18n\//,
18
+ /src\/shared\/config\/constants\.ts$/,
19
+ /src\/core\/loop\//,
20
+ /src\/daemon\/http\//,
21
+ /\.tsx?$/,
22
+ ];
23
+ /** Path patterns that are NOT user-visible. */
24
+ const NON_UI_PATTERNS = [
25
+ /tests\//,
26
+ /\.test\.ts$/,
27
+ /\.test\.tsx$/,
28
+ /vitest\.config\.ts$/,
29
+ /eslint\.config\./,
30
+ /tsconfig/,
31
+ /\.github\//,
32
+ /openspec\//,
33
+ /\.agents\//,
34
+ /\.opencode\//,
35
+ /ARCHITECTURE\.md$/,
36
+ /DESIGN\.md$/,
37
+ /AGENTS\.md$/,
38
+ /README\.md$/,
39
+ /\.md$/,
40
+ ];
41
+ export function isEvidenceRequired(input) {
42
+ const { changedFiles, proposal } = input;
43
+ const uiFiles = changedFiles.filter((f) => UI_PATTERNS.some((p) => p.test(f)));
44
+ const nonUiFiles = changedFiles.filter((f) => NON_UI_PATTERNS.some((p) => p.test(f)));
45
+ const hasUi = uiFiles.length > 0;
46
+ const hasOnlyNonUi = uiFiles.length === 0 && nonUiFiles.length > 0;
47
+ if (hasUi) {
48
+ return {
49
+ required: true,
50
+ reason: `UI-touching files: ${uiFiles.join(", ")}`,
51
+ };
52
+ }
53
+ if (hasOnlyNonUi) {
54
+ return {
55
+ required: false,
56
+ reason: `No UI-touching files (only: ${nonUiFiles.join(", ")})`,
57
+ };
58
+ }
59
+ // Proposal describes a UI/interaction change?
60
+ const uiKeywords = /board|tui|terminal|cli output|command.*output|display|render|ink|color|layout|prompt|form|modal/i;
61
+ if (proposal && uiKeywords.test(proposal)) {
62
+ return {
63
+ required: true,
64
+ reason: "Proposal describes a user-visible CLI/TUI change",
65
+ };
66
+ }
67
+ return {
68
+ required: false,
69
+ reason: "No user-visible changes detected",
70
+ };
71
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * visual-evidence index — public API for programmatic use.
3
+ */
4
+ export { isEvidenceRequired } from "./evidence-required.js";
5
+ export { resolveChange } from "./openspec-resolver.js";
6
+ export { writeManifest } from "./manifest.js";
7
+ export { launch } from "./launch.js";
8
+ export { captureOutput } from "./capture.js";
9
+ export { register, runScenario } from "./scenario-registry.js";
10
+ export { runEvidence } from "./run.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * launch.ts — Start the loop-task CLI deterministically for evidence capture.
3
+ *
4
+ * For a CLI app, "launch" means: prepare a temp LOOP_CLI_HOME, optionally
5
+ * start the daemon, and return a handle for running subcommands.
6
+ *
7
+ * No headless browser needed — we capture stdout/stderr from the CLI itself.
8
+ */
9
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { execaNode } from "execa";
13
+ /** Path to the CLI entry point (dev mode via tsx) */
14
+ const CLI_PATH = join(process.cwd(), "src", "cli.ts");
15
+ export function launch() {
16
+ const homeDir = mkdtempSync(join(tmpdir(), "loop-task-evidence-"));
17
+ // Ensure subdirs exist
18
+ mkdirSync(join(homeDir, "logs"), { recursive: true });
19
+ const env = {
20
+ ...process.env,
21
+ LOOP_CLI_HOME: homeDir,
22
+ // Force no interactive TTY
23
+ CI: "true",
24
+ TERM: "dumb",
25
+ };
26
+ return {
27
+ homeDir,
28
+ async run(args, timeoutMs = 30_000) {
29
+ try {
30
+ const result = await execaNode(CLI_PATH, args, {
31
+ nodeOptions: ["--import", "tsx"],
32
+ env,
33
+ timeout: timeoutMs,
34
+ reject: false,
35
+ });
36
+ return {
37
+ stdout: result.stdout,
38
+ stderr: result.stderr,
39
+ exitCode: result.exitCode ?? 1,
40
+ timedOut: result.timedOut ?? false,
41
+ };
42
+ }
43
+ catch (err) {
44
+ return {
45
+ stdout: "",
46
+ stderr: String(err),
47
+ exitCode: 1,
48
+ timedOut: false,
49
+ };
50
+ }
51
+ },
52
+ cleanup() {
53
+ try {
54
+ rmSync(homeDir, { recursive: true, force: true });
55
+ }
56
+ catch {
57
+ // best-effort cleanup
58
+ }
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * manifest.ts — Write evidence.json following the v1 contract.
3
+ *
4
+ * Evidence lives at openspec/changes/<id>/evidence/evidence.json
5
+ */
6
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ export function writeManifest(manifest) {
9
+ const evidenceDir = join("openspec", "changes", manifest.changeId, "evidence");
10
+ if (!existsSync(evidenceDir)) {
11
+ mkdirSync(evidenceDir, { recursive: true });
12
+ }
13
+ const manifestPath = join(evidenceDir, "evidence.json");
14
+ // Generate PR markdown
15
+ const statusEmoji = {
16
+ passed: "✅",
17
+ skipped: "⏭️",
18
+ failed: "❌",
19
+ blocked: "🚫",
20
+ };
21
+ manifest.prMarkdown = [
22
+ `## Evidence ${statusEmoji[manifest.status]} ${manifest.status}`,
23
+ "",
24
+ manifest.reason ? `> ${manifest.reason}` : "",
25
+ "",
26
+ ...manifest.assets.map((a) => `- **${a.caption}** (${a.format}, ${a.bytes} bytes)`),
27
+ ]
28
+ .filter(Boolean)
29
+ .join("\n");
30
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
31
+ return manifestPath;
32
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * openspec-resolver.ts — Locate an OpenSpec change by ID.
3
+ *
4
+ * Looks in openspec/changes/<id>/ (active) then
5
+ * openspec/changes/archive/*<id>/ (archived, prefers newest).
6
+ * Refuses to guess between multiple active changes.
7
+ */
8
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ export function resolveChange(changeId) {
11
+ const activeDir = join("openspec", "changes", changeId);
12
+ if (existsSync(activeDir) && statSync(activeDir).isDirectory()) {
13
+ return readChangeDir(changeId, activeDir);
14
+ }
15
+ // Check archive
16
+ const archiveDir = join("openspec", "changes", "archive");
17
+ if (!existsSync(archiveDir)) {
18
+ throw new Error(`Change "${changeId}" not found (no active or archived)`);
19
+ }
20
+ const archived = readdirSync(archiveDir)
21
+ .filter((d) => d.includes(changeId))
22
+ .map((d) => ({ name: d, mtime: statSync(join(archiveDir, d)).mtimeMs }))
23
+ .sort((a, b) => b.mtime - a.mtime);
24
+ if (archived.length === 0) {
25
+ throw new Error(`Change "${changeId}" not found`);
26
+ }
27
+ const newest = join(archiveDir, archived[0].name);
28
+ return readChangeDir(changeId, newest);
29
+ }
30
+ function readChangeDir(changeId, dir) {
31
+ const proposalPath = join(dir, "proposal.md");
32
+ const tasksPath = join(dir, "tasks.md");
33
+ const proposal = existsSync(proposalPath)
34
+ ? readFileSync(proposalPath, "utf-8")
35
+ : "";
36
+ const tasks = existsSync(tasksPath) ? readFileSync(tasksPath, "utf-8") : "";
37
+ // Extract affected files from proposal
38
+ const filePattern = /`([^`]+\.(?:ts|tsx|js|json|css|md))`/g;
39
+ const affectedFiles = [];
40
+ let match;
41
+ while ((match = filePattern.exec(proposal)) !== null) {
42
+ affectedFiles.push(match[1]);
43
+ }
44
+ return { changeId, changeDir: dir, proposal, tasks, affectedFiles };
45
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * publish.ts — Publish evidence to GitHub issue + PR as idempotent comments.
3
+ *
4
+ * After the branch is pushed, verify each asset exists on the remote,
5
+ * then upsert one marked, idempotent comment on both the issue and the PR.
6
+ * Publish failure blocks shipping.
7
+ *
8
+ * Usage:
9
+ * pnpm visual-evidence:publish --change <id> [--pr <n>] [--issue <n>]
10
+ */
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { execSync } from "node:child_process";
14
+ function parseArgs(args) {
15
+ const result = {};
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i] === "--change" && args[i + 1]) {
18
+ result.changeId = args[++i];
19
+ }
20
+ else if (args[i] === "--pr" && args[i + 1]) {
21
+ result.pr = Number(args[++i]);
22
+ }
23
+ else if (args[i] === "--issue" && args[i + 1]) {
24
+ result.issue = Number(args[++i]);
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ function getRepoSlug() {
30
+ const remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
31
+ // Handle both HTTPS and SSH URLs
32
+ const match = remoteUrl.match(/[:/]([^/]+\/[^/.]+)(?:\.git)?$/);
33
+ if (!match)
34
+ throw new Error(`Cannot parse repo slug from remote: ${remoteUrl}`);
35
+ return match[1];
36
+ }
37
+ function getCurrentSha() {
38
+ return execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
39
+ }
40
+ function verifyAssetOnRemote(repo, sha, assetPath) {
41
+ try {
42
+ execSync(`gh api repos/${repo}/contents/${assetPath}?ref=${sha} --jq .size`, { encoding: "utf-8", stdio: "pipe" });
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ function upsertComment(repo, kind, number, marker, body) {
50
+ const fullBody = `<!-- ${marker} -->\n\n${body}`;
51
+ const listCmd = `gh api repos/${repo}/${kind === "pr" ? "issues" : "issues"}/${number}/comments --jq '.[] | select(.body | startswith("<!-- ${marker} -->")) | .id'`;
52
+ let existingId;
53
+ try {
54
+ existingId = execSync(listCmd, { encoding: "utf-8", stdio: "pipe" }).trim().split("\n")[0];
55
+ }
56
+ catch {
57
+ // No comments or no match — will create new
58
+ }
59
+ if (existingId) {
60
+ // PATCH existing
61
+ execSync(`gh api repos/${repo}/issues/comments/${existingId} -f body="${Buffer.from(fullBody).toString("base64")}" --input -`, {
62
+ encoding: "utf-8",
63
+ input: JSON.stringify({ body: fullBody }),
64
+ });
65
+ console.log(` Updated ${kind} #${number} comment`);
66
+ }
67
+ else {
68
+ // POST new
69
+ execSync(`gh api repos/${repo}/${kind === "pr" ? "issues" : "issues"}/${number}/comments -f body="${Buffer.from(fullBody).toString("base64")}" --input -`, {
70
+ encoding: "utf-8",
71
+ input: JSON.stringify({ body: fullBody }),
72
+ });
73
+ console.log(` Created ${kind} #${number} comment`);
74
+ }
75
+ }
76
+ async function main() {
77
+ const args = process.argv.slice(2);
78
+ const parsed = parseArgs(args);
79
+ if (!parsed.changeId) {
80
+ console.error("Usage: visual-evidence:publish --change <id> [--pr <n>] [--issue <n>]");
81
+ return 3;
82
+ }
83
+ const { changeId } = parsed;
84
+ // Read manifest
85
+ const manifestPath = join("openspec", "changes", changeId, "evidence", "evidence.json");
86
+ if (!existsSync(manifestPath)) {
87
+ console.error(`No evidence manifest found at ${manifestPath}`);
88
+ return 1;
89
+ }
90
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
91
+ console.log(`📤 Publishing evidence for change: ${changeId} (${manifest.status})`);
92
+ // Verify assets on remote
93
+ const repo = getRepoSlug();
94
+ const sha = getCurrentSha();
95
+ for (const asset of manifest.assets) {
96
+ const relativePath = asset.path.replace(/\\/g, "/");
97
+ const onRemote = verifyAssetOnRemote(repo, sha, relativePath);
98
+ if (!onRemote) {
99
+ console.error(` ❌ Asset not on remote: ${relativePath}`);
100
+ return 1;
101
+ }
102
+ console.log(` ✅ Asset verified: ${relativePath}`);
103
+ }
104
+ // Publish comment
105
+ const marker = `ob-visual-evidence:${changeId}`;
106
+ const body = manifest.prMarkdown ?? `Evidence: ${manifest.status}`;
107
+ if (parsed.pr) {
108
+ upsertComment(repo, "pr", parsed.pr, marker, body);
109
+ }
110
+ if (parsed.issue) {
111
+ upsertComment(repo, "issue", parsed.issue, marker, body);
112
+ }
113
+ if (!parsed.pr && !parsed.issue) {
114
+ console.warn(" ⚠️ No --pr or --issue specified, comments not posted");
115
+ }
116
+ console.log("✅ Publish complete");
117
+ return 0;
118
+ }
119
+ main()
120
+ .then((code) => {
121
+ process.exit(code);
122
+ })
123
+ .catch((err) => {
124
+ console.error("Publish error:", err);
125
+ process.exit(1);
126
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * run.ts — Orchestrator: resolve change → decide required → run scenario → capture → manifest.
3
+ *
4
+ * On failure after launch: keep temp failure artifacts under .tmp/ (gitignored),
5
+ * promote nothing to evidence/. Return structured result.
6
+ */
7
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { isEvidenceRequired } from "./evidence-required.js";
10
+ import { resolveChange } from "./openspec-resolver.js";
11
+ import { writeManifest } from "./manifest.js";
12
+ import { launch } from "./launch.js";
13
+ import { captureOutput } from "./capture.js";
14
+ import { runScenario } from "./scenario-registry.js";
15
+ export async function runEvidence(changeId) {
16
+ // 1. Resolve the change
17
+ let proposal;
18
+ let affectedFiles;
19
+ try {
20
+ const ctx = resolveChange(changeId);
21
+ void ctx.changeDir;
22
+ proposal = ctx.proposal;
23
+ affectedFiles = ctx.affectedFiles;
24
+ }
25
+ catch (err) {
26
+ return {
27
+ status: "blocked",
28
+ changeId,
29
+ reason: `Cannot resolve change: ${err}`,
30
+ };
31
+ }
32
+ // 2. Decide if evidence is required
33
+ const decision = isEvidenceRequired({ changedFiles: affectedFiles, proposal });
34
+ if (!decision.required) {
35
+ const manifest = {
36
+ version: 1,
37
+ changeId,
38
+ required: false,
39
+ status: "skipped",
40
+ assets: [],
41
+ reason: decision.reason,
42
+ };
43
+ writeManifest(manifest);
44
+ return { status: "skipped", changeId, reason: decision.reason };
45
+ }
46
+ // 3. Launch CLI
47
+ const cli = launch();
48
+ try {
49
+ // 4. Run scenario
50
+ const result = await runScenario(changeId, cli);
51
+ if (result.status === "blocked") {
52
+ const manifest = {
53
+ version: 1,
54
+ changeId,
55
+ required: true,
56
+ status: "blocked",
57
+ assets: [],
58
+ reason: "No scenario registered for this change ID",
59
+ };
60
+ writeManifest(manifest);
61
+ return { status: "blocked", changeId, reason: "No scenario registered" };
62
+ }
63
+ // 5. Capture output
64
+ const assets = captureOutput(changeId, result.checkpoints);
65
+ if (result.status === "failed") {
66
+ // Keep failure artifacts in .tmp/ for debugging
67
+ writeFailureArtifact(changeId, result.checkpoints);
68
+ const manifest = {
69
+ version: 1,
70
+ changeId,
71
+ required: true,
72
+ status: "failed",
73
+ assets,
74
+ failedStep: result.failedStep,
75
+ };
76
+ writeManifest(manifest);
77
+ return {
78
+ status: "failed",
79
+ changeId,
80
+ failedStep: result.failedStep,
81
+ };
82
+ }
83
+ // 6. Success — write manifest
84
+ const manifest = {
85
+ version: 1,
86
+ changeId,
87
+ required: true,
88
+ status: "passed",
89
+ assets,
90
+ };
91
+ const manifestPath = writeManifest(manifest);
92
+ return { status: "passed", changeId, manifestPath };
93
+ }
94
+ catch (err) {
95
+ // Unexpected error after launch
96
+ writeFailureArtifact(changeId, [{ label: "error", stdout: "", stderr: String(err), exitCode: 1 }]);
97
+ const manifest = {
98
+ version: 1,
99
+ changeId,
100
+ required: true,
101
+ status: "failed",
102
+ assets: [],
103
+ failedStep: "unexpected error",
104
+ reason: String(err),
105
+ };
106
+ writeManifest(manifest);
107
+ return { status: "failed", changeId, failedStep: "unexpected error" };
108
+ }
109
+ finally {
110
+ cli.cleanup();
111
+ }
112
+ }
113
+ function writeFailureArtifact(changeId, checkpoints) {
114
+ const tmpDir = join(".tmp", "visual-evidence", changeId);
115
+ if (!existsSync(tmpDir)) {
116
+ mkdirSync(tmpDir, { recursive: true });
117
+ }
118
+ for (const cp of checkpoints) {
119
+ writeFileSync(join(tmpDir, `failure-${cp.label.replace(/\s+/g, "-").toLowerCase()}.txt`), `Exit: ${cp.exitCode}\n\nstdout:\n${cp.stdout}\n\nstderr:\n${cp.stderr}`, "utf-8");
120
+ }
121
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * scenario-registry.ts — Map change IDs to CLI scenarios with assertions.
3
+ *
4
+ * A scenario runs CLI commands, asserts on output/exit-code, and names
5
+ * capture checkpoints. Returns `blocked` for unknown change IDs.
6
+ */
7
+ const registry = new Map();
8
+ /** Register a scenario factory for a change ID */
9
+ export function register(changeId, factory) {
10
+ registry.set(changeId, factory);
11
+ }
12
+ /** Look up and run a scenario. Returns `blocked` for unknown IDs. */
13
+ export async function runScenario(changeId, cli) {
14
+ const factory = registry.get(changeId);
15
+ if (!factory) {
16
+ return { status: "blocked", checkpoints: [] };
17
+ }
18
+ const scenario = await factory(cli);
19
+ const checkpoints = [];
20
+ for (const step of scenario.steps) {
21
+ const result = await cli.run(step.args);
22
+ try {
23
+ step.assert(result);
24
+ }
25
+ catch {
26
+ // Still capture on failure for debugging
27
+ checkpoints.push({
28
+ label: step.label,
29
+ stdout: result.stdout,
30
+ stderr: result.stderr,
31
+ exitCode: result.exitCode,
32
+ });
33
+ return {
34
+ status: "failed",
35
+ checkpoints,
36
+ failedStep: step.label,
37
+ };
38
+ }
39
+ checkpoints.push({
40
+ label: step.label,
41
+ stdout: result.stdout,
42
+ stderr: result.stderr,
43
+ exitCode: result.exitCode,
44
+ });
45
+ }
46
+ return { status: "passed", checkpoints };
47
+ }
48
+ // ─── Sample scenario: verify the CLI starts and shows help ───
49
+ register("sample-cli-help", async (_cli) => ({
50
+ changeId: "sample-cli-help",
51
+ steps: [
52
+ {
53
+ args: ["--help"],
54
+ label: "Help output",
55
+ assert: (r) => {
56
+ if (r.exitCode !== 0) {
57
+ throw new Error(`Expected exit code 0, got ${r.exitCode}`);
58
+ }
59
+ if (!r.stdout.includes("loop-task")) {
60
+ throw new Error("Help output missing 'loop-task'");
61
+ }
62
+ if (!r.stdout.includes("Commands:")) {
63
+ throw new Error("Help output missing 'Commands:'");
64
+ }
65
+ },
66
+ },
67
+ ],
68
+ }));
69
+ // ─── Sample scenario: verify version command ───
70
+ register("sample-version", async (_cli) => ({
71
+ changeId: "sample-version",
72
+ steps: [
73
+ {
74
+ args: ["--version"],
75
+ label: "Version output",
76
+ assert: (r) => {
77
+ if (r.exitCode !== 0) {
78
+ throw new Error(`Expected exit code 0, got ${r.exitCode}`);
79
+ }
80
+ if (!r.stdout.match(/\d+\.\d+\.\d+/)) {
81
+ throw new Error(`Version output doesn't match semver: ${r.stdout}`);
82
+ }
83
+ },
84
+ },
85
+ ],
86
+ }));
87
+ // ─── GH-58: Per-task max runs limit ───
88
+ register("gh-58-per-task-max-runs", async (_cli) => ({
89
+ changeId: "gh-58-per-task-max-runs",
90
+ steps: [
91
+ {
92
+ args: ["--help"],
93
+ label: "CLI starts and shows help",
94
+ assert: (r) => {
95
+ if (r.exitCode !== 0) {
96
+ throw new Error(`Expected exit code 0, got ${r.exitCode}`);
97
+ }
98
+ if (!r.stdout.includes("loop-task")) {
99
+ throw new Error("Help output missing 'loop-task'");
100
+ }
101
+ },
102
+ },
103
+ ],
104
+ }));
105
+ // ─── GH-63: Daemon-managed unified OpenTelemetry ───
106
+ register("opentelemetry-unified", async (_cli) => ({
107
+ changeId: "opentelemetry-unified",
108
+ steps: [
109
+ {
110
+ args: ["telemetry", "--help"],
111
+ label: "Telemetry CLI subcommand is registered",
112
+ assert: (r) => {
113
+ if (r.exitCode !== 0) {
114
+ throw new Error(`Expected exit code 0, got ${r.exitCode}`);
115
+ }
116
+ if (!r.stdout.includes("telemetry")) {
117
+ throw new Error("Help output missing 'telemetry'");
118
+ }
119
+ },
120
+ },
121
+ {
122
+ args: ["telemetry", "status"],
123
+ label: "Telemetry status command runs",
124
+ assert: (r) => {
125
+ // May fail if daemon not running, but should at least attempt
126
+ if (!r.stdout.includes("OpenTelemetry") && !r.stderr.includes("Failed")) {
127
+ throw new Error("Status output missing 'OpenTelemetry'");
128
+ }
129
+ },
130
+ },
131
+ ],
132
+ }));
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { darkTheme as theme } from "../../shared/ui/theme.js";
4
+ import { t } from "../../shared/i18n/index.js";
5
+ export function renderInputLine(value, cursorOffset) {
6
+ if (value.length === 0) {
7
+ return "\x1b[7m \x1b[27m";
8
+ }
9
+ let result = "";
10
+ for (let i = 0; i < value.length; i++) {
11
+ if (i === cursorOffset) {
12
+ result += "\x1b[7m" + value[i] + "\x1b[27m";
13
+ }
14
+ else {
15
+ result += value[i];
16
+ }
17
+ }
18
+ if (cursorOffset >= value.length) {
19
+ result += "\x1b[7m \x1b[27m";
20
+ }
21
+ return result;
22
+ }
23
+ function renderHighlightedLabel(label, matchRanges, isFocused) {
24
+ if (matchRanges.length === 0) {
25
+ return isFocused
26
+ ? `\x1b[38;2;251;191;36m${label}\x1b[39m`
27
+ : label;
28
+ }
29
+ let result = "";
30
+ let pos = 0;
31
+ for (const range of matchRanges) {
32
+ if (pos < range.start) {
33
+ const segment = label.slice(pos, range.start);
34
+ result += isFocused
35
+ ? `\x1b[38;2;251;191;36m${segment}\x1b[39m`
36
+ : segment;
37
+ }
38
+ const matched = label.slice(range.start, range.end);
39
+ result += `\x1b[1m\x1b[38;2;251;191;36m${matched}\x1b[39m\x1b[22m`;
40
+ pos = range.end;
41
+ }
42
+ if (pos < label.length) {
43
+ const segment = label.slice(pos);
44
+ result += isFocused
45
+ ? `\x1b[38;2;251;191;36m${segment}\x1b[39m`
46
+ : segment;
47
+ }
48
+ return result;
49
+ }
50
+ export function CommandDropdown({ state, rankedFiltered, }) {
51
+ if (!state.isOpen || state.isLoading || state.error)
52
+ return null;
53
+ const filtered = rankedFiltered;
54
+ const visibleOptions = filtered.slice(state.visibleFromIndex, state.visibleToIndex);
55
+ if (visibleOptions.length === 0) {
56
+ if (state.inputValue.length > 0) {
57
+ return (_jsx(Box, { paddingLeft: 3, position: "absolute", bottom: 3, borderStyle: "single", borderColor: theme.border.dim, children: _jsx(Text, { color: theme.text.muted, children: t("cmdInput.noMatches") }) }));
58
+ }
59
+ return null;
60
+ }
61
+ const aboveCount = state.visibleFromIndex;
62
+ const belowCount = filtered.length - state.visibleToIndex;
63
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 3, position: "absolute", bottom: 3, children: [aboveCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2191 ${aboveCount} more` })), visibleOptions.map((match, i) => {
64
+ const actualIndex = state.visibleFromIndex + i;
65
+ const isFocused = actualIndex === state.focusedIndex;
66
+ const label = renderHighlightedLabel(match.option.label, match.matchRanges, isFocused);
67
+ return (_jsxs(Box, { backgroundColor: isFocused ? theme.bg.active : undefined, children: [_jsx(Text, { color: isFocused ? theme.text.inverse : theme.text.muted, children: isFocused ? "\u276f " : " " }), _jsx(Text, { color: isFocused ? theme.text.inverse : undefined, children: label })] }, match.option.value));
68
+ }), belowCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2193 ${belowCount} more` }))] }));
69
+ }