The Atlas doc.haus documentation, bound to its code
108 documents

The redline pipeline

From "change clause 9" to native Word tracked changes — proposal, review, and the accept that bakes the edit into the canonical .docx.

dochaus/tool/redline.ts101 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import { tool } from "@opencode-ai/plugin"
import { existsSync } from "node:fs"
import path from "node:path"
import { docxodus } from "../lib/docxodus"
import { recordRedline, pendingRedlinesForDoc, conflictingRedlines, supersedeRedlines } from "../lib/redlines"

// doc.haus redline tool. Proposes rewriting a whole clause — the paragraph a
// search-document citation points at — as a tracked change a reviewer accepts or
// rejects in the doc.haus app.
//
// This is the citation-driven, clause-level counterpart to the surgical
// find/replace tools (word-integration, tracked-changes): `clause` locates the
// paragraph and the whole paragraph is rewritten to `replacement`. We match on
// the block's flat text, whitespace-tolerant, because citation excerpts come
// from a different text extraction (mammoth) than Docxodus' own projection and
// their spacing/char offsets don't align — so we only ever LOCATE the block, we
// never offset-index into it.
//
// The proposal is recorded against the matter's index; the canonical .docx stays
// clean (the accepted state) until a reviewer accepts, when ingest bakes the edit
// in. So here we only locate the clause and capture its current text — we never
// modify the file.

export default tool({
  description:
    "Propose rewriting a whole clause as a tracked change, using a passage retrieved from search-document. Locates the paragraph containing `clause` and proposes replacing its entire text with `replacement`, attributed to an author. The change is recorded as a pending redline the user reviews and accepts or rejects in the doc.haus app — the document is not modified until they accept. Use this to redline a clause you found as a citation; use tracked-changes for a surgical word/phrase swap.",
  args: {
    document: tool.schema.string().describe("Document file name within the matter (the docPath from a citation)"),
    clause: tool.schema
      .string()
      .describe("Text from the clause to rewrite — the citation excerpt, or a sentence within it. Used to locate the paragraph."),
    replacement: tool.schema
      .string()
      .describe("The new clause text. Replaces the whole located paragraph; markdown is supported."),
    author: tool.schema.string().optional().describe("Name to attribute the tracked change to (default: doc.haus)"),
  },
  async execute(args, ctx) {
    const file = path.isAbsolute(args.document) ? args.document : path.join(ctx.directory, args.document)
    if (!existsSync(file)) return `Document not found in this matter: ${args.document}`

    const dx = await docxodus()
    const session = dx.openDocxSession(await Bun.file(file).bytes(), {})

    const target = session.findByText(args.clause, { ignoreWhitespace: true })
    if (!target) {
      session.close()
      return `Clause not found in ${path.basename(file)}: ${JSON.stringify(args.clause)}`
    }
    // projectAnchor emits the block's addressing token (`{#p:body:UNID}`) ahead of
    // its text and takes no setting to suppress it — strip every `{#…}` marker so
    // the captured clause is the human-readable text the reviewer sees struck
    // through, not the engine's internal anchor.
    const oldText = session.projectAnchor(target.id).markdown.replace(/\{#[^}]*\}/g, "").trim()
    session.close()

    // A clause rewrite replaces the whole paragraph, so any pending proposal on
    // the same paragraph would be replayed against text this one erases — the
    // collision that 500s the redlined view. The newest edit wins: record this
    // one, retire the ones it supersedes. The model sees what it replaced so it
    // can reason about the running state of the negotiation.
    const conflicts = conflictingRedlines(pendingRedlinesForDoc(ctx.directory, file), {
      anchorId: target.id,
      scope: "clause",
      findText: args.clause,
    })

    const author = args.author ?? "doc.haus"
    // Recording the proposal is gated on the matter owner's approval (permission
    // "redline" in opencode.json) — the redline review queue is itself a work
    // product, so the assistant must not stack proposals into it unasked.
    // ctx.ask blocks until they reply and throws on reject. "Always" approves
    // future proposals against this document only.
    await ctx.ask({
      permission: "redline",
      patterns: [file],
      always: [file],
      metadata: { document: path.basename(file), clause: args.clause, replacement: args.replacement, author },
    })
    const id = recordRedline(ctx.directory, {
      docPath: file,
      docName: path.basename(file),
      scope: "clause",
      findText: args.clause,
      oldText,
      newText: args.replacement,
      author,
      anchorId: target.id,
    })
    supersedeRedlines(ctx.directory, conflicts.map((c) => c.id))

    const superseded = conflicts.length
      ? ` Supersedes pending redline${conflicts.length === 1 ? "" : "s"} ${conflicts.map((c) => `#${c.id}`).join(", ")} on the same clause — only this latest rewrite stays pending.`
      : ""
    return {
      title: `Proposed redline in ${path.basename(file)}`,
      output: `Proposed rewriting the clause matching ${JSON.stringify(args.clause)} in ${path.basename(file)}, attributed to ${author}. Recorded as pending redline #${id} — the user reviews and accepts or rejects it in the doc.haus app.${superseded}`,
      metadata: { document: file, clause: args.clause, oldText, replacement: args.replacement, anchor: target.id, author, redline: id, superseded: conflicts.map((c) => c.id) },
    }
  },
})