doctrina-cli 0.18.0 → 0.21.0
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/README.md +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +84 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +164 -17
- package/src/commands/clarify.js +1 -204
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +81 -9
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +356 -0
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +28 -1
- package/src/commands/intake.js +60 -3
- package/src/commands/metrics.js +43 -3
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +125 -7
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +33 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +126 -7
- package/src/commands/workspace.js +419 -0
- package/src/index.js +100 -66
- package/src/lib/actions.js +44 -4
- package/src/lib/adapters.js +143 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +770 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +149 -0
- package/src/lib/delivery.js +286 -0
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +43 -2
- package/src/lib/task-card.js +195 -0
- package/src/lib/views.js +52 -3
- package/src/lib/work-model.js +66 -2
- package/src/lib/workspace-agents.js +306 -0
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +160 -0
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +323 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -1
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +63 -0
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +44 -0
- package/templates/playbooks/work.md.template +1 -1
package/src/lib/json-out.js
CHANGED
|
@@ -71,12 +71,14 @@ export function deferJson() {
|
|
|
71
71
|
* Write the held payload with the exit code the command actually returned,
|
|
72
72
|
* and leave deferred mode. A no-op when the command emitted nothing.
|
|
73
73
|
*/
|
|
74
|
+
/** Write the held payload; false when the command held none. */
|
|
74
75
|
export function flushJson(code) {
|
|
75
76
|
deferring = false;
|
|
76
|
-
if (!held) return;
|
|
77
|
+
if (!held) return false;
|
|
77
78
|
const { command, data } = held;
|
|
78
79
|
held = null;
|
|
79
80
|
writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
|
|
81
|
+
return true;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// Print a structured payload. `command` is the invocation, `data` whatever
|
|
@@ -165,6 +167,12 @@ export async function captureOutput(fn) {
|
|
|
165
167
|
try {
|
|
166
168
|
const code = await fn();
|
|
167
169
|
return { code: code ?? EXIT.OK, stdout, stderr };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// A thrown precondition still answers inside the envelope (change 0234):
|
|
172
|
+
// it escaped as English on stderr, with no JSON on stdout at all.
|
|
173
|
+
stderr.push(`error: ${err?.message ?? err}`);
|
|
174
|
+
if (err?.remedy) stderr.push(`hint: run ${err.remedy} first, then retry`);
|
|
175
|
+
return { code: err?.exitCode ?? EXIT.GATE, stdout, stderr, error: err };
|
|
168
176
|
} finally {
|
|
169
177
|
console.log = origLog;
|
|
170
178
|
console.error = origError;
|
package/src/lib/metrics-model.js
CHANGED
|
@@ -48,6 +48,16 @@ export function computeSnapshot(commits, since) {
|
|
|
48
48
|
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Workflow adherence (change 0218): of the commits that changed code, how
|
|
52
|
+
// many carried a change — touched `.doctrina/changes/`, or named one in the
|
|
53
|
+
// subject. A field review found an agent that "knows the framework
|
|
54
|
+
// exists" and still went straight to code when the request did not name
|
|
55
|
+
// it; this is the number that says whether the hooks and the contract
|
|
56
|
+
// moved that. A proxy, like re-edit: a typo fix needs no change.
|
|
57
|
+
const code = commits.filter((cm) => cm.files.some(isCodePath));
|
|
58
|
+
const withChange = code.filter((cm) =>
|
|
59
|
+
cm.files.some((f) => f.startsWith(".doctrina/changes/")) || CHANGE_REF.test(cm.subject));
|
|
60
|
+
|
|
51
61
|
return {
|
|
52
62
|
generated: today(),
|
|
53
63
|
window: since,
|
|
@@ -57,10 +67,22 @@ export function computeSnapshot(commits, since) {
|
|
|
57
67
|
fix_share: round(fixes / total),
|
|
58
68
|
reedit_window_days: REEDIT_WINDOW_DAYS,
|
|
59
69
|
reedit_rate: round(reedits / total),
|
|
70
|
+
code_commits: code.length,
|
|
71
|
+
change_adherence: code.length > 0 ? round(withChange.length / code.length) : null,
|
|
60
72
|
top_churn: topChurn,
|
|
61
73
|
};
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
// A subject that names a change: "Change 0212", or an id like "0212-x".
|
|
77
|
+
const CHANGE_REF = /\bchange\s+\d{4}\b|\b\d{4}-[a-z0-9][a-z0-9-]*\b/i;
|
|
78
|
+
|
|
79
|
+
// Code, for the adherence rate: not the Doctrina tree, not an agent's own
|
|
80
|
+
// config or generated skills, not prose.
|
|
81
|
+
function isCodePath(f) {
|
|
82
|
+
if (/^\.(doctrina|agents|claude|cursor)\//.test(f)) return false;
|
|
83
|
+
return !/\.(md|mdx|txt)$/i.test(f);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
|
|
65
87
|
export function parseLog(stdout) {
|
|
66
88
|
const commits = [];
|
|
@@ -174,7 +196,7 @@ export function latestSnapshot(projectRoot, excludeBase = null) {
|
|
|
174
196
|
* @param {ReturnType<typeof readSeries>} series
|
|
175
197
|
*/
|
|
176
198
|
export function trend(series) {
|
|
177
|
-
const rates = ["revert_rate", "fix_share", "reedit_rate"];
|
|
199
|
+
const rates = ["revert_rate", "fix_share", "reedit_rate", "change_adherence"];
|
|
178
200
|
if (series.length < 2) return { span: series.length, rows: [] };
|
|
179
201
|
const first = series[0];
|
|
180
202
|
const last = series[series.length - 1];
|
package/src/lib/playbook.js
CHANGED
|
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
-
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate"];
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate", "absorb"];
|
package/src/lib/shell.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
// THE COMMANDS THE CLI PRINTS ARE RUN AS PRINTED (fourth audit, change 0235).
|
|
5
|
+
//
|
|
6
|
+
// `next`, `delivery status`, the playbooks and the hub hand an agent a line
|
|
7
|
+
// to paste into its shell: `cd <repo> && doctrina work "<title>" --delivery
|
|
8
|
+
// <id>`. The title comes from the tracker — text the delivery playbook calls
|
|
9
|
+
// data, never an instruction — and it was interpolated between double quotes
|
|
10
|
+
// as it came: a story titled `Fix $(curl … | sh)` became a command the agent
|
|
11
|
+
// ran, and a repository under `~/my api/` became a `cd` into `~/my`.
|
|
12
|
+
|
|
13
|
+
// Inside double quotes a POSIX shell still expands these.
|
|
14
|
+
const DOUBLE_QUOTE_UNSAFE = /["`$\\!]/;
|
|
15
|
+
const BARE_PATH = /^[A-Za-z0-9_./:\\~+@%=,-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One shell argument, quoted so the shell reads it back verbatim. Plain text
|
|
19
|
+
* keeps the double quotes every surface has always printed; text a double
|
|
20
|
+
* quote would still expand is single-quoted — POSIX `'\''` for an embedded
|
|
21
|
+
* quote, PowerShell's `''` on Windows.
|
|
22
|
+
* @param {string} value
|
|
23
|
+
* @param {NodeJS.Platform} [platform]
|
|
24
|
+
*/
|
|
25
|
+
export function qarg(value, platform = process.platform) {
|
|
26
|
+
const s = String(value ?? "");
|
|
27
|
+
if (!DOUBLE_QUOTE_UNSAFE.test(s)) return `"${s}"`;
|
|
28
|
+
const inner = platform === "win32" ? s.replace(/'/g, "''") : s.replace(/'/g, "'\\''");
|
|
29
|
+
return `'${inner}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A path as a shell argument: bare when nothing in it needs quoting.
|
|
34
|
+
* @param {string} dir
|
|
35
|
+
* @param {NodeJS.Platform} [platform]
|
|
36
|
+
*/
|
|
37
|
+
export function qpath(dir, platform = process.platform) {
|
|
38
|
+
const s = String(dir ?? "");
|
|
39
|
+
return BARE_PATH.test(s) ? s : qarg(s, platform);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `cd <dir> && <command>`, with the directory quoted when it must be. */
|
|
43
|
+
export function cdThen(dir, command) {
|
|
44
|
+
return `cd ${qpath(dir)} && ${command}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, pruneEmptyDirs, read, remove, write, walk } from "./fs-ops.js";
|
|
5
|
+
import { parseFrontmatter } from "./doc-model.js";
|
|
6
|
+
import { describeAdapter, ADAPTER_STATES } from "./adapters.js";
|
|
7
|
+
|
|
8
|
+
// The project's skills, where the agent looks for skills (change 0212).
|
|
9
|
+
//
|
|
10
|
+
// A field review of an agent in Cursor: the editor's skill catalogue lists
|
|
11
|
+
// `~/.cursor/skills` and plugins, never `.doctrina/skills/`, so a skill that
|
|
12
|
+
// matched the task fired only when the request named its file. AGENTS.md
|
|
13
|
+
// said "skills are on demand"; nothing said the editor would never offer
|
|
14
|
+
// them. The agents now share a format — a folder per skill holding a
|
|
15
|
+
// SKILL.md with `name` and `description` — and discover it on their own:
|
|
16
|
+
// `.agents/skills/` (Cursor, Codex) and `.claude/skills/` (Claude Code;
|
|
17
|
+
// Cursor reads it too). `.doctrina/skills/` stays the one source; the
|
|
18
|
+
// exports are generated from it, marked as generated, and pruned with it.
|
|
19
|
+
|
|
20
|
+
export const EXPORT_MARK = "<!-- doctrina:skill-export";
|
|
21
|
+
const DESCRIPTION_MAX = 1024;
|
|
22
|
+
|
|
23
|
+
/** Where this project's skills are exported, as project-relative roots. */
|
|
24
|
+
export function exportRoots(projectRoot) {
|
|
25
|
+
const roots = [".agents/skills"];
|
|
26
|
+
if (describeAdapter(projectRoot, "claude").state === ADAPTER_STATES.INSTALLED) roots.push(".claude/skills");
|
|
27
|
+
return roots;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The SKILL.md a Doctrina skill becomes. */
|
|
31
|
+
export function renderSkillExport(name, text) {
|
|
32
|
+
const body = text.replace(/\r\n/g, "\n");
|
|
33
|
+
const description = parseFrontmatter(body, "description") ?? name;
|
|
34
|
+
const when = parseFrontmatter(body, "when");
|
|
35
|
+
// The agent decides from the description alone, so the trigger goes in it.
|
|
36
|
+
const full = (when ? `${description} Use when: ${when}` : description).replace(/\s+/g, " ").trim();
|
|
37
|
+
const clipped = full.length > DESCRIPTION_MAX ? `${full.slice(0, DESCRIPTION_MAX - 1)}…` : full;
|
|
38
|
+
const rest = body.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/^\n+/, "");
|
|
39
|
+
return [
|
|
40
|
+
"---",
|
|
41
|
+
`name: ${name}`,
|
|
42
|
+
`description: ${JSON.stringify(clipped)}`,
|
|
43
|
+
"---",
|
|
44
|
+
`${EXPORT_MARK} — generated from .doctrina/skills/${name}.md by \`doctrina index rebuild\`; edit that file, not this one. -->`,
|
|
45
|
+
"",
|
|
46
|
+
rest,
|
|
47
|
+
].join("\n").replace(/\n*$/, "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What the export would change: files to write (missing or different) and
|
|
52
|
+
* generated skill folders to remove (their source is gone). Folders not
|
|
53
|
+
* carrying the generated mark are someone else's and never touched.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} projectRoot
|
|
56
|
+
* @returns {{ writes: Array<{ rel: string, body: string }>, removes: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function planSkillExport(projectRoot) {
|
|
59
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
60
|
+
const skills = isDir(skillsDir)
|
|
61
|
+
? walk(skillsDir).filter((f) => f.endsWith(".md") && path.dirname(f) === skillsDir)
|
|
62
|
+
: [];
|
|
63
|
+
const wanted = new Map(skills.map((f) => [path.basename(f, ".md"), read(f)]));
|
|
64
|
+
const writes = [];
|
|
65
|
+
const removes = [];
|
|
66
|
+
if (wanted.size === 0 && !exportRoots(projectRoot).some((r) => isDir(path.join(projectRoot, r)))) {
|
|
67
|
+
return { writes, removes };
|
|
68
|
+
}
|
|
69
|
+
for (const root of exportRoots(projectRoot)) {
|
|
70
|
+
for (const [name, text] of wanted) {
|
|
71
|
+
const rel = `${root}/${name}/SKILL.md`;
|
|
72
|
+
const body = renderSkillExport(name, text);
|
|
73
|
+
const abs = path.join(projectRoot, rel);
|
|
74
|
+
if (!isFile(abs) || read(abs).replace(/\r\n/g, "\n") !== body) writes.push({ rel, body });
|
|
75
|
+
}
|
|
76
|
+
const rootAbs = path.join(projectRoot, root);
|
|
77
|
+
if (!isDir(rootAbs)) continue;
|
|
78
|
+
for (const entry of readdirSync(rootAbs)) {
|
|
79
|
+
const skillFile = path.join(rootAbs, entry, "SKILL.md");
|
|
80
|
+
if (wanted.has(entry) || !isFile(skillFile)) continue;
|
|
81
|
+
if (read(skillFile).includes(EXPORT_MARK)) removes.push(`${root}/${entry}/SKILL.md`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { writes, removes };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Apply a plan; returns it. */
|
|
88
|
+
export function applySkillExport(projectRoot, plan = planSkillExport(projectRoot)) {
|
|
89
|
+
for (const w of plan.writes) write(path.join(projectRoot, w.rel), w.body, { force: true });
|
|
90
|
+
for (const r of plan.removes) {
|
|
91
|
+
remove(path.join(projectRoot, r)); // the generated file only
|
|
92
|
+
pruneEmptyDirs(path.dirname(path.join(projectRoot, r)), projectRoot);
|
|
93
|
+
}
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
package/src/lib/snapshot.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs";
|
|
|
4
4
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
5
|
import { projectName } from "./project.js";
|
|
6
6
|
import * as idx from "./index-json.js";
|
|
7
|
-
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
7
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader, deltaCapabilities } from "./scan.js";
|
|
8
8
|
import { cliVersion } from "./version.js";
|
|
9
9
|
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
10
|
import { summarize as traceSummary } from "./trace-model.js";
|
|
@@ -72,6 +72,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
|
72
72
|
decisions: countDecisions(projectRoot),
|
|
73
73
|
skills: countSkills(projectRoot),
|
|
74
74
|
openChanges: openChanges(projectRoot),
|
|
75
|
+
recentlyClosed: recentlyClosed(projectRoot),
|
|
75
76
|
adrs: acceptedDecisions(projectRoot),
|
|
76
77
|
nonGoals: productSection(projectRoot, "Non-goals"),
|
|
77
78
|
hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
|
|
@@ -151,7 +152,47 @@ export function openChanges(projectRoot) {
|
|
|
151
152
|
const tasksDone = progress.done;
|
|
152
153
|
const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
|
|
153
154
|
const date = proposal ? (listHeader(proposal, "Date") ?? null) : null;
|
|
154
|
-
|
|
155
|
+
// The capabilities its deltas target — what `prime` turns into the
|
|
156
|
+
// exact read command for the change (change 0217).
|
|
157
|
+
const capabilities = deltaCapabilities(path.join(changesDir, id));
|
|
158
|
+
// The delivery a change belongs to (change 0226): a session opened in the
|
|
159
|
+
// repository — an executor's, or a human's — sees it is one part of a
|
|
160
|
+
// larger item of work, not a change on its own.
|
|
161
|
+
const delivery = proposal ? (listHeader(proposal, "Delivery") ?? null) : null;
|
|
162
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities, delivery });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// How far back `prime` and the pulse look for closed changes (change 0214).
|
|
168
|
+
export const RECENT_CLOSE_DAYS = 7;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Changes archived in the last RECENT_CLOSE_DAYS, newest first: what the
|
|
172
|
+
* human may ask for again without knowing it is done. A field review: the
|
|
173
|
+
* close moved the work to `changes/archive/`, out of every default read, and
|
|
174
|
+
* the next request was to "create the doctrina work" for it.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} projectRoot
|
|
177
|
+
* @param {{ limit?: number }} [opts]
|
|
178
|
+
* @returns {Array<{ id: string, title: string|null, date: string, path: string, ageDays: number|null }>}
|
|
179
|
+
*/
|
|
180
|
+
export function recentlyClosed(projectRoot, { limit = 3 } = {}) {
|
|
181
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
182
|
+
if (!isDir(archiveDir)) return [];
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const name of readdirSync(archiveDir).sort().reverse()) {
|
|
185
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
186
|
+
if (!m || !isDir(path.join(archiveDir, name))) continue;
|
|
187
|
+
const ageDays = ageInDays(m[1]);
|
|
188
|
+
if (ageDays === null || ageDays > RECENT_CLOSE_DAYS) break;
|
|
189
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
190
|
+
out.push({
|
|
191
|
+
id: m[2], date: m[1], ageDays,
|
|
192
|
+
title: isFile(proposal) ? parseChangeTitle(read(proposal)) : null,
|
|
193
|
+
path: `.doctrina/changes/archive/${name}/`,
|
|
194
|
+
});
|
|
195
|
+
if (out.length >= limit) break;
|
|
155
196
|
}
|
|
156
197
|
return out;
|
|
157
198
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
4
|
+
import { getSection, parseChangeTitle, parseChecklist } from "./doc-model.js";
|
|
5
|
+
import { listHeader, deltaCapabilities, parseAdrScope } from "./scan.js";
|
|
6
|
+
import { extractOps } from "./spec-ops.js";
|
|
7
|
+
import { findWorkspace } from "./workspace.js";
|
|
8
|
+
import { deliveryPath, resolveDeliveryId, sectionText } from "./delivery.js";
|
|
9
|
+
import { sliceSpec, withoutComments } from "./context-slice.js";
|
|
10
|
+
import { terms } from "./lexicon.js";
|
|
11
|
+
|
|
12
|
+
// A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
|
|
13
|
+
// an executor needs to do it ALONE — the task, the lines indented under it
|
|
14
|
+
// (files, requirement, verify command), why the change exists, and the spec
|
|
15
|
+
// cut to the task. A subagent does not see the planner's conversation, so
|
|
16
|
+
// the card is the whole of its context; a card that needs the conversation
|
|
17
|
+
// is a card the planner has not finished writing.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The tasks.md boxes, each with the indented lines under it.
|
|
21
|
+
* Ordinals are the same ones `change tick` uses (tasks.md comes first).
|
|
22
|
+
*/
|
|
23
|
+
export function taskBoxes(tasksText) {
|
|
24
|
+
const lines = String(tasksText).replace(/\r\n/g, "\n").split("\n");
|
|
25
|
+
return parseChecklist(tasksText).map((box, i) => {
|
|
26
|
+
const indent = (lines[box.line].match(/^\s*/)?.[0] ?? "").length;
|
|
27
|
+
const details = [];
|
|
28
|
+
for (let j = box.line + 1; j < lines.length; j++) {
|
|
29
|
+
const l = lines[j];
|
|
30
|
+
if (l.trim() === "") {
|
|
31
|
+
if (details.length) break;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if ((l.match(/^\s*/)?.[0] ?? "").length <= indent) break;
|
|
35
|
+
details.push(l.trim().replace(/^[-*]\s+/, ""));
|
|
36
|
+
}
|
|
37
|
+
return { ordinal: i + 1, text: box.text, checked: box.checked, placeholder: box.placeholder, details };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The `Field: value` lines of a card's details, keyed by lowercased field. */
|
|
42
|
+
export function cardFields(details) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const d of details) {
|
|
45
|
+
const m = d.match(/^([A-Za-z][A-Za-z ]{0,20}):\s*(.+)$/);
|
|
46
|
+
if (m) out[m[1].trim().toLowerCase()] = m[2].trim();
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstParagraph(text, heading) {
|
|
52
|
+
const m = String(text).match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n\\s*\\n|\\n## |$)`, "m"));
|
|
53
|
+
return m ? m[1].replace(/<!--[\s\S]*?-->/g, "").trim() : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The card for task `ordinal` of a change — or, with no ordinal, the first
|
|
58
|
+
* open one. Null `card` with a reason when there is none to give.
|
|
59
|
+
* @returns {{ card: null, reason: string } | { card: object, reason: null }}
|
|
60
|
+
*/
|
|
61
|
+
export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
62
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
|
|
63
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
64
|
+
if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
|
|
65
|
+
const boxes = taskBoxes(read(tasksPath));
|
|
66
|
+
if (boxes.length === 0) return { card: null, reason: "tasks.md has no task box" };
|
|
67
|
+
let box;
|
|
68
|
+
if (ordinal === null) {
|
|
69
|
+
box = boxes.find((b) => !b.checked && !b.placeholder);
|
|
70
|
+
// Only scaffold boxes left is not "every task is ticked" (third audit,
|
|
71
|
+
// change 0234): the planner was told to review and close a change
|
|
72
|
+
// whose tasks no one had written.
|
|
73
|
+
if (!box && boxes.some((b) => !b.checked && b.placeholder)) return { card: null, reason: "tasks.md holds only the scaffold's placeholder boxes — write the tasks as cards first" };
|
|
74
|
+
if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
|
|
75
|
+
} else {
|
|
76
|
+
box = boxes.find((b) => b.ordinal === ordinal);
|
|
77
|
+
if (!box) return { card: null, reason: `no task ${ordinal} (tasks.md has ${boxes.length})` };
|
|
78
|
+
}
|
|
79
|
+
if (box.placeholder) return { card: null, reason: `task ${box.ordinal} is a scaffold placeholder — write the task first` };
|
|
80
|
+
// CRLF on a Windows checkout: the Why is read paragraph by paragraph.
|
|
81
|
+
const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
|
|
82
|
+
const fields = cardFields(box.details);
|
|
83
|
+
// What this change DOES to each spec (change 0231). The slice below is
|
|
84
|
+
// the spec as it stands — the current truth, before apply — so the
|
|
85
|
+
// requirement the executor is there to implement lived only in the delta,
|
|
86
|
+
// and reached the card only if the planner copied it into a line by hand.
|
|
87
|
+
const changes = [];
|
|
88
|
+
for (const cap of deltaCapabilities(changeDir)) {
|
|
89
|
+
const delta = path.join(changeDir, "specs", cap, "delta.md");
|
|
90
|
+
if (!isFile(delta)) continue;
|
|
91
|
+
for (const op of extractOps(read(delta))) {
|
|
92
|
+
if (op.verb === "append-requirement") changes.push({ capability: cap, line: `adds ${/^[aeiou]/.test(op.section) ? "an" : "a"} ${op.section} requirement: ${op.value}` });
|
|
93
|
+
else if (op.verb === "replace-requirement") changes.push({ capability: cap, line: `replaces ${op.section} requirement ${op.n} with: ${op.value}` });
|
|
94
|
+
else if (op.verb === "remove-requirement") changes.push({ capability: cap, line: `removes ${op.section} requirement ${op.n}` });
|
|
95
|
+
else if (op.verb === "append-criterion") changes.push({ capability: cap, line: `adds the criterion: ${op.value}` });
|
|
96
|
+
else if (op.verb === "replace-criterion") changes.push({ capability: cap, line: `replaces criterion ${op.n} with: ${op.value}` });
|
|
97
|
+
else if (op.verb === "remove-criterion") changes.push({ capability: cap, line: `removes criterion ${op.n}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// The task's words AND what the change writes into the spec (second
|
|
101
|
+
// audit, change 0233): a card for "lock the account on the third wrong
|
|
102
|
+
// password" cut the spec's only /login requirement, because the task never
|
|
103
|
+
// said "login" — the delta it implements did. Requirements only: a
|
|
104
|
+
// criterion's "[unverified] … — verified by `…`" matched any spec's
|
|
105
|
+
// placeholder on the boilerplate alone.
|
|
106
|
+
const query = terms([box.text, ...box.details, parseChangeTitle(proposal) ?? "", ...changes.filter((ch) => / requirement[^:]*: /.test(ch.line)).map((ch) => ch.line.slice(ch.line.indexOf(": ") + 2))].join(" "));
|
|
107
|
+
const slices = [];
|
|
108
|
+
for (const cap of deltaCapabilities(changeDir)) {
|
|
109
|
+
const rel = `.doctrina/specs/${cap}/spec.md`;
|
|
110
|
+
const specPath = path.join(projectRoot, rel);
|
|
111
|
+
if (!isFile(specPath)) continue;
|
|
112
|
+
// Nothing in the spec matched the task: the whole spec, as `context`
|
|
113
|
+
// gives it (change 0231). Dropping it left a card with no spec at all.
|
|
114
|
+
const text = read(specPath);
|
|
115
|
+
const slice = sliceSpec(text, query, rel);
|
|
116
|
+
slices.push(slice ? { capability: cap, ...slice } : { capability: cap, text: withoutComments(text) ?? text, kept: null, total: null, whole: true });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
card: {
|
|
120
|
+
change: changeId,
|
|
121
|
+
title: parseChangeTitle(proposal) ?? changeId,
|
|
122
|
+
delivery: listHeader(proposal, "Delivery") ?? null,
|
|
123
|
+
ordinal: box.ordinal,
|
|
124
|
+
total: boxes.length,
|
|
125
|
+
done: boxes.filter((b) => b.checked).length,
|
|
126
|
+
checked: box.checked,
|
|
127
|
+
text: box.text,
|
|
128
|
+
details: box.details,
|
|
129
|
+
fields,
|
|
130
|
+
why: firstParagraph(proposal, "Why"),
|
|
131
|
+
slices,
|
|
132
|
+
changes,
|
|
133
|
+
story: deliveryStory(projectRoot, listHeader(proposal, "Delivery")),
|
|
134
|
+
decisions: bindingDecisions(projectRoot, deltaCapabilities(changeDir)),
|
|
135
|
+
},
|
|
136
|
+
reason: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The story the change serves, when it belongs to a delivery (change 0231):
|
|
141
|
+
// its acceptance criteria are what the work is checked against, and they
|
|
142
|
+
// live in the workspace, where an executor opened in the repository never
|
|
143
|
+
// looks.
|
|
144
|
+
const STORY_MAX_CHARS = 1500;
|
|
145
|
+
function deliveryStory(projectRoot, deliveryId) {
|
|
146
|
+
if (!deliveryId) return null;
|
|
147
|
+
const found = findWorkspace(projectRoot);
|
|
148
|
+
if (!found) return null;
|
|
149
|
+
const id = resolveDeliveryId(found.dir, deliveryId.trim());
|
|
150
|
+
if (!id || !isFile(deliveryPath(found.dir, id))) return null;
|
|
151
|
+
const story = sectionText(read(deliveryPath(found.dir, id)), "Story");
|
|
152
|
+
if (!story) return null;
|
|
153
|
+
return story.length > STORY_MAX_CHARS ? `${story.slice(0, STORY_MAX_CHARS).replace(/\s+\S*$/, "")} …` : story;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The accepted decisions that bind the capabilities the change touches:
|
|
157
|
+
// those whose Scope names one, and the unscoped ones that cite one. A card
|
|
158
|
+
// the executor follows without them is how an implementation re-decides
|
|
159
|
+
// what an ADR settled.
|
|
160
|
+
function bindingDecisions(projectRoot, capabilities) {
|
|
161
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
162
|
+
if (!isDir(dir) || capabilities.length === 0) return { list: [], more: 0 };
|
|
163
|
+
/** @type {Array<{ id: string, title: string, decision: string, rank: number }>} */
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const f of walk(dir).sort()) {
|
|
166
|
+
const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
|
|
167
|
+
if (!m) continue;
|
|
168
|
+
const text = read(f).replace(/\r\n/g, "\n");
|
|
169
|
+
if ((listHeader(text, "Status") ?? "").trim().toLowerCase() !== "accepted") continue;
|
|
170
|
+
const scope = parseAdrScope(text);
|
|
171
|
+
const cites = (/** @type {string} */ cap) => new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text);
|
|
172
|
+
// An unscoped ADR is GLOBAL — it binds every capability, as `decision
|
|
173
|
+
// scope` documents and the context pack loads it (sixth audit, change
|
|
174
|
+
// 0237). The card bound one only when its text named the capability, so
|
|
175
|
+
// a repository-wide rule ("error codes are MAIUSCULAS_SNAKE") never
|
|
176
|
+
// reached the executor, who reads nothing else. Scoped first, then the
|
|
177
|
+
// global ones that name the capability, then the rest.
|
|
178
|
+
const rank = scope.length > 0 ? (capabilities.some((cap) => scope.includes(cap)) ? 0 : -1) : (capabilities.some(cites) ? 1 : 2);
|
|
179
|
+
if (rank < 0) continue;
|
|
180
|
+
const decision = (getSection(text, "Decision") ?? "").replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ").trim();
|
|
181
|
+
found.push({
|
|
182
|
+
id: m[1],
|
|
183
|
+
title: text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m)?.[1]?.trim() ?? path.basename(f),
|
|
184
|
+
decision: decision.length > DECISION_EXCERPT ? `${decision.slice(0, DECISION_EXCERPT).replace(/\s+\S*$/, "")} …` : decision,
|
|
185
|
+
rank,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const sorted = found.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id));
|
|
189
|
+
return { list: sorted.slice(0, DECISIONS_ON_CARD), more: Math.max(0, sorted.length - DECISIONS_ON_CARD) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// How many binding decisions a card names, and how much of each it quotes.
|
|
193
|
+
const DECISIONS_ON_CARD = 8;
|
|
194
|
+
const DECISION_EXCERPT = 240;
|
|
195
|
+
|
package/src/lib/views.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { c } from "./colors.js";
|
|
3
3
|
import { today } from "./dates.js";
|
|
4
4
|
import { churnByCapability } from "./ledger.js";
|
|
5
|
-
import { STALE_CHANGE_DAYS } from "./snapshot.js";
|
|
5
|
+
import { STALE_CHANGE_DAYS, RECENT_CLOSE_DAYS } from "./snapshot.js";
|
|
6
6
|
|
|
7
7
|
// Executed proof and signed proof are different claims (change 0039). One
|
|
8
8
|
// phrasing, used by every view, so none of them can quietly add the two
|
|
@@ -187,7 +187,17 @@ export function prime(s) {
|
|
|
187
187
|
// one forgotten for three weeks used to read identically.
|
|
188
188
|
const age = ch.ageDays !== null && ch.ageDays !== undefined && ch.ageDays >= STALE_CHANGE_DAYS
|
|
189
189
|
? ` · open ${ch.ageDays}d` : "";
|
|
190
|
-
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age})`)}`);
|
|
190
|
+
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age}${ch.delivery ? ` · delivery ${ch.delivery}` : ""})`)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// What was just finished, where it went (change 0214): archived work is out
|
|
195
|
+
// of every default read, so without this line it looks undone.
|
|
196
|
+
if ((s.recentlyClosed ?? []).length > 0) {
|
|
197
|
+
out.push("");
|
|
198
|
+
out.push(c.bold("Recently closed") + c.gray(` (last ${RECENT_CLOSE_DAYS} days)`));
|
|
199
|
+
for (const ch of s.recentlyClosed) {
|
|
200
|
+
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.date} → ${ch.path})`)}`);
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
|
|
@@ -201,10 +211,49 @@ export function prime(s) {
|
|
|
201
211
|
}
|
|
202
212
|
|
|
203
213
|
out.push("");
|
|
204
|
-
|
|
214
|
+
// The read for the work in hand, as a command to copy (change 0217): a
|
|
215
|
+
// field review found `prime` taken as enough and `context` skipped, and a
|
|
216
|
+
// generic "read deeper" is the line a reader skips first.
|
|
217
|
+
const focus = s.openChanges.find((ch) => (ch.capabilities ?? []).length > 0);
|
|
218
|
+
if (focus) {
|
|
219
|
+
const query = String(focus.title ?? focus.id).replace(/["`]/g, "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
220
|
+
out.push(c.bold("Read for ") + c.cyan(focus.id) + ": " +
|
|
221
|
+
c.cyan(`doctrina context ${focus.capabilities[0]} --for "${query}" --concat`) +
|
|
222
|
+
(focus.capabilities.length > 1 ? c.gray(` (also: ${focus.capabilities.slice(1).join(", ")})`) : ""));
|
|
223
|
+
}
|
|
224
|
+
out.push(c.gray("Read deeper: `doctrina context <cap> --for \"<task>\" --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
|
|
205
225
|
return out;
|
|
206
226
|
}
|
|
207
227
|
|
|
228
|
+
// ------------------------------------------------------------------- pulse
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The one fact an agent needs with EVERY prompt (change 0211): whether a
|
|
232
|
+
* change is open, which, and that a request changing behaviour opens one.
|
|
233
|
+
* A field review: "without the framework's name in the request, the request
|
|
234
|
+
* for code wins" — the cycle sat in AGENTS.md, read once, against the
|
|
235
|
+
* request read now. A UserPromptSubmit hook prints this beside each prompt.
|
|
236
|
+
* Worded as facts, not orders: an agent's prompt-injection defences treat
|
|
237
|
+
* imperatives from a hook with suspicion, and they should.
|
|
238
|
+
*
|
|
239
|
+
* @param {Array<{id: string, title: string|null, status: string, delivery?: string|null}>} open
|
|
240
|
+
* @param {Array<{id: string, date: string, path: string}>} [recent]
|
|
241
|
+
*/
|
|
242
|
+
export function pulse(open, recent = []) {
|
|
243
|
+
if (open.length === 0) {
|
|
244
|
+
// The last closed change, so "do the work for X" is answered "already
|
|
245
|
+
// done, archived at …" instead of done twice (change 0214).
|
|
246
|
+
const last = recent[0] ? ` Last closed: ${recent[0].id} (${recent[0].date}, ${recent[0].path}).` : "";
|
|
247
|
+
return ["Doctrina: no change is open in this project. A request that changes behaviour is opened as a change " +
|
|
248
|
+
"before code is edited — `doctrina work \"<request>\"` (it classifies the lane itself); a question or a read needs none." + last];
|
|
249
|
+
}
|
|
250
|
+
const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status}${ch.delivery ? `, part of delivery ${ch.delivery}` : ""})`).join("; ");
|
|
251
|
+
const more = open.length > 3 ? ` and ${open.length - 3} more` : "";
|
|
252
|
+
return [`Doctrina: open change${open.length === 1 ? "" : "s"}: ${named}${more}. Work that belongs to ${open.length === 1 ? "it" : "one of them"} ` +
|
|
253
|
+
"is named by its id in the first sentence to the human and finishes with `doctrina close <id>`; " +
|
|
254
|
+
"a different request is its own change (`doctrina work \"<request>\"`)."];
|
|
255
|
+
}
|
|
256
|
+
|
|
208
257
|
// ----------------------------------------------------------------- handoff
|
|
209
258
|
|
|
210
259
|
export function handoff(s) {
|
package/src/lib/work-model.js
CHANGED
|
@@ -16,7 +16,13 @@ import { globToRegExp } from "./runtime.js";
|
|
|
16
16
|
// file scores its capability when the file sits under a path segment named for
|
|
17
17
|
// it, or when the spec cites the file as evidence. A deterministic overlap
|
|
18
18
|
// hint for the agent — never a decision (ADR 0005).
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// `pending` (change 0228) is, per capability, the spec as the change under
|
|
21
|
+
// review will leave it: the criterion a change adds cites its new test, a
|
|
22
|
+
// `set-header Source:` claims new paths, and `review` runs before `apply`
|
|
23
|
+
// merges either — so without it the files the change itself claims were
|
|
24
|
+
// reported as belonging to no capability.
|
|
25
|
+
export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3, pending = new Map() } = {}) {
|
|
20
26
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
21
27
|
if (!files || files.length === 0 || !isDir(specsDir)) return [];
|
|
22
28
|
const norm = files.map((f) => f.replace(/\\/g, "/"));
|
|
@@ -25,7 +31,7 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
|
|
|
25
31
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
26
32
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
27
33
|
if (!isFile(specPath)) continue;
|
|
28
|
-
const text = read(specPath);
|
|
34
|
+
const text = pending.get(cap) ?? read(specPath);
|
|
29
35
|
specs.push({ cap, text, declared: parseSourceGlobs(text).map((g) => globToRegExp(g)) });
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -120,3 +126,61 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
120
126
|
// 0, the same convention `triage` uses for a lane nothing scored against.
|
|
121
127
|
return sorted.slice(0, 3).map((e, i) => ({ ...e, margin: e.score - (sorted[i + 1]?.score ?? 0) }));
|
|
122
128
|
}
|
|
129
|
+
|
|
130
|
+
// How far back `work` looks for an archived change the prompt may repeat,
|
|
131
|
+
// and how much of one side the other must cover to count (change 0215).
|
|
132
|
+
export const SIMILAR_CHANGE_DAYS = 14;
|
|
133
|
+
// Three shared terms: two matched "mudar uma coisa" with "mudar outra coisa".
|
|
134
|
+
const SIMILAR_MIN_SHARED = 3;
|
|
135
|
+
const SIMILAR_MIN_COVER = 0.6;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Open changes, and changes archived in the last SIMILAR_CHANGE_DAYS, whose
|
|
139
|
+
* title and Why the prompt mostly repeats. A field review: two changes were
|
|
140
|
+
* done and archived, and the next request — "create the doctrina work for
|
|
141
|
+
* payment and admin" — would have opened the same work twice. Deterministic
|
|
142
|
+
* term overlap (ADR 0005), measured from both sides: a short prompt that
|
|
143
|
+
* names most of a change, or a change named mostly by a long prompt.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} projectRoot
|
|
146
|
+
* @param {string} prompt
|
|
147
|
+
* @returns {Array<{ id: string, title: string, where: "open"|"archived", path: string, shared: string[] }>}
|
|
148
|
+
*/
|
|
149
|
+
export function similarChanges(projectRoot, prompt) {
|
|
150
|
+
const asked = terms(prompt);
|
|
151
|
+
if (asked.length === 0) return [];
|
|
152
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
153
|
+
if (!isDir(changesDir)) return [];
|
|
154
|
+
const candidates = [];
|
|
155
|
+
for (const name of readdirSync(changesDir).sort()) {
|
|
156
|
+
if (name === "archive" || name.startsWith(".")) continue;
|
|
157
|
+
const proposal = path.join(changesDir, name, "proposal.md");
|
|
158
|
+
if (isFile(proposal)) candidates.push({ id: name, where: /** @type {"open"|"archived"} */ ("open"), rel: `.doctrina/changes/${name}/`, text: read(proposal) });
|
|
159
|
+
}
|
|
160
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
161
|
+
if (isDir(archiveDir)) {
|
|
162
|
+
const cutoff = Date.now() - SIMILAR_CHANGE_DAYS * 86400000;
|
|
163
|
+
for (const name of readdirSync(archiveDir)) {
|
|
164
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
165
|
+
if (!m || Date.parse(`${m[1]}T00:00:00Z`) < cutoff) continue;
|
|
166
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
167
|
+
if (isFile(proposal)) candidates.push({ id: m[2], where: /** @type {"open"|"archived"} */ ("archived"), rel: `.doctrina/changes/archive/${name}/`, text: read(proposal) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const cand of candidates) {
|
|
172
|
+
const title = (cand.text.match(/^#\s+Change\s+\S+\s+—\s+(.+)$/m)?.[1] ?? cand.id).trim();
|
|
173
|
+
const why = cand.text.match(/##\s*Why\s*\r?\n+([^\r\n]+)/)?.[1] ?? "";
|
|
174
|
+
const theirs = terms(`${cand.id.replace(/^\d+-/, "").replace(/-/g, " ")} ${title} ${why}`);
|
|
175
|
+
const shared = asked.filter((t) => theirs.includes(t));
|
|
176
|
+
if (shared.length < SIMILAR_MIN_SHARED) continue;
|
|
177
|
+
// Each side's cover counts only the terms on that side: a word the Why
|
|
178
|
+
// happens to share must not stand in for the title (a prompt about "the
|
|
179
|
+
// work" matched "trabalho aparece para o humano" through its Why).
|
|
180
|
+
const titleTerms = terms(title);
|
|
181
|
+
const titleShared = asked.filter((t) => titleTerms.includes(t));
|
|
182
|
+
const cover = Math.max(shared.length / asked.length, titleTerms.length > 0 ? titleShared.length / titleTerms.length : 0);
|
|
183
|
+
if (cover >= SIMILAR_MIN_COVER) out.push({ id: cand.id, title, where: cand.where, path: cand.rel, shared, cover });
|
|
184
|
+
}
|
|
185
|
+
return out.sort((a, b) => b.cover - a.cover).map(({ cover: _c, ...rest }) => rest);
|
|
186
|
+
}
|