Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"name": "unic-spec-review",
"source": "./",
"tags": ["productivity", "quality"],
"version": "0.1.9"
"version": "0.1.10"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"keywords": ["spec-review", "confluence", "figma", "adversarial-review", "six-hats", "unic"],
"license": "LGPL-3.0-or-later",
"name": "unic-spec-review",
"version": "0.1.9"
"version": "0.1.10"
}
2 changes: 1 addition & 1 deletion apps/claude-code/unic-spec-review/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Guidance for any AI agent working inside this Plugin directory. `CLAUDE.md` in t

`unic-spec-review` is a Claude Code Plugin in the [`unic-agents-plugins`](../../../AGENTS.md) monorepo. It runs an adversarial review of web specifications across four sources (Confluence pages & comments, Figma designs via the Dev Mode MCP, the live production system via the Playwright MCP, and the local repo) and emits Confidence-scored, Six-Hats-tagged Findings. An interactive Approval Loop, gated behind `--post`, publishes selected Findings as Confluence comments.

> Status: S1–S9 implemented (URL classify of all pasted Confluence/Figma/live links → Confluence fetch → Figma gathering via the Dev Mode MCP → live-system gathering via the Playwright MCP → all eleven review agents, with Spec-versus-Design fed real Figma context and Spec-versus-Live fed real live observations → ranked hat-grouped report; Confluence comments read path; `LandscapeBrief` detection and injection into Testability, Feasibility, Spec-versus-Live, and Non-functional agents; multi-Finding write path via `--post`: `dedup-matcher` Jaccard similarity against existing comments with human tiebreak for borderline matches, inline-anchor resolution, attribution footer; page traversal of child pages and in-body `/wiki/` links with a budget-gated confirmation; fail-loud MCP checks for Figma and live sources). All slices complete.
> Status: S1–S9 implemented (URL classify of all pasted Confluence/Figma/live links → Confluence fetch → Figma gathering via the Dev Mode MCP → live-system gathering via the Playwright MCP → all eleven review agents, with Spec-versus-Design fed real Figma context and Spec-versus-Live fed real live observations → ranked hat-grouped report; Confluence comments read path; `LandscapeBrief` detection and injection into Testability, Feasibility, Spec-versus-Live, and Non-functional agents; multi-Finding write path via `--post`: `dedup-matcher` Jaccard similarity against existing comments with human tiebreak for borderline matches, inline-anchor resolution, Markdown-to-storage conversion (`md-to-storage.mjs`), attribution footer; page traversal of child pages and in-body `/wiki/` links with a budget-gated confirmation; fail-loud MCP checks for Figma and live sources). All slices complete.

## Where to start

Expand Down
11 changes: 11 additions & 0 deletions apps/claude-code/unic-spec-review/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- (none)

## [0.1.10] — 2026-06-09

### Breaking
- (none)

### Added
- Convert Finding body Markdown to Confluence storage format (XHTML) before posting via `--post`. Adds a vendored, dependency-free `md-to-storage` converter supporting bold/italic, inline code, links, bullet/ordered lists, and fenced code blocks (`ac:structured-macro`). Unrecognised constructs (headings, tables, raw HTML) degrade to HTML-escaped literal text — never malformed XHTML. The title line and attribution footer are emitted as storage fragments with escaped interpolated values, keeping the footer marker byte-exact for `recognizeFooter()` (ADR-0002 de-dup unaffected). Resolves #231.

### Fixed
- (none)

## [0.1.9] — 2026-06-09

### Breaking
Expand Down
2 changes: 1 addition & 1 deletion apps/claude-code/unic-spec-review/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Plugin-scoped ADRs. Monorepo-wide decisions live in `../../../../../docs/adr/`.
- [0004](0004-inline-anchored-comments-footer-fallback.md): Inline-anchored Confluence comments with a footer fallback
- [0005](0005-gate-dedup-when-comparison-incomplete.md): Gate de-dup posts when the comparison basis is incomplete

> Status: these ADRs record decisions locked during the design grilling for the [PRD](../issues/unic-spec-review/PRD.md). ADR-0001 (vendor self-containment) and ADR-0003 (six-hats lens + all eleven review agents) are implemented as of S4. ADR-0004 (inline-anchored comments with footer fallback) is implemented as of S5 (`inline-anchor-resolver.mjs`, `attribution-footer.mjs`, `confluence-writer.mjs`). ADR-0002 (similarity de-dup) is implemented as of S8 (`dedup-matcher.mjs`). ADR-0005 (gate incomplete comparisons) refines ADR-0002 and is implemented as of v0.1.9 (`dedup-matcher.mjs` envelope + `review-spec.md` Steps 10a–10d).
> Status: these ADRs record decisions locked during the design grilling for the [PRD](../issues/unic-spec-review/PRD.md). ADR-0001 (vendor self-containment) and ADR-0003 (six-hats lens + all eleven review agents) are implemented as of S4. ADR-0004 (inline-anchored comments with footer fallback) is implemented as of S5 (`inline-anchor-resolver.mjs`, `attribution-footer.mjs`, `confluence-writer.mjs`); the 2026-06 storage-format refinement adds `md-to-storage.mjs` (v0.1.10). ADR-0002 (similarity de-dup) is implemented as of S8 (`dedup-matcher.mjs`). ADR-0005 (gate incomplete comparisons) refines ADR-0002 and is implemented as of v0.1.9 (`dedup-matcher.mjs` envelope + `review-spec.md` Steps 10a–10d).
2 changes: 1 addition & 1 deletion apps/claude-code/unic-spec-review/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unic-spec-review",
"version": "0.1.9",
"version": "0.1.10",
"private": true,
"license": "LGPL-3.0-or-later",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions apps/claude-code/unic-spec-review/scripts/atlassian-fetch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ export async function fetchConfluencePageBody(pageIdOrUrl, creds, deps = {}) {
* footer comments and inline comments anchored to a text selection.
* Pass anchor=null only when type==='footer'; passing null with type==='inline' throws.
* @param {string} pageId
* @param {string} body - comment body in wiki markup format
* @param {string} body - comment body in Confluence storage format (XHTML)
* @param {'footer' | 'inline'} type
* @param {InlineAnchor | null} anchor - required (non-null) when type === 'inline'
* @param {AtlassianCreds} creds
Expand All @@ -741,7 +741,7 @@ export async function postConfluenceComment(pageId, body, type, anchor, creds, d
? `${confluenceBase}/wiki/api/v2/inline-comments`
: `${confluenceBase}/wiki/api/v2/footer-comments`
/** @type {any} */
const payload = { pageId, body: { representation: 'wiki', value: body } }
const payload = { pageId, body: { representation: 'storage', value: body } }
if (type === 'inline') {
const a = /** @type {InlineAnchor} */ (anchor)
payload.inlineCommentProperties = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function renderFooter(dimension, hat) {

/**
* Append the attribution footer to a comment body, separated by a blank line.
* Used by the reactive wiki fallback (#232).
* @param {string} commentBody
* @param {string} dimension
* @param {string} hat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { extractConfluencePageId, fetchConfluencePageBody, postConfluenceComment } from '../atlassian-fetch.mjs'
import { withFooter } from './attribution-footer.mjs'
import { FOOTER_MARKER } from './attribution-footer.mjs'
import { loadAtlassianCreds } from './credentials.mjs'
import { resolveAnchor } from './inline-anchor-resolver.mjs'
import { escapeHtml, mdToStorage } from './md-to-storage.mjs'

async function main() {
const argv = process.argv.slice(2)
Expand Down Expand Up @@ -36,6 +37,11 @@ async function main() {
process.exit(1)
}

if (typeof finding.body !== 'string') {
process.stderr.write(`${JSON.stringify({ error: 'finding.body must be a string — malformed finding file' })}\n`)
process.exit(1)
}

const creds = loadAtlassianCreds()
if (!creds) {
process.stderr.write(
Expand All @@ -53,8 +59,10 @@ async function main() {
try {
const pageHtml = await fetchConfluencePageBody(pageUrl, creds, { fetch: globalThis.fetch })
const resolution = resolveAnchor(finding.anchor ?? null, pageHtml)
const commentBody = `*${finding.title}* (${finding.severity}, ${finding.confidence}%, ${finding.dimension})\n\n${finding.body}`
const bodyWithFooter = withFooter(commentBody, finding.dimension, finding.hat)
const titleLine = `<p><strong>${escapeHtml(finding.title)}</strong> (${escapeHtml(finding.severity)}, ${escapeHtml(String(finding.confidence))}%, ${escapeHtml(finding.dimension)})</p>`
const convertedBody = mdToStorage(finding.body)
const footerLine = `<p>${FOOTER_MARKER} | dimension: ${escapeHtml(finding.dimension)} | hat: ${escapeHtml(finding.hat)}</p>`
const bodyWithFooter = `${titleLine}\n${convertedBody}\n${footerLine}`
const type = resolution.type
const anchor =
resolution.type === 'inline'
Expand Down
217 changes: 217 additions & 0 deletions apps/claude-code/unic-spec-review/scripts/lib/md-to-storage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// @ts-check
// Copyright © 2026 Unic

/**
* Vendored, dependency-free Markdown-to-Confluence-storage converter.
*
* Supported constructs:
* HTML-escaping (mandatory for valid XHTML), bold (**text** / __text__),
* italic (*text* / _text_), inline code (`code`), links ([text](url)),
* bullet lists (- or * prefix), ordered lists (N. prefix),
* fenced code blocks (```lang … ```) → ac:structured-macro.
*
* Any unrecognised construct (headings, tables, raw HTML) degrades to
* HTML-escaped literal text — never malformed XHTML.
*/

/**
* Escape HTML special characters for safe embedding in XHTML.
* @param {string} text
* @returns {string}
*/
export function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

/**
* Find the closing position of a single-character inline delimiter
* starting from `from` (inclusive). Returns the index of the closing
* delimiter on success. Returns -1 if not found on the same line or
* if the closing delimiter would form a double-delimiter (** / __).
* Known limitation: `*italic***bold**` abutted (no space between italic close
* and bold open) mismatch — the closing `*` is skipped because `text[j+1]` is
* also `*`. AI-generated content virtually never produces this pattern.
* @param {string} text
* @param {string} delim - single character: '*' or '_'
* @param {number} from - start of search (inclusive); typically i+1 to skip the opener
* @returns {number} index of closing delimiter, or -1
*/
function findInlineEnd(text, delim, from) {
for (let j = from; j < text.length; j++) {
if (text[j] === '\n') return -1
if (text[j] === delim && text[j + 1] !== delim) return j
}
return -1
}

/**
* Convert inline Markdown to XHTML inline nodes.
* Any character that is not part of a recognised construct is HTML-escaped.
* @param {string} text
* @returns {string}
*/
export function convertInline(text) {
let out = ''
let i = 0
while (i < text.length) {
const ch = text[i]

// Inline code: `code`
if (ch === '`') {
const end = text.indexOf('`', i + 1)
if (end > i) {
out += `<code>${escapeHtml(text.slice(i + 1, end))}</code>`
i = end + 1
continue
}
}

// Bold: **text**
if (ch === '*' && text[i + 1] === '*') {
const end = text.indexOf('**', i + 2)
if (end > i + 1) {
out += `<strong>${escapeHtml(text.slice(i + 2, end))}</strong>`
i = end + 2
continue
}
}

// Bold: __text__
if (ch === '_' && text[i + 1] === '_') {
const end = text.indexOf('__', i + 2)
if (end > i + 1) {
out += `<strong>${escapeHtml(text.slice(i + 2, end))}</strong>`
i = end + 2
continue
}
}

// Italic: *text* (single star only — ** already handled above)
if (ch === '*' && text[i + 1] !== '*') {
const end = findInlineEnd(text, '*', i + 1)
if (end > i) {
out += `<em>${escapeHtml(text.slice(i + 1, end))}</em>`
i = end + 1
continue
}
}

// Italic: _text_ (single underscore only — __ already handled above)
if (ch === '_' && text[i + 1] !== '_') {
const end = findInlineEnd(text, '_', i + 1)
if (end > i) {
out += `<em>${escapeHtml(text.slice(i + 1, end))}</em>`
i = end + 1
continue
}
}

// Link: [text](url) — balanced-paren scan handles URLs like Wikipedia's Foo_(bar)
if (ch === '[') {
const closeBracket = text.indexOf(']', i + 1)
if (closeBracket > i && text[closeBracket + 1] === '(') {
let closeParen = -1
let depth = 0
for (let k = closeBracket + 2; k < text.length; k++) {
if (text[k] === '(') depth++
else if (text[k] === ')') {
if (depth === 0) {
closeParen = k
break
}
depth--
}
}
if (closeParen > closeBracket + 1) {
const linkText = text.slice(i + 1, closeBracket)
const url = text.slice(closeBracket + 2, closeParen)
out += `<a href="${escapeHtml(url)}">${escapeHtml(linkText)}</a>`
i = closeParen + 1
continue
}
}
}

out += escapeHtml(ch)
i++
}
return out
}

/**
* Convert a Markdown string to Confluence storage format (XHTML).
* @param {string} markdown
* @returns {string}
*/
export function mdToStorage(markdown) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const chunks = []
let i = 0

while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()

// Fenced code block: ```lang
if (trimmed.startsWith('```')) {
const lang = trimmed.slice(3).trim()
const codeLines = []
i++
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i])
i++
}
i++ // skip closing ```
// Escape ]]> so it cannot break the CDATA section
const codeContent = codeLines.join('\n').replace(/]]>/g, ']]]]><![CDATA[>')
const langParam = lang ? `<ac:parameter ac:name="language">${escapeHtml(lang)}</ac:parameter>` : ''
chunks.push(
`<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${codeContent}]]></ac:plain-text-body></ac:structured-macro>`
)
continue
}

// Unordered list block
if (/^[-*] /.test(trimmed)) {
const items = []
while (i < lines.length && /^[-*] /.test(lines[i].trim())) {
items.push(`<li>${convertInline(lines[i].trim().slice(2))}</li>`)
i++
}
chunks.push(`<ul>${items.join('')}</ul>`)
continue
}

// Ordered list block
if (/^\d+\. /.test(trimmed)) {
const items = []
while (i < lines.length && /^\d+\. /.test(lines[i].trim())) {
items.push(`<li>${convertInline(lines[i].trim().replace(/^\d+\. /, ''))}</li>`)
i++
}
chunks.push(`<ol>${items.join('')}</ol>`)
continue
}

// Blank line — paragraph separator, skip
if (trimmed === '') {
i++
continue
}

// Paragraph: gather non-empty, non-special lines
const paraLines = []
while (i < lines.length) {
const t = lines[i].trim()
if (t === '' || t.startsWith('```') || /^[-*] /.test(t) || /^\d+\. /.test(t)) break
paraLines.push(lines[i])
i++
}
if (paraLines.length > 0) {
chunks.push(`<p>${convertInline(paraLines.join(' '))}</p>`)
}
}

return chunks.join('\n')
}
12 changes: 12 additions & 0 deletions apps/claude-code/unic-spec-review/tests/atlassian-fetch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,18 @@ describe('postConfluenceComment', () => {
assert.equal(result.id, '')
assert.equal(result.created, '')
})

it('sends representation storage (not wiki)', async () => {
/** @type {any} */
let capturedPayload
/** @param {string} _url @param {{ body: string }} opts */
const capturingFetch = async (_url, opts) => {
capturedPayload = JSON.parse(opts.body)
return { ok: true, status: 201, json: async () => ({ id: 'x', version: { createdAt: '' } }) }
}
await postConfluenceComment('123', '<p>storage body</p>', 'footer', null, CREDS, { fetch: capturingFetch })
assert.equal(capturedPayload.body.representation, 'storage')
})
})

describe('parseCommentsArg', () => {
Expand Down
Loading
Loading