scrumrun 4.0.0 → 4.1.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.
- package/CHANGELOG.md +26 -0
- package/README.md +45 -1
- package/SPEC.md +31 -0
- package/bin/scrumrun.js +89 -11
- package/docs/COMMANDS.md +12 -2
- package/docs/ERROR-CODES.md +4 -0
- package/docs/SCHEMA.md +4 -0
- package/docs/SEMANTIC-MEMORY.md +28 -0
- package/lib/actions/index.js +81 -0
- package/lib/commands/manifest.js +3 -2
- package/lib/commands/repair.js +24 -3
- package/lib/errors.js +4 -0
- package/lib/git/context.js +30 -0
- package/lib/guardrails/changeset.js +45 -0
- package/lib/guardrails/evaluate.js +175 -0
- package/lib/memory/compaction.js +289 -0
- package/lib/memory/index.js +62 -2
- package/lib/migrate/ops.js +92 -0
- package/lib/migrate/run.js +108 -0
- package/lib/runtime/context.js +3 -1
- package/lib/runtime/policy-engine.js +13 -1
- package/lib/runtime/watcher.js +185 -0
- package/lib/v2/conformance.js +27 -2
- package/lib/v2/runs-jsonl.js +134 -0
- package/lib/v2/task-schema.js +133 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +4 -0
- package/templates/project/.scrumrun/config.md +9 -0
- package/templates/shared/hooks/pre-commit +16 -0
- package/templates/shared/view.html +281 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const REQUIRED_SECTIONS = Object.freeze({
|
|
4
|
+
task: ["## Request", "## Done when"],
|
|
5
|
+
feature: [],
|
|
6
|
+
sprint: [],
|
|
7
|
+
run: []
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const COMPLETION_MIN_LENGTH = 24;
|
|
11
|
+
|
|
12
|
+
function findSectionLine(lines, heading) {
|
|
13
|
+
const target = heading.trim().toLowerCase();
|
|
14
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
15
|
+
if (lines[index].trim().toLowerCase() === target) return index + 1;
|
|
16
|
+
}
|
|
17
|
+
return -1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sectionBody(lines, heading) {
|
|
21
|
+
const start = findSectionLine(lines, heading);
|
|
22
|
+
if (start === -1) return null;
|
|
23
|
+
const body = [];
|
|
24
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
25
|
+
const line = lines[index];
|
|
26
|
+
if (/^## /.test(line) && index !== start - 1) break;
|
|
27
|
+
if (index !== start - 1) body.push(line);
|
|
28
|
+
}
|
|
29
|
+
return body.join("\n").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bodyLines(bodyOrSource) {
|
|
33
|
+
const text = String(bodyOrSource || "");
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (lines[0] !== "---") return lines;
|
|
36
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
37
|
+
if (lines[index] === "---") return lines.slice(index + 1);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function completionSatisfied(taskBody, runs = []) {
|
|
43
|
+
const body = sectionBody(bodyLines(taskBody), "## Completion");
|
|
44
|
+
if (body && body.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) return { ok: true, via: "section" };
|
|
45
|
+
for (const run of runs) {
|
|
46
|
+
const runBody = String(run.body || "");
|
|
47
|
+
const summary = sectionBody(bodyLines(runBody), "## Technical Summary");
|
|
48
|
+
if (summary && summary.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) {
|
|
49
|
+
return { ok: true, via: `run:${run.id || "unknown"}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ok: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateTaskArtifact(artifact, options = {}) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const warnings = [];
|
|
58
|
+
if (!artifact || !artifact.record) return { errors, warnings };
|
|
59
|
+
const record = artifact.record;
|
|
60
|
+
const source = artifact.body != null ? artifact.body : (artifact.source || "");
|
|
61
|
+
const lines = bodyLines(source);
|
|
62
|
+
const required = REQUIRED_SECTIONS[record.kind] || [];
|
|
63
|
+
const schemaOptIn = Number(record.task_schema || 0) >= 1;
|
|
64
|
+
if (schemaOptIn) {
|
|
65
|
+
for (const heading of required) {
|
|
66
|
+
if (findSectionLine(lines, heading) === -1) {
|
|
67
|
+
errors.push({
|
|
68
|
+
code: "SR-E-452",
|
|
69
|
+
message: `${record.id}: missing required section "${heading}".`,
|
|
70
|
+
file: artifact.file || null,
|
|
71
|
+
line: null
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (schemaOptIn && record.kind === "task" && record.status === "completed") {
|
|
77
|
+
const runs = options.runsByTask ? options.runsByTask[record.id] || [] : [];
|
|
78
|
+
const result = completionSatisfied(source, runs);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
const line = findSectionLine(lines, "## Completion");
|
|
81
|
+
errors.push({
|
|
82
|
+
code: "SR-E-453",
|
|
83
|
+
message: `${record.id} is completed but lacks a non-empty "## Completion" (min ${COMPLETION_MIN_LENGTH} chars) and no associated Run carries a "## Technical Summary".`,
|
|
84
|
+
file: artifact.file || null,
|
|
85
|
+
line: line === -1 ? null : line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (schemaOptIn && record.kind === "task" && ["running", "in_progress", "validating", "learning"].includes(record.status)) {
|
|
90
|
+
const git = options.gitContext;
|
|
91
|
+
if (git && git.isRepo) {
|
|
92
|
+
const branch = record.branch || (record.git && record.git.branch);
|
|
93
|
+
if (!branch) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
code: "SR-E-454",
|
|
96
|
+
message: `${record.id} is ${record.status} inside a git repository but has no git.branch recorded in frontmatter. Reconcile with \`scrumrun repair --apply\` or add \`git: { branch, base_sha }\`.`,
|
|
97
|
+
file: artifact.file || null
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { errors, warnings };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateArtifacts(artifacts, options = {}) {
|
|
106
|
+
const runsByTask = {};
|
|
107
|
+
for (const artifact of artifacts) {
|
|
108
|
+
if (!artifact || !artifact.record) continue;
|
|
109
|
+
if (artifact.record.kind !== "run") continue;
|
|
110
|
+
const taskId = artifact.record.task;
|
|
111
|
+
if (!taskId) continue;
|
|
112
|
+
if (!runsByTask[taskId]) runsByTask[taskId] = [];
|
|
113
|
+
runsByTask[taskId].push({ id: artifact.record.id, body: (artifact.body != null ? artifact.body : (artifact.source || "")) });
|
|
114
|
+
}
|
|
115
|
+
const errors = [];
|
|
116
|
+
const warnings = [];
|
|
117
|
+
for (const artifact of artifacts) {
|
|
118
|
+
const result = validateTaskArtifact(artifact, { ...options, runsByTask });
|
|
119
|
+
errors.push(...result.errors);
|
|
120
|
+
warnings.push(...result.warnings);
|
|
121
|
+
}
|
|
122
|
+
return { errors, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
REQUIRED_SECTIONS,
|
|
127
|
+
COMPLETION_MIN_LENGTH,
|
|
128
|
+
validateTaskArtifact,
|
|
129
|
+
validateArtifacts,
|
|
130
|
+
findSectionLine,
|
|
131
|
+
sectionBody,
|
|
132
|
+
bodyLines
|
|
133
|
+
};
|
package/package.json
CHANGED
|
@@ -97,6 +97,10 @@ ${Object.entries(TRUTH_OWNERSHIP).map(([kind, owner]) => `- **${kind}:** ${owner
|
|
|
97
97
|
|---|---|
|
|
98
98
|
${lifecycleRows.join("\n")}
|
|
99
99
|
|
|
100
|
+
## Declarative Guardrail enforcement
|
|
101
|
+
|
|
102
|
+
Project Guardrails may include an optional fenced \`yaml enforcement\` block. Its restricted, dependency-free YAML schema is defined normatively in \`SPEC.md §6.1\`: \`match.paths[]\`, \`match.diff[]\`, \`match.symbols[]\`, \`on_violation\` (\`block\` or \`warn\`), \`severity\`, and optional \`evidence\`. The pure evaluator consumes only that normalized rule data and a supplied ChangeSet; it has no network or LLM dependency. Prose-only Guardrails remain valid.
|
|
103
|
+
|
|
100
104
|
## Projections
|
|
101
105
|
|
|
102
106
|
\`state.md\`, \`map.md\`, context packages, and \`.cache/\` are disposable. They may summarize or index canonical artifacts, but they cannot introduce status, policy, relations, decisions, or knowledge.
|
|
@@ -7,7 +7,16 @@ Interaction Mode: guided
|
|
|
7
7
|
Execution Approval: always
|
|
8
8
|
Quick Tasks: ask
|
|
9
9
|
Agent Identity: agent
|
|
10
|
+
watcher.enabled: false
|
|
11
|
+
watcher.debounce_ms: 250
|
|
12
|
+
watcher.poll_interval_ms: 1500
|
|
13
|
+
memory.compaction.threshold: 0.6
|
|
14
|
+
memory.compaction.min_members: 3
|
|
10
15
|
|
|
11
16
|
These are operating preferences. They can never weaken `.scrumrun/guardrails.md`.
|
|
12
17
|
|
|
13
18
|
`Agent Identity` is the default agent name recorded as the Task `assignee` and Run event `actor`. In shared teams, prefer the per-agent `SCRUMRUN_AGENT` environment variable over this project-wide default.
|
|
19
|
+
|
|
20
|
+
`watcher.enabled` is opt-in. When enabled, `scrumrun config watch --start` keeps only generated `state.md`, `map.md`, and `.cache/` projections fresh; it never writes canonical Markdown.
|
|
21
|
+
|
|
22
|
+
`memory.compaction.*` controls an explicitly invoked, deterministic Dossier suggestion. It never calls an LLM, runs automatically, or deletes memory records.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Optional local hook. Copy to .git/hooks/pre-commit and chmod +x it.
|
|
3
|
+
# It is intentionally offline: the evaluator reads only the staged Git diff.
|
|
4
|
+
#
|
|
5
|
+
# Cross-platform notes:
|
|
6
|
+
# - macOS / Linux: works out of the box.
|
|
7
|
+
# - Windows: Git for Windows ships an embedded POSIX shell, so this hook
|
|
8
|
+
# runs as-is when committing from any git client (Git Bash, VS Code,
|
|
9
|
+
# JetBrains, GitHub Desktop). No `chmod` is needed on NTFS.
|
|
10
|
+
set -eu
|
|
11
|
+
|
|
12
|
+
if command -v scrumrun >/dev/null 2>&1; then
|
|
13
|
+
exec scrumrun review artifact --run --staged
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
echo "ScrumRun pre-commit hook skipped: scrumrun is not installed." >&2
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ScrumRun View</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
--bg: #0f1115;
|
|
11
|
+
--panel: #171a21;
|
|
12
|
+
--border: #262a34;
|
|
13
|
+
--muted: #8b93a1;
|
|
14
|
+
--fg: #e6e9ef;
|
|
15
|
+
--accent: #5aa1ff;
|
|
16
|
+
--ok: #4ac68a;
|
|
17
|
+
--warn: #e9b949;
|
|
18
|
+
--err: #ef6f6f;
|
|
19
|
+
--mono: ui-monospace, "SF Mono", "Menlo", "Consolas", monospace;
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: light) {
|
|
22
|
+
:root { --bg:#f7f8fa; --panel:#fff; --border:#e2e5ec; --muted:#5f6773; --fg:#0f1115; --accent:#1e6feb; }
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
26
|
+
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; gap: 16px; }
|
|
27
|
+
h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
|
28
|
+
.sub { color: var(--muted); font-size: 12px; }
|
|
29
|
+
main { padding: 16px 24px; display: grid; gap: 16px; }
|
|
30
|
+
section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
|
|
31
|
+
section h2 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; color: var(--muted); letter-spacing: .04em; font-weight: 600; }
|
|
32
|
+
.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
33
|
+
.col h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: .04em; }
|
|
34
|
+
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; cursor: pointer; transition: border-color .15s; }
|
|
35
|
+
.card:hover { border-color: var(--accent); }
|
|
36
|
+
.card .id { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
37
|
+
.card .title { font-size: 13px; margin-top: 2px; }
|
|
38
|
+
.card .meta { font-size: 11px; color: var(--muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
39
|
+
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--border); font-size: 10px; font-family: var(--mono); }
|
|
40
|
+
.badge.type-fix { color: var(--err); }
|
|
41
|
+
.badge.type-feature { color: var(--accent); }
|
|
42
|
+
.badge.type-docs { color: var(--muted); }
|
|
43
|
+
.search { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font: inherit; width: 240px; }
|
|
44
|
+
.empty { color: var(--muted); font-style: italic; padding: 8px 0; }
|
|
45
|
+
.err { color: var(--err); font-family: var(--mono); font-size: 12px; }
|
|
46
|
+
#detail { position: fixed; top: 0; right: 0; height: 100vh; width: min(560px, 60vw); background: var(--panel); border-left: 1px solid var(--border); padding: 16px 20px; overflow: auto; box-shadow: -6px 0 24px rgba(0,0,0,.15); transform: translateX(100%); transition: transform .18s; z-index: 10; }
|
|
47
|
+
#detail.open { transform: translateX(0); }
|
|
48
|
+
#detail pre { white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; background: var(--bg); padding: 10px; border-radius: 6px; border: 1px solid var(--border); }
|
|
49
|
+
#detail .close { float: right; background: none; border: 0; color: var(--muted); font-size: 20px; cursor: pointer; }
|
|
50
|
+
.list-item { padding: 6px 0; border-bottom: 1px dotted var(--border); cursor: pointer; }
|
|
51
|
+
.list-item:last-child { border-bottom: 0; }
|
|
52
|
+
.list-item:hover { color: var(--accent); }
|
|
53
|
+
.row { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
|
|
54
|
+
.row .muted { color: var(--muted); }
|
|
55
|
+
.header-right { margin-left: auto; display: flex; gap: 12px; align-items: center; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<header>
|
|
60
|
+
<h1>ScrumRun</h1>
|
|
61
|
+
<span class="sub" id="project-label">loading…</span>
|
|
62
|
+
<div class="header-right">
|
|
63
|
+
<input type="search" class="search" id="search" placeholder="filter by id or title" />
|
|
64
|
+
</div>
|
|
65
|
+
</header>
|
|
66
|
+
<main>
|
|
67
|
+
<section id="err-panel" style="display:none"><h2>Load error</h2><div class="err" id="err-msg"></div></section>
|
|
68
|
+
<section>
|
|
69
|
+
<h2>Tasks</h2>
|
|
70
|
+
<div class="kanban" id="kanban"></div>
|
|
71
|
+
</section>
|
|
72
|
+
<section>
|
|
73
|
+
<h2>Guardrails</h2>
|
|
74
|
+
<div id="guardrails"><div class="empty">loading…</div></div>
|
|
75
|
+
</section>
|
|
76
|
+
<section>
|
|
77
|
+
<h2>Recent Runs</h2>
|
|
78
|
+
<div id="runs"><div class="empty">loading…</div></div>
|
|
79
|
+
</section>
|
|
80
|
+
<section>
|
|
81
|
+
<h2>Decisions</h2>
|
|
82
|
+
<div id="decisions"><div class="empty">loading…</div></div>
|
|
83
|
+
</section>
|
|
84
|
+
</main>
|
|
85
|
+
<aside id="detail" aria-hidden="true">
|
|
86
|
+
<button class="close" id="detail-close" aria-label="close">×</button>
|
|
87
|
+
<div id="detail-body"></div>
|
|
88
|
+
</aside>
|
|
89
|
+
<script>
|
|
90
|
+
(() => {
|
|
91
|
+
"use strict";
|
|
92
|
+
const STATUS_ORDER = ["backlog", "running", "in_progress", "validating", "learning", "blocked", "failed", "completed"];
|
|
93
|
+
const STATUS_LABELS = {
|
|
94
|
+
backlog: "Backlog",
|
|
95
|
+
running: "Running",
|
|
96
|
+
in_progress: "In progress",
|
|
97
|
+
validating: "Validating",
|
|
98
|
+
learning: "Learning",
|
|
99
|
+
blocked: "Blocked",
|
|
100
|
+
failed: "Failed",
|
|
101
|
+
completed: "Completed"
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function parseFrontmatter(text) {
|
|
105
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/.exec(text);
|
|
106
|
+
if (!match) return { record: {}, body: text };
|
|
107
|
+
const record = {};
|
|
108
|
+
for (const raw of match[1].split(/\r?\n/)) {
|
|
109
|
+
const line = raw.trim();
|
|
110
|
+
if (!line || line.startsWith("#")) continue;
|
|
111
|
+
const colon = line.indexOf(":");
|
|
112
|
+
if (colon === -1) continue;
|
|
113
|
+
const key = line.slice(0, colon).trim();
|
|
114
|
+
const value = line.slice(colon + 1).trim();
|
|
115
|
+
record[key] = value.replace(/^"(.*)"$/, "$1");
|
|
116
|
+
}
|
|
117
|
+
return { record, body: match[2].trimStart() };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function tryFetch(url) {
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(url);
|
|
123
|
+
if (!res.ok) return null;
|
|
124
|
+
return await res.text();
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchArtifactsByPrefix(dir, prefix, cap = 999) {
|
|
131
|
+
const found = [];
|
|
132
|
+
// Try increasing IDs; stop after 3 consecutive misses beyond the last hit.
|
|
133
|
+
let consecutiveMisses = 0;
|
|
134
|
+
for (let i = 1; i <= cap && consecutiveMisses < 5; i += 1) {
|
|
135
|
+
const id = `${prefix}-${String(i).padStart(3, "0")}`;
|
|
136
|
+
const text = await tryFetch(`${dir}/${id}.md`);
|
|
137
|
+
if (text === null) {
|
|
138
|
+
consecutiveMisses += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
consecutiveMisses = 0;
|
|
142
|
+
const parsed = parseFrontmatter(text);
|
|
143
|
+
parsed.record.id = parsed.record.id || id;
|
|
144
|
+
parsed.file = `${dir}/${id}.md`;
|
|
145
|
+
found.push(parsed);
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function extractHeading(body) {
|
|
151
|
+
const line = body.split(/\r?\n/).find((l) => l.trim().startsWith("# "));
|
|
152
|
+
return line ? line.replace(/^# /, "").trim() : "(untitled)";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderKanban(tasks) {
|
|
156
|
+
const bucket = {};
|
|
157
|
+
for (const task of tasks) {
|
|
158
|
+
const status = task.record.status || "backlog";
|
|
159
|
+
if (!bucket[status]) bucket[status] = [];
|
|
160
|
+
bucket[status].push(task);
|
|
161
|
+
}
|
|
162
|
+
const ordered = STATUS_ORDER.filter((s) => bucket[s] && bucket[s].length);
|
|
163
|
+
const container = document.getElementById("kanban");
|
|
164
|
+
container.innerHTML = "";
|
|
165
|
+
if (!ordered.length) { container.innerHTML = '<div class="empty">no tasks found</div>'; return; }
|
|
166
|
+
for (const status of ordered) {
|
|
167
|
+
const col = document.createElement("div");
|
|
168
|
+
col.className = "col";
|
|
169
|
+
col.innerHTML = `<h3>${STATUS_LABELS[status] || status} (${bucket[status].length})</h3>`;
|
|
170
|
+
for (const task of bucket[status]) {
|
|
171
|
+
const title = extractHeading(task.body);
|
|
172
|
+
const type = task.record.type || "task";
|
|
173
|
+
const branch = task.record.branch;
|
|
174
|
+
const card = document.createElement("div");
|
|
175
|
+
card.className = "card";
|
|
176
|
+
card.dataset.id = task.record.id;
|
|
177
|
+
card.innerHTML = `
|
|
178
|
+
<div class="id">${task.record.id}</div>
|
|
179
|
+
<div class="title"></div>
|
|
180
|
+
<div class="meta">
|
|
181
|
+
<span class="badge type-${type}">${type}</span>
|
|
182
|
+
${branch ? `<span class="badge">${branch}</span>` : ""}
|
|
183
|
+
${task.record.sprint ? `<span class="badge">${task.record.sprint}</span>` : ""}
|
|
184
|
+
</div>`;
|
|
185
|
+
card.querySelector(".title").textContent = title;
|
|
186
|
+
card.addEventListener("click", () => openDetail(task));
|
|
187
|
+
col.appendChild(card);
|
|
188
|
+
}
|
|
189
|
+
container.appendChild(col);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderList(id, items, format) {
|
|
194
|
+
const el = document.getElementById(id);
|
|
195
|
+
el.innerHTML = "";
|
|
196
|
+
if (!items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const row = document.createElement("div");
|
|
199
|
+
row.className = "list-item";
|
|
200
|
+
row.innerHTML = format(item);
|
|
201
|
+
row.addEventListener("click", () => openDetail(item));
|
|
202
|
+
el.appendChild(row);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderGuardrailsFromText(text) {
|
|
207
|
+
const el = document.getElementById("guardrails");
|
|
208
|
+
if (!text) { el.innerHTML = '<div class="empty">guardrails.md not found</div>'; return; }
|
|
209
|
+
const rules = [];
|
|
210
|
+
for (const match of text.matchAll(/^##\s+(GR-\d+)\s+-\s+(.+)$/gm)) {
|
|
211
|
+
rules.push({ id: match[1], title: match[2] });
|
|
212
|
+
}
|
|
213
|
+
el.innerHTML = rules.length
|
|
214
|
+
? rules.map((r) => `<div class="row"><span>${r.id}</span><span class="muted">${r.title}</span></div>`).join("")
|
|
215
|
+
: '<div class="empty">no guardrails declared</div>';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function openDetail(artifact) {
|
|
219
|
+
const body = document.getElementById("detail-body");
|
|
220
|
+
const record = artifact.record || {};
|
|
221
|
+
const meta = Object.entries(record).map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
222
|
+
body.innerHTML = `
|
|
223
|
+
<h2 style="margin-top:0;font-size:14px">${record.id || "(unknown)"}</h2>
|
|
224
|
+
<div class="sub" style="margin-bottom:10px">${extractHeading(artifact.body || "")}</div>
|
|
225
|
+
<pre>${escape(meta)}</pre>
|
|
226
|
+
<pre>${escape(artifact.body || "")}</pre>
|
|
227
|
+
<div class="sub"><a href="${artifact.file || "#"}" target="_blank" rel="noopener">open raw file</a></div>`;
|
|
228
|
+
document.getElementById("detail").classList.add("open");
|
|
229
|
+
document.getElementById("detail").setAttribute("aria-hidden", "false");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function escape(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
233
|
+
|
|
234
|
+
document.getElementById("detail-close").addEventListener("click", () => {
|
|
235
|
+
document.getElementById("detail").classList.remove("open");
|
|
236
|
+
document.getElementById("detail").setAttribute("aria-hidden", "true");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
document.getElementById("search").addEventListener("input", (event) => {
|
|
240
|
+
const q = event.target.value.trim().toLowerCase();
|
|
241
|
+
for (const card of document.querySelectorAll(".card")) {
|
|
242
|
+
const text = card.textContent.toLowerCase();
|
|
243
|
+
card.style.display = !q || text.includes(q) ? "" : "none";
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
async function boot() {
|
|
248
|
+
const label = document.getElementById("project-label");
|
|
249
|
+
const [tasks, runs, decisions, guardrailsText, methodText, stateText] = await Promise.all([
|
|
250
|
+
fetchArtifactsByPrefix("tasks", "TASK", 999),
|
|
251
|
+
fetchArtifactsByPrefix("runs", "RUN", 999),
|
|
252
|
+
fetchArtifactsByPrefix("memory/decisions", "DEC", 999),
|
|
253
|
+
tryFetch("guardrails.md"),
|
|
254
|
+
tryFetch("method.json"),
|
|
255
|
+
tryFetch("state.md")
|
|
256
|
+
]);
|
|
257
|
+
if (methodText) {
|
|
258
|
+
try { const m = JSON.parse(methodText); label.textContent = `method ${m.method || "?"} · ${tasks.length} tasks · ${runs.length} runs`; }
|
|
259
|
+
catch { label.textContent = `${tasks.length} tasks · ${runs.length} runs`; }
|
|
260
|
+
} else {
|
|
261
|
+
label.textContent = `${tasks.length} tasks · ${runs.length} runs`;
|
|
262
|
+
}
|
|
263
|
+
if (!tasks.length && !runs.length && !guardrailsText && !stateText) {
|
|
264
|
+
document.getElementById("err-panel").style.display = "";
|
|
265
|
+
document.getElementById("err-msg").textContent = "No .scrumrun/ artifacts loaded. If you are viewing via file:// and your browser blocks local fetch, serve the folder: cd .scrumrun && python3 -m http.server 8080, then open http://localhost:8080/view.html";
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
renderKanban(tasks);
|
|
269
|
+
renderList("runs", runs.slice().reverse().slice(0, 20), (r) => `<div class="row"><span>${r.record.id}</span><span class="muted">${extractHeading(r.body)}</span></div>`);
|
|
270
|
+
renderList("decisions", decisions, (d) => `<div class="row"><span>${d.record.id}</span><span class="muted">${extractHeading(d.body)}</span></div>`);
|
|
271
|
+
renderGuardrailsFromText(guardrailsText);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
boot().catch((error) => {
|
|
275
|
+
document.getElementById("err-panel").style.display = "";
|
|
276
|
+
document.getElementById("err-msg").textContent = String(error && error.message || error);
|
|
277
|
+
});
|
|
278
|
+
})();
|
|
279
|
+
</script>
|
|
280
|
+
</body>
|
|
281
|
+
</html>
|