doctrina-cli 0.16.0 → 0.18.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 +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/git.js +59 -2
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +15 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- package/src/commands/constitution.js +0 -68
package/src/lib/scan.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
|
|
3
|
+
import { projectName } from "./project.js";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { readdirSync } from "node:fs";
|
|
5
6
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
6
7
|
import { today } from "./dates.js";
|
|
7
8
|
import { cliVersion, newestVersion } from "./version.js";
|
|
8
9
|
import { load } from "./index-json.js";
|
|
9
|
-
import { parseFrontmatter } from "./doc-model.js";
|
|
10
|
+
import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
|
|
10
11
|
import { parseCapabilityFromDelta } from "./doc-model.js";
|
|
11
12
|
import { parseOperation } from "./doc-model.js";
|
|
13
|
+
import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
|
|
12
14
|
|
|
13
15
|
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
14
16
|
// These two names survive because dozens of call sites use them and the
|
|
@@ -168,6 +170,23 @@ export function changeEntry(proposal, id, prev, date) {
|
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
|
|
173
|
+
// An archived change, as an index entry — the one constructor both the
|
|
174
|
+
// rebuild and `change archive` use. They each built their own, and neither
|
|
175
|
+
// carried the lane: an archived change lost the lane it was born in, so the
|
|
176
|
+
// report's "Lanes" section counted the whole history as unknown (change
|
|
177
|
+
// 0192), which is the question change 0042 recorded the lane to answer.
|
|
178
|
+
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
|
|
182
|
+
path: `.doctrina/changes/archive/${archiveName}`,
|
|
183
|
+
status: "applied",
|
|
184
|
+
applied,
|
|
185
|
+
specs_affected: specsAffected,
|
|
186
|
+
...laneOf(proposal, prev),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
172
191
|
// the source of truth; fields with no on-disk source (project name,
|
|
173
192
|
// framework_version, product metadata) are carried over from `current`.
|
|
@@ -178,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
178
197
|
|
|
179
198
|
const out = {
|
|
180
199
|
$schema_version: current?.$schema_version ?? "0.1.0",
|
|
181
|
-
project:
|
|
200
|
+
project: projectName(projectRoot, current),
|
|
182
201
|
framework_version: current?.framework_version ?? "0.0.0",
|
|
183
202
|
last_updated: current?.last_updated ?? date,
|
|
184
203
|
// Project settings, carried over verbatim. `deriveIndex` rebuilds the
|
|
@@ -284,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
|
|
|
284
303
|
specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
|
|
285
304
|
}
|
|
286
305
|
}
|
|
287
|
-
out.artifacts.changes_archive.push({
|
|
288
|
-
id: m[2],
|
|
289
|
-
|
|
290
|
-
path: `.doctrina/changes/archive/${name}`,
|
|
291
|
-
status: "applied",
|
|
292
|
-
applied: m[1],
|
|
293
|
-
specs_affected: specsAffected,
|
|
294
|
-
});
|
|
306
|
+
out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
|
|
307
|
+
id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
|
|
308
|
+
}));
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
// Contracts — the integration/runtime surface (ports, env, interfaces)
|
|
@@ -310,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
|
|
|
310
324
|
});
|
|
311
325
|
}
|
|
312
326
|
|
|
313
|
-
// Skills — frontmatter description wins.
|
|
327
|
+
// Skills — a WRITTEN frontmatter description wins. The rebuild is the one
|
|
328
|
+
// reconciliation (change 0177 retired `skill sync`, which did a subset of
|
|
329
|
+
// this), so it carries sync's rule: a description still in the scaffold's
|
|
330
|
+
// `<...>` form never overwrites one somebody wrote (change 0111), and the
|
|
331
|
+
// entry's date moves when its description does.
|
|
314
332
|
const skillsDir = path.join(dot, "skills");
|
|
315
333
|
for (const f of walk(skillsDir)) {
|
|
316
334
|
if (!f.endsWith(".md")) continue;
|
|
317
335
|
const id = path.basename(f, ".md");
|
|
318
336
|
const prev = (cur.skills ?? []).find((s) => s.id === id);
|
|
337
|
+
const written = parseFrontmatter(read(f), "description");
|
|
338
|
+
const kept = prev?.description && !isScaffoldValue(prev.description) ? prev.description : null;
|
|
339
|
+
const description = written && !isScaffoldValue(written)
|
|
340
|
+
? written
|
|
341
|
+
: kept ?? written ?? "<edit me — one-sentence summary>";
|
|
319
342
|
out.artifacts.skills.push({
|
|
320
343
|
id,
|
|
321
344
|
path: `.doctrina/skills/${id}.md`,
|
|
322
|
-
description
|
|
323
|
-
|
|
324
|
-
last_updated: prev?.last_updated ?? date,
|
|
345
|
+
description,
|
|
346
|
+
last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
|
|
325
347
|
});
|
|
326
348
|
}
|
|
327
349
|
|
|
@@ -487,3 +509,106 @@ export function dependentsOf(projectRoot, caps) {
|
|
|
487
509
|
}
|
|
488
510
|
return out;
|
|
489
511
|
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------- staged
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The same question as `collectIndexDrift`, asked of the COMMIT instead of
|
|
517
|
+
* the disk.
|
|
518
|
+
*
|
|
519
|
+
* `index.json` is derived from the whole tree, so it cannot describe a
|
|
520
|
+
* subset of it. Stage some of `.doctrina/` and the index that rides along
|
|
521
|
+
* names artifacts the commit does not carry — and every gate that would
|
|
522
|
+
* catch it reads the working tree, where those artifacts are still present.
|
|
523
|
+
* Four commits of this repository shipped that way, each green locally and
|
|
524
|
+
* each red on checkout: `index.json references missing artifact`.
|
|
525
|
+
*
|
|
526
|
+
* Read with git plumbing, never from disk: `:<path>` is the staged blob and
|
|
527
|
+
* `ls-files --cached` is the file list the commit will have.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} projectRoot
|
|
530
|
+
* @returns {{ok: boolean, applicable: boolean, missing: string[], unindexed: string[], reason: string|null}}
|
|
531
|
+
*/
|
|
532
|
+
export function collectStagedIndexDrift(projectRoot) {
|
|
533
|
+
const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
|
|
534
|
+
if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
|
|
535
|
+
|
|
536
|
+
const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
|
|
537
|
+
if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
|
|
538
|
+
// Not staged at all — this commit does not carry an index, so it cannot
|
|
539
|
+
// carry one that disagrees with itself.
|
|
540
|
+
return { ...nothing, reason: "no staged .doctrina/index.json" };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let staged;
|
|
544
|
+
try {
|
|
545
|
+
staged = JSON.parse(blob.stdout);
|
|
546
|
+
} catch {
|
|
547
|
+
return { ok: false, applicable: true, missing: [], unindexed: [],
|
|
548
|
+
reason: "the staged .doctrina/index.json is not valid JSON" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const files = new Set(gitLines(projectRoot, ["ls-files", "--cached", "--", ".doctrina"]));
|
|
552
|
+
// An entry's path is a file for specs, ADRs, skills and contracts, and a
|
|
553
|
+
// DIRECTORY for changes — a directory is in the commit when any file under
|
|
554
|
+
// it is.
|
|
555
|
+
const inCommit = (p) => files.has(p) || [...files].some((f) => f.startsWith(`${p}/`));
|
|
556
|
+
|
|
557
|
+
const artifacts = staged.artifacts ?? {};
|
|
558
|
+
const missing = [];
|
|
559
|
+
for (const [kind, entries] of Object.entries(artifacts)) {
|
|
560
|
+
if (!Array.isArray(entries)) continue;
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
const p = String(entry?.path ?? "");
|
|
563
|
+
if (p && !inCommit(p)) missing.push(`${kind}: ${p}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The mirror case: an artifact staged into the commit that the staged index
|
|
568
|
+
// never heard of. Same shapes `index rebuild` derives entries from.
|
|
569
|
+
const indexed = new Set();
|
|
570
|
+
for (const entries of Object.values(artifacts)) {
|
|
571
|
+
if (Array.isArray(entries)) for (const e of entries) indexed.add(String(e?.path ?? ""));
|
|
572
|
+
}
|
|
573
|
+
const unindexed = [];
|
|
574
|
+
for (const f of [...files].sort()) {
|
|
575
|
+
const owner = artifactPathOf(f);
|
|
576
|
+
if (owner && !indexed.has(owner)) unindexed.push(owner);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const unique = [...new Set(unindexed)];
|
|
580
|
+
return {
|
|
581
|
+
ok: missing.length === 0 && unique.length === 0,
|
|
582
|
+
applicable: true,
|
|
583
|
+
missing,
|
|
584
|
+
unindexed: unique,
|
|
585
|
+
reason: null,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The index entry a staged file belongs to, or null when the file is not an
|
|
590
|
+
// artifact (templates, config.json, product.md, the index itself).
|
|
591
|
+
function artifactPathOf(file) {
|
|
592
|
+
let m = /^\.doctrina\/specs\/[^/]+\/spec\.md$/.exec(file);
|
|
593
|
+
if (m) return file;
|
|
594
|
+
m = /^\.doctrina\/(?:decisions|skills|contracts)\/[^/]+\.md$/.exec(file);
|
|
595
|
+
if (m) return file;
|
|
596
|
+
m = /^(\.doctrina\/changes\/archive\/[^/]+)\/proposal\.md$/.exec(file);
|
|
597
|
+
if (m) return m[1];
|
|
598
|
+
m = /^(\.doctrina\/changes\/(?!archive\/)[^/]+)\/proposal\.md$/.exec(file);
|
|
599
|
+
if (m) return m[1];
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// The capabilities a change's deltas target, sorted — the change's own scope.
|
|
604
|
+
// `close` gates coverage on it and `review --change` reads it as "these specs
|
|
605
|
+
// are being updated by this change", because the delta merges at apply.
|
|
606
|
+
export function deltaCapabilities(changeDir) {
|
|
607
|
+
const caps = new Set();
|
|
608
|
+
for (const deltaPath of walk(path.join(changeDir, "specs"))) {
|
|
609
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
610
|
+
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
611
|
+
if (cap) caps.add(cap);
|
|
612
|
+
}
|
|
613
|
+
return [...caps].sort();
|
|
614
|
+
}
|
package/src/lib/signoff.js
CHANGED
|
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
|
|
|
96
96
|
return [...out].sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Which of `paths` the uncommitted work touches — edits against HEAD plus
|
|
101
|
+
* untracked files, `.gitignore` respected (change 0204). The same pathspec
|
|
102
|
+
* reading a sign-off's "paths" gets, so "what this check covers" means one
|
|
103
|
+
* thing in verify.json. Null when git cannot answer: a caller then runs the
|
|
104
|
+
* check rather than reading "nothing changed" out of a question it never
|
|
105
|
+
* got to ask.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectRoot
|
|
108
|
+
* @param {string[]} paths
|
|
109
|
+
* @returns {string[] | null}
|
|
110
|
+
*/
|
|
111
|
+
export function workingChanges(projectRoot, paths) {
|
|
112
|
+
const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
|
|
113
|
+
// No commit yet: every file is new, so what is staged counts as changed.
|
|
114
|
+
const staged = tracked.state === GIT_STATE.EMPTY
|
|
115
|
+
? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
|
|
116
|
+
: tracked;
|
|
117
|
+
if (staged.state !== GIT_STATE.OK) return null;
|
|
118
|
+
const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
|
|
119
|
+
if (untracked.state !== GIT_STATE.OK) return null;
|
|
120
|
+
return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
/** The commit a signature is anchored to, or null outside a repository. */
|
|
100
124
|
export function currentSha(projectRoot) {
|
|
101
125
|
const r = git(projectRoot, ["rev-parse", "HEAD"]);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import { git, GIT_STATE } from "./git.js";
|
|
6
|
+
import { FIX_SHAPED, FIX_SHAPED_SUBJECT, skillSlug } from "./lexicon.js";
|
|
7
|
+
|
|
8
|
+
// The lessons worth a skill, as DATA (change 0202). `skill suggest` listed
|
|
9
|
+
// them and `close` printed that whole listing at the end of every close —
|
|
10
|
+
// the same thirteen candidates, close after close, which a field review of
|
|
11
|
+
// 0.17 called noise. The close now asks for the list and renders only what
|
|
12
|
+
// THIS close produced, so the collection lives here, where both read it
|
|
13
|
+
// (ADR 0025: commands render, lib decides).
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} SkillCandidate
|
|
17
|
+
* @property {string} id
|
|
18
|
+
* @property {string} slug
|
|
19
|
+
* @property {"change"|"commit"} source
|
|
20
|
+
* @property {string} [from] archive folder name, for a change candidate
|
|
21
|
+
* @property {string} [ref] short sha, for a commit candidate
|
|
22
|
+
* @property {string} [why]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fix-shaped lessons not yet captured as a skill, from the archive and the
|
|
27
|
+
* git history, deduped by slug.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} projectRoot
|
|
30
|
+
* @param {{ since?: string, limit?: number }} [opts]
|
|
31
|
+
* @returns {SkillCandidate[]}
|
|
32
|
+
*/
|
|
33
|
+
export function skillCandidates(projectRoot, { since, limit = 200 } = {}) {
|
|
34
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
35
|
+
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
36
|
+
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
37
|
+
// A lesson may already be captured under a DIFFERENT slug (0.11.0 field
|
|
38
|
+
// review item 11: the human names the skill after the lesson, not after the
|
|
39
|
+
// change id — "tela-preta-crash-de-render..." for change 0049). Two extra
|
|
40
|
+
// dedup signals: an existing skill whose body cites the candidate's change
|
|
41
|
+
// id / commit hash, and token-overlap similarity between the slugs.
|
|
42
|
+
const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
|
|
43
|
+
const captured = (cand) => {
|
|
44
|
+
if (existing.has(cand.slug)) return true;
|
|
45
|
+
for (const sk of existingBodies) {
|
|
46
|
+
if (cand.id && sk.body.includes(cand.id)) return true;
|
|
47
|
+
if (cand.from && sk.body.includes(cand.from)) return true;
|
|
48
|
+
if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
54
|
+
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
55
|
+
/** @type {SkillCandidate[]} */
|
|
56
|
+
const candidates = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
|
|
59
|
+
// Source 1 — archived change proposals with a fix-shaped folder name.
|
|
60
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
61
|
+
if (isDir(archiveDir)) {
|
|
62
|
+
for (const name of readdirSync(archiveDir).sort()) {
|
|
63
|
+
if (!isDir(path.join(archiveDir, name))) continue;
|
|
64
|
+
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
65
|
+
if (!FIX_SHAPED.test(id)) continue;
|
|
66
|
+
const slug = skillSlug(id);
|
|
67
|
+
if (seen.has(slug)) continue;
|
|
68
|
+
/** @type {SkillCandidate} */
|
|
69
|
+
const cand = { id, slug, source: "change", from: name };
|
|
70
|
+
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
71
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
72
|
+
cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
73
|
+
seen.add(slug);
|
|
74
|
+
candidates.push(cand);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
79
|
+
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
80
|
+
for (const cand of gitFixCommits(projectRoot, { since, limit })) {
|
|
81
|
+
if (seen.has(cand.slug) || captured(cand)) continue;
|
|
82
|
+
seen.add(cand.slug);
|
|
83
|
+
candidates.push(cand);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return candidates;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
|
|
90
|
+
// 0013). Deterministic — a regex on the subject, never a read of the diff
|
|
91
|
+
// (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
|
|
92
|
+
// git repo, git is absent, or the --since ref is unknown, so the command
|
|
93
|
+
// degrades to archive-only rather than erroring.
|
|
94
|
+
function gitFixCommits(projectRoot, { since, limit }) {
|
|
95
|
+
// US (0x1f) between fields, RS (0x1e) between records — robust against any
|
|
96
|
+
// newline or punctuation inside a subject or body.
|
|
97
|
+
const format = "%H%x1f%s%x1f%b%x1e";
|
|
98
|
+
const args = ["log", "--no-merges", `--format=${format}`];
|
|
99
|
+
if (since) args.push(`${since}..HEAD`);
|
|
100
|
+
else args.push("-n", String(limit));
|
|
101
|
+
|
|
102
|
+
const r = git(projectRoot, args);
|
|
103
|
+
if (r.state !== GIT_STATE.OK || !r.stdout) return [];
|
|
104
|
+
|
|
105
|
+
/** @type {SkillCandidate[]} */
|
|
106
|
+
const out = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const record of r.stdout.split("\x1e")) {
|
|
109
|
+
const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
|
|
110
|
+
if (!sha || !sha.trim()) continue;
|
|
111
|
+
if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
|
|
112
|
+
const slug = commitSlug(subject);
|
|
113
|
+
if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
|
|
114
|
+
seen.add(slug);
|
|
115
|
+
const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
|
116
|
+
out.push({
|
|
117
|
+
id: slug,
|
|
118
|
+
slug,
|
|
119
|
+
source: "commit",
|
|
120
|
+
ref: sha.trim().slice(0, 9),
|
|
121
|
+
why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
128
|
+
// "type(scope): " prefix and hand the rest to the shared slug, which folds.
|
|
129
|
+
function commitSlug(subject) {
|
|
130
|
+
return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
134
|
+
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
135
|
+
// error-boundary" share tela/preta/render → high overlap even though the
|
|
136
|
+
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
137
|
+
function slugSimilarity(a, b) {
|
|
138
|
+
const toks = (s) => new Set(
|
|
139
|
+
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
140
|
+
);
|
|
141
|
+
const A = toks(a);
|
|
142
|
+
const B = toks(b);
|
|
143
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
144
|
+
let inter = 0;
|
|
145
|
+
for (const t of A) if (B.has(t)) inter += 1;
|
|
146
|
+
return inter / (A.size + B.size - inter);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
150
|
+
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
151
|
+
const SLUG_NOISE = new Set([
|
|
152
|
+
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
153
|
+
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
function firstWhyLine(proposalText) {
|
|
157
|
+
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
158
|
+
if (!m) return "";
|
|
159
|
+
const line = m[1].trim();
|
|
160
|
+
return /^<!--/.test(line) ? "" : line;
|
|
161
|
+
}
|
|
162
|
+
|
package/src/lib/snapshot.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import { projectName } from "./project.js";
|
|
5
6
|
import * as idx from "./index-json.js";
|
|
6
7
|
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
7
8
|
import { cliVersion } from "./version.js";
|
|
@@ -12,6 +13,9 @@ import { computeActions } from "./actions.js";
|
|
|
12
13
|
import { readLedger } from "./ledger.js";
|
|
13
14
|
import { parseChangeTitle, checklistProgress } from "./doc-model.js";
|
|
14
15
|
import { summarizeSignoffs } from "./signoff.js";
|
|
16
|
+
import { gitWindow, historyState, windowCutoff } from "./git.js";
|
|
17
|
+
import { collectMetrics } from "./metrics-model.js";
|
|
18
|
+
import { today } from "./dates.js";
|
|
15
19
|
|
|
16
20
|
// ONE collector, several views (audit finding F7).
|
|
17
21
|
//
|
|
@@ -57,7 +61,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
|
57
61
|
const verify = readVerifyConfig(projectRoot);
|
|
58
62
|
|
|
59
63
|
return {
|
|
60
|
-
project:
|
|
64
|
+
project: projectName(projectRoot, index),
|
|
61
65
|
stamp: index?.framework_version ?? null,
|
|
62
66
|
cli: cliVersion(),
|
|
63
67
|
indexState,
|
|
@@ -146,11 +150,47 @@ export function openChanges(projectRoot) {
|
|
|
146
150
|
const tasksTotal = progress.total;
|
|
147
151
|
const tasksDone = progress.done;
|
|
148
152
|
const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
|
|
149
|
-
|
|
153
|
+
const date = proposal ? (listHeader(proposal, "Date") ?? null) : null;
|
|
154
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date) });
|
|
150
155
|
}
|
|
151
156
|
return out;
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
// How old a change has to be before "still open" is worth saying on its own.
|
|
160
|
+
export const STALE_CHANGE_DAYS = 7;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The open changes worth a line at the end of someone else's close (change
|
|
164
|
+
* 0201): a change that LOOKS finished — applied, or every task ticked — and
|
|
165
|
+
* was never closed, and a change open for a week or more. A field review of
|
|
166
|
+
* 0.17 found change 0078 shipped and left open, and no later close said so:
|
|
167
|
+
* the close is the moment someone is looking, so it is where to say it.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} projectRoot
|
|
170
|
+
* @param {{ exclude?: string[] }} [opts]
|
|
171
|
+
*/
|
|
172
|
+
export function unfinishedChanges(projectRoot, { exclude = [] } = {}) {
|
|
173
|
+
const skip = new Set(exclude);
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const ch of openChanges(projectRoot)) {
|
|
176
|
+
if (skip.has(ch.id)) continue;
|
|
177
|
+
const finished = ch.status === "applied" ||
|
|
178
|
+
(ch.tasksTotal > 0 && ch.tasksDone === ch.tasksTotal);
|
|
179
|
+
const old = ch.ageDays !== null && ch.ageDays >= STALE_CHANGE_DAYS;
|
|
180
|
+
const reason = ch.status === "applied"
|
|
181
|
+
? "applied but never closed"
|
|
182
|
+
: finished ? "every task ticked, never closed" : old ? "still open" : null;
|
|
183
|
+
out.push({ ...ch, finished, old, reason });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function ageInDays(date) {
|
|
189
|
+
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
|
190
|
+
const ms = Date.parse(`${today()}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`);
|
|
191
|
+
return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 86400000)) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
154
194
|
function countSpecs(projectRoot) {
|
|
155
195
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
156
196
|
const impl = {};
|
|
@@ -207,3 +247,22 @@ function countSkills(projectRoot) {
|
|
|
207
247
|
if (!isDir(skillsDir)) return 0;
|
|
208
248
|
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
209
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Everything the period digest reads beyond the snapshot, for a window of
|
|
253
|
+
* `days`. ONE collector for every door that renders the digest: `report`
|
|
254
|
+
* passed the metrics and `status --view report` did not, so the view that
|
|
255
|
+
* promised "never different numbers" printed two lines fewer (change 0176).
|
|
256
|
+
*
|
|
257
|
+
* @param {string} projectRoot
|
|
258
|
+
* @param {number} days
|
|
259
|
+
*/
|
|
260
|
+
export function collectReportWindow(projectRoot, days) {
|
|
261
|
+
return {
|
|
262
|
+
days,
|
|
263
|
+
cutoffIso: windowCutoff(days),
|
|
264
|
+
git: gitWindow(projectRoot, days),
|
|
265
|
+
gitState: historyState(projectRoot),
|
|
266
|
+
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
267
|
+
};
|
|
268
|
+
}
|