oh-my-opencode 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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh-cn.md +1 -1
- package/dist/agents/sisyphus/grok-4.d.ts +3 -2
- package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
- package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
- package/dist/cli/index.js +1661 -859
- package/dist/cli/install-native/index.d.ts +9 -1
- package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
- package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
- package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
- package/dist/cli/install-native/plan.d.ts +8 -0
- package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
- package/dist/cli/install-native/run-native-install.d.ts +11 -1
- package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
- package/dist/cli/native-edition-hint.d.ts +2 -2
- package/dist/cli-node/index.js +1661 -859
- package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
- package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
- package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
- package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
- package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
- package/dist/index.js +1433 -1055
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
- package/dist/skills/browser/SKILL.md +8 -1
- package/dist/skills/browser/references/install.md +34 -5
- package/dist/skills/browser/runtime/omowright/index.js +2 -2
- package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
- package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/dist/skills/browser/scripts/browser-install.mjs +13 -1
- package/dist/skills/ulw-research/AGENTS.md +63 -0
- package/dist/skills/ulw-research/SKILL.md +38 -26
- package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/dist/skills/ulw-research/references/report-gates.md +66 -0
- package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
- package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- package/dist/skills/visual-qa/references/browser-setup.md +3 -1
- package/dist/tui.js +534 -66
- package/package.json +15 -15
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
- package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
- package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
- package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
- package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
- package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
- package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
- package/packages/shared-skills/skills/browser/SKILL.md +8 -1
- package/packages/shared-skills/skills/browser/references/install.md +34 -5
- package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
- package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// outcome.mjs - the per-run outcome manifest (`outcome.json`) and the closing briefing.
|
|
2
|
+
// Manifest functions are pure (they return a new manifest); the briefing reads only the
|
|
3
|
+
// manifest, the sources-ledger text, and an injected `now`. node: builtins only.
|
|
4
|
+
|
|
5
|
+
import * as nodeFs from "node:fs"
|
|
6
|
+
import { basename, dirname, join } from "node:path"
|
|
7
|
+
|
|
8
|
+
import { DELIVERABLE_FORMATS, DELIVERABLE_STATUSES, GATE_NAMES, GATE_STATUSES, LANES } from "./contracts.mjs"
|
|
9
|
+
import { MANIFEST_CODES, SCHEMA_VERSION, validateOutcomeManifest } from "./contracts.mjs"
|
|
10
|
+
|
|
11
|
+
export const MANIFEST_FILENAME = "outcome.json"
|
|
12
|
+
const MISSING = MANIFEST_CODES[0]
|
|
13
|
+
const SESSION_STAMP = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
|
|
14
|
+
const SOURCE_LINE = /^\[(S|Source )\d+\]/
|
|
15
|
+
const URL_PATTERN = /https?:\/\/[^\s)<>\]"'`]+/g
|
|
16
|
+
const DRAFT_MARKER = "STATUS: draft"
|
|
17
|
+
const DRAFT_SCAN_LINES = 5
|
|
18
|
+
const REASON_REQUIRED = new Set(["blocked_capability", "skipped", "failed"])
|
|
19
|
+
|
|
20
|
+
export class OutcomeError extends Error {
|
|
21
|
+
/** @param {string} code @param {string} message @param {readonly string[]} [allowed] */
|
|
22
|
+
constructor(code, message, allowed) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = "OutcomeError"
|
|
25
|
+
this.code = code
|
|
26
|
+
if (allowed) this.allowed = [...allowed]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertOneOf(code, label, value, allowed) {
|
|
31
|
+
if (!allowed.includes(value)) {
|
|
32
|
+
throw new OutcomeError(code, `unknown ${label} ${JSON.stringify(value)}; allowed: ${allowed.join(", ")}`, allowed)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const clone = (manifest) => structuredClone(manifest)
|
|
37
|
+
const isMissing = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR"
|
|
38
|
+
|
|
39
|
+
function stamp(now) {
|
|
40
|
+
return (now ?? new Date()).toISOString()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Local wall-clock start time from a `YYYYMMDD-HHMMSS` session dir basename. */
|
|
44
|
+
export function parseSessionStart(sessionDir) {
|
|
45
|
+
const match = SESSION_STAMP.exec(basename(sessionDir))
|
|
46
|
+
if (!match) throw new OutcomeError("invalid_session_dir", `session dir basename must be YYYYMMDD-HHMMSS: ${sessionDir}`)
|
|
47
|
+
const [y, mo, d, h, mi, s] = match.slice(1).map(Number)
|
|
48
|
+
const date = new Date(y, mo - 1, d, h, mi, s)
|
|
49
|
+
if (date.getMonth() !== mo - 1 || date.getDate() !== d || date.getHours() !== h) {
|
|
50
|
+
throw new OutcomeError("invalid_session_dir", `session dir basename is not a real local time: ${sessionDir}`)
|
|
51
|
+
}
|
|
52
|
+
return date
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {{ sessionDir: string, promised: string[], lane: string|null, interview?: object|null, now?: Date }} input */
|
|
56
|
+
export function initManifest({ sessionDir, promised, lane, interview = null, now }) {
|
|
57
|
+
const startedAt = parseSessionStart(sessionDir).toISOString()
|
|
58
|
+
if (lane !== null && lane !== undefined) assertOneOf("unknown_lane", "lane", lane, LANES)
|
|
59
|
+
const formats = [...new Set(promised)]
|
|
60
|
+
for (const format of formats) assertOneOf("unknown_format", "deliverable format", format, DELIVERABLE_FORMATS)
|
|
61
|
+
const updatedAt = stamp(now)
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: SCHEMA_VERSION,
|
|
64
|
+
sessionDir,
|
|
65
|
+
startedAt,
|
|
66
|
+
finishedAt: null,
|
|
67
|
+
elapsedMinutes: null,
|
|
68
|
+
lane: lane ?? null,
|
|
69
|
+
interview,
|
|
70
|
+
deliverables: formats.map((format) => ({ format, status: "pending", promised: true, updatedAt })),
|
|
71
|
+
renders: [],
|
|
72
|
+
gates: Object.fromEntries(GATE_NAMES.map((gate) => [gate, "not_run"])),
|
|
73
|
+
residualDefects: [],
|
|
74
|
+
repair: null,
|
|
75
|
+
sources: null,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set one format's row; every other row is kept as is. An unpromised format gets a new
|
|
81
|
+
* row with promised=false. The row's detail fields are replaced by the ones given.
|
|
82
|
+
* @param {{ path?: string, reason?: string, pages?: number, bytes?: number, now?: Date }} [detail]
|
|
83
|
+
*/
|
|
84
|
+
export function setDeliverable(manifest, format, status, detail = {}) {
|
|
85
|
+
assertOneOf("unknown_format", "deliverable format", format, DELIVERABLE_FORMATS)
|
|
86
|
+
assertOneOf("unknown_status", "deliverable status", status, DELIVERABLE_STATUSES)
|
|
87
|
+
const next = clone(manifest)
|
|
88
|
+
const index = next.deliverables.findIndex((row) => row.format === format)
|
|
89
|
+
const row = { format, status, promised: index >= 0 ? next.deliverables[index].promised : false, updatedAt: stamp(detail.now) }
|
|
90
|
+
for (const key of ["path", "reason", "pages", "bytes"]) {
|
|
91
|
+
if (detail[key] !== undefined) row[key] = detail[key]
|
|
92
|
+
}
|
|
93
|
+
if (index >= 0) next.deliverables[index] = row
|
|
94
|
+
else next.deliverables.push(row)
|
|
95
|
+
return next
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function setGate(manifest, gate, status) {
|
|
99
|
+
assertOneOf("unknown_gate", "gate", gate, GATE_NAMES)
|
|
100
|
+
assertOneOf("unknown_gate_status", "gate status", status, GATE_STATUSES)
|
|
101
|
+
const next = clone(manifest)
|
|
102
|
+
next.gates[gate] = status
|
|
103
|
+
return next
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function addRender(manifest, path, page) {
|
|
107
|
+
if (typeof path !== "string" || path.length === 0) throw new OutcomeError("invalid_render", "render path must be a non-empty string")
|
|
108
|
+
if (!Number.isInteger(page) || page < 1) throw new OutcomeError("invalid_render", `render page must be an integer >= 1, got ${page}`)
|
|
109
|
+
const next = clone(manifest)
|
|
110
|
+
if (!next.renders.some((render) => render.path === path && render.page === page)) next.renders.push({ path, page })
|
|
111
|
+
return next
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fileSize(fs, path) {
|
|
115
|
+
try {
|
|
116
|
+
const stat = fs.statSync(path)
|
|
117
|
+
return stat.isFile() ? stat.size : null
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isMissing(error)) return null
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {object} manifest
|
|
126
|
+
* @param {{ statSync: typeof nodeFs.statSync }} [fs]
|
|
127
|
+
* @returns {{ ok: boolean, problems: { code: string, format: string, message: string, path?: string }[] }}
|
|
128
|
+
*/
|
|
129
|
+
export function verifyManifest(manifest, fs = nodeFs) {
|
|
130
|
+
const problems = []
|
|
131
|
+
for (const row of manifest.deliverables) {
|
|
132
|
+
const problem = (message, path) => problems.push({ code: MISSING, format: row.format, message, ...(path ? { path } : {}) })
|
|
133
|
+
if (row.status === "pending") problem(`${row.format} is still pending`)
|
|
134
|
+
else if (row.status === "delivered") {
|
|
135
|
+
if (!row.path) problem(`${row.format} is delivered without a path`)
|
|
136
|
+
else {
|
|
137
|
+
const size = fileSize(fs, row.path)
|
|
138
|
+
if (size === null) problem(`${row.format} delivered file is missing: ${row.path}`, row.path)
|
|
139
|
+
else if (size === 0) problem(`${row.format} delivered file is empty: ${row.path}`, row.path)
|
|
140
|
+
}
|
|
141
|
+
} else if (REASON_REQUIRED.has(row.status) && !row.reason) problem(`${row.format} is ${row.status} without a reason`)
|
|
142
|
+
}
|
|
143
|
+
return { ok: problems.length === 0, problems }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* none = missing or empty file; partial = `STATUS: draft` in the first 5 lines, or the
|
|
148
|
+
* manifest (given, else a sibling outcome.json) still has a pending row; else complete.
|
|
149
|
+
* @returns {"none"|"partial"|"complete"}
|
|
150
|
+
*/
|
|
151
|
+
export function deliverableState(deliverablePath, manifestOrNull) {
|
|
152
|
+
let text
|
|
153
|
+
try {
|
|
154
|
+
text = nodeFs.readFileSync(deliverablePath, "utf8")
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (isMissing(error)) return "none"
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
if (text.trim() === "") return "none"
|
|
160
|
+
if (text.split(/\r?\n/, DRAFT_SCAN_LINES).some((line) => line.includes(DRAFT_MARKER))) return "partial"
|
|
161
|
+
const sibling = join(dirname(deliverablePath), MANIFEST_FILENAME)
|
|
162
|
+
const manifest = manifestOrNull ?? (nodeFs.existsSync(sibling) ? readManifest(sibling) : null)
|
|
163
|
+
if (manifest && manifest.deliverables.some((row) => row.status === "pending")) return "partial"
|
|
164
|
+
return "complete"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Hostname without a leading `www.`; null for a string URL cannot parse (not a source host). */
|
|
168
|
+
function hostOf(url) {
|
|
169
|
+
return URL.canParse(url) ? new URL(url).hostname.toLowerCase().replace(/^www\./, "") : null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function repairSummary(repair) {
|
|
173
|
+
if (!repair) return null
|
|
174
|
+
const attempts = Array.isArray(repair.attempts) ? repair.attempts.length : repair.attempts ?? null
|
|
175
|
+
return { attempts, action: repair.decision?.action ?? repair.action ?? null, reason: repair.decision?.reason ?? repair.reason ?? null }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The briefing numbers, from the manifest + ledger text + injected `now` only. */
|
|
179
|
+
export function briefingFacts(manifest, sourcesLedgerText, now) {
|
|
180
|
+
const sourceLines = String(sourcesLedgerText ?? "").split(/\r?\n/).filter((line) => SOURCE_LINE.test(line))
|
|
181
|
+
const hosts = new Set()
|
|
182
|
+
for (const line of sourceLines) {
|
|
183
|
+
for (const url of line.match(URL_PATTERN) ?? []) {
|
|
184
|
+
const host = hostOf(url)
|
|
185
|
+
if (host) hosts.add(host)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const elapsedMs = now.getTime() - new Date(manifest.startedAt).getTime()
|
|
189
|
+
return {
|
|
190
|
+
sources: sourceLines.length,
|
|
191
|
+
domains: hosts.size,
|
|
192
|
+
hosts: [...hosts].sort(),
|
|
193
|
+
minutes: Math.max(0, Math.round(elapsedMs / 60000)),
|
|
194
|
+
deliverables: manifest.deliverables.map((row) => ({ format: row.format, status: row.status, reason: row.reason ?? null, path: row.path ?? null })),
|
|
195
|
+
gates: { ...manifest.gates },
|
|
196
|
+
residualDefects: manifest.residualDefects.length,
|
|
197
|
+
repair: repairSummary(manifest.repair),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The closing briefing block as plain text. */
|
|
202
|
+
export function buildBriefing(manifest, sourcesLedgerText, now) {
|
|
203
|
+
const facts = briefingFacts(manifest, sourcesLedgerText, now)
|
|
204
|
+
const repair = facts.repair
|
|
205
|
+
? `${facts.repair.attempts ?? "?"} attempt(s), last decision ${facts.repair.action ?? "?"} (${facts.repair.reason ?? "?"})`
|
|
206
|
+
: "not run"
|
|
207
|
+
return [
|
|
208
|
+
"Closing briefing",
|
|
209
|
+
`- Sources: ${facts.sources} from ${facts.domains} distinct domain(s), counted from the sources ledger.`,
|
|
210
|
+
`- Elapsed: ${facts.minutes} minute(s) since ${manifest.startedAt}.`,
|
|
211
|
+
"- Deliverables:",
|
|
212
|
+
" | format | status | detail |",
|
|
213
|
+
" |---|---|---|",
|
|
214
|
+
...facts.deliverables.map((row) => ` | ${row.format} | ${row.status} | ${row.reason ?? row.path ?? ""} |`),
|
|
215
|
+
`- Gates: ${GATE_NAMES.map((gate) => `${gate} ${facts.gates[gate]}`).join(", ")}.`,
|
|
216
|
+
`- Residual defects: ${facts.residualDefects}.`,
|
|
217
|
+
`- Repair: ${repair}.`,
|
|
218
|
+
].join("\n")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertValid(manifest, where) {
|
|
222
|
+
const { ok, errors } = validateOutcomeManifest(manifest)
|
|
223
|
+
if (!ok) throw new OutcomeError("invalid_manifest", `${where}: ${errors.join("; ")}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function readManifest(file) {
|
|
227
|
+
let manifest
|
|
228
|
+
try {
|
|
229
|
+
manifest = JSON.parse(nodeFs.readFileSync(file, "utf8"))
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SyntaxError) throw new OutcomeError("invalid_manifest", `${file}: not valid JSON (${error.message})`)
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
234
|
+
assertValid(manifest, file)
|
|
235
|
+
return manifest
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Validate, then write atomically (temp file in the same dir + rename). */
|
|
239
|
+
export function writeManifest(file, manifest) {
|
|
240
|
+
assertValid(manifest, file)
|
|
241
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${Date.now()}.tmp`)
|
|
242
|
+
try {
|
|
243
|
+
nodeFs.writeFileSync(temp, `${JSON.stringify(manifest, null, "\t")}\n`, "utf8")
|
|
244
|
+
nodeFs.renameSync(temp, file)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
nodeFs.rmSync(temp, { force: true })
|
|
247
|
+
throw error
|
|
248
|
+
}
|
|
249
|
+
return file
|
|
250
|
+
}
|
|
@@ -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,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
|
+
}
|