@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,146 @@
1
+ // Pure core, part two: WHICH repository a project belongs to, WHETHER `gh`
2
+ // can be used, and WHAT argv the three `gh` commands this epic runs would
3
+ // be. Nothing here spawns `gh` -- every test asserts on the argv that WOULD
4
+ // have been run.
5
+ //
6
+ // SECURITY: never interpolates untrusted text into a command, never sets
7
+ // `shell: true`, never logs a URL without `redactCredentials`, never
8
+ // reads/stores/prints a token. `readOriginUrl` hands back credentials
9
+ // intact; this module extracts only host and slug.
10
+ //
11
+ // Issue bodies travel by `--body-file <temp file>`, not inline `--body` or
12
+ // stdin: keeps gh's own flag parsing and argv-length limits out of scope,
13
+ // and keeps the runner's `(cmd, args) => result` signature free of stdin.
14
+ import { execFileSync } from 'node:child_process';
15
+ import { mkdtempSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import path from 'node:path';
18
+ import { GitCommandError, readOriginUrl, redactCredentials } from './git.js';
19
+ const HTTPS_RE = /^https?:\/\/(?:[^/@]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
20
+ const SSH_URL_RE = /^ssh:\/\/git@([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/;
21
+ const SCP_RE = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/;
22
+ function parseRemote(url) {
23
+ for (const re of [HTTPS_RE, SSH_URL_RE, SCP_RE]) {
24
+ const m = url.match(re);
25
+ if (m)
26
+ return { host: m[1] ?? '', owner: m[2] ?? '', repo: m[3] ?? '' };
27
+ }
28
+ return null;
29
+ }
30
+ /**
31
+ * Turns a git remote URL into an `owner/repo` slug. Parses the https, scp-
32
+ * style and `ssh://` forms, including an https URL carrying userinfo
33
+ * credentials -- the credentials are never part of the returned value. A
34
+ * host that is not `github.com` is refused, not guessed.
35
+ */
36
+ export function slugFromRemoteUrl(url) {
37
+ const parsed = parseRemote(url);
38
+ const safeUrl = redactCredentials(url);
39
+ if (!parsed) {
40
+ return { reason: 'unparseable-remote', detail: `cannot parse remote URL: ${safeUrl}` };
41
+ }
42
+ if (parsed.host !== 'github.com') {
43
+ return {
44
+ reason: 'non-github-host',
45
+ detail: `remote host "${parsed.host}" is not github.com: ${safeUrl}`,
46
+ };
47
+ }
48
+ return { slug: `${parsed.owner}/${parsed.repo}` };
49
+ }
50
+ /**
51
+ * Which repository the git checkout at `dir` belongs to. Classifies a
52
+ * `GitCommandError` from `readOriginUrl` into `not-a-repo` or `no-origin`
53
+ * before handing a resolved URL to `slugFromRemoteUrl`; any other error is
54
+ * a programming or environment fault and is rethrown, not mis-filed.
55
+ */
56
+ export function resolveRepoAtDir(dir) {
57
+ let url;
58
+ try {
59
+ url = readOriginUrl(dir);
60
+ }
61
+ catch (err) {
62
+ if (err instanceof GitCommandError) {
63
+ if (/not a git repository/i.test(err.stderr)) {
64
+ return { reason: 'not-a-repo', detail: `${dir} is not a git repository` };
65
+ }
66
+ return { reason: 'no-origin', detail: `${dir} has no "origin" remote` };
67
+ }
68
+ throw err;
69
+ }
70
+ return slugFromRemoteUrl(url);
71
+ }
72
+ /**
73
+ * Which repository a named project belongs to, answered from a register
74
+ * handed in -- never a name lookup under `PROJECTS_DIR`/`WORKSPACES_DIR` and
75
+ * never a fallback to this process's own directory (projects.ts: this
76
+ * clone's parent can hold an unrelated checkout with an unrelated remote).
77
+ * A project the register does not name is refused as `no-checkout`, never
78
+ * answered with another project's slug.
79
+ */
80
+ export function resolveProjectRepo(project, register) {
81
+ const entry = register.find((r) => r.name === project);
82
+ if (!entry) {
83
+ return { reason: 'no-checkout', detail: `no checkout registered for project "${project}"` };
84
+ }
85
+ return resolveRepoAtDir(entry.dir);
86
+ }
87
+ /** Not reachable from the test suite: every test in gh.test.ts passes a stub. */
88
+ function defaultRunner(cmd, args) {
89
+ try {
90
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
91
+ return { status: 0, stdout, stderr: '' };
92
+ }
93
+ catch (err) {
94
+ const e = err;
95
+ const status = typeof e.status === 'number' ? e.status : null;
96
+ const spawnError = typeof e.code === 'string' ? e.code : undefined;
97
+ return {
98
+ status,
99
+ stdout: e.stdout ?? '',
100
+ stderr: redactCredentials(e.stderr ?? ''),
101
+ spawnError,
102
+ };
103
+ }
104
+ }
105
+ /** Classifies `gh` availability by driving `gh auth status` through the injected runner. */
106
+ export function classifyGh(runner = defaultRunner) {
107
+ const result = runner('gh', ['auth', 'status']);
108
+ if (result.spawnError !== undefined) {
109
+ return { outcome: 'missing', reason: `gh could not be run (${result.spawnError})` };
110
+ }
111
+ if (result.status === 0) {
112
+ return { outcome: 'ready' };
113
+ }
114
+ const said = `${result.stdout}\n${result.stderr}`.toLowerCase();
115
+ if (said.includes('not logged') || said.includes('auth login')) {
116
+ return {
117
+ outcome: 'unauthenticated',
118
+ reason: result.stderr.trim() || result.stdout.trim(),
119
+ };
120
+ }
121
+ return { outcome: 'unknown', reason: `gh auth status exited ${String(result.status)}` };
122
+ }
123
+ /**
124
+ * Writes `body` to a fresh temp file and returns its path, for `--body-file`.
125
+ * The body never becomes an argv element, so "no element of argv contains a
126
+ * composed shell fragment" holds by construction.
127
+ */
128
+ function writeBodyFile(body) {
129
+ const dir = mkdtempSync(path.join(tmpdir(), 'smith-gh-body-'));
130
+ const file = path.join(dir, 'body.md');
131
+ writeFileSync(file, body, 'utf8');
132
+ return file;
133
+ }
134
+ /** Pure builder: the argv for `gh issue create`. Spawns nothing. */
135
+ export function buildCreateIssueArgv(repo, title, body) {
136
+ return ['issue', 'create', '--repo', repo, '--title', title, '--body-file', writeBodyFile(body)];
137
+ }
138
+ /** Pure builder: the argv for `gh issue list` restricted to open issues matching `query`. */
139
+ export function buildSearchIssuesArgv(repo, query) {
140
+ return ['issue', 'list', '--repo', repo, '--state', 'open', '--search', query];
141
+ }
142
+ /** Pure builder: the argv for `gh issue comment`. Spawns nothing. */
143
+ export function buildCommentArgv(repo, issueNumber, body) {
144
+ const file = writeBodyFile(body);
145
+ return ['issue', 'comment', String(issueNumber), '--repo', repo, '--body-file', file];
146
+ }
@@ -0,0 +1,87 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { SmithError } from './errors.js';
3
+ /**
4
+ * The one place this factory shells out to git.
5
+ *
6
+ * P9-16(b)/D-24. `execFileSync` sends the child's stderr to the PARENT's
7
+ * stderr unless `stdio` is given explicitly — the default is documented as
8
+ * `'pipe'`, but stderr is inherited on top of it. Every module that had its
9
+ * own two-line `git()` helper therefore printed git's chatter straight onto
10
+ * the operator's terminal: `fatal: ref refs/remotes/origin/HEAD is not a
11
+ * symbolic ref` for every project without a remote (which is every project
12
+ * `smith new` creates), `Preparing worktree (new branch ...)` on each
13
+ * dispatch, `warning: You appear to have cloned an empty repository.` The
14
+ * factory speaks JSON on stdout; anything else on the terminal reads as a
15
+ * failure that isn't one.
16
+ *
17
+ * Silencing a channel must not delete what was on it, which is the D-47
18
+ * mistake one layer down. So stderr is captured and folded into the thrown
19
+ * error: the operator sees nothing while git succeeds, and sees git's own
20
+ * words the moment it doesn't.
21
+ *
22
+ * stdin is `'ignore'` rather than `'pipe'`: a git that decides to prompt —
23
+ * for a credential, for an editor — would otherwise wait forever on input
24
+ * that no unattended agent is going to type.
25
+ */
26
+ export class GitCommandError extends SmithError {
27
+ /** git's own stderr, credential-redacted. Empty when it said nothing. */
28
+ stderr;
29
+ /** Exit code, or null when git was killed by a signal or never started. */
30
+ status;
31
+ constructor(cwd, args, status, stderr) {
32
+ const said = stderr.trim();
33
+ const how = status === null ? 'did not exit normally' : `exit ${status}`;
34
+ super('git.command-failed', `git ${args.join(' ')} failed in ${cwd} (${how})` +
35
+ (said === '' ? ' and said nothing' : `: ${said}`), { cwd, args, status, stderr });
36
+ this.stderr = stderr;
37
+ this.status = status;
38
+ }
39
+ }
40
+ /** `https://user:token@host/…` → `https://user:***@host/…`, and the token-only form too. */
41
+ export function redactCredentials(text) {
42
+ return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/\s@]+)@/g, (_match, scheme, userinfo) => {
43
+ const colon = userinfo.indexOf(':');
44
+ return colon === -1 ? `${scheme}***@` : `${scheme}${userinfo.slice(0, colon)}:***@`;
45
+ });
46
+ }
47
+ function exec(cwd, args) {
48
+ try {
49
+ return execFileSync('git', args, {
50
+ cwd,
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ });
54
+ }
55
+ catch (err) {
56
+ const e = err;
57
+ // A child that never spawned has no exit code; `code` carries the errno
58
+ // string instead, and that is the only thing worth reporting.
59
+ const status = typeof e.status === 'number' ? e.status : null;
60
+ const said = e.stderr ?? (typeof e.code === 'string' ? `git could not be run (${e.code})` : '');
61
+ throw new GitCommandError(cwd, args, status, redactCredentials(said));
62
+ }
63
+ }
64
+ /** Run git and return its stdout, trailing newline removed. Throws GitCommandError. */
65
+ export function runGit(cwd, args) {
66
+ return exec(cwd, args).trim();
67
+ }
68
+ /**
69
+ * Same, byte-for-byte. For `-z` output, where the record separator is a NUL
70
+ * and the trailing one is part of the format rather than noise to trim.
71
+ */
72
+ export function runGitRaw(cwd, args) {
73
+ return exec(cwd, args);
74
+ }
75
+ /**
76
+ * The `origin` remote's URL, exactly as git reports it — credentials and
77
+ * all, since redaction happens only on the error path inside `exec`. Throws
78
+ * `GitCommandError` when `cwd` is not a git repository at all, or when it is
79
+ * one but has no `origin` remote. This function does not tell those two
80
+ * apart: git's own stderr already differs ("not a git repository" versus "No
81
+ * such remote"), and the one caller that needs to classify it (`gh.ts`) reads
82
+ * that text itself rather than this file growing a second refusal taxonomy
83
+ * beside the one it already has (`GitCommandError`).
84
+ */
85
+ export function readOriginUrl(cwd) {
86
+ return runGit(cwd, ['remote', 'get-url', 'origin']);
87
+ }
@@ -0,0 +1,380 @@
1
+ // Spec-vs-goal gate.
2
+ //
3
+ // Every other gate in this factory reads something the planner authored. The
4
+ // schema check reads the plan's own shape, the grader reads the plan's own
5
+ // acceptance criteria, the reviewer reads a diff against the plan, and the
6
+ // closing spec review (spec.ts) re-reads the plan against the code. All of
7
+ // them answer "does the work match the spec". None of them can answer "is the
8
+ // spec the right spec" -- a plan that decomposes the wrong goal perfectly
9
+ // passes every one of them, and the epic closes green having built the wrong
10
+ // thing. That is the failure this module exists for.
11
+ //
12
+ // The reference text therefore has to come from outside the plan. It is the
13
+ // `- goal:` line of the roadmap milestone that owns the epic (roadmap.ts,
14
+ // `ownsEpic`) -- written by the operator before planning, and never rewritten
15
+ // by the planner. The gate splits it into clauses mechanically and asks: for
16
+ // each clause, which live plan task delivers it?
17
+ //
18
+ // Three properties keep this from being a rubber stamp:
19
+ //
20
+ // 1. The clause list is not the judge's to choose. `goalClauses()` splits
21
+ // the goal deterministically, and recordGoalCheck refuses a coverage map
22
+ // whose clauses are not exactly that list, in that order. A judge cannot
23
+ // make an epic green by declining to mention the clause it failed.
24
+ // 2. `covered` has to name live plan task ids, and they are checked against
25
+ // the plan roster. "Yes, somewhere" is not an answer.
26
+ // 3. `out-of-scope` has to carry a reason, and the reason is recorded in the
27
+ // event. A milestone that holds several epics has clauses that belong to
28
+ // the siblings, and a retrospective goal paragraph has sentences that are
29
+ // history rather than requirements -- both are legitimate, and both are
30
+ // now a written dismissal an operator can audit instead of a silent drop.
31
+ //
32
+ // An `uncovered` clause mints a spec-scoped finding, so the remedy is the one
33
+ // the plan's immutability allows: `smith plan amend`, cutting a version that
34
+ // covers it. It is deliberately not a task-scoped finding -- no coder's diff
35
+ // can contain the fix.
36
+ //
37
+ // Fail-closed on a missing goal, unlike mcp.ts's MCP_SURFACE_NOT_REQUIRED.
38
+ // An epic can legitimately owe no MCP surface; an epic that owes no statement
39
+ // of what it was for is an epic nobody can say succeeded. Treating that as
40
+ // "not required" would rebuild the exact hole roadmap.ts's ownsEpic() doc
41
+ // already regrets in writing -- a gate that skipped every project silently
42
+ // from the day it shipped -- so the blockers below name the one-line roadmap
43
+ // edit that clears them.
44
+ //
45
+ // Dependency direction matches spec.ts: epic.ts imports from here, never the
46
+ // reverse.
47
+ import { createHash } from 'node:crypto';
48
+ import { SmithError } from './errors.js';
49
+ import { appendEvent } from './events.js';
50
+ import { mintFindings, raiseFinding } from './findings.js';
51
+ import { loadRoadmap, ownsEpic } from './roadmap.js';
52
+ import { RESERVED_TASK_ID } from './worktree.js';
53
+ export class GoalCheckError extends SmithError {
54
+ }
55
+ export const GOAL_CHECK_EVENT = 'goal-check-recorded';
56
+ /**
57
+ * An uncovered clause is always S2-major, and the judge does not get to grade
58
+ * it. "This epic does not deliver a clause of the goal it exists for" is one
59
+ * kind of defect, not a spectrum, and a judge that scores the severity of its
60
+ * own finding is the self-assessment problem the tester isolation just
61
+ * removed from the test gate.
62
+ */
63
+ export const UNCOVERED_SEVERITY = 'S2-major';
64
+ /**
65
+ * `finding_category` has no `spec-gap` value -- the taxonomy keeps that
66
+ * vocabulary in the `error.spec` group, for errors rather than findings. The
67
+ * scope (`finding_scope: spec`, set by mintFindings' `spec` context) is what
68
+ * says this is about the plan; the category says what kind of wrong it is,
69
+ * and a plan that does not deliver its goal is not correct.
70
+ */
71
+ const UNCOVERED_CATEGORY = 'correctness';
72
+ export const CLAUSE_VERDICTS = ['covered', 'out-of-scope', 'uncovered'];
73
+ /** No milestone owns this epic, or the one that does declares no goal. */
74
+ export const EPIC_GOAL_UNDECLARED = Object.freeze({
75
+ milestoneId: null,
76
+ goal: null,
77
+ clauses: [],
78
+ digest: null,
79
+ });
80
+ /**
81
+ * Split a goal into the clauses a plan has to answer, deterministically.
82
+ *
83
+ * Sentence-level and nothing finer. Comma splitting is the tempting next step
84
+ * and it is unsafe -- "Test gate, reviewer/verifier chain, severity policy"
85
+ * and "the worktree, which the coder owns, is disposable" are the same shape
86
+ * to a splitter and opposite things to a reader. A goal author who wants a
87
+ * requirement graded on its own writes it as its own sentence.
88
+ *
89
+ * Paragraph breaks split too, so a goal written as a bulleted or multi-line
90
+ * block grades line by line even where the lines carry no full stop.
91
+ *
92
+ * No abbreviation table. "e.g." splits a clause in two, and both halves then
93
+ * have to be answered or dismissed in writing -- noisier than ideal, but the
94
+ * safe direction: this function can over-split, and must never drop text a
95
+ * requirement was hiding in.
96
+ */
97
+ export function goalClauses(text) {
98
+ const clauses = [];
99
+ for (const line of text.split(/\r?\n/)) {
100
+ // A terminator ends a clause only where the next sentence visibly starts:
101
+ // an uppercase letter, a digit, a quote or a backtick. "v1.2" and
102
+ // "state/events/x.jsonl" stay whole.
103
+ for (const piece of line.split(/(?<=[.!?])\s+(?=[A-Z0-9"'`(])/)) {
104
+ const clause = piece.trim();
105
+ if (clause.length > 0)
106
+ clauses.push(clause);
107
+ }
108
+ }
109
+ return clauses;
110
+ }
111
+ /**
112
+ * Digest of the goal text, whitespace-normalized so rewrapping a roadmap
113
+ * paragraph does not invalidate a check while rewording it does.
114
+ */
115
+ export function goalDigest(text) {
116
+ const material = text.replace(/\s+/g, ' ').trim();
117
+ return createHash('sha256').update(material).digest('hex').slice(0, 16);
118
+ }
119
+ /**
120
+ * Read the epic's goal off the roadmap. Mirrors resolveMcpSurface: the I/O
121
+ * lives here and in the CLI that calls it, so summarizeEpic stays a pure fold
122
+ * over values it was handed.
123
+ *
124
+ * A milestone with a `- goal:` line that is present but empty resolves the
125
+ * same as one with no line at all. Both are "nobody wrote down what this was
126
+ * for", and the remedy is identical.
127
+ */
128
+ export function resolveEpicGoal(opts) {
129
+ const milestone = loadRoadmap(opts.roadmapPath).find((m) => ownsEpic(m, opts.epicId));
130
+ if (!milestone)
131
+ return EPIC_GOAL_UNDECLARED;
132
+ const goal = milestone.goal?.trim() ?? '';
133
+ if (goal.length === 0) {
134
+ return { milestoneId: milestone.milestoneId, goal: null, clauses: [], digest: null };
135
+ }
136
+ return {
137
+ milestoneId: milestone.milestoneId,
138
+ goal,
139
+ clauses: goalClauses(goal),
140
+ digest: goalDigest(goal),
141
+ };
142
+ }
143
+ /** Last-wins fold: a re-check supersedes its predecessor, it does not stack. */
144
+ export function latestGoalCheck(events, epicId) {
145
+ let latest = null;
146
+ for (const event of events) {
147
+ if (event.record.event_type !== GOAL_CHECK_EVENT)
148
+ continue;
149
+ const payload = event.record.payload;
150
+ if (payload.epic_id !== epicId)
151
+ continue;
152
+ latest = {
153
+ epicId,
154
+ milestoneId: String(payload.milestone_id ?? ''),
155
+ planVersion: Number(payload.plan_version ?? 0),
156
+ goalDigest: String(payload.goal_digest ?? ''),
157
+ checkedBy: String(payload.checked_by ?? ''),
158
+ coverage: (payload.coverage ?? []),
159
+ findingIds: (payload.finding_ids ?? []),
160
+ eventId: event.event_id,
161
+ ts: event.record.ts,
162
+ };
163
+ }
164
+ return latest;
165
+ }
166
+ const ROADMAP_REMEDY = "Give the milestone that owns it a `- goal:` line in factory/specs/roadmap.md, or add the epic to an existing milestone's `- epics:` list.";
167
+ /**
168
+ * The epic gate refusing to certify a plan nobody checked against the goal it
169
+ * was written from.
170
+ *
171
+ * Every check here is mechanical — a deterministic oracle over recorded
172
+ * values, decided before any judge's opinion enters. The judge's opinion is
173
+ * in the coverage map; these are the things it is not allowed to be wrong
174
+ * about.
175
+ *
176
+ * A check reads two things and so goes stale two ways, exactly as D-125 found
177
+ * for the spec review: the plan it graded and the goal it graded against. An
178
+ * amendment bumps the version; an operator sharpening a vague goal line bumps
179
+ * the digest. Neither may stand in for the other — the moment a goal is
180
+ * rewritten is the moment a plan is likeliest to no longer answer it.
181
+ *
182
+ * `livePlanTaskIds` is the plan's live roster. Coverage citing an id that is
183
+ * not on it is rejected here as well as at record time, because an event can
184
+ * also arrive by hand.
185
+ */
186
+ export function goalCheckBlockers(epicId, status, planVersion, livePlanTaskIds) {
187
+ const { check, goal } = status;
188
+ if (goal.milestoneId === null) {
189
+ return [
190
+ `No roadmap milestone owns epic "${epicId}", so there is no goal to check its plan against — the spec could be wrong from the first line and every other gate would still pass. ${ROADMAP_REMEDY}`,
191
+ ];
192
+ }
193
+ if (goal.goal === null || goal.digest === null) {
194
+ return [
195
+ `Milestone "${goal.milestoneId}" owns epic "${epicId}" but declares no goal, so there is nothing to check its plan against. ${ROADMAP_REMEDY}`,
196
+ ];
197
+ }
198
+ if (check === null) {
199
+ return [
200
+ `Epic "${epicId}" has no spec-vs-goal check on record: nothing has asked whether its plan answers the goal milestone "${goal.milestoneId}" declares. Run \`smith epic goal-check --epic ${epicId}\`.`,
201
+ ];
202
+ }
203
+ if (check.goalDigest !== goal.digest) {
204
+ return [
205
+ `The spec-vs-goal check for "${epicId}" is stale: it read a goal that digests to ${check.goalDigest}, and milestone "${goal.milestoneId}" now declares one that digests to ${goal.digest}. The plan has been checked against a goal the roadmap no longer states.`,
206
+ ];
207
+ }
208
+ if (planVersion !== null) {
209
+ // latestGoalCheck reads a missing plan_version as 0, and "I did not record
210
+ // which plan I graded" is unverifiable rather than current — the same
211
+ // fail-closed call specReviewBlockers makes.
212
+ if (!Number.isInteger(check.planVersion) || check.planVersion < 1) {
213
+ return [
214
+ `The spec-vs-goal check for "${epicId}" records no plan version, so it cannot be shown to cover plan v${planVersion}.`,
215
+ ];
216
+ }
217
+ if (check.planVersion < planVersion) {
218
+ return [
219
+ `The spec-vs-goal check for "${epicId}" is stale: it graded plan v${check.planVersion}, and the epic's live plan is v${planVersion}. Whatever the amendment changed has been checked against no goal at all.`,
220
+ ];
221
+ }
222
+ if (check.planVersion > planVersion) {
223
+ return [
224
+ `The spec-vs-goal check for "${epicId}" records plan v${check.planVersion}, and the epic's live plan is v${planVersion}: the check names a version the repository does not have.`,
225
+ ];
226
+ }
227
+ const live = new Set(livePlanTaskIds);
228
+ const phantom = [
229
+ ...new Set(check.coverage.flatMap((entry) => entry.taskIds ?? []).filter((id) => !live.has(id))),
230
+ ];
231
+ if (phantom.length > 0) {
232
+ return [
233
+ `The spec-vs-goal check for "${epicId}" credits ${phantom.length === 1 ? 'a task' : 'tasks'} that plan v${planVersion} does not have: ${phantom.join(', ')}. A clause covered by a task that does not exist is a clause nothing delivers.`,
234
+ ];
235
+ }
236
+ }
237
+ // The clause list is the goal's, not the check's (see goalClauses). A record
238
+ // whose clauses have drifted from the goal's is answering a different
239
+ // question, whatever its verdicts say.
240
+ const recorded = check.coverage.map((entry) => entry.clause);
241
+ if (recorded.length !== goal.clauses.length || recorded.some((c, i) => c !== goal.clauses[i])) {
242
+ return [
243
+ `The spec-vs-goal check for "${epicId}" covers ${recorded.length} clause(s) and milestone "${goal.milestoneId}" declares ${goal.clauses.length}. The recorded check does not answer the goal as written.`,
244
+ ];
245
+ }
246
+ const uncovered = check.coverage.filter((entry) => entry.verdict === 'uncovered');
247
+ if (uncovered.length > 0) {
248
+ return [
249
+ `The spec-vs-goal check for "${epicId}" left ${uncovered.length} goal clause(s) uncovered by plan v${check.planVersion}: ${uncovered.map((entry) => JSON.stringify(entry.clause)).join('; ')}. Answer them with \`smith plan amend\` — no task diff can contain the fix.`,
250
+ ];
251
+ }
252
+ return [];
253
+ }
254
+ /**
255
+ * Run the spec-vs-goal check's bookkeeping: validate the judge's coverage map
256
+ * against the goal it claims to answer, mint every uncovered clause as a
257
+ * spec-scoped finding, and record that the check happened.
258
+ *
259
+ * Everything validates before anything acts, the way amendPlan does: a run
260
+ * that raises three findings and then rejects the fourth clause has already
261
+ * changed the log for a check that never completed.
262
+ *
263
+ * The event is written even when every clause is covered, for the reason
264
+ * recordSpecReview's is: "ran and was clean" and "never ran" are different
265
+ * facts, and a gate that only logged the first would make a silent skip
266
+ * indistinguishable from a pass.
267
+ */
268
+ export async function recordGoalCheck(input, ctx, opts = {}) {
269
+ const { epicId, milestoneId, planVersion, checkedBy } = input;
270
+ const taskId = `${epicId}/${RESERVED_TASK_ID}`;
271
+ const expected = goalClauses(input.goal);
272
+ if (expected.length === 0) {
273
+ throw new GoalCheckError('goal-check.empty-goal', `Milestone "${milestoneId}" declares no goal text for "${epicId}", so there are no clauses to check.`, { epicId, milestoneId });
274
+ }
275
+ const recorded = input.coverage.map((entry) => entry.clause);
276
+ if (recorded.length !== expected.length || recorded.some((c, i) => c !== expected[i])) {
277
+ throw new GoalCheckError('goal-check.clause-mismatch', `The coverage map for "${epicId}" must carry one entry per goal clause, in order: ${expected.length} expected, ${recorded.length} given. The clause list is the goal's, not the check's.`, { epicId, expected, recorded });
278
+ }
279
+ const live = new Set(input.livePlanTaskIds);
280
+ input.coverage.forEach((entry, index) => {
281
+ // Verdict first, and against a runtime list rather than the union type:
282
+ // this input arrives as JSON from a judge, so the compiler's guarantee is
283
+ // about the callers it can see, not about the value that shows up.
284
+ if (!CLAUSE_VERDICTS.includes(entry.verdict)) {
285
+ throw new GoalCheckError('goal-check.unknown-verdict', `Clause ${index + 1} of "${epicId}" carries verdict "${entry.verdict}". Valid: ${CLAUSE_VERDICTS.join(', ')}.`, { epicId, index, verdict: entry.verdict });
286
+ }
287
+ if (entry.verdict === 'covered') {
288
+ const cited = entry.taskIds ?? [];
289
+ if (cited.length === 0) {
290
+ throw new GoalCheckError('goal-check.covered-without-task', `Clause ${index + 1} of "${epicId}" is marked covered and names no task. "Covered somewhere" is not an answer a plan can be graded on.`, { epicId, index, clause: entry.clause });
291
+ }
292
+ const phantom = cited.filter((id) => !live.has(id));
293
+ if (phantom.length > 0) {
294
+ throw new GoalCheckError('goal-check.unknown-task', `Clause ${index + 1} of "${epicId}" is covered by ${phantom.join(', ')}, which plan v${planVersion} does not have as a live task.`, { epicId, index, clause: entry.clause, taskIds: phantom });
295
+ }
296
+ return;
297
+ }
298
+ if (entry.verdict === 'out-of-scope' && (entry.reason ?? '').trim().length === 0) {
299
+ throw new GoalCheckError('goal-check.dismissal-without-reason', `Clause ${index + 1} of "${epicId}" is dismissed as out of scope with no reason. A dismissal an operator cannot audit is a clause silently dropped.`, { epicId, index, clause: entry.clause });
300
+ }
301
+ });
302
+ // Anchored at the plan file's repo-relative path, written out rather than
303
+ // derived from opts.specsDir: the path is fingerprint material, and a
304
+ // tmpdir-rooted one would make the same defect dedup differently on every
305
+ // machine. Carrying the version means a re-check of an amended plan raises
306
+ // its own finding — a distinct claim about a distinct immutable artifact.
307
+ const anchor = `factory/specs/active/${epicId}/plan-v${planVersion}.json`;
308
+ const evidence = [];
309
+ input.coverage.forEach((entry, index) => {
310
+ if (entry.verdict !== 'uncovered')
311
+ return;
312
+ evidence.push({
313
+ file_path: anchor,
314
+ finding_category: UNCOVERED_CATEGORY,
315
+ severity: UNCOVERED_SEVERITY,
316
+ criterion_ref: `goal:${milestoneId}#${index + 1}`,
317
+ summary: `Plan v${planVersion} delivers no clause of the epic goal: ${entry.clause}`,
318
+ failure_scenario: {
319
+ inputs: `Milestone "${milestoneId}" declares the goal clause: ${entry.clause}`,
320
+ expected: `Some live task of plan v${planVersion} delivers it.`,
321
+ actual: 'No task in the plan answers it, so every task gate can pass, the epic can close, and the clause is still undelivered.',
322
+ },
323
+ });
324
+ });
325
+ const drafts = mintFindings(evidence, {
326
+ taskId,
327
+ foundBy: checkedBy,
328
+ ...(input.checkedByProvider === undefined
329
+ ? {}
330
+ : { foundByProvider: input.checkedByProvider }),
331
+ spec: { planVersion },
332
+ }, opts);
333
+ const findingIds = [];
334
+ for (const draft of drafts) {
335
+ const raised = await raiseFinding(draft, ctx, opts);
336
+ // A suppressed raise (fingerprint already waived) logged its own
337
+ // finding-suppressed event and appended no finding; citing an id that was
338
+ // never raised would make this record unresolvable.
339
+ if (raised.suppressed)
340
+ continue;
341
+ findingIds.push(raised.finding.finding_id);
342
+ }
343
+ const coverage = input.coverage.map((entry) => ({ ...entry }));
344
+ const digest = goalDigest(input.goal);
345
+ const stored = await appendEvent({
346
+ session_id: ctx.sessionId,
347
+ actor: ctx.actor ?? 'system',
348
+ event_type: GOAL_CHECK_EVENT,
349
+ task_id: taskId,
350
+ plan_version: ctx.planVersion,
351
+ causal_parent: ctx.causalParent,
352
+ payload: {
353
+ epic_id: epicId,
354
+ milestone_id: milestoneId,
355
+ plan_version: planVersion,
356
+ goal_digest: digest,
357
+ checked_by: checkedBy,
358
+ ...(input.checkedByProvider === undefined
359
+ ? {}
360
+ : { checked_by_provider: input.checkedByProvider }),
361
+ coverage,
362
+ clause_count: coverage.length,
363
+ uncovered_count: coverage.filter((entry) => entry.verdict === 'uncovered').length,
364
+ out_of_scope_count: coverage.filter((entry) => entry.verdict === 'out-of-scope').length,
365
+ finding_ids: findingIds,
366
+ finding_count: findingIds.length,
367
+ },
368
+ }, opts);
369
+ return {
370
+ epicId,
371
+ milestoneId,
372
+ planVersion,
373
+ goalDigest: digest,
374
+ checkedBy,
375
+ coverage,
376
+ findingIds,
377
+ eventId: stored.event_id,
378
+ ts: stored.record.ts,
379
+ };
380
+ }
@@ -0,0 +1,61 @@
1
+ export function topoSort(nodes, edges) {
2
+ const nodeSet = new Set(nodes);
3
+ const dependents = new Map(); // dependsOn -> [tasks waiting on it]
4
+ const inDegree = new Map();
5
+ for (const node of nodeSet) {
6
+ inDegree.set(node, 0);
7
+ dependents.set(node, []);
8
+ }
9
+ for (const edge of edges) {
10
+ if (!nodeSet.has(edge.task) || !nodeSet.has(edge.dependsOn))
11
+ continue;
12
+ dependents.get(edge.dependsOn)?.push(edge.task);
13
+ inDegree.set(edge.task, (inDegree.get(edge.task) ?? 0) + 1);
14
+ }
15
+ const ready = [...nodeSet].filter((n) => inDegree.get(n) === 0).sort();
16
+ const order = [];
17
+ while (ready.length > 0) {
18
+ ready.sort();
19
+ const next = ready.shift();
20
+ order.push(next);
21
+ for (const dependent of dependents.get(next) ?? []) {
22
+ const remaining = (inDegree.get(dependent) ?? 0) - 1;
23
+ inDegree.set(dependent, remaining);
24
+ if (remaining === 0)
25
+ ready.push(dependent);
26
+ }
27
+ }
28
+ if (order.length === nodeSet.size) {
29
+ return { ok: true, order };
30
+ }
31
+ const cycle = [...nodeSet].filter((n) => !order.includes(n)).sort();
32
+ return { ok: false, cycle };
33
+ }
34
+ /**
35
+ * Longest-path depth per node (Phase 6b, Flow page's wave bands —
36
+ * docs/standards/stack.md: "layered by longest-path depth... no separate
37
+ * layout library"). A node with no dependencies is wave 0; every other
38
+ * node's wave is `1 + max(wave of its dependencies)` — so a node is never
39
+ * drawn in an earlier band than something it depends on, and independent
40
+ * branches that happen to be the same length land in the same band
41
+ * (visually "these can run in parallel right now"). Cyclic input is
42
+ * rejected via topoSort() first — wave computation assumes acyclic input.
43
+ */
44
+ export function waveLayers(nodes, edges) {
45
+ const topo = topoSort(nodes, edges);
46
+ const waves = new Map();
47
+ if (!topo.ok)
48
+ return waves; // cyclic — caller's problem to surface, not this function's
49
+ const dependsOnByTask = new Map();
50
+ for (const e of edges) {
51
+ const list = dependsOnByTask.get(e.task) ?? [];
52
+ list.push(e.dependsOn);
53
+ dependsOnByTask.set(e.task, list);
54
+ }
55
+ for (const node of topo.order) {
56
+ const deps = dependsOnByTask.get(node) ?? [];
57
+ const wave = deps.length === 0 ? 0 : Math.max(...deps.map((d) => (waves.get(d) ?? 0) + 1));
58
+ waves.set(node, wave);
59
+ }
60
+ return waves;
61
+ }