lazycodex-ai 5.0.0-beta.89 → 5.0.0-beta.90

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 (159) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  7. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  8. package/dist/cli/index.js +1661 -859
  9. package/dist/cli/install-native/index.d.ts +9 -1
  10. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  11. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  12. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  13. package/dist/cli/install-native/plan.d.ts +8 -0
  14. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  15. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  16. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  17. package/dist/cli/native-edition-hint.d.ts +2 -2
  18. package/dist/cli-node/index.js +1661 -859
  19. package/package.json +1 -1
  20. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  21. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  26. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  30. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  31. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  33. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  34. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  35. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  39. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  40. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  41. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  46. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  48. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  49. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  71. package/packages/omo-codex/plugin/package-lock.json +12 -12
  72. package/packages/omo-codex/plugin/package.json +1 -1
  73. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  74. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  75. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  76. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  77. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  78. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  79. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  80. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  81. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  83. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  84. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  87. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  88. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  89. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  90. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  91. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  102. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  103. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  104. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  105. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  106. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  107. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  108. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  109. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  110. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  111. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  112. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  113. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  114. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  115. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  116. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  117. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  118. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  119. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  120. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  121. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  122. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  123. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  124. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  125. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  126. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  127. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  128. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  129. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  130. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  131. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  132. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  133. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  134. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  135. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  136. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  137. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  138. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  139. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  140. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  141. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  142. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  143. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  144. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  145. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  146. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  147. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  148. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  149. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  150. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  151. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  152. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  153. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  154. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  155. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  156. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  157. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  158. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  159. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,171 @@
1
+ // repair-tracker.mjs - the bounded repair loop as a pure state machine.
2
+ //
3
+ // Every decide() call is one check of the current artifact: the first call is attempt 0 (the
4
+ // initial check), each later call follows one repair already taken. The loop stops on max
5
+ // attempts, a measure plateau, a repeated defect signature (oscillation), or the wall clock.
6
+ // On a stop the artifact is delivered with its residual defects unless a content-integrity
7
+ // code remains, which blocks delivery. Time is always injected; this module never reads a
8
+ // clock. Severities come from contracts.mjs only, so no caller can demote a defect.
9
+
10
+ import { DEFECT_CODES, INTEGRITY_CODES, REPAIR_DEFAULTS, SCHEMA_VERSION, validateRepairState } from "./contracts.mjs"
11
+
12
+ /**
13
+ * @typedef {{ code: string, message?: string }} Defect
14
+ * @typedef {{ at: string, measure: number[], signature: string, action: string }} Attempt
15
+ * @typedef {{ schemaVersion: number, startedAt: string, budget: { maxAttempts: number, plateauLimit: number, wallClockMs: number }, integrityCodes: string[], attempts: Attempt[], consecutiveNonProgress: number, seenSignatures: string[] }} RepairState
16
+ * @typedef {{ action: "repair"|"deliver"|"block", reason: string|null, residual: Defect[], blocking: Defect[] }} Decision
17
+ */
18
+
19
+ const SEVERITY_INDEX = Object.freeze({ blocker: 0, major: 1, minor: 2 })
20
+ const MIN_ARTIFACT_BYTES = 512
21
+ const MIN_RENDERS = 1
22
+
23
+ export class RepairTrackerError extends Error {
24
+ /** @param {string} code @param {string} message */
25
+ constructor(code, message) {
26
+ super(message)
27
+ this.name = "RepairTrackerError"
28
+ this.code = code
29
+ }
30
+ }
31
+
32
+ function toIso(value, label) {
33
+ const date = value instanceof Date ? value : typeof value === "string" || typeof value === "number" ? new Date(value) : null
34
+ if (date === null || !Number.isFinite(date.getTime())) {
35
+ throw new RepairTrackerError("invalid_time", `${label} must be a Date, an ISO timestamp, or epoch milliseconds`)
36
+ }
37
+ return date.toISOString()
38
+ }
39
+
40
+ function assertKnownCode(code, label) {
41
+ if (typeof code !== "string" || !Object.hasOwn(DEFECT_CODES, code)) {
42
+ throw new RepairTrackerError("unknown_defect_code", `${label} ${JSON.stringify(code)} is not a known defect code`)
43
+ }
44
+ }
45
+
46
+ /** Callers may add integrity codes; the contract set is always included, so none can be removed. */
47
+ function integritySet(extra) {
48
+ return new Set([...INTEGRITY_CODES, ...(extra ?? [])])
49
+ }
50
+
51
+ /**
52
+ * @param {{ maxAttempts?: number, plateauLimit?: number, wallClockMs?: number, integrityCodes?: Iterable<string>, startedAt: Date|string|number }} options
53
+ * @returns {RepairState}
54
+ */
55
+ export function createTracker({ maxAttempts, plateauLimit, wallClockMs, integrityCodes, startedAt }) {
56
+ if (startedAt === undefined) throw new RepairTrackerError("invalid_time", "startedAt is required (the tracker never reads the clock)")
57
+ const codes = [...integritySet(integrityCodes)].sort()
58
+ for (const code of codes) assertKnownCode(code, "integrity code")
59
+ const state = {
60
+ schemaVersion: SCHEMA_VERSION,
61
+ startedAt: toIso(startedAt, "startedAt"),
62
+ budget: {
63
+ maxAttempts: maxAttempts ?? REPAIR_DEFAULTS.maxAttempts,
64
+ plateauLimit: plateauLimit ?? REPAIR_DEFAULTS.plateauLimit,
65
+ wallClockMs: wallClockMs ?? REPAIR_DEFAULTS.wallClockMs,
66
+ },
67
+ integrityCodes: codes,
68
+ attempts: [],
69
+ consecutiveNonProgress: 0,
70
+ seenSignatures: [],
71
+ }
72
+ assertValid(state)
73
+ return state
74
+ }
75
+
76
+ /** The artifact is worth delivering with residual defects only when it exists and was rendered. */
77
+ export function isArtifactOk({ bytes, renders }) {
78
+ return Number.isFinite(bytes) && bytes >= MIN_ARTIFACT_BYTES && Number.isFinite(renders) && renders >= MIN_RENDERS
79
+ }
80
+
81
+ export function measureOf(defects) {
82
+ const measure = [0, 0, 0]
83
+ for (const defect of defects) measure[SEVERITY_INDEX[DEFECT_CODES[defect.code].severity]] += 1
84
+ return measure
85
+ }
86
+
87
+ export function signatureOf(defects) {
88
+ return [...new Set(defects.map((defect) => defect.code))].sort().join("|")
89
+ }
90
+
91
+ function lessThan(a, b) {
92
+ for (let i = 0; i < a.length; i += 1) {
93
+ if (a[i] !== b[i]) return a[i] < b[i]
94
+ }
95
+ return false
96
+ }
97
+
98
+ function stopReason(state, { signature, consecutiveNonProgress, repairsTaken, elapsedMs }) {
99
+ const { maxAttempts, plateauLimit, wallClockMs } = state.budget
100
+ if (state.seenSignatures.includes(signature)) return "oscillation"
101
+ if (consecutiveNonProgress >= plateauLimit) return "plateau"
102
+ if (repairsTaken >= maxAttempts) return "max_attempts"
103
+ if (elapsedMs >= wallClockMs) return "wall_clock"
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * @param {RepairState} state a tracker state (createTracker or deserializeState)
109
+ * @param {Defect[]} defects the current check's defects
110
+ * @param {Date|string|number} now injected time of this check
111
+ * @param {{ artifactOk?: boolean }} [artifact] caller-supplied; see isArtifactOk
112
+ * @returns {{ state: RepairState, decision: Decision }}
113
+ */
114
+ export function decide(state, defects, now, { artifactOk = false } = {}) {
115
+ if (!Array.isArray(defects)) throw new RepairTrackerError("invalid_defects", "defects must be an array")
116
+ defects.forEach((defect, index) => assertKnownCode(defect?.code, `defects[${index}].code`))
117
+ const at = toIso(now, "now")
118
+ const measure = measureOf(defects)
119
+ const signature = signatureOf(defects)
120
+ const previous = state.attempts.at(-1)
121
+ const consecutiveNonProgress = previous && !lessThan(measure, previous.measure) ? state.consecutiveNonProgress + 1 : 0
122
+ const repairsTaken = state.attempts.filter((attempt) => attempt.action === "repair").length
123
+ const integrity = integritySet(state.integrityCodes)
124
+ const blocking = defects.filter((defect) => integrity.has(defect.code))
125
+
126
+ /** @type {Decision} */
127
+ let decision
128
+ if (defects.length === 0) decision = { action: "deliver", reason: "clean", residual: [], blocking: [] }
129
+ else {
130
+ const elapsedMs = Date.parse(at) - Date.parse(state.startedAt)
131
+ const reason = stopReason(state, { signature, consecutiveNonProgress, repairsTaken, elapsedMs })
132
+ if (reason === null) decision = { action: "repair", reason: null, residual: [...defects], blocking }
133
+ else if (blocking.length > 0) decision = { action: "block", reason: "blocking", residual: [...defects], blocking }
134
+ else decision = { action: artifactOk ? "deliver" : "block", reason, residual: [...defects], blocking }
135
+ }
136
+
137
+ const next = {
138
+ ...state,
139
+ budget: { ...state.budget },
140
+ integrityCodes: [...state.integrityCodes],
141
+ attempts: [...state.attempts, { at, measure, signature, action: decision.action }],
142
+ consecutiveNonProgress,
143
+ seenSignatures: state.seenSignatures.includes(signature) ? [...state.seenSignatures] : [...state.seenSignatures, signature],
144
+ }
145
+ return { state: next, decision }
146
+ }
147
+
148
+ function assertValid(state) {
149
+ const { ok, errors } = validateRepairState(state)
150
+ if (!ok) throw new RepairTrackerError("invalid_repair_state", `invalid repair state: ${errors.join("; ")}`)
151
+ if (!Array.isArray(state.integrityCodes)) throw new RepairTrackerError("invalid_repair_state", "integrityCodes must be an array")
152
+ for (const code of state.integrityCodes) assertKnownCode(code, "integrity code")
153
+ }
154
+
155
+ /** @returns {string} the repair-state.json text */
156
+ export function serializeState(state) {
157
+ assertValid(state)
158
+ return `${JSON.stringify(state, null, 2)}\n`
159
+ }
160
+
161
+ /** @param {string} text repair-state.json contents @returns {RepairState} */
162
+ export function deserializeState(text) {
163
+ let state
164
+ try {
165
+ state = JSON.parse(text)
166
+ } catch (error) {
167
+ throw new RepairTrackerError("invalid_repair_state", `repair state is not JSON: ${error.message}`)
168
+ }
169
+ assertValid(state)
170
+ return state
171
+ }
@@ -0,0 +1,246 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { REPAIR_DEFAULTS, validateRepairState } from "./contracts.mjs"
4
+ import {
5
+ RepairTrackerError,
6
+ createTracker,
7
+ decide,
8
+ deserializeState,
9
+ isArtifactOk,
10
+ serializeState,
11
+ } from "./repair-tracker.mjs"
12
+
13
+ const START = "2026-09-24T00:00:00.000Z"
14
+ const MINUTE = 60 * 1000
15
+ const OK = { artifactOk: true }
16
+
17
+ function at(minutes: number): string {
18
+ return new Date(Date.parse(START) + minutes * MINUTE).toISOString()
19
+ }
20
+
21
+ function defects(...codes: string[]) {
22
+ return codes.map((code) => ({ code, message: `${code} found` }))
23
+ }
24
+
25
+ type Step = { codes: string[]; minutes?: number }
26
+
27
+ function runSequence(options: Record<string, unknown>, steps: Step[], extra: Record<string, unknown> = OK) {
28
+ let state = createTracker({ startedAt: START, ...options })
29
+ const decisions = []
30
+ for (const [index, step] of steps.entries()) {
31
+ const result = decide(state, defects(...step.codes), at(step.minutes ?? index), extra)
32
+ state = result.state
33
+ decisions.push(result.decision)
34
+ }
35
+ return { state, decisions }
36
+ }
37
+
38
+ describe("createTracker", () => {
39
+ test("#given no budget #when a tracker is created #then it uses REPAIR_DEFAULTS and validates", () => {
40
+ const state = createTracker({ startedAt: START })
41
+
42
+ expect(state.budget).toEqual({ ...REPAIR_DEFAULTS })
43
+ expect(state.attempts).toEqual([])
44
+ expect(state.consecutiveNonProgress).toBe(0)
45
+ expect(validateRepairState(state)).toEqual({ ok: true, errors: [] })
46
+ })
47
+
48
+ test("#given no startedAt #when a tracker is created #then it throws instead of reading the clock", () => {
49
+ // @ts-expect-error startedAt is deliberately omitted to prove the runtime guard
50
+ expect(() => createTracker({})).toThrow(RepairTrackerError)
51
+ })
52
+
53
+ test("#given an empty integrityCodes list #when a blocker remains at stop #then the integrity code is still blocking", () => {
54
+ const { decisions } = runSequence({ maxAttempts: 1, integrityCodes: [] }, [
55
+ { codes: ["missing_citation_section"] },
56
+ { codes: ["missing_citation_section", "heading_generic"] },
57
+ ])
58
+
59
+ expect(decisions[1]).toMatchObject({ action: "block", reason: "blocking" })
60
+ })
61
+
62
+ test("#given an extra integrity code #when it remains at stop #then delivery is blocked on it", () => {
63
+ const { decisions } = runSequence({ maxAttempts: 1, integrityCodes: ["em_dash_in_prose"] }, [
64
+ { codes: ["em_dash_in_prose"] },
65
+ { codes: ["em_dash_in_prose", "heading_generic"] },
66
+ ])
67
+
68
+ expect(decisions[1].action).toBe("block")
69
+ expect(decisions[1].blocking.map((d: { code: string }) => d.code)).toEqual(["em_dash_in_prose"])
70
+ })
71
+
72
+ test("#given an unknown integrity code #when a tracker is created #then it throws a typed error", () => {
73
+ expect(() => createTracker({ startedAt: START, integrityCodes: ["not_a_code"] })).toThrow(RepairTrackerError)
74
+ })
75
+ })
76
+
77
+ describe("decide stop conditions", () => {
78
+ test("#given plateauLimit 2 #when the measure stops improving twice #then the fourth decide stops with plateau", () => {
79
+ // [0,3,1] -> [0,2,1] -> [0,2,1] -> [0,2,1], every signature distinct
80
+ const { decisions, state } = runSequence({ maxAttempts: 5, plateauLimit: 2 }, [
81
+ { codes: ["emoji_in_prose", "em_dash_in_prose", "unsourced_number", "heading_generic"] },
82
+ { codes: ["chart_without_figure", "chart_svg_no_text", "heading_too_long"] },
83
+ { codes: ["korean_no_keep_all", "korean_serif_font", "palette_off_token"] },
84
+ { codes: ["layout_overflow", "layout_text_clipped", "en_dash_in_prose"] },
85
+ ])
86
+
87
+ expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"])
88
+ expect(decisions[3]).toMatchObject({ action: "deliver", reason: "plateau" })
89
+ expect(decisions[3].residual.map((d: { code: string }) => d.code)).toEqual([
90
+ "layout_overflow",
91
+ "layout_text_clipped",
92
+ "en_dash_in_prose",
93
+ ])
94
+ expect(state.consecutiveNonProgress).toBe(2)
95
+ })
96
+
97
+ test("#given the literal blocker plateau sequence #when it stops #then the integrity codes block delivery", () => {
98
+ // [2,1,0] -> [1,1,0] -> [1,1,0] -> [1,1,0]
99
+ const { decisions } = runSequence({ maxAttempts: 5, plateauLimit: 2 }, [
100
+ { codes: ["missing_citation_section", "missing_closing_section", "emoji_in_prose"] },
101
+ { codes: ["missing_citation_section", "em_dash_in_prose"] },
102
+ { codes: ["missing_closing_section", "unsourced_number"] },
103
+ { codes: ["broken_asset_reference", "layout_overflow"] },
104
+ ])
105
+
106
+ expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"])
107
+ expect(decisions[3]).toMatchObject({ action: "block", reason: "blocking" })
108
+ expect(decisions[3].blocking.map((d: { code: string }) => d.code)).toEqual(["broken_asset_reference"])
109
+ })
110
+
111
+ test("#given signatures A, B, A #when the third decide runs #then it stops with oscillation", () => {
112
+ const { decisions } = runSequence({ maxAttempts: 5 }, [
113
+ { codes: ["emoji_in_prose", "heading_generic"] },
114
+ { codes: ["unsourced_number"] },
115
+ { codes: ["heading_generic", "emoji_in_prose"] },
116
+ ])
117
+
118
+ expect(decisions.map((d) => d.action)).toEqual(["repair", "repair", "deliver"])
119
+ expect(decisions[2].reason).toBe("oscillation")
120
+ })
121
+
122
+ test("#given oscillation and plateau both hold #when decided #then oscillation wins by precedence", () => {
123
+ const { decisions } = runSequence({ maxAttempts: 5, plateauLimit: 1 }, [
124
+ { codes: ["unsourced_number"] },
125
+ { codes: ["unsourced_number"] },
126
+ ])
127
+
128
+ expect(decisions[1]).toMatchObject({ action: "deliver", reason: "oscillation" })
129
+ })
130
+
131
+ test("#given maxAttempts 3 and improving measures #when the fourth decide runs #then it stops with max_attempts", () => {
132
+ // [0,3,0] -> [0,2,0] -> [0,1,0] -> [0,1,0], every signature distinct
133
+ const { decisions } = runSequence({ maxAttempts: 3 }, [
134
+ { codes: ["emoji_in_prose", "em_dash_in_prose", "unsourced_number"] },
135
+ { codes: ["chart_without_figure", "chart_svg_no_text"] },
136
+ { codes: ["korean_serif_font"] },
137
+ { codes: ["layout_text_clipped"] },
138
+ ])
139
+
140
+ expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"])
141
+ expect(decisions[3]).toMatchObject({ action: "deliver", reason: "max_attempts" })
142
+ })
143
+
144
+ test("#given the literal blocker max_attempts sequence #when the fourth decide runs #then it blocks", () => {
145
+ // [3,0,0] -> [2,0,0] -> [1,0,0] -> [1,0,0]
146
+ const { decisions } = runSequence({ maxAttempts: 3 }, [
147
+ { codes: ["missing_citation_section", "missing_closing_section", "broken_asset_reference"] },
148
+ { codes: ["missing_citation_section", "missing_required_section"] },
149
+ { codes: ["missing_closing_section"] },
150
+ { codes: ["missing_citation_section"] },
151
+ ])
152
+
153
+ expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"])
154
+ expect(decisions[3]).toMatchObject({ action: "block", reason: "blocking" })
155
+ })
156
+
157
+ test("#given wallClockMs 900000 and improving measures #when now is start + 15 min #then it stops with wall_clock", () => {
158
+ const { decisions } = runSequence({ maxAttempts: 5, wallClockMs: 900000 }, [
159
+ { codes: ["emoji_in_prose", "em_dash_in_prose"], minutes: 0 },
160
+ { codes: ["unsourced_number"], minutes: 7 },
161
+ { codes: ["heading_generic"], minutes: 15 },
162
+ ])
163
+
164
+ expect(decisions.map((d) => d.action)).toEqual(["repair", "repair", "deliver"])
165
+ expect(decisions[2].reason).toBe("wall_clock")
166
+ })
167
+
168
+ test("#given an integrity code and no stop condition #when decided #then it schedules a repair", () => {
169
+ const { decisions } = runSequence({}, [{ codes: ["missing_citation_section"] }])
170
+
171
+ expect(decisions[0]).toMatchObject({ action: "repair", reason: null })
172
+ expect(decisions[0].blocking.map((d: { code: string }) => d.code)).toEqual(["missing_citation_section"])
173
+ })
174
+
175
+ test("#given a stop without a usable artifact #when decided #then it blocks with the stop reason", () => {
176
+ const { decisions } = runSequence({ maxAttempts: 1 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }], {
177
+ artifactOk: false,
178
+ })
179
+
180
+ expect(decisions[1]).toMatchObject({ action: "block", reason: "max_attempts", blocking: [] })
181
+ })
182
+
183
+ test("#given zero defects #when decided #then it delivers clean", () => {
184
+ const { decisions } = runSequence({ maxAttempts: 1 }, [{ codes: ["emoji_in_prose"] }, { codes: [] }], { artifactOk: false })
185
+
186
+ expect(decisions[1]).toEqual({ action: "deliver", reason: "clean", residual: [], blocking: [] })
187
+ })
188
+
189
+ test("#given a defect with an unknown code #when decided #then it throws a typed error", () => {
190
+ const state = createTracker({ startedAt: START })
191
+ let caught: unknown
192
+ try {
193
+ decide(state, [{ code: "made_up_code", message: "x" }], at(1), OK)
194
+ } catch (error) {
195
+ caught = error
196
+ }
197
+
198
+ expect(caught).toBeInstanceOf(RepairTrackerError)
199
+ expect((caught as RepairTrackerError).code).toBe("unknown_defect_code")
200
+ })
201
+
202
+ test("#given an invalid now #when decided #then it throws a typed error", () => {
203
+ const state = createTracker({ startedAt: START })
204
+
205
+ expect(() => decide(state, [], "not a date", OK)).toThrow(RepairTrackerError)
206
+ })
207
+
208
+ test("#given a state #when decided #then the input state is not mutated", () => {
209
+ const state = createTracker({ startedAt: START })
210
+ const before = serializeState(state)
211
+ decide(state, defects("emoji_in_prose"), at(1), OK)
212
+
213
+ expect(serializeState(state)).toBe(before)
214
+ })
215
+ })
216
+
217
+ describe("isArtifactOk", () => {
218
+ test("#given bytes and renders #when checked #then it needs at least 512 bytes and one render", () => {
219
+ expect(isArtifactOk({ bytes: 512, renders: 1 })).toBe(true)
220
+ expect(isArtifactOk({ bytes: 511, renders: 1 })).toBe(false)
221
+ expect(isArtifactOk({ bytes: 4096, renders: 0 })).toBe(false)
222
+ })
223
+ })
224
+
225
+ describe("serializeState / deserializeState", () => {
226
+ test("#given a state after several decides #when round-tripped #then the bytes are identical and valid", () => {
227
+ const { state } = runSequence({ maxAttempts: 5 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }])
228
+ const text = serializeState(state)
229
+ const restored = deserializeState(text)
230
+
231
+ expect(serializeState(restored)).toBe(text)
232
+ expect(validateRepairState(restored)).toEqual({ ok: true, errors: [] })
233
+ })
234
+
235
+ test("#given a restored state #when decide continues #then it keeps the attempt history", () => {
236
+ const { state } = runSequence({ maxAttempts: 5 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }])
237
+ const { decision } = decide(deserializeState(serializeState(state)), defects("emoji_in_prose"), at(3), OK)
238
+
239
+ expect(decision.reason).toBe("oscillation")
240
+ })
241
+
242
+ test("#given an invalid state file #when deserialized #then it throws a typed error", () => {
243
+ expect(() => deserializeState('{"schemaVersion":1}')).toThrow(RepairTrackerError)
244
+ expect(() => deserializeState("not json")).toThrow(RepairTrackerError)
245
+ })
246
+ })
@@ -0,0 +1,214 @@
1
+ // report-tools-commands.mjs - one function per report-tools subcommand. Each returns
2
+ // { json | text, summary?, exitCode } and never exits the process; report-tools.mjs dispatches.
3
+
4
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
5
+ import { dirname, join, resolve } from "node:path"
6
+
7
+ import { CliError, parseArgs, readJsonFile, writeJsonAtomic } from "./cli-support.mjs"
8
+ import { REPAIR_DEFAULTS } from "./contracts.mjs"
9
+ import { parseLineageMode, parsePalette, renderDesignSpec } from "./design-spec.mjs"
10
+ import { extractFromHtml, extractFromPath, fetchHtml } from "./format-extract.mjs"
11
+ import { checkLayout } from "./gates-layout.mjs"
12
+ import { checkStatic } from "./gates-static.mjs"
13
+ import { parseHtml } from "./html-lite.mjs"
14
+ import { buildProbeSource } from "./layout-probe.mjs"
15
+ import * as outcome from "./outcome.mjs"
16
+ import { createTracker, decide, deserializeState, isArtifactOk, serializeState } from "./repair-tracker.mjs"
17
+
18
+ const SEVERITY_ORDER = { blocker: 0, major: 1, minor: 2 }
19
+
20
+ function readText(path) {
21
+ try {
22
+ return readFileSync(path, "utf8")
23
+ } catch (error) {
24
+ throw new CliError(`cannot read ${path}: ${error.code ?? error.message}`)
25
+ }
26
+ }
27
+
28
+ function nowFrom(flags) {
29
+ if (flags.now === undefined) return new Date()
30
+ const date = new Date(flags.now)
31
+ if (Number.isNaN(date.getTime())) throw new CliError("--now requires an ISO timestamp")
32
+ return date
33
+ }
34
+
35
+ function sessionDirFrom(flags) {
36
+ const dir = flags["session-dir"] ?? process.env.SESSION_DIR
37
+ if (!dir) throw new CliError("--session-dir is required (or set SESSION_DIR)")
38
+ return resolve(dir)
39
+ }
40
+
41
+ export function runCheck(argv) {
42
+ const args = parseArgs(argv, { repeatable: ["require-section"] })
43
+ const reportPath = args.positionals[0]
44
+ if (!reportPath) throw new CliError("usage: check <report.html> --design-spec <design-spec.md> [--layout <boxes.json>] [--require-section <regex>]... [--max-defects N]")
45
+ const specText = readText(args.required("design-spec"))
46
+ const doc = parseHtml(readText(reportPath))
47
+ const requireSections = (args.flags["require-section"] ?? []).map((source) => new RegExp(source, "i"))
48
+ const result = checkStatic(doc, {
49
+ palette: parsePalette(specText),
50
+ lineageMode: parseLineageMode(specText),
51
+ requireSections,
52
+ baseDir: dirname(resolve(reportPath)),
53
+ })
54
+ const defects = [...result.defects]
55
+ const gatesRun = [...result.gatesRun]
56
+ let layout = "not_run"
57
+ if (args.flags.layout !== undefined) {
58
+ const layoutResult = checkLayout(readJsonFile(args.flags.layout))
59
+ defects.push(...layoutResult.defects)
60
+ gatesRun.push("layout")
61
+ layout = layoutResult.defects.some((d) => d.severity === "blocker") ? "fail" : "pass"
62
+ }
63
+ defects.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || (a.line ?? 0) - (b.line ?? 0) || a.code.localeCompare(b.code))
64
+ const counts = { blocker: 0, major: 0, minor: 0, total: defects.length }
65
+ for (const defect of defects) counts[defect.severity] += 1
66
+ const max = args.int("max-defects", 200)
67
+ const status = counts.blocker > 0 ? "fail" : "pass"
68
+ const exitCode = status === "fail" ? 1 : 0
69
+ return {
70
+ json: { command: "check", status, exitCode, layout, defects: defects.slice(0, max), counts, gatesRun, gatesSkipped: result.gatesSkipped },
71
+ summary: `check: ${status.toUpperCase()} - ${counts.blocker} blocker, ${counts.major} major, ${counts.minor} minor; layout ${layout}`,
72
+ exitCode,
73
+ }
74
+ }
75
+
76
+ export function runLayoutProbe(argv) {
77
+ const args = parseArgs(argv, { booleans: ["json"] })
78
+ const source = buildProbeSource({ cap: args.int("cap", 400), root: args.flags.root ?? "main, article" })
79
+ return args.flags.json ? { json: { command: "layout-probe", source }, exitCode: 0 } : { text: source, exitCode: 0 }
80
+ }
81
+
82
+ export function runRepairDecide(argv) {
83
+ const args = parseArgs(argv)
84
+ const statePath = args.required("state")
85
+ const now = nowFrom(args.flags)
86
+ const defectsInput = readJsonFile(args.required("defects"))
87
+ const defects = Array.isArray(defectsInput) ? defectsInput : defectsInput.defects
88
+ if (!Array.isArray(defects)) throw new CliError("--defects must be a JSON array or a check result with a defects array")
89
+ const state = existsSync(statePath)
90
+ ? deserializeState(readText(statePath))
91
+ : createTracker({
92
+ maxAttempts: args.int("max-attempts", REPAIR_DEFAULTS.maxAttempts),
93
+ plateauLimit: args.int("plateau-limit", REPAIR_DEFAULTS.plateauLimit),
94
+ wallClockMs: args.int("wall-clock-ms", REPAIR_DEFAULTS.wallClockMs),
95
+ startedAt: now,
96
+ })
97
+ // Fail closed: deliver-on-exhaustion needs proof of a usable artifact (bytes and renders).
98
+ const artifactOk =
99
+ args.flags["artifact-bytes"] !== undefined && args.flags.renders !== undefined
100
+ ? isArtifactOk({ bytes: Number(args.flags["artifact-bytes"]), renders: Number(args.flags.renders) })
101
+ : false
102
+ const { state: next, decision } = decide(state, defects, now, { artifactOk })
103
+ writeFileSync(statePath, serializeState(next))
104
+ recordRepair(args.flags, next, decision)
105
+ const exitCode = decision.action === "block" ? 1 : 0
106
+ return { json: { command: "repair decide", decision }, summary: `repair: ${decision.action}${decision.reason ? ` (${decision.reason})` : ""}`, exitCode }
107
+ }
108
+
109
+ /** With a session dir that holds outcome.json, the repair summary and residual list land in the manifest. */
110
+ function recordRepair(flags, state, decision) {
111
+ const dir = flags["session-dir"] ?? process.env.SESSION_DIR
112
+ if (!dir) return
113
+ const file = join(resolve(dir), outcome.MANIFEST_FILENAME)
114
+ if (!existsSync(file)) return
115
+ const manifest = outcome.readManifest(file)
116
+ manifest.repair = { attempts: state.attempts.length, action: decision.action, reason: decision.reason }
117
+ if (decision.action !== "repair") manifest.residualDefects = decision.residual.map((defect) => ({ ...defect, message: defect.message ?? defect.code }))
118
+ outcome.writeManifest(file, manifest)
119
+ }
120
+
121
+ function manifestPath(flags) {
122
+ return join(sessionDirFrom(flags), outcome.MANIFEST_FILENAME)
123
+ }
124
+
125
+ export function runOutcome(argv) {
126
+ const [sub, ...rest] = argv
127
+ const args = parseArgs(rest, { booleans: ["json"] })
128
+ const file = sub === "state" ? null : manifestPath(args.flags)
129
+ switch (sub) {
130
+ case "init": {
131
+ const promised = args.required("promised").split(",").map((s) => s.trim()).filter(Boolean)
132
+ const manifest = outcome.initManifest({ sessionDir: sessionDirFrom(args.flags), promised, lane: args.flags.lane ?? null, now: nowFrom(args.flags) })
133
+ outcome.writeManifest(file, manifest)
134
+ return { json: { command: "outcome init", path: file, deliverables: manifest.deliverables }, exitCode: 0 }
135
+ }
136
+ case "set": {
137
+ const [format, status] = args.positionals
138
+ if (!format || !status) throw new CliError("usage: outcome set <format> <status> [--path p] [--reason r] [--pages n]")
139
+ const manifest = outcome.readManifest(file)
140
+ const detail = {}
141
+ if (args.flags.path !== undefined) {
142
+ detail.path = resolve(args.flags.path)
143
+ if (existsSync(detail.path)) detail.bytes = statSync(detail.path).size
144
+ }
145
+ if (args.flags.reason !== undefined) detail.reason = args.flags.reason
146
+ if (args.flags.pages !== undefined) detail.pages = args.int("pages")
147
+ outcome.writeManifest(file, outcome.setDeliverable(manifest, format, status, { ...detail, now: nowFrom(args.flags) }))
148
+ return { json: { command: "outcome set", format, status }, exitCode: 0 }
149
+ }
150
+ case "gate": {
151
+ const [gate, status] = args.positionals
152
+ if (!gate || !status) throw new CliError("usage: outcome gate <static|layout|visual|proofread> <pass|fail|not_run>")
153
+ outcome.writeManifest(file, outcome.setGate(outcome.readManifest(file), gate, status))
154
+ return { json: { command: "outcome gate", gate, status }, exitCode: 0 }
155
+ }
156
+ case "render": {
157
+ const [path] = args.positionals
158
+ if (!path) throw new CliError("usage: outcome render <path> --page N")
159
+ outcome.writeManifest(file, outcome.addRender(outcome.readManifest(file), resolve(path), args.int("page")))
160
+ return { json: { command: "outcome render", path: resolve(path) }, exitCode: 0 }
161
+ }
162
+ case "state": {
163
+ const deliverable = args.required("deliverable")
164
+ const sibling = join(dirname(resolve(deliverable)), outcome.MANIFEST_FILENAME)
165
+ const dir = args.flags["session-dir"] ?? process.env.SESSION_DIR
166
+ const candidate = dir ? join(resolve(dir), outcome.MANIFEST_FILENAME) : sibling
167
+ const manifest = existsSync(candidate) ? outcome.readManifest(candidate) : null
168
+ const state = outcome.deliverableState(resolve(deliverable), manifest)
169
+ return { json: { command: "outcome state", state }, summary: `state: ${state}`, exitCode: 0 }
170
+ }
171
+ case "verify": {
172
+ const result = outcome.verifyManifest(outcome.readManifest(file))
173
+ return { json: { command: "outcome verify", ...result }, summary: `verify: ${result.ok ? "OK" : `${result.problems.length} problem(s)`}`, exitCode: result.ok ? 0 : 1 }
174
+ }
175
+ case "finish": {
176
+ const manifest = outcome.readManifest(file)
177
+ const ledgerPath = args.flags.ledger ?? join(sessionDirFrom(args.flags), "sources-ledger.md")
178
+ const now = nowFrom(args.flags)
179
+ const facts = outcome.briefingFacts(manifest, existsSync(ledgerPath) ? readText(ledgerPath) : "", now)
180
+ manifest.finishedAt = now.toISOString()
181
+ manifest.elapsedMinutes = facts.minutes
182
+ manifest.sources = { total: facts.sources, domains: facts.domains }
183
+ outcome.writeManifest(file, manifest)
184
+ return { json: { command: "outcome finish", finishedAt: manifest.finishedAt, elapsedMinutes: facts.minutes, sources: manifest.sources }, exitCode: 0 }
185
+ }
186
+ case "briefing": {
187
+ const manifest = outcome.readManifest(file)
188
+ const ledgerPath = args.flags.ledger ?? join(sessionDirFrom(args.flags), "sources-ledger.md")
189
+ const ledger = existsSync(ledgerPath) ? readText(ledgerPath) : ""
190
+ const now = nowFrom(args.flags)
191
+ if (args.flags.json) return { json: { command: "outcome briefing", ...outcome.briefingFacts(manifest, ledger, now) }, exitCode: 0 }
192
+ return { text: outcome.buildBriefing(manifest, ledger, now), exitCode: 0 }
193
+ }
194
+ default:
195
+ throw new CliError("usage: outcome <init|set|gate|render|state|verify|finish|briefing> ...")
196
+ }
197
+ }
198
+
199
+ export async function runFormatExtract(argv) {
200
+ const args = parseArgs(argv, { booleans: ["from-url"] })
201
+ const [reference] = args.positionals
202
+ if (!reference) throw new CliError("usage: format-extract <reference> --out <design-spec.md> [--from-url]")
203
+ const out = args.required("out")
204
+ let extract
205
+ try {
206
+ extract = args.flags["from-url"] ? extractFromHtml(await fetchHtml(reference), { origin: reference }) : extractFromPath(reference)
207
+ } catch (error) {
208
+ // An unsupported, unreadable, or unreachable reference is an input problem (exit 2), not a gate result.
209
+ throw new CliError(`format-extract ${reference}: ${error.message}`)
210
+ }
211
+ writeJsonAtomic(`${out}.extract.json`, { ...extract, tokens: { light: Object.fromEntries(extract.tokens?.light ?? []), dark: Object.fromEntries(extract.tokens?.dark ?? []) } })
212
+ writeFileSync(out, renderDesignSpec(extract))
213
+ return { json: { command: "format-extract", out: resolve(out), origin: extract.origin, lineageMode: extract.lineageMode, notes: extract.notes ?? [] }, summary: `format-extract: wrote ${out}`, exitCode: 0 }
214
+ }