@dougharness/cli 1.0.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // SubagentStart, SubagentStop, PreToolUse, PostToolUse, InstructionsLoaded, PermissionDenied: append one JSON
3
+ // line per event to .doug/.state/trace/<session>.jsonl (lib/trace.mjs). Observation only: never blocks, never prints a decision —
4
+ // except a PostToolUse inside a subagent whose own measured context (lib/agent-context.mjs, card
5
+ // worker-context-handoff) has crossed contextWindow.threshold, which gets one additionalContext notice telling
6
+ // the worker to wrap up. Off with `"trace": { "enabled": false }` in .doug/config.json; contextWindow.enabled
7
+ // (also off by default) gates the notice, not the trace line itself.
8
+ //
9
+ // Two review fixes (worker-context-handoff, brief A) live here:
10
+ // - (major 2) edit-loop.mjs writes the same session state file on the same PostToolUse event, and
11
+ // stop-gate.mjs holds its own copy for a whole SubagentStop gate run. A plain load -> mutate -> save here
12
+ // can win a race and drop an agentBlocks increment or an edit-loop counter written in between. So the
13
+ // state read at the top is used only to decide what changed; the actual write reloads the file fresh
14
+ // immediately before saving and merges in only this agent's own entry, touching nothing else.
15
+ // - (minor 4) re-reading and re-parsing a subagent's whole transcript on every tool call is wasted work once
16
+ // nothing has changed: the transcript's byte size at the last reading is cached in the entry, and a
17
+ // PostToolUse whose file is still that size skips straight to allow() without opening it.
18
+
19
+ import { statSync } from "node:fs";
20
+ import { runHook, allow, addContext } from "../lib/io.mjs";
21
+ import { loadConfig, projectDir } from "../lib/config.mjs";
22
+ import { appendTrace, traceLine } from "../lib/trace.mjs";
23
+ import { loadState, saveState } from "../lib/state.mjs";
24
+ import { agentTranscriptPath, contextReading, recordAgentContext, shouldNotify, noticeText } from "../lib/agent-context.mjs";
25
+
26
+ runHook("trace", async (input) => {
27
+ const dir = projectDir(input);
28
+ const cfg = loadConfig(dir);
29
+ if (!cfg.trace || cfg.trace.enabled === false) return allow();
30
+
31
+ const isSubagentPostToolUse = input.hook_event_name === "PostToolUse" && input.agent_id && cfg.contextWindow && cfg.contextWindow.enabled;
32
+ let state = null;
33
+ let reading = null;
34
+
35
+ if (isSubagentPostToolUse) {
36
+ const file = agentTranscriptPath(input);
37
+ if (file) {
38
+ state = loadState(dir, input.session_id);
39
+ let size = null;
40
+ try {
41
+ size = statSync(file).size;
42
+ } catch {
43
+ size = null;
44
+ }
45
+ const prevContext = state.agents && state.agents[input.agent_id] && state.agents[input.agent_id].context;
46
+ const unchanged = size !== null && prevContext && typeof prevContext.size === "number" && prevContext.size === size;
47
+ if (!unchanged) {
48
+ reading = contextReading(file);
49
+ if (reading && size !== null) reading.size = size;
50
+ }
51
+ }
52
+ }
53
+
54
+ appendTrace(dir, traceLine(input, { context: reading ? reading.pct : null }));
55
+
56
+ if (!reading) return allow();
57
+
58
+ const prevEntry = state.agents && state.agents[input.agent_id] && state.agents[input.agent_id].context;
59
+ const entry = recordAgentContext(state, input.agent_id, reading, new Date());
60
+ let notice = null;
61
+ if (shouldNotify(entry, cfg)) {
62
+ entry.notifiedAt = entry.pct;
63
+ notice = noticeText(entry, cfg);
64
+ }
65
+
66
+ const changed = !prevEntry || prevEntry.pct !== entry.pct || prevEntry.tokens !== entry.tokens || prevEntry.notifiedAt !== entry.notifiedAt || prevEntry.size !== entry.size;
67
+ if (changed) {
68
+ const fresh = loadState(dir, input.session_id);
69
+ fresh.agents = fresh.agents || {};
70
+ fresh.agents[input.agent_id] = { ...(fresh.agents[input.agent_id] || {}), context: entry };
71
+ saveState(dir, input.session_id, fresh);
72
+ }
73
+
74
+ return notice ? addContext("PostToolUse", notice) : allow();
75
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@dougharness/cli",
3
+ "version": "1.0.1",
4
+ "description": "doug: tailored, approval-gated harness install for Claude Code.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eschwa3/dougharness.git",
10
+ "directory": "packages/doug-cli"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "bin": {
16
+ "doug": "./dist/bin.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "templates"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22.16"
24
+ },
25
+ "dependencies": {
26
+ "@dougharness/flow": "1.0.1",
27
+ "@dougharness/gates": "1.0.1"
28
+ },
29
+ "bundleDependencies": [
30
+ "@dougharness/flow",
31
+ "@dougharness/gates"
32
+ ]
33
+ }
@@ -0,0 +1,533 @@
1
+ // Doug Board: the page is the record. Cards are rendered from BOARD (embedded JSON); a move is
2
+ // written to the board file by `doug board serve`.
3
+ const CSS = `
4
+ :root{--ground:#f3f4f1;--surface:#fff;--ink:#1b1f1d;--ink-soft:#545c58;--rule:#d6dbd5;--rule-strong:#aeb6af;--accent:#2f6f4e;--accent-ink:#fff;--drop:#e2efe6;--code-bg:#eceee9;
5
+ --c-gates:#2f6f4e;--c-flow:#2b5f8a;--c-codex:#7a4d05;--c-init:#5d5a8f;--c-memory:#8a3b5e;--c-learn:#3e6e6a;--c-eval:#6b5d2a;--c-release:#4b5760;--c-decision:#8b2f2f}
6
+ @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--ground:#15181a;--surface:#1c2022;--ink:#e6e9e5;--ink-soft:#a3aca6;--rule:#2d3336;--rule-strong:#465056;--accent:#6fbf95;--accent-ink:#0f1a14;--drop:#1f3a2c;--code-bg:#23282b;
7
+ --c-gates:#7fcaa3;--c-flow:#86b6e0;--c-codex:#e0b46b;--c-init:#b2aee6;--c-memory:#e08fb3;--c-learn:#8fc9c4;--c-eval:#d1c07a;--c-release:#aab5bd;--c-decision:#e58a8a}}
8
+ :root[data-theme=dark]{--ground:#15181a;--surface:#1c2022;--ink:#e6e9e5;--ink-soft:#a3aca6;--rule:#2d3336;--rule-strong:#465056;--accent:#6fbf95;--accent-ink:#0f1a14;--drop:#1f3a2c;--code-bg:#23282b;
9
+ --c-gates:#7fcaa3;--c-flow:#86b6e0;--c-codex:#e0b46b;--c-init:#b2aee6;--c-memory:#e08fb3;--c-learn:#8fc9c4;--c-eval:#d1c07a;--c-release:#aab5bd;--c-decision:#e58a8a}
10
+ *{box-sizing:border-box}html,body{margin:0}
11
+ body{background:var(--ground);color:var(--ink);font-family:"Source Sans 3","Helvetica Neue",Arial,sans-serif;font-size:15px;line-height:1.4;min-height:100vh}
12
+ h1{font-family:Archivo,"Helvetica Neue",Arial,sans-serif;font-size:26px;font-weight:700;letter-spacing:-.01em;margin:0}
13
+ .mono{font-family:"IBM Plex Mono",Menlo,monospace}
14
+ header{display:flex;flex-wrap:wrap;align-items:center;gap:10px 24px;padding:18px 24px 12px;border-bottom:2px solid var(--ink)}
15
+ header .sub{color:var(--ink-soft);font-size:14px}
16
+ .tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-left:auto}
17
+ .tools label{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);font-family:"IBM Plex Mono",Menlo,monospace}
18
+ select,button{font:inherit;font-size:14px;color:var(--ink);background:var(--surface);border:1px solid var(--rule-strong);border-radius:3px;padding:5px 9px}
19
+ button{cursor:pointer}button:focus-visible,select:focus-visible,.card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
20
+ .status{font-size:13px;color:var(--ink-soft);min-height:1.2em}
21
+ .board{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(250px,1fr);gap:14px;padding:16px 24px 40px;overflow-x:auto;align-items:start}
22
+ .col{background:var(--surface);border:1px solid var(--rule);display:flex;flex-direction:column;min-height:200px}
23
+ .col.over{background:var(--drop);border-color:var(--accent)}
24
+ .col-head{padding:10px 12px 8px;border-bottom:1px solid var(--rule-strong);display:flex;align-items:baseline;gap:8px}
25
+ .col-head h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;font-weight:500;margin:0}
26
+ .col-head .n{margin-left:auto;font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;color:var(--ink-soft);font-variant-numeric:tabular-nums}
27
+ .col-hint{padding:6px 12px 0;font-size:12px;color:var(--ink-soft)}
28
+ .cards{padding:10px;display:flex;flex-direction:column;gap:8px;flex:1}
29
+ .card{background:var(--ground);border:1px solid var(--rule);padding:9px 10px 8px;cursor:grab;position:relative}
30
+ .card[hidden]{display:none}
31
+ .card.dragging{opacity:.45}
32
+ .card.drop-before{box-shadow:inset 0 3px 0 var(--accent)}
33
+ .card.drop-after{box-shadow:inset 0 -3px 0 var(--accent)}
34
+ .card .top{display:flex;gap:8px;align-items:center;margin-bottom:4px}
35
+ .chip{font-family:"IBM Plex Mono",Menlo,monospace;font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;border:1px solid currentColor}
36
+ .chip.hand{border-style:dashed;color:var(--ink-soft)}
37
+ .chip.tag{color:var(--ink-soft)}
38
+ .chip.class{color:var(--ink-soft)}
39
+ .size{margin-left:auto;font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;color:var(--ink-soft)}
40
+ .title{font-weight:600;font-size:14.5px;text-wrap:balance}
41
+ .meta{font-size:12px;color:var(--ink-soft);margin-top:3px}
42
+ .meta code{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;background:var(--code-bg);padding:0 4px;border-radius:3px}
43
+ details{margin-top:6px}summary{font-size:12px;color:var(--accent);cursor:pointer}
44
+ .goal{font-size:13px;margin:6px 0 0;color:var(--ink);white-space:pre-wrap}
45
+ .move{margin-top:8px;display:flex;gap:6px;align-items:center}.move select{font-size:12px;padding:3px 6px}
46
+ .ro .card{cursor:default}.ro .move{display:none}
47
+ footer{padding:0 24px 24px;font-size:12px;color:var(--ink-soft)}
48
+ @media (max-width:720px){.board{grid-auto-columns:minmax(85vw,1fr)}}
49
+ `;
50
+
51
+ const FONTS = '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700&family=Source+Sans+3:wght@400;600&family=IBM+Plex+Mono:wght@400;500&display=swap">';
52
+
53
+ function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
54
+
55
+ function cardHtml(c, columns) {
56
+ const deps = (c.deps || []).length ? `<div class="meta">after <code>${c.deps.map(esc).join("</code> <code>")}</code></div>` : "";
57
+ const opts = columns.map((col) => `<option value="${col.id}"${col.id === c.column ? " selected" : ""}>${esc(col.title)}</option>`).join("");
58
+ const chip = c.component ? `<span class="chip" style="color:var(--c-${esc(c.component)})">${esc(c.component)}</span>` : "";
59
+ // A hand-track card is built in the checkout, never by /doug-next (decision 0005); the chip says so at a glance.
60
+ const hand = c.track === "hand" ? `<span class="chip hand" title="hand track: built in the checkout, not by /doug-next">by hand</span>` : "";
61
+ const tags = c.tags && c.tags.length ? c.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`).join("") : "";
62
+ const cls = c.class ? `<span class="chip class" title="class">${esc(c.class)}</span>` : "";
63
+ const compAttr = c.component ? ` data-component="${esc(c.component)}"` : "";
64
+ const tagsAttr = c.tags && c.tags.length ? ` data-tags="${esc(c.tags.join(" "))}"` : "";
65
+ return `<article class="card" draggable="true" tabindex="0" data-id="${esc(c.id)}"${compAttr} data-column="${esc(c.column)}"${tagsAttr}>
66
+ <div class="top">${chip}${hand}${tags}${cls}<span class="size">${esc(c.size || "")}</span></div>
67
+ <div class="title">${esc(c.title)}</div>
68
+ <div class="meta"><code>${esc(c.id)}</code>${c.source ? ` · ${esc(c.source)}` : ""}</div>
69
+ ${deps}
70
+ ${c.goal ? `<details><summary>goal</summary><p class="goal">${esc(c.goal)}</p></details>` : ""}
71
+ <div class="move"><label class="meta" for="mv-${esc(c.id)}">move to</label><select id="mv-${esc(c.id)}" data-move="${esc(c.id)}">${opts}</select></div>
72
+ </article>`;
73
+ }
74
+
75
+ // Done reads newest first: a card moves to Done long after it was added, so record order would bury the latest
76
+ // landing at the bottom of the longest column. Every other column keeps record order, which is priority.
77
+ function columnHtml(col, cards, columns) {
78
+ const mine = cards.filter((c) => c.column === col.id);
79
+ const ordered = col.id === "done" ? [...mine].reverse() : mine;
80
+ const hint = col.id === "done" ? `${col.hint || ""}${col.hint ? " · " : ""}newest first` : col.hint || "";
81
+ return `<section class="col" data-column="${esc(col.id)}">
82
+ <div class="col-head"><h2>${esc(col.title)}</h2><span class="n">${mine.length}</span></div>
83
+ <div class="col-hint">${esc(hint)}</div>
84
+ <div class="cards">${ordered.map((c) => cardHtml(c, columns)).join("\n")}</div>
85
+ </section>`;
86
+ }
87
+
88
+ function footerHtml(config) {
89
+ const first = 'Drag a card to a column or above or below another card in its column, or use its "move to" select; Alt+Up and Alt+Down move the focused card within its column.';
90
+ const rest = ` Served by doug board serve, a move writes ${esc(config.record)}; opened as a file, this page is read-only.`;
91
+ return `<footer>${first}${rest}</footer>`;
92
+ }
93
+
94
+ // Which side of a card a drop lands on: the card's top half is "before", the bottom half is "after".
95
+ function dropPlacement(clientY, rect) {
96
+ return clientY < rect.top + rect.height / 2 ? "before" : "after";
97
+ }
98
+
99
+ // Reorders `cards` per the library's reorderCard slot rule (the column's cards keep the same array
100
+ // slots; only their order changes) but never throws: a target that is the card itself or lives in
101
+ // another column returns an unchanged copy of the input. Never mutates its input.
102
+ function reorderCards(cards, id, placement) {
103
+ const result = cards.slice();
104
+ const key = placement && typeof placement === "object" && Object.prototype.hasOwnProperty.call(placement, "before")
105
+ ? "before"
106
+ : placement && typeof placement === "object" && Object.prototype.hasOwnProperty.call(placement, "after")
107
+ ? "after"
108
+ : null;
109
+ const card = result.find((c) => c.id === id);
110
+ if (!card || !key) return result;
111
+ const targetId = placement[key];
112
+ if (targetId === id) return result;
113
+ const target = result.find((c) => c.id === targetId);
114
+ if (!target || target.column !== card.column) return result;
115
+ const column = card.column;
116
+ const indices = [];
117
+ result.forEach((c, i) => { if (c.column === column) indices.push(i); });
118
+ const others = indices.map((i) => result[i]).filter((c) => c.id !== id);
119
+ const targetPos = others.findIndex((c) => c.id === targetId);
120
+ const insertAt = key === "before" ? targetPos : targetPos + 1;
121
+ const reordered = [...others.slice(0, insertAt), card, ...others.slice(insertAt)];
122
+ indices.forEach((slot, i) => { result[slot] = reordered[i]; });
123
+ return result;
124
+ }
125
+
126
+ // Opens an SSE connection to `opts.url` and reconnects after a drop. `opts.EventSource`,
127
+ // `opts.setTimeout` and `opts.clearTimeout` are injected so a test can supply fakes; when omitted
128
+ // (as a real page's caller may do) they default to the global timer functions, so a dropped
129
+ // connection always reconnects. `opts.retryMs` (default 1000) is how long it waits after a drop
130
+ // before opening a new source. Returns { close() }, which closes the current source and cancels a
131
+ // pending retry.
132
+ function connectLive(opts) {
133
+ const retryMs = opts.retryMs === undefined ? 1000 : opts.retryMs;
134
+ const doSetTimeout = opts.setTimeout || setTimeout;
135
+ const doClearTimeout = opts.clearTimeout || clearTimeout;
136
+ let source = null;
137
+ let retryTimer = null;
138
+ let closed = false;
139
+ let dropped = false;
140
+
141
+ function open() {
142
+ const es = new opts.EventSource(opts.url);
143
+ source = es;
144
+ es.addEventListener("open", () => {
145
+ if (source !== es) return;
146
+ dropped = false;
147
+ opts.onState("live");
148
+ });
149
+ es.addEventListener("error", () => {
150
+ if (source !== es) return;
151
+ es.close();
152
+ source = null;
153
+ if (!dropped) {
154
+ dropped = true;
155
+ opts.onState("reconnecting");
156
+ }
157
+ retryTimer = doSetTimeout(() => {
158
+ retryTimer = null;
159
+ if (!closed) open();
160
+ }, retryMs);
161
+ });
162
+ es.addEventListener("board", (e) => {
163
+ if (source !== es) return;
164
+ opts.onBoard(JSON.parse(e.data));
165
+ });
166
+ }
167
+ open();
168
+
169
+ return {
170
+ close() {
171
+ closed = true;
172
+ if (retryTimer !== null) {
173
+ try { doClearTimeout(retryTimer); } catch (_) {}
174
+ retryTimer = null;
175
+ }
176
+ if (source) { source.close(); source = null; }
177
+ },
178
+ };
179
+ }
180
+
181
+ // The serialized shape of a board's columns: id, order, title, and hint. Two boards whose columns
182
+ // differ in any of these need a reload rather than an in-place re-render.
183
+ function columnShape(board) {
184
+ return JSON.stringify(board.columns.map((c) => ({ id: c.id, title: c.title, hint: c.hint == null ? null : c.hint })));
185
+ }
186
+
187
+ // Sorted component list and sorted tag list, compared to decide whether the filters need a reload.
188
+ function componentAndTagShape(board) {
189
+ return JSON.stringify({ components: [...board.components].sort(), tags: tagOptions(board.cards).sort() });
190
+ }
191
+
192
+ // The header's counts-and-record line: how many cards, how many done, when the record was last
193
+ // updated, and where it lives. Shared by the initial render and a live update's in-place patch.
194
+ function headerSubHtml(board, config) {
195
+ const done = board.cards.filter((c) => c.column === "done").length;
196
+ return `${board.cards.length} cards · ${done} done · updated ${esc(board.updated)} · record: <span class="mono">${esc(config.record)}</span>`;
197
+ }
198
+
199
+ function CSS_escape(s) { return (typeof window !== "undefined" && window.CSS && window.CSS.escape) ? window.CSS.escape(s) : String(s).replace(/"/g, '\\"'); }
200
+
201
+ // Applied to a live board pushed over SSE, in local mode. When the columns (their ids, order,
202
+ // titles, or hints) or the set of components/tags changed, the view is stale in ways an in-place
203
+ // patch cannot fix cleanly, so it reloads instead. Otherwise it re-renders each column's cards in
204
+ // place (the column elements carrying drop listeners are untouched) and the header counts line,
205
+ // and returns the board to keep as current state; a reload returns null. `opts.document` and
206
+ // `opts.location` default to the globals and are overridable for a test.
207
+ function applyLiveBoard(board, next, config, opts) {
208
+ opts = opts || {};
209
+ const doc = opts.document !== undefined ? opts.document : (typeof document !== "undefined" ? document : null);
210
+ const loc = opts.location !== undefined ? opts.location : (typeof location !== "undefined" ? location : null);
211
+ if (columnShape(board) !== columnShape(next) || componentAndTagShape(board) !== componentAndTagShape(next)) {
212
+ if (loc && typeof loc.reload === "function") loc.reload();
213
+ return null;
214
+ }
215
+ if (doc) {
216
+ for (const col of next.columns) {
217
+ const container = doc.querySelector('.col[data-column="' + CSS_escape(col.id) + '"] .cards');
218
+ if (!container) continue;
219
+ const mine = next.cards.filter((c) => c.column === col.id);
220
+ const ordered = col.id === "done" ? [...mine].reverse() : mine;
221
+ container.innerHTML = ordered.map((c) => cardHtml(c, next.columns)).join("\n");
222
+ }
223
+ const sub = doc.querySelector("header .sub");
224
+ if (sub) sub.innerHTML = headerSubHtml(next, config);
225
+ }
226
+ return next;
227
+ }
228
+
229
+ // Sorted unique tags across the cards that carry any; an empty array when none does. Never mutates its input.
230
+ function tagOptions(cards) {
231
+ const set = new Set();
232
+ for (const c of cards) for (const t of c.tags || []) set.add(t);
233
+ return Array.from(set).sort();
234
+ }
235
+
236
+ // Whether a card passes { component, tag } filters, where "" means no filter. A card with no `tags`
237
+ // matches the tag filter only when it is "". Never mutates its input.
238
+ function cardMatches(card, filters) {
239
+ const component = (filters && filters.component) || "";
240
+ const tag = (filters && filters.tag) || "";
241
+ if (component && card.component !== component) return false;
242
+ if (tag) {
243
+ const tags = card.tags || [];
244
+ if (!tags.includes(tag)) return false;
245
+ }
246
+ return true;
247
+ }
248
+
249
+ // The complete document for a given board state; `doug board serve` renders it from the record on
250
+ // every request.
251
+ function renderDoc(board, appSource, config) {
252
+ const comps = board.components.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
253
+ const tags = tagOptions(board.cards);
254
+ const tagFilter = tags.length
255
+ ? `<label for="tag-filter">tag</label><select id="tag-filter"><option value="">all</option>${tags.map((t) => `<option value="${esc(t)}">${esc(t)}</option>`).join("")}</select>`
256
+ : "";
257
+ const head = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>Doug Board</title>\n${FONTS}\n<style>${CSS}</style>\n</head>\n<body>\n`;
258
+ const tail = `\n</body>\n</html>\n`;
259
+ return head + `<header>
260
+ <div><h1>Doug Board</h1><div class="sub">${headerSubHtml(board, config)}</div></div>
261
+ <div class="tools">
262
+ <label for="filter">component</label><select id="filter"><option value="">all</option>${comps}</select>
263
+ ${tagFilter ? tagFilter + "\n" : ""}<span class="status" id="filter-note"></span>
264
+ <button type="button" id="copy">Copy board JSON</button>
265
+ <span class="status" id="status"></span>
266
+ </div>
267
+ </header>
268
+ <main class="board" id="board">
269
+ ${board.columns.map((col) => columnHtml(col, board.cards, board.columns)).join("\n")}
270
+ </main>
271
+ ${footerHtml(config)}
272
+ <script type="application/json" id="board-config">${JSON.stringify(config).replace(/</g, "\\u003c")}<\/script>
273
+ <script type="application/json" id="board-data">${JSON.stringify(board).replace(/</g, "\\u003c")}<\/script>
274
+ <script id="app">${appSource}<\/script>` + tail;
275
+ }
276
+
277
+ function app() {
278
+ const dataEl = document.getElementById("board-data");
279
+ let board = JSON.parse(dataEl.textContent);
280
+ const config = JSON.parse(document.getElementById("board-config").textContent);
281
+ const local = config.mode === "local";
282
+ const status = document.getElementById("status");
283
+ // In local mode the page stays read-only until `doug board serve` answers for it.
284
+ let readOnly = local;
285
+
286
+ const say = (m) => { status.textContent = m; };
287
+ const setReadOnly = (why) => { readOnly = true; document.body.classList.add("ro"); say(why); };
288
+
289
+ function moveCard(id, column) {
290
+ const card = board.cards.find((c) => c.id === id);
291
+ if (!card || card.column === column) return;
292
+ if (readOnly) { say("This view is read-only; the move was not saved."); return; }
293
+ card.column = column;
294
+ board.updated = new Date().toISOString().slice(0, 10);
295
+ // Optimistic: move the element now; the save confirms it (or puts the view back).
296
+ const el = document.querySelector(`.card[data-id="${CSS_escape(id)}"]`);
297
+ const target = document.querySelector(`.col[data-column="${CSS_escape(column)}"] .cards`);
298
+ if (el && target) { if (column === "done") target.prepend(el); else target.appendChild(el); el.dataset.column = column; }
299
+ refreshCounts();
300
+ if (local) save(id, { column: column });
301
+ }
302
+
303
+ // Places a card relative to another card, possibly moving it to that card's column first.
304
+ // Same column rule as moveCard, minus its early return: an unchanged column still reorders.
305
+ function placeCard(id, column, placement) {
306
+ const key = Object.prototype.hasOwnProperty.call(placement, "before") ? "before" : "after";
307
+ const targetId = placement[key];
308
+ if (targetId === id) return;
309
+ const card = board.cards.find((c) => c.id === id);
310
+ if (!card) return;
311
+ if (readOnly) { say("This view is read-only; the move was not saved."); return; }
312
+ if (card.column !== column) card.column = column;
313
+ const el = document.querySelector(`.card[data-id="${CSS_escape(id)}"]`);
314
+ const targetEl = document.querySelector(`.card[data-id="${CSS_escape(targetId)}"]`);
315
+ if (el) el.dataset.column = column;
316
+ if (el && targetEl) { if (key === "before") targetEl.before(el); else targetEl.after(el); }
317
+ refreshCounts();
318
+ if (local) save(id, { column: column, [key]: targetId });
319
+ }
320
+
321
+ function refreshCounts() {
322
+ for (const col of document.querySelectorAll(".col")) {
323
+ col.querySelector(".n").textContent = col.querySelectorAll(".card:not([hidden])").length;
324
+ }
325
+ }
326
+
327
+ // Local mode: `doug board serve` writes the move with the same library `doug board move` uses,
328
+ // so it is validated the same way. Nothing on this path ever touches window.claude.
329
+ let saving = false;
330
+ async function save(id, body) {
331
+ if (saving) return;
332
+ saving = true;
333
+ say("Saving…");
334
+ try {
335
+ const res = await fetch("/api/cards/" + encodeURIComponent(id), {
336
+ method: "PUT",
337
+ headers: { "content-type": "application/json" },
338
+ body: JSON.stringify(body),
339
+ });
340
+ if (res.status === 200) {
341
+ board = await res.json();
342
+ say("Saved to " + config.record + ".");
343
+ } else {
344
+ let message = "The move was rejected (" + res.status + ").";
345
+ try { const body = await res.json(); if (body && body.error) message = body.error; } catch (_) {}
346
+ reloadAfter(message);
347
+ }
348
+ } catch (_) {
349
+ reloadAfter("Could not reach doug board serve; the move was not saved.");
350
+ } finally {
351
+ saving = false;
352
+ }
353
+ }
354
+
355
+ // Say why the move did not stick, then put the view back to what the file says.
356
+ function reloadAfter(message) {
357
+ say(message);
358
+ setTimeout(() => location.reload(), 1500);
359
+ }
360
+
361
+ // Drag and drop, including reordering: hovering over a card in another column (except Done)
362
+ // remembers where the drop would land and marks the card with an insertion line.
363
+ let dragId = null;
364
+ let dropTarget = null;
365
+ function clearDropMarks() {
366
+ for (const el of document.querySelectorAll(".drop-before, .drop-after")) el.classList.remove("drop-before", "drop-after");
367
+ }
368
+ document.addEventListener("dragstart", (e) => {
369
+ const card = e.target.closest && e.target.closest(".card");
370
+ if (!card || readOnly) return;
371
+ dragId = card.dataset.id;
372
+ card.classList.add("dragging");
373
+ e.dataTransfer.effectAllowed = "move";
374
+ try { e.dataTransfer.setData("text/plain", dragId); } catch (_) {}
375
+ });
376
+ document.addEventListener("dragend", (e) => {
377
+ const card = e.target.closest && e.target.closest(".card");
378
+ if (card) card.classList.remove("dragging");
379
+ for (const col of document.querySelectorAll(".col.over")) col.classList.remove("over");
380
+ clearDropMarks();
381
+ dropTarget = null;
382
+ });
383
+ document.addEventListener("dragover", (e) => {
384
+ const card = e.target.closest && e.target.closest(".card");
385
+ if (!card || !dragId || card.dataset.id === dragId) return;
386
+ const col = card.closest(".col");
387
+ if (!col || col.dataset.column === "done") return;
388
+ e.preventDefault();
389
+ const placement = dropPlacement(e.clientY, card.getBoundingClientRect());
390
+ dropTarget = { id: card.dataset.id, placement: placement, column: col.dataset.column };
391
+ clearDropMarks();
392
+ card.classList.add(placement === "before" ? "drop-before" : "drop-after");
393
+ });
394
+ document.addEventListener("dragleave", (e) => {
395
+ const card = e.target.closest && e.target.closest(".card");
396
+ if (card) card.classList.remove("drop-before", "drop-after");
397
+ dropTarget = null;
398
+ });
399
+ for (const col of document.querySelectorAll(".col")) {
400
+ col.addEventListener("dragover", (e) => { if (dragId && !readOnly) { e.preventDefault(); col.classList.add("over"); } });
401
+ col.addEventListener("dragleave", () => col.classList.remove("over"));
402
+ col.addEventListener("drop", (e) => {
403
+ e.preventDefault();
404
+ col.classList.remove("over");
405
+ const id = dragId || e.dataTransfer.getData("text/plain");
406
+ dragId = null;
407
+ const target = dropTarget && dropTarget.column === col.dataset.column ? dropTarget : null;
408
+ clearDropMarks();
409
+ dropTarget = null;
410
+ if (!id) return;
411
+ if (target) placeCard(id, col.dataset.column, target.placement === "before" ? { before: target.id } : { after: target.id });
412
+ else moveCard(id, col.dataset.column);
413
+ });
414
+ }
415
+ // Alt+Up / Alt+Down move the focused card within its column; Done stays chronological.
416
+ document.addEventListener("keydown", (e) => {
417
+ if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
418
+ const card = document.activeElement && document.activeElement.closest && document.activeElement.closest(".card");
419
+ if (!card) return;
420
+ const id = card.dataset.id;
421
+ const column = card.dataset.column;
422
+ if (column === "done") { e.preventDefault(); say("Done is chronological; it is not reordered."); return; }
423
+ const list = card.closest(".cards");
424
+ const cards = list ? Array.from(list.querySelectorAll(".card:not([hidden])")) : [];
425
+ const idx = cards.indexOf(card);
426
+ if (e.key === "ArrowUp") {
427
+ const prev = idx > 0 ? cards[idx - 1] : null;
428
+ if (!prev) { say("Already at the top."); return; }
429
+ e.preventDefault();
430
+ placeCard(id, column, { before: prev.dataset.id });
431
+ } else {
432
+ const next = idx >= 0 && idx < cards.length - 1 ? cards[idx + 1] : null;
433
+ if (!next) { say("Already at the bottom."); return; }
434
+ e.preventDefault();
435
+ placeCard(id, column, { after: next.dataset.id });
436
+ }
437
+ card.focus();
438
+ });
439
+ // Touch fallback.
440
+ document.addEventListener("change", (e) => {
441
+ const sel = e.target.closest && e.target.closest("select[data-move]");
442
+ if (sel) moveCard(sel.dataset.move, sel.value);
443
+ });
444
+
445
+ // Per-view filter (never written to the record).
446
+ const filter = document.getElementById("filter");
447
+ const tagFilter = document.getElementById("tag-filter");
448
+ try { const saved = localStorage.getItem("doug-board-filter"); if (saved) filter.value = saved; } catch (_) {}
449
+ if (tagFilter) {
450
+ try {
451
+ const saved = localStorage.getItem("doug-board-tag-filter");
452
+ if (saved && Array.from(tagFilter.options).some((o) => o.value === saved)) tagFilter.value = saved;
453
+ } catch (_) {}
454
+ }
455
+ // A tag itself may contain spaces (e.g. "needs review"), and data-tags joins every tag on the card
456
+ // with a single space, so a naive split cannot tell a two-word tag from two one-word tags. Reconstruct
457
+ // against the known tag options instead, matching the longest known tag first.
458
+ const knownTags = tagFilter ? Array.from(tagFilter.options).map((o) => o.value).filter((v) => v) : [];
459
+ const sortedKnownTags = [...knownTags].sort((a, b) => b.split(" ").length - a.split(" ").length);
460
+ const parseCardTags = (attr) => {
461
+ if (!attr) return [];
462
+ const words = attr.split(" ");
463
+ const tags = [];
464
+ let i = 0;
465
+ while (i < words.length) {
466
+ const found = sortedKnownTags.find((t) => {
467
+ const tWords = t.split(" ");
468
+ return words.slice(i, i + tWords.length).join(" ") === t;
469
+ });
470
+ if (found) { tags.push(found); i += found.split(" ").length; }
471
+ else { tags.push(words[i]); i += 1; }
472
+ }
473
+ return tags;
474
+ };
475
+ const applyFilter = () => {
476
+ const component = filter.value;
477
+ const tag = tagFilter ? tagFilter.value : "";
478
+ for (const card of document.querySelectorAll(".card")) {
479
+ const shape = { component: card.dataset.component || "", tags: parseCardTags(card.dataset.tags) };
480
+ card.hidden = !cardMatches(shape, { component: component, tag: tag });
481
+ }
482
+ refreshCounts();
483
+ // A saved filter is easy to forget; say what it hides so the column counts are not mistaken for the board.
484
+ const all = document.querySelectorAll(".card").length;
485
+ const shown = document.querySelectorAll(".card:not([hidden])").length;
486
+ document.getElementById("filter-note").textContent = component || tag ? `showing ${shown} of ${all} cards` : "";
487
+ try { localStorage.setItem("doug-board-filter", component); } catch (_) {}
488
+ if (tagFilter) { try { localStorage.setItem("doug-board-tag-filter", tag); } catch (_) {} }
489
+ };
490
+ filter.addEventListener("change", applyFilter);
491
+ if (tagFilter) tagFilter.addEventListener("change", applyFilter);
492
+ applyFilter();
493
+
494
+ document.getElementById("copy").addEventListener("click", async () => {
495
+ const text = JSON.stringify(board, null, 2) + "\n";
496
+ try { await navigator.clipboard.writeText(text); say("Board JSON copied; paste it into " + config.record + "."); }
497
+ catch (_) { say("Clipboard unavailable here. Read the JSON from the page source (script#board-data)."); }
498
+ });
499
+
500
+ // Light up saving once the server answers.
501
+ if (local) {
502
+ // Hidden until the server answers, so a file:// copy never shows controls that cannot save.
503
+ document.body.classList.add("ro");
504
+ fetch("/api/board").then((res) => {
505
+ if (!res || res.status !== 200) throw new Error("not served");
506
+ return res.json();
507
+ }).then(() => {
508
+ readOnly = false;
509
+ document.body.classList.remove("ro");
510
+ say("");
511
+ // Once served, follow the record live: another writer's move (or this tab's own, echoed
512
+ // back) re-renders in place, or reloads when the columns or filters themselves changed.
513
+ connectLive({
514
+ EventSource: window.EventSource,
515
+ url: "/api/events",
516
+ setTimeout: window.setTimeout.bind(window),
517
+ clearTimeout: window.clearTimeout.bind(window),
518
+ onBoard: (next) => {
519
+ const updated = applyLiveBoard(board, next, config);
520
+ if (updated) {
521
+ board = updated;
522
+ refreshCounts();
523
+ applyFilter();
524
+ }
525
+ },
526
+ onState: (state) => {
527
+ if (state === "reconnecting") say("Lost the connection to doug board serve; reconnecting…");
528
+ else if (state === "live") say("");
529
+ },
530
+ });
531
+ }).catch(() => setReadOnly("Read-only copy: run `doug board serve` and open the URL it prints to move cards."));
532
+ }
533
+ }