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.
- 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/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/package.json +1 -1
- 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,239 @@
|
|
|
1
|
+
// html-lite.mjs - a dependency-free HTML tokenizer for report checks.
|
|
2
|
+
//
|
|
3
|
+
// Single forward scan with indexOf (no whole-document regex, no backtracking). Produces a
|
|
4
|
+
// LiteNode tree: { tag, attrs, children, parent, line, text, raw }. Text nodes carry
|
|
5
|
+
// tag "#text"; <style>/<script>/<svg> keep their inner source in `raw` and have no parsed
|
|
6
|
+
// children. The "#document" root also carries `ids` (id -> occurrence count) so
|
|
7
|
+
// selectorPath can tell whether an id is unique. Plain Node ESM, no dependencies.
|
|
8
|
+
|
|
9
|
+
import { decodeEntities } from "./entities.mjs"
|
|
10
|
+
|
|
11
|
+
export { decodeEntities }
|
|
12
|
+
|
|
13
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"])
|
|
14
|
+
const RAW_TAGS = new Set(["style", "script", "svg"])
|
|
15
|
+
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
|
|
16
|
+
const P_CLOSERS = new Set([
|
|
17
|
+
"address", "article", "blockquote", "details", "div", "dl", "fieldset", "figcaption", "figure", "footer",
|
|
18
|
+
"form", "header", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul", ...HEADINGS,
|
|
19
|
+
])
|
|
20
|
+
const P_SCOPE = new Set(["td", "th", "caption", "button", "body", "html"])
|
|
21
|
+
const LI_SCOPE = new Set(["ul", "ol", "menu", "body", "html"])
|
|
22
|
+
const PROSE_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg"])
|
|
23
|
+
const SAFE_ID = /^[A-Za-z_][\w-]*$/
|
|
24
|
+
|
|
25
|
+
export const PROSE_TAGS = new Set(["p", "li", ...HEADINGS, "td", "th", "figcaption", "blockquote", "dd", "dt"])
|
|
26
|
+
const PROSE_BREAKS = new Set([...P_CLOSERS, ...PROSE_TAGS, "br", "tr"])
|
|
27
|
+
|
|
28
|
+
function makeNode(tag, attrs, parent, line) {
|
|
29
|
+
return { tag, attrs, children: [], parent, line, text: "", raw: null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lineStarts(source) {
|
|
33
|
+
const starts = [0]
|
|
34
|
+
for (let at = source.indexOf("\n"); at !== -1; at = source.indexOf("\n", at + 1)) starts.push(at + 1)
|
|
35
|
+
return starts
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineAt(starts, offset) {
|
|
39
|
+
let lo = 0
|
|
40
|
+
let hi = starts.length - 1
|
|
41
|
+
while (lo < hi) {
|
|
42
|
+
const mid = (lo + hi + 1) >> 1
|
|
43
|
+
if (starts[mid] <= offset) lo = mid
|
|
44
|
+
else hi = mid - 1
|
|
45
|
+
}
|
|
46
|
+
return lo + 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isSpace = (ch) => ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"
|
|
50
|
+
const isLetter = (ch) => (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")
|
|
51
|
+
|
|
52
|
+
function skipSpaces(source, at) {
|
|
53
|
+
while (at < source.length && isSpace(source[at])) at++
|
|
54
|
+
return at
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read `<name attrs...>` starting just after `<`. */
|
|
58
|
+
function readTag(source, start) {
|
|
59
|
+
const end = source.length
|
|
60
|
+
let at = start
|
|
61
|
+
while (at < end && !isSpace(source[at]) && source[at] !== ">" && source[at] !== "/") at++
|
|
62
|
+
const name = source.slice(start, at).toLowerCase()
|
|
63
|
+
const attrs = {}
|
|
64
|
+
let selfClosing = false
|
|
65
|
+
while (at < end) {
|
|
66
|
+
const ch = source[at]
|
|
67
|
+
if (isSpace(ch)) { at++; continue }
|
|
68
|
+
if (ch === ">") return { name, attrs, end: at + 1, selfClosing }
|
|
69
|
+
selfClosing = ch === "/" && source[at + 1] === ">"
|
|
70
|
+
if (ch === "/") { at++; continue }
|
|
71
|
+
let stop = at + 1
|
|
72
|
+
while (stop < end && !isSpace(source[stop]) && source[stop] !== "/" && source[stop] !== ">" && source[stop] !== "=") stop++
|
|
73
|
+
const key = source.slice(at, stop).toLowerCase()
|
|
74
|
+
at = skipSpaces(source, stop)
|
|
75
|
+
let value = ""
|
|
76
|
+
if (source[at] === "=") {
|
|
77
|
+
at = skipSpaces(source, at + 1)
|
|
78
|
+
const quote = source[at]
|
|
79
|
+
if (quote === '"' || quote === "'") {
|
|
80
|
+
const close = source.indexOf(quote, at + 1)
|
|
81
|
+
const valueEnd = close === -1 ? end : close
|
|
82
|
+
value = source.slice(at + 1, valueEnd)
|
|
83
|
+
at = valueEnd + 1
|
|
84
|
+
} else {
|
|
85
|
+
let valueEnd = at
|
|
86
|
+
while (valueEnd < end && !isSpace(source[valueEnd]) && source[valueEnd] !== ">") valueEnd++
|
|
87
|
+
value = source.slice(at, valueEnd)
|
|
88
|
+
at = valueEnd
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (key !== "__proto__" && !Object.hasOwn(attrs, key)) attrs[key] = decodeEntities(value)
|
|
92
|
+
}
|
|
93
|
+
return { name, attrs, end, selfClosing }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function tagNameAt(source, at, tag) {
|
|
97
|
+
if (source.slice(at, at + tag.length).toLowerCase() !== tag) return false
|
|
98
|
+
const next = source[at + tag.length] ?? ">"
|
|
99
|
+
return isSpace(next) || next === ">" || next === "/"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Find the close of a raw element; nested <svg> is depth-counted. Returns [innerEnd, resumeAt]. */
|
|
103
|
+
function findRawClose(source, from, tag) {
|
|
104
|
+
let depth = 0
|
|
105
|
+
for (let lt = source.indexOf("<", from); lt !== -1; lt = source.indexOf("<", lt + 1)) {
|
|
106
|
+
if (source[lt + 1] === "/" && tagNameAt(source, lt + 2, tag)) {
|
|
107
|
+
if (depth === 0) {
|
|
108
|
+
const gt = source.indexOf(">", lt)
|
|
109
|
+
return [lt, gt === -1 ? source.length : gt + 1]
|
|
110
|
+
}
|
|
111
|
+
depth--
|
|
112
|
+
} else if (tag === "svg" && tagNameAt(source, lt + 1, tag)) {
|
|
113
|
+
depth++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [source.length, source.length]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Parse an HTML string into a LiteNode tree rooted at "#document"; never throws on malformed input. */
|
|
120
|
+
export function parseHtml(source) {
|
|
121
|
+
const src = String(source)
|
|
122
|
+
const starts = lineStarts(src)
|
|
123
|
+
const root = makeNode("#document", {}, null, 1)
|
|
124
|
+
root.ids = new Map()
|
|
125
|
+
const stack = [root]
|
|
126
|
+
let sawBody = false
|
|
127
|
+
let textStart = 0
|
|
128
|
+
let at = 0
|
|
129
|
+
const top = () => stack[stack.length - 1]
|
|
130
|
+
const flushText = (to) => {
|
|
131
|
+
if (to > textStart) {
|
|
132
|
+
const node = makeNode("#text", {}, top(), lineAt(starts, textStart))
|
|
133
|
+
node.text = decodeEntities(src.slice(textStart, to))
|
|
134
|
+
top().children.push(node)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const closeWithin = (tag, scope) => {
|
|
138
|
+
for (let k = stack.length - 1; k > 0; k--) {
|
|
139
|
+
if (stack[k].tag === tag) { stack.length = k; return }
|
|
140
|
+
if (scope.has(stack[k].tag)) return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
while (at < src.length) {
|
|
144
|
+
const lt = src.indexOf("<", at)
|
|
145
|
+
if (lt === -1) break
|
|
146
|
+
const next = src[lt + 1] ?? ""
|
|
147
|
+
if (src.startsWith("<!--", lt)) {
|
|
148
|
+
flushText(lt)
|
|
149
|
+
const close = src.indexOf("-->", lt + 4)
|
|
150
|
+
at = textStart = close === -1 ? src.length : close + 3
|
|
151
|
+
} else if (next === "!" || next === "?") {
|
|
152
|
+
flushText(lt)
|
|
153
|
+
const close = src.indexOf(">", lt)
|
|
154
|
+
at = textStart = close === -1 ? src.length : close + 1
|
|
155
|
+
} else if (next === "/" && isLetter(src[lt + 2] ?? "")) {
|
|
156
|
+
flushText(lt)
|
|
157
|
+
const tag = readTag(src, lt + 2)
|
|
158
|
+
if (tag.name !== "body" && tag.name !== "html") {
|
|
159
|
+
for (let k = stack.length - 1; k > 0; k--) {
|
|
160
|
+
if (stack[k].tag === tag.name) { stack.length = k; break }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
at = textStart = tag.end
|
|
164
|
+
} else if (isLetter(next)) {
|
|
165
|
+
flushText(lt)
|
|
166
|
+
const tag = readTag(src, lt + 1)
|
|
167
|
+
at = textStart = tag.end
|
|
168
|
+
if ((tag.name === "html" || tag.name === "body" || tag.name === "head") && stack.some((n) => n.tag === tag.name)) continue
|
|
169
|
+
if (P_CLOSERS.has(tag.name)) closeWithin("p", P_SCOPE)
|
|
170
|
+
if (tag.name === "li") closeWithin("li", LI_SCOPE)
|
|
171
|
+
const node = makeNode(tag.name, tag.attrs, top(), lineAt(starts, lt))
|
|
172
|
+
top().children.push(node)
|
|
173
|
+
if (tag.name === "body") sawBody = true
|
|
174
|
+
if (tag.attrs.id) root.ids.set(tag.attrs.id, (root.ids.get(tag.attrs.id) ?? 0) + 1)
|
|
175
|
+
if (RAW_TAGS.has(tag.name)) {
|
|
176
|
+
node.raw = ""
|
|
177
|
+
if (!tag.selfClosing) {
|
|
178
|
+
const [innerEnd, resume] = findRawClose(src, tag.end, tag.name)
|
|
179
|
+
node.raw = src.slice(tag.end, innerEnd)
|
|
180
|
+
at = textStart = resume
|
|
181
|
+
}
|
|
182
|
+
} else if (!VOID_TAGS.has(tag.name)) {
|
|
183
|
+
stack.push(node)
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
at = lt + 1
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
flushText(src.length)
|
|
190
|
+
if (!sawBody) wrapInBody(root)
|
|
191
|
+
return root
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Fragments without <body>: move everything except <head> under a synthesized body. */
|
|
195
|
+
function wrapInBody(root) {
|
|
196
|
+
const host = root.children.find((child) => child.tag === "html") ?? root
|
|
197
|
+
const kept = host.children.filter((child) => child.tag === "head")
|
|
198
|
+
const moved = host.children.filter((child) => child.tag !== "head")
|
|
199
|
+
const body = makeNode("body", {}, host, moved[0]?.line ?? 1)
|
|
200
|
+
for (const child of moved) child.parent = body
|
|
201
|
+
body.children = moved
|
|
202
|
+
host.children = [...kept, body]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function segment(node) {
|
|
206
|
+
const same = node.parent.children.filter((child) => child.tag === node.tag)
|
|
207
|
+
return same.length > 1 ? `${node.tag}:nth-of-type(${same.indexOf(node) + 1})` : node.tag
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** CSS selector for a node: nearest unique-id anchor, else `body > tag:nth-of-type(k) > ...`. */
|
|
211
|
+
export function selectorPath(node) {
|
|
212
|
+
let root = node
|
|
213
|
+
while (root.parent) root = root.parent
|
|
214
|
+
const ids = root.ids ?? new Map()
|
|
215
|
+
const segments = []
|
|
216
|
+
for (let cur = node.tag === "#text" ? node.parent : node; cur?.parent; cur = cur.parent) {
|
|
217
|
+
const id = cur.attrs.id
|
|
218
|
+
if (id && SAFE_ID.test(id) && ids.get(id) === 1) return [`#${id}`, ...segments].join(" > ")
|
|
219
|
+
if (cur.tag === "body") return ["body", ...segments].join(" > ")
|
|
220
|
+
if (cur.tag === "html") break
|
|
221
|
+
segments.unshift(segment(cur))
|
|
222
|
+
}
|
|
223
|
+
return segments.join(" > ")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Descendant text minus code/pre/kbd/samp/script/style/svg, whitespace collapsed. */
|
|
227
|
+
export function proseText(node) {
|
|
228
|
+
const parts = []
|
|
229
|
+
const walk = (cur) => {
|
|
230
|
+
if (cur.tag === "#text") { parts.push(cur.text); return }
|
|
231
|
+
if (PROSE_SKIP.has(cur.tag)) return
|
|
232
|
+
const breaks = PROSE_BREAKS.has(cur.tag)
|
|
233
|
+
if (breaks) parts.push(" ")
|
|
234
|
+
for (const child of cur.children) walk(child)
|
|
235
|
+
if (breaks) parts.push(" ")
|
|
236
|
+
}
|
|
237
|
+
for (const child of node.children) walk(child)
|
|
238
|
+
return parts.join("").replace(/\s+/g, " ").trim()
|
|
239
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// layout-probe.mjs - the in-page probe the layout gates consume.
|
|
2
|
+
//
|
|
3
|
+
// buildProbeSource() returns ONE async IIFE expression (the engine's evaluate awaits it): it first
|
|
4
|
+
// forces lazy images to load eagerly and awaits their decode (bounded), so natural sizes are real. The orchestrator evaluates it in the rendered
|
|
5
|
+
// report through the browser skill's owned headless engine and writes the result to boxes.json;
|
|
6
|
+
// gates-layout.mjs reads that file. This module never launches a browser.
|
|
7
|
+
//
|
|
8
|
+
// Selector paths use the SAME rule as html-lite.selectorPath (a unique safe #id anchors the path,
|
|
9
|
+
// otherwise tag segments with :nth-of-type(k) only when the parent has more than one child of that
|
|
10
|
+
// tag, prefixed "body"), so a layout defect can be mapped back to the static tree. The rule lives in
|
|
11
|
+
// PATH_FUNCTION_SOURCE, embedded verbatim in the probe and exercised by the parity test.
|
|
12
|
+
|
|
13
|
+
export const PROBE_VERSION = 1
|
|
14
|
+
export const SAMPLED_SELECTOR = "figure, .fig, img, svg, table, h1, h2, h3, p, pre"
|
|
15
|
+
export const EXCLUDED_ANCESTORS = Object.freeze(["svg", "nav", "header", "footer"])
|
|
16
|
+
|
|
17
|
+
/** In-page path rule. idCount(id) returns how many elements carry that id. */
|
|
18
|
+
export const PATH_FUNCTION_SOURCE = `function ulwSelectorPath(el, idCount) {
|
|
19
|
+
const SAFE_ID = /^[A-Za-z_][\\w-]*$/;
|
|
20
|
+
const segments = [];
|
|
21
|
+
for (let cur = el; cur && cur.parentElement; cur = cur.parentElement) {
|
|
22
|
+
const tag = cur.tagName.toLowerCase();
|
|
23
|
+
const id = cur.id;
|
|
24
|
+
if (id && SAFE_ID.test(id) && idCount(id) === 1) return ["#" + id].concat(segments).join(" > ");
|
|
25
|
+
if (tag === "body") return ["body"].concat(segments).join(" > ");
|
|
26
|
+
if (tag === "html") break;
|
|
27
|
+
const same = Array.from(cur.parentElement.children).filter((c) => c.tagName === cur.tagName);
|
|
28
|
+
segments.unshift(same.length > 1 ? tag + ":nth-of-type(" + (same.indexOf(cur) + 1) + ")" : tag);
|
|
29
|
+
}
|
|
30
|
+
return segments.join(" > ");
|
|
31
|
+
}`
|
|
32
|
+
|
|
33
|
+
export class ProbeError extends Error {
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = "ProbeError"
|
|
37
|
+
this.code = code
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {{ cap?: number, root?: string }} [options] */
|
|
42
|
+
export function buildProbeSource({ cap = 400, root = "main, article" } = {}) {
|
|
43
|
+
if (!Number.isInteger(cap) || cap <= 0) throw new ProbeError("invalid_cap", `--cap requires a positive integer, got ${cap}`)
|
|
44
|
+
if (typeof root !== "string" || root.trim().length === 0) throw new ProbeError("invalid_root", "--root requires a non-empty CSS selector")
|
|
45
|
+
return `(async () => {
|
|
46
|
+
const CAP = ${cap};
|
|
47
|
+
const IMAGE_WAIT_MS = 5000;
|
|
48
|
+
const ROOT = document.querySelector(${JSON.stringify(root)}) || document.body;
|
|
49
|
+
const EXCLUDED = ${JSON.stringify(EXCLUDED_ANCESTORS)};
|
|
50
|
+
const images = Array.from(document.images);
|
|
51
|
+
for (const img of images) if (img.loading === "lazy") img.loading = "eager";
|
|
52
|
+
const settle = Promise.all(images.map((img) => (img.complete && img.naturalWidth > 0 ? null : img.decode().catch(() => null))));
|
|
53
|
+
await Promise.race([settle, new Promise((resolve) => setTimeout(resolve, IMAGE_WAIT_MS))]);
|
|
54
|
+
${PATH_FUNCTION_SOURCE}
|
|
55
|
+
const idCount = (id) => document.querySelectorAll("#" + CSS.escape(id)).length;
|
|
56
|
+
const path = (el) => ulwSelectorPath(el, idCount);
|
|
57
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
58
|
+
const rect = (el) => { const b = el.getBoundingClientRect(); return { x: r1(b.left + scrollX), y: r1(b.top + scrollY), w: r1(b.width), h: r1(b.height) }; };
|
|
59
|
+
const excluded = (el) => { for (let p = el.parentElement; p; p = p.parentElement) if (EXCLUDED.includes(p.tagName.toLowerCase())) return true; return false; };
|
|
60
|
+
const all = Array.from(ROOT.querySelectorAll(${JSON.stringify(SAMPLED_SELECTOR)})).filter((el) => {
|
|
61
|
+
if (excluded(el)) return false;
|
|
62
|
+
const b = el.getBoundingClientRect();
|
|
63
|
+
return b.width > 0 || b.height > 0;
|
|
64
|
+
});
|
|
65
|
+
const boxes = all.slice(0, CAP).map((el) => {
|
|
66
|
+
const cs = getComputedStyle(el);
|
|
67
|
+
const parent = el.parentElement;
|
|
68
|
+
const box = {
|
|
69
|
+
selector: path(el), tag: el.tagName.toLowerCase(), rect: rect(el),
|
|
70
|
+
parentSelector: parent ? path(parent) : null, parentRect: parent ? rect(parent) : null,
|
|
71
|
+
parentOverflow: parent ? { x: getComputedStyle(parent).overflowX, y: getComputedStyle(parent).overflowY } : null,
|
|
72
|
+
scroll: { sw: el.scrollWidth, cw: el.clientWidth, sh: el.scrollHeight, ch: el.clientHeight },
|
|
73
|
+
overflow: { x: cs.overflowX, y: cs.overflowY }, position: cs.position,
|
|
74
|
+
textLen: (el.textContent || "").trim().length,
|
|
75
|
+
};
|
|
76
|
+
if (el.tagName === "IMG") { box.objectFit = cs.objectFit; box.natural = { w: el.naturalWidth, h: el.naturalHeight }; }
|
|
77
|
+
return box;
|
|
78
|
+
});
|
|
79
|
+
return { probe_version: ${PROBE_VERSION}, viewport: { w: innerWidth, h: innerHeight },
|
|
80
|
+
document: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight },
|
|
81
|
+
count: all.length, truncated: all.length > CAP, boxes };
|
|
82
|
+
})()`
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|