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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  7. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  8. package/dist/cli/index.js +1661 -859
  9. package/dist/cli/install-native/index.d.ts +9 -1
  10. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  11. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  12. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  13. package/dist/cli/install-native/plan.d.ts +8 -0
  14. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  15. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  16. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  17. package/dist/cli/native-edition-hint.d.ts +2 -2
  18. package/dist/cli-node/index.js +1661 -859
  19. package/package.json +1 -1
  20. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  21. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  26. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  30. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  31. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  33. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  34. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  35. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  39. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  40. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  41. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  46. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  48. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  49. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  71. package/packages/omo-codex/plugin/package-lock.json +12 -12
  72. package/packages/omo-codex/plugin/package.json +1 -1
  73. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  74. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  75. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  76. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  77. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  78. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  79. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  80. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  81. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  83. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  84. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  87. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  88. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  89. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  90. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  91. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  102. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  103. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  104. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  105. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  106. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  107. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  108. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  109. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  110. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  111. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  112. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  113. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  114. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  115. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  116. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  117. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  118. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  119. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  120. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  121. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  122. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  123. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  124. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  125. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  126. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  127. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  128. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  129. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  130. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  131. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  132. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  133. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  134. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  135. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  136. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  137. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  138. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  139. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  140. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  141. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  142. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  143. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  144. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  145. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  146. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  147. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  148. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  149. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  150. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  151. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  152. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  153. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  154. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  155. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  156. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  157. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  158. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  159. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,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
+ }