@opsee/cli 0.11.9

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 (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,77 @@
1
+ /** Registers the Opsee MCP server in the two per-repo config files coding agents read, touching
2
+ * only the `opsee` entry so other servers listed there survive untouched. */
3
+ import type { WritePlan } from "./managed.js";
4
+ import { appendBlock, detectJsonIndent, parseJsonObject } from "./text.js";
5
+
6
+ export const MCP_SERVER_KEY = "opsee";
7
+
8
+ /** Claude Code: `.mcp.json` at the repo root, `{ "mcpServers": { name: {...} } }`. */
9
+ export function mergeClaudeMcpJson(existing: string | null, url: string): WritePlan {
10
+ const entry = { type: "http", url };
11
+ let root: Record<string, unknown> = {};
12
+ if (existing !== null && existing.trim() !== "") {
13
+ const parsed = parseJsonObject(existing);
14
+ if ("reason" in parsed) return { action: "kept-invalid", reason: parsed.reason };
15
+ root = parsed.root;
16
+ }
17
+ const serversRaw = root.mcpServers;
18
+ if (serversRaw !== undefined && (!serversRaw || typeof serversRaw !== "object" || Array.isArray(serversRaw))) {
19
+ return { action: "kept-invalid", reason: "mcpServers is not an object" };
20
+ }
21
+ const servers = { ...((serversRaw as Record<string, unknown> | undefined) ?? {}) };
22
+ if (JSON.stringify(servers[MCP_SERVER_KEY]) === JSON.stringify(entry)) return { action: "unchanged" };
23
+ servers[MCP_SERVER_KEY] = entry;
24
+ const content = JSON.stringify({ ...root, mcpServers: servers }, null, existing === null ? " " : detectJsonIndent(existing)) + "\n";
25
+ return { action: existing === null ? "create" : "update", content };
26
+ }
27
+
28
+ /** A TOML table header alone on its line: `[a.b]`, `[ a."b" ]`, `[[arr]]`, with an optional
29
+ * comment. A line that merely starts with `[`, such as a nested array `["a", "b"],`, is not one. */
30
+ const HEADER_RE = /^\s*\[\[?\s*([^\]]*?)\s*\]\]?\s*(?:#.*)?$/;
31
+
32
+ /** Normalises a header's dotted key so `mcp_servers."opsee"` and `mcp_servers . opsee` match. */
33
+ function headerKey(line: string): string | null {
34
+ const match = HEADER_RE.exec(line);
35
+ if (!match) return null;
36
+ return match[1]
37
+ .split(".")
38
+ .map((part) => part.trim().replace(/^["']|["']$/g, ""))
39
+ .join(".");
40
+ }
41
+
42
+ const OPSEE_TABLE = `mcp_servers.${MCP_SERVER_KEY}`;
43
+
44
+ function codexBlock(url: string): string {
45
+ return `[${OPSEE_TABLE}]\nurl = ${JSON.stringify(url)}\n`;
46
+ }
47
+
48
+ /** Codex: `.codex/config.toml`, one `[mcp_servers.<name>]` table per server. Handled textually
49
+ * rather than through a TOML round-trip so every other byte of the user's file is preserved: the
50
+ * table is the span from its header to the next header or end of file. An `opsee = { ... }`
51
+ * inline table under `[mcp_servers]` is the same server in a shape this merge does not rewrite,
52
+ * so it is reported and left alone. */
53
+ export function mergeCodexConfigToml(existing: string | null, url: string): WritePlan {
54
+ const block = codexBlock(url);
55
+ if (existing === null) return { action: "create", content: block };
56
+ const lines = existing.split("\n");
57
+ let table: string | null = null;
58
+ for (const line of lines) {
59
+ const key = headerKey(line);
60
+ if (key !== null) table = key;
61
+ else if (table === "mcp_servers" && new RegExp(`^\\s*"?${MCP_SERVER_KEY}"?\\s*=`).test(line)) {
62
+ return { action: "kept-invalid", reason: `${MCP_SERVER_KEY} is an inline table under [mcp_servers]; move it to a [${OPSEE_TABLE}] table or delete it` };
63
+ }
64
+ }
65
+ const start = lines.findIndex((l) => headerKey(l) === OPSEE_TABLE);
66
+ if (start === -1) return { action: "update", content: appendBlock(existing, block) };
67
+ let end = start + 1;
68
+ while (end < lines.length && headerKey(lines[end]) === null) end++;
69
+ // Trailing blank lines belong to the separator, not the table.
70
+ while (end > start + 1 && lines[end - 1].trim() === "") end--;
71
+ const current = lines.slice(start, end).join("\n") + "\n";
72
+ if (current === block) return { action: "unchanged" };
73
+ const before = lines.slice(0, start).join("\n");
74
+ const after = lines.slice(end).join("\n");
75
+ const content = `${before}${before ? "\n" : ""}${block}${after ? `${after.startsWith("\n") ? "" : "\n"}${after}` : ""}`;
76
+ return { action: "update", content };
77
+ }
@@ -0,0 +1,16 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /** The cli package root: the skills and the tracker-doc template ship inside it, next to src/. */
5
+ export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
6
+ export const SKILLS_DIR = join(PACKAGE_ROOT, "skills");
7
+ export const TRACKER_TEMPLATE_PATH = join(PACKAGE_ROOT, "templates", "issue-tracker.md");
8
+
9
+ /** Where `opsee init` writes, relative to the repo root. */
10
+ export const TRACKER_DOC_PATH = "docs/agents/issue-tracker.md";
11
+ export const SKILL_TARGETS = [".claude/skills", ".agents/skills"] as const;
12
+ export const CLAUDE_MCP_PATH = ".mcp.json";
13
+ export const CODEX_CONFIG_PATH = ".codex/config.toml";
14
+
15
+ /** The remote Opsee MCP server every agent config points at (OAuth on first use). */
16
+ export const DEFAULT_MCP_URL = "https://mcp.api.opsee.ai/mcp";
@@ -0,0 +1,45 @@
1
+ /** The block `opsee init` adds to the repo's agent instructions so a session that reads
2
+ * AGENTS.md or CLAUDE.md knows the planning skills and the tracker doc exist. Bounded by begin
3
+ * and end markers so a re-run replaces it in place and never appends a second copy. */
4
+ import type { WritePlan } from "./managed.js";
5
+ import { appendBlock } from "./text.js";
6
+
7
+ export const POINTER_BEGIN = "<!-- opsee:skills:begin -->";
8
+ export const POINTER_END = "<!-- opsee:skills:end -->";
9
+
10
+ export const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"] as const;
11
+
12
+ export function pointerBlock(trackerDocPath: string): string {
13
+ return [
14
+ POINTER_BEGIN,
15
+ "## Agent skills",
16
+ "",
17
+ "`opsee init` installs Opsee's planning skills (`/wayfinder`, `/to-spec`, `/to-issues`) into",
18
+ "`.claude/skills/` and `.agents/skills/`, and writes the tracker doc they read to",
19
+ `\`${trackerDocPath}\`. Opsee owns those files; edit them upstream and re-run \`opsee init\`,`,
20
+ "which updates what it wrote and leaves any file you changed alone.",
21
+ POINTER_END,
22
+ "",
23
+ ].join("\n");
24
+ }
25
+
26
+ /** Inserts or replaces the block in `existing` (null when the file does not exist yet). A begin
27
+ * marker with no end marker means someone cut the block in half; appending another would grow
28
+ * the file on every run, so the file is kept and the reason reported. */
29
+ export function upsertPointerBlock(existing: string | null, block: string): WritePlan {
30
+ if (existing === null) return { action: "create", content: block };
31
+ const begin = existing.indexOf(POINTER_BEGIN);
32
+ const end = begin === -1 ? -1 : existing.indexOf(POINTER_END, begin);
33
+ if (begin === -1) return { action: "update", content: appendBlock(existing, block) };
34
+ if (end === -1) return { action: "kept-invalid", reason: `has ${POINTER_BEGIN} without ${POINTER_END}; restore the end marker or remove the block` };
35
+ const tail = existing.slice(end + POINTER_END.length).replace(/^\n/, "");
36
+ const content = `${existing.slice(0, begin)}${block}${tail}`;
37
+ return content === existing ? { action: "unchanged" } : { action: "update", content };
38
+ }
39
+
40
+ /** Which instruction file gets the block: an existing AGENTS.md wins over an existing CLAUDE.md;
41
+ * with neither, AGENTS.md is created (Codex and Cursor read it; Claude Code reads CLAUDE.md, which
42
+ * may @-include it). */
43
+ export function pickInstructionFile(exists: (name: string) => boolean): string {
44
+ return INSTRUCTION_FILES.find(exists) ?? INSTRUCTION_FILES[0];
45
+ }
@@ -0,0 +1,22 @@
1
+ export interface ProjectChoice {
2
+ id: number;
3
+ key: string;
4
+ name: string;
5
+ }
6
+
7
+ /** Which project the repo is set up for: `--project <key>` when given, the only one when there
8
+ * is one, else whatever `choose` answers (null when it had no terminal to ask on). The string
9
+ * result is the message to show when nothing was picked. */
10
+ export async function pickProject(
11
+ projects: ProjectChoice[],
12
+ key: string | undefined,
13
+ choose: (projects: ProjectChoice[]) => Promise<string | null>,
14
+ ): Promise<ProjectChoice | string> {
15
+ if (projects.length === 0) return "Your Opsee account has no active project. Create one in the Opsee app first.";
16
+ const list = projects.map((p) => `${p.key} (${p.name})`).join(", ");
17
+ const byKey = (wanted: string) => projects.find((p) => p.key.toLowerCase() === wanted.toLowerCase());
18
+ if (key !== undefined) return byKey(key) ?? `No project with key ${key}. Projects: ${list}`;
19
+ if (projects.length === 1) return projects[0];
20
+ const chosen = await choose(projects);
21
+ return (chosen === null ? undefined : byKey(chosen)) ?? `Several projects; pass --project <key>. Projects: ${list}`;
22
+ }
@@ -0,0 +1,45 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import type { ProjectChoice } from "./project.js";
3
+ import type { RunRecipe } from "../foreman/run-recipe.js";
4
+ import type { RecipeDefaults } from "./run-recipe-config.js";
5
+
6
+ /** Asks for the Run Recipe with the inferred values as defaults (Enter keeps them). Without a
7
+ * terminal the defaults stand as they are, and null comes back when no start command could be
8
+ * inferred, since a recipe without one cannot start anything. */
9
+ export async function askRecipe(defaults: RecipeDefaults): Promise<RunRecipe | null> {
10
+ if (!process.stdin.isTTY) {
11
+ return defaults.start ? { ...defaults, start: defaults.start } : null;
12
+ }
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ const ask = async (label: string, fallback: string | undefined): Promise<string> => {
15
+ const answer = (await rl.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
16
+ return answer || fallback || "";
17
+ };
18
+ try {
19
+ rl.write("Run Recipe (how the Foreman starts the app for verification; {port} is the Worker's port):\n");
20
+ const start = await ask("Start command", defaults.start);
21
+ if (!start) return null;
22
+ const readinessUrl = await ask("Readiness URL", defaults.readinessUrl);
23
+ const portEnv = await ask("Port variable", defaults.portEnv);
24
+ const typecheck = await ask("Typecheck command (blank for none)", defaults.gates.typecheck);
25
+ return { start, readinessUrl, portEnv, gates: { ...defaults.gates, typecheck: typecheck || undefined } };
26
+ } finally {
27
+ rl.close();
28
+ }
29
+ }
30
+
31
+ /** Asks which project to set the repo up for; null when stdin is not a terminal, in which case
32
+ * the caller has to be told to pass `--project`. */
33
+ export async function chooseProject(projects: ProjectChoice[]): Promise<string | null> {
34
+ if (!process.stdin.isTTY) return null;
35
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
36
+ try {
37
+ projects.forEach((p, i) => rl.write(` ${i + 1}. ${p.key} ${p.name}\n`));
38
+ const answer = (await rl.question(`Project [1-${projects.length}]: `)).trim();
39
+ const index = Number(answer);
40
+ if (Number.isInteger(index) && index >= 1 && index <= projects.length) return projects[index - 1].key;
41
+ return answer || null;
42
+ } finally {
43
+ rl.close();
44
+ }
45
+ }
@@ -0,0 +1,133 @@
1
+ /** The Run Recipe step of `opsee init`: writes the `foreman` block into the analyzer's
2
+ * `.opsee/config` (yaml and its json copy) when absent and leaves an existing one exactly as it is,
3
+ * so a re-run never clobbers values the user or the analyzer wrote (spec story 5). */
4
+ import { DEFAULT_PORT_ENV, DEFAULT_READINESS_URL, RECIPE_KEY, recipeFromConfig, type RunRecipe, type RunRecipeGates } from "../foreman/run-recipe.js";
5
+ import { detectYamlIndent, parseOpseeJson, readYamlBlock, yamlScalar, type OpseeConfigFiles } from "../opsee-config.js";
6
+ import type { WritePlan } from "./managed.js";
7
+ import { detectJsonIndent, parseJsonObject } from "./text.js";
8
+
9
+ /** A recipe with `start` still possibly unknown: what inference produces and the prompt offers
10
+ * as defaults. The prompt or the flags turn it into a RunRecipe. */
11
+ export type RecipeDefaults = Omit<RunRecipe, "start"> & { start?: string };
12
+
13
+ /** `--start`, `--readiness-url`, `--port-env`, `--typecheck` on `opsee init`: the recipe values
14
+ * for a non-interactive run; anything absent is inferred from `.opsee/config` or asked for. */
15
+ export interface RecipeFlags {
16
+ start?: string;
17
+ readinessUrl?: string;
18
+ portEnv?: string;
19
+ typecheck?: string;
20
+ }
21
+
22
+ /** The analyzer's `commands` block (`dev`, `start`, `test`, `lint`, `install`...), string values
23
+ * only, from whichever of the two config files exists; the Foreman's install step reads it too. */
24
+ export function commandsOf(files: OpseeConfigFiles): Record<string, string> {
25
+ const json = files.json === null ? null : parseOpseeJson(files.json);
26
+ const block = json ? json.commands : files.yaml === null ? undefined : readYamlBlock(files.yaml, "commands");
27
+ if (!block || typeof block !== "object") return {};
28
+ return Object.fromEntries(Object.entries(block as Record<string, unknown>).filter(([, v]) => typeof v === "string")) as Record<string, string>;
29
+ }
30
+
31
+ /** Defaults for the recipe: flags first, then the analyzer's `commands` (`dev`, or `start` when
32
+ * there is no dev server, for the start command; `test` and `lint` for the Gates). The typecheck
33
+ * Gate has no analyzer equivalent and is only ever a flag or a prompt answer. */
34
+ export function inferRecipe(files: OpseeConfigFiles, flags: RecipeFlags): RecipeDefaults {
35
+ const commands = commandsOf(files);
36
+ return {
37
+ start: flags.start ?? commands.dev ?? commands.start,
38
+ readinessUrl: flags.readinessUrl ?? DEFAULT_READINESS_URL,
39
+ portEnv: flags.portEnv ?? DEFAULT_PORT_ENV,
40
+ gates: { test: commands.test, lint: commands.lint, typecheck: flags.typecheck },
41
+ };
42
+ }
43
+
44
+ /** Why `init` skipped the recipe when nothing said how to start the app. */
45
+ export const NO_START_COMMAND =
46
+ "no start command: pass --start <cmd>, answer the prompt on a terminal, or set commands.dev in .opsee/config";
47
+
48
+ /** The recipe a config file already carries, so a block present in one of the two files is copied
49
+ * into the other rather than asked for again; null when neither has a block that can serve as a
50
+ * recipe. The YAML is the analyzer's primary file and wins when both have one. */
51
+ export function existingRecipe(files: OpseeConfigFiles): RunRecipe | null {
52
+ const candidates: unknown[] = [];
53
+ if (files.yaml !== null) candidates.push(readYamlBlock(files.yaml, RECIPE_KEY));
54
+ if (files.json !== null) candidates.push(parseOpseeJson(files.json)?.[RECIPE_KEY]);
55
+ for (const block of candidates) {
56
+ if (block === null || block === undefined) continue;
57
+ try {
58
+ return recipeFromConfig(block, RECIPE_KEY);
59
+ } catch {
60
+ // An incomplete block: mergeRecipe* reports it for its own file.
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function gateEntries(gates: RunRecipeGates): Array<[string, string]> {
67
+ return (["test", "lint", "typecheck"] as const).flatMap((name) => (gates[name] ? [[name, gates[name]!] as [string, string]] : []));
68
+ }
69
+
70
+ function yamlRecipeBlock(recipe: RunRecipe, indent: string): string {
71
+ const lines = [
72
+ `${RECIPE_KEY}:`,
73
+ `${indent}start: ${yamlScalar(recipe.start)}`,
74
+ `${indent}readiness_url: ${yamlScalar(recipe.readinessUrl)}`,
75
+ `${indent}port_env: ${yamlScalar(recipe.portEnv)}`,
76
+ ];
77
+ const gates = gateEntries(recipe.gates);
78
+ if (gates.length > 0) {
79
+ lines.push(`${indent}gates:`);
80
+ for (const [name, command] of gates) lines.push(`${indent}${indent}${name}: ${yamlScalar(command)}`);
81
+ }
82
+ return lines.join("\n") + "\n";
83
+ }
84
+
85
+ const YAML_HEADER = "# Opsee Project Configuration\n# Run Recipe written by opsee init; the Opsee analyzer fills in the rest.\n\n";
86
+
87
+ /** Textual splice, like the Codex TOML merge: the analyzer's header comments and key order have
88
+ * no representation in a parsed document, so the block is appended rather than the file
89
+ * re-serialised. An existing `foreman` block is the user's or the analyzer's and is not rewritten;
90
+ * one that cannot serve as a recipe is reported instead. */
91
+ export function mergeRecipeYaml(existing: string | null, recipe: RunRecipe): WritePlan {
92
+ const current = existing === null ? null : readYamlBlock(existing, RECIPE_KEY);
93
+ if (current !== null) return existingBlockPlan(current);
94
+ if (existing === null) {
95
+ return { action: "create", content: `${YAML_HEADER}version: "1.0"\n${yamlRecipeBlock(recipe, " ")}` };
96
+ }
97
+ // No blank line before the block: yaml.v3, which wrote the rest of the file, puts none between
98
+ // top-level keys either.
99
+ const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
100
+ return { action: "update", content: `${existing}${sep}${yamlRecipeBlock(recipe, detectYamlIndent(existing))}` };
101
+ }
102
+
103
+ function existingBlockPlan(block: unknown): WritePlan {
104
+ try {
105
+ recipeFromConfig(block, RECIPE_KEY);
106
+ return { action: "unchanged" };
107
+ } catch (error) {
108
+ return { action: "kept-invalid", reason: `${error instanceof Error ? error.message : String(error)} Fix or delete the foreman block to let opsee init write its own.` };
109
+ }
110
+ }
111
+
112
+ function jsonRecipe(recipe: RunRecipe): Record<string, unknown> {
113
+ const gates = Object.fromEntries(gateEntries(recipe.gates));
114
+ return {
115
+ start: recipe.start,
116
+ readiness_url: recipe.readinessUrl,
117
+ port_env: recipe.portEnv,
118
+ ...(Object.keys(gates).length > 0 ? { gates } : {}),
119
+ };
120
+ }
121
+
122
+ /** The JSON copy of the same document: re-serialised in its own indentation with `foreman` added
123
+ * after the analyzer's members. */
124
+ export function mergeRecipeJson(existing: string | null, recipe: RunRecipe): WritePlan {
125
+ if (existing === null) {
126
+ return { action: "create", content: JSON.stringify({ version: "1.0", [RECIPE_KEY]: jsonRecipe(recipe) }, null, " ") + "\n" };
127
+ }
128
+ const parsed = parseJsonObject(existing);
129
+ if ("reason" in parsed) return { action: "kept-invalid", reason: parsed.reason };
130
+ if (RECIPE_KEY in parsed.root) return existingBlockPlan(parsed.root[RECIPE_KEY]);
131
+ const content = JSON.stringify({ ...parsed.root, [RECIPE_KEY]: jsonRecipe(recipe) }, null, detectJsonIndent(existing));
132
+ return { action: "update", content: existing.endsWith("\n") ? content + "\n" : content };
133
+ }
@@ -0,0 +1,38 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { MarkerStyle } from "./managed.js";
4
+
5
+ export interface SkillFile {
6
+ /** Path relative to the skills dir, e.g. `to-issues/SKILL.md`. */
7
+ rel: string;
8
+ content: string;
9
+ style: MarkerStyle;
10
+ }
11
+
12
+ /** Every file of every skill folder under `dir`, file-for-file; `opsee init` copies exactly this
13
+ * set into each target. Files at the top level (the folder's own README) are not skills. A file
14
+ * whose type has no comment syntax we stamp is refused rather than copied unmarked, because an
15
+ * unmarked copy could never be told from a user's file on re-run. */
16
+ export function listSkillFiles(dir: string): SkillFile[] {
17
+ const out: SkillFile[] = [];
18
+ const walk = (path: string, depth: number) => {
19
+ const entries = readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
20
+ for (const entry of entries) {
21
+ const full = join(path, entry.name);
22
+ if (entry.isDirectory()) walk(full, depth + 1);
23
+ else if (depth > 0) out.push({ rel: relative(dir, full), content: readFileSync(full, "utf8"), style: styleFor(entry.name) });
24
+ }
25
+ };
26
+ walk(dir, 0);
27
+ return out;
28
+ }
29
+
30
+ function styleFor(name: string): MarkerStyle {
31
+ if (/\.md$/i.test(name)) return "markdown";
32
+ if (/\.ya?ml$/i.test(name)) return "yaml";
33
+ throw new Error(`Skill file ${name} has a type opsee init cannot mark as managed`);
34
+ }
35
+
36
+ export function skillNames(files: SkillFile[]): string[] {
37
+ return [...new Set(files.map((f) => f.rel.split("/")[0]))];
38
+ }
@@ -0,0 +1,22 @@
1
+ /** The indentation a JSON file already uses, so a merge does not reformat it. */
2
+ export function detectJsonIndent(json: string): string {
3
+ const match = /^([ \t]+)"/m.exec(json);
4
+ return match ? match[1] : " ";
5
+ }
6
+
7
+ /** Parses a JSON document that must be an object; `reason` says why it is not one. */
8
+ export function parseJsonObject(text: string): { root: Record<string, unknown> } | { reason: string } {
9
+ try {
10
+ const parsed: unknown = JSON.parse(text);
11
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { reason: "top level is not an object" };
12
+ return { root: parsed as Record<string, unknown> };
13
+ } catch (error) {
14
+ return { reason: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ }
17
+
18
+ /** Appends `block` to `existing` with exactly one blank line between them. */
19
+ export function appendBlock(existing: string, block: string): string {
20
+ const sep = existing === "" || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
21
+ return `${existing}${sep}${block}`;
22
+ }
@@ -0,0 +1,106 @@
1
+ /** Renders the per-repo tracker doc from the template shipped in this package, filling in what
2
+ * differs per Opsee project: its key and id, the label ids, the board and its columns, the task
3
+ * types and priorities. The doc's structure (its section names) is fixed by the template; the
4
+ * planning skills' structural test asserts it. */
5
+
6
+ export interface Named {
7
+ id: number;
8
+ name: string;
9
+ }
10
+
11
+ export interface TrackerDocData {
12
+ project: { id: number; name: string; key: string };
13
+ labels: Named[];
14
+ board: Named;
15
+ /** In board order. */
16
+ columns: Named[];
17
+ types: Named[];
18
+ priorities: Named[];
19
+ }
20
+
21
+ export const READY_LABEL = "ready-for-agent";
22
+
23
+ /** What the doc says where the ready label's id should go when the project has none; the id is
24
+ * filled in by the next `opsee init` once the label exists. Rendering never creates it: setting
25
+ * up a repo must not write to the tracker. */
26
+ export const READY_LABEL_MISSING = `<no ${READY_LABEL} label yet: create it in Opsee, then re-run opsee init>`;
27
+
28
+ const norm = (name: string) => name.trim().toLowerCase().replace(/[\s_-]+/g, " ");
29
+
30
+ /** Pipes would start a new markdown table cell; names come from the user's project. */
31
+ const cell = (text: string) => text.replace(/\|/g, "\\|");
32
+
33
+ const LABEL_MEANINGS: Array<[RegExp, string]> = [
34
+ [/^ready for agent$/, "Triage: spec is complete, ready for an unattended Worker"],
35
+ [/^from pr$/, "Task created from a merge request"],
36
+ [/^wayfinder:/, "Wayfinder ticket type (see Wayfinding operations)"],
37
+ ];
38
+
39
+ type Role = "todo" | "inProgress" | "done";
40
+
41
+ /** The one place a column name is interpreted: what the skills call it, whether it counts as
42
+ * closed, and which role (if any) it fills in the doc's tool calls. */
43
+ const COLUMN_KINDS: Array<{ re: RegExp; meaning: string; role?: Role; closed?: boolean }> = [
44
+ { re: /^backlog$/, meaning: "open, not yet scheduled" },
45
+ { re: /^to ?do$/, meaning: "open; where new Tasks land", role: "todo" },
46
+ { re: /^(in progress|doing)$/, meaning: "open, claimed and being worked", role: "inProgress" },
47
+ { re: /^in review$/, meaning: "open, Hand-off (draft MR) exists" },
48
+ { re: /^(done|closed|completed?)$/, meaning: "**closed**", role: "done", closed: true },
49
+ { re: /^archived?$/, meaning: "closed, hidden", closed: true },
50
+ ];
51
+
52
+ const kindOf = (column: Named) => COLUMN_KINDS.find((k) => k.re.test(norm(column.name)));
53
+
54
+ /** The columns the skills address by role, resolved by name with a positional fallback so a
55
+ * board with unusual names still yields a doc whose ids point somewhere sensible. */
56
+ export function columnRoles(columns: Named[]): Record<Role, Named> {
57
+ if (columns.length === 0) throw new Error("The board has no columns");
58
+ const byRole = (role: Role) => columns.find((c) => kindOf(c)?.role === role);
59
+ const open = columns.filter((c) => !kindOf(c)?.closed);
60
+ const todo = byRole("todo") ?? open[0] ?? columns[0];
61
+ const inProgress = byRole("inProgress") ?? todo;
62
+ const done = byRole("done") ?? open[open.length - 1] ?? columns[columns.length - 1];
63
+ return { todo, inProgress, done };
64
+ }
65
+
66
+ export function findReadyLabel(labels: Named[]): Named | undefined {
67
+ return labels.find((l) => norm(l.name) === norm(READY_LABEL));
68
+ }
69
+
70
+ const idList = (items: Named[]) => items.map((i) => `${i.name} \`${i.id}\``).join(", ");
71
+
72
+ export function renderTrackerDoc(template: string, data: TrackerDocData): string {
73
+ const ready = findReadyLabel(data.labels);
74
+ const bug = data.types.find((t) => /bug/.test(norm(t.name))) ?? data.types[0];
75
+ if (!bug) throw new Error("The project has no task types");
76
+ const roles = columnRoles(data.columns);
77
+ const labelsOrdered = ready ? [ready, ...data.labels.filter((l) => l !== ready)] : data.labels;
78
+ const labelRows = labelsOrdered
79
+ .map((l) => ` | \`${cell(l.name)}\` | ${l.id} | ${LABEL_MEANINGS.find(([re]) => re.test(norm(l.name)))?.[1] ?? "Project label, optional"} |`)
80
+ .join("\n");
81
+ const columnRows = data.columns.map((c) => ` | ${cell(c.name)} | ${c.id} | ${kindOf(c)?.meaning ?? "open"} |`).join("\n");
82
+ const values: Record<string, string> = {
83
+ PROJECT_NAME: data.project.name,
84
+ PROJECT_KEY: data.project.key,
85
+ PROJECT_ID: String(data.project.id),
86
+ LABELS_ROWS: labelRows,
87
+ READY_LABEL_ID: ready ? String(ready.id) : READY_LABEL_MISSING,
88
+ BOARD_NAME: data.board.name,
89
+ BOARD_ID: String(data.board.id),
90
+ COLUMNS_ROWS: columnRows,
91
+ TODO_COLUMN_ID: String(roles.todo.id),
92
+ IN_PROGRESS_COLUMN_ID: String(roles.inProgress.id),
93
+ DONE_COLUMN_ID: String(roles.done.id),
94
+ BUG_TYPE_ID: String(bug.id),
95
+ TYPES_LINE: idList(data.types),
96
+ PRIORITIES_LINE: idList(data.priorities),
97
+ };
98
+ const rendered = template.replace(/\{\{([A-Z_]+)\}\}/g, (whole, key: string) => {
99
+ const value = values[key];
100
+ if (value === undefined) throw new Error(`Template placeholder ${whole} has no value`);
101
+ return value;
102
+ });
103
+ const left = /\{\{[A-Z_]+\}\}/.exec(rendered);
104
+ if (left) throw new Error(`Unfilled placeholder ${left[0]}`);
105
+ return rendered;
106
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseJsonObject } from "./init/text.js";
4
+
5
+ /** The analyzer writes `.opsee/config.yaml` and a JSON copy of the same document
6
+ * (orchestrator/internal/analysis/analyzer.go); anything that edits one must keep the other in step. */
7
+ export const OPSEE_CONFIG_YAML = ".opsee/config.yaml";
8
+ export const OPSEE_CONFIG_JSON = ".opsee/config.json";
9
+
10
+ export interface OpseeConfigFiles {
11
+ yaml: string | null;
12
+ json: string | null;
13
+ }
14
+
15
+ export function readOpseeConfigFiles(root: string): OpseeConfigFiles {
16
+ const read = (rel: string) => (existsSync(join(root, rel)) ? readFileSync(join(root, rel), "utf8") : null);
17
+ return { yaml: read(OPSEE_CONFIG_YAML), json: read(OPSEE_CONFIG_JSON) };
18
+ }
19
+
20
+ /** The JSON copy as an object, or null when it is not one; callers that must report why parse it
21
+ * through `parseJsonObject` instead. */
22
+ export function parseOpseeJson(json: string): Record<string, unknown> | null {
23
+ const parsed = parseJsonObject(json);
24
+ return "root" in parsed ? parsed.root : null;
25
+ }
26
+
27
+ /** A mapping of scalars, one level of nesting deep: the shape of the config blocks the CLI reads
28
+ * (`commands`, `foreman`). Anything else in the block is skipped, not an error. */
29
+ export type ScalarBlock = Record<string, string | Record<string, string>>;
30
+
31
+ /** Strips a trailing `# comment` from a scalar and unquotes a single- or double-quoted one. Quoted
32
+ * strings honour the escapes that appear in generated configs (`\"`, `\\`, `''`); YAML's wider
33
+ * escape set is out of scope for a reader that only ever sees commands and URLs. */
34
+ export function parseScalar(raw: string): string {
35
+ const text = raw.trim();
36
+ const quote = text[0];
37
+ if (quote !== '"' && quote !== "'") return text.replace(/\s+#.*$/, "").trim();
38
+ // Walk to the closing quote so a comment after it (`'x' # don't`) is not mistaken for content.
39
+ let out = "";
40
+ for (let i = 1; i < text.length; i++) {
41
+ const c = text[i];
42
+ if (quote === '"' && c === "\\" && i + 1 < text.length) {
43
+ out += text[++i];
44
+ } else if (quote === "'" && c === "'" && text[i + 1] === "'") {
45
+ out += "'";
46
+ i++;
47
+ } else if (c === quote) {
48
+ return out;
49
+ } else {
50
+ out += c;
51
+ }
52
+ }
53
+ return text;
54
+ }
55
+
56
+ function indentOf(line: string): number {
57
+ return line.length - line.trimStart().length;
58
+ }
59
+
60
+ const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
61
+
62
+ /** Reads the top-level `key:` block of a YAML document as scalars plus one nested mapping level.
63
+ * Returns null when the document has no such key; an empty object when it is present but empty. */
64
+ export function readYamlBlock(yaml: string, key: string): ScalarBlock | null {
65
+ const lines = yaml.split("\n");
66
+ const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(#.*)?$`).test(line));
67
+ if (start === -1) return null;
68
+ const block: ScalarBlock = {};
69
+ let child: { name: string; indent: number; map: Record<string, string> } | null = null;
70
+ let blockIndent = -1;
71
+ for (let i = start + 1; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
74
+ const indent = indentOf(line);
75
+ if (indent === 0) break;
76
+ const match = KEY_LINE.exec(line.trim());
77
+ if (!match) continue;
78
+ const [, name, value] = match;
79
+ if (blockIndent === -1) blockIndent = indent;
80
+ if (indent === blockIndent) {
81
+ if (value === undefined || value.trim() === "" || value.trim().startsWith("#")) {
82
+ child = { name, indent: -1, map: {} };
83
+ block[name] = child.map;
84
+ } else {
85
+ child = null;
86
+ block[name] = parseScalar(value);
87
+ }
88
+ } else if (child && indent > blockIndent && (child.indent === -1 || indent === child.indent) && value !== undefined) {
89
+ child.indent = indent;
90
+ child.map[name] = parseScalar(value);
91
+ }
92
+ }
93
+ return block;
94
+ }
95
+
96
+ /** The indentation step the document already uses (the analyzer's yaml.v3 output uses four
97
+ * spaces), so an appended block does not look foreign. */
98
+ export function detectYamlIndent(yaml: string): string {
99
+ const match = /^( +)\S/m.exec(yaml);
100
+ return match ? match[1] : " ";
101
+ }
102
+
103
+ /** Quotes a scalar the way the analyzer's output does: bare when plain, double-quoted when YAML
104
+ * would otherwise misread it (a leading `$` or `- `, a `{`, a `: `, a `#`, or surrounding
105
+ * whitespace). */
106
+ export function yamlScalar(value: string): string {
107
+ if (value === "") return '""';
108
+ if (
109
+ /^[A-Za-z0-9_./-][^#:{}[\],&*?!|>'"%@`]*$/.test(value) &&
110
+ !/^-(\s|$)|:\s|\s#|\s$/.test(value) &&
111
+ !/^(true|false|null|yes|no|~)$/i.test(value)
112
+ ) {
113
+ return value;
114
+ }
115
+ return JSON.stringify(value);
116
+ }