@sylad/cadence 0.2.0 → 0.5.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +185 -11
- package/agents/code-reviewer.md +89 -0
- package/bin/cadence.js +3 -2
- package/dist/audit.js +95 -5
- package/dist/check.js +2 -1
- package/dist/cli.js +100 -46
- package/dist/config.js +106 -0
- package/dist/dates.js +8 -0
- package/dist/deliver.js +87 -29
- package/dist/git.js +34 -1
- package/dist/link.js +3 -3
- package/dist/news.js +82 -9
- package/dist/plan.js +203 -19
- package/dist/session.js +36 -6
- package/package.json +3 -2
- package/skills/deliver/SKILL.md +23 -1
- package/skills/lead/SKILL.md +80 -0
- package/skills/session-close/SKILL.md +17 -0
- package/skills/session-start/SKILL.md +25 -1
package/dist/git.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { basename, dirname, resolve } from 'node:path';
|
|
3
3
|
function git(cwd, args) {
|
|
4
|
-
|
|
4
|
+
// Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
|
|
5
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024 });
|
|
5
6
|
}
|
|
6
7
|
export function gitRoot(cwd) {
|
|
7
8
|
try {
|
|
@@ -60,6 +61,10 @@ function tryGit(cwd, args) {
|
|
|
60
61
|
export function headSha(cwd) {
|
|
61
62
|
return tryGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD']);
|
|
62
63
|
}
|
|
64
|
+
/** Sha complet du commit que désigne `rev` (sha abrégé, branche, tag), null s'il n'existe pas. */
|
|
65
|
+
export function resolveCommit(cwd, rev) {
|
|
66
|
+
return tryGit(cwd, ['rev-parse', '--verify', '-q', `${rev}^{commit}`]);
|
|
67
|
+
}
|
|
63
68
|
export function repoStatus(cwd) {
|
|
64
69
|
const lines = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean);
|
|
65
70
|
const untracked = lines.filter((l) => l.startsWith('??')).length;
|
|
@@ -89,3 +94,31 @@ export function gitCommonDir(cwd) {
|
|
|
89
94
|
export function isAncestor(cwd, a, b) {
|
|
90
95
|
return tryGit(cwd, ['merge-base', '--is-ancestor', a, b]) !== null;
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Instant (ms), date d'auteur (conservée par rebase, amend, cherry-pick), du commit qui a ajouté chaque fichier
|
|
99
|
+
* de `dir` SOUS CE NOM (--no-renames : un renommage compte comme un ajout, l'historique antérieur n'est pas suivi),
|
|
100
|
+
* clé = chemin relatif à `dir`.
|
|
101
|
+
* Vide hors d'un dépôt git ; un fichier jamais commité en est absent.
|
|
102
|
+
*/
|
|
103
|
+
export function addedTimes(dir) {
|
|
104
|
+
const times = new Map();
|
|
105
|
+
let out;
|
|
106
|
+
try {
|
|
107
|
+
// quotepath=off + -z : noms accentués tels quels, sans guillemets ni échappement octal.
|
|
108
|
+
out = git(dir, ['-c', 'core.quotepath=off', 'log', '-z', '--diff-filter=A', '--no-renames', '--relative', '--name-only', `--format=${RECORD}%aI`, '--', '.']);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return times;
|
|
112
|
+
}
|
|
113
|
+
// Enregistrement = RECORD date \0 \n nom \0 nom \0 … ; le plus ancien ajout l'emporte.
|
|
114
|
+
for (const record of out.split(RECORD).filter(Boolean)) {
|
|
115
|
+
const [stamp, ...files] = record.split('\0');
|
|
116
|
+
const t = Date.parse(stamp);
|
|
117
|
+
for (const [i, f] of files.entries()) {
|
|
118
|
+
const name = i === 0 ? f.replace(/^\n/, '') : f;
|
|
119
|
+
if (name)
|
|
120
|
+
times.set(name, Math.min(t, times.get(name) ?? Infinity));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return times;
|
|
124
|
+
}
|
package/dist/link.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { extractRefs } from './plan.js';
|
|
2
1
|
export function isMerge(c) {
|
|
3
2
|
return /^Merge\b/.test(c.subject);
|
|
4
3
|
}
|
|
5
|
-
|
|
4
|
+
/** `refs` lit les références d'un message : `plan.refs`. */
|
|
5
|
+
export function linkCommits(lots, commits, refsOf) {
|
|
6
6
|
const ids = new Set(lots.map((l) => l.id));
|
|
7
7
|
const tasks = new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`)));
|
|
8
8
|
const byLot = new Map();
|
|
9
9
|
const orphans = [];
|
|
10
10
|
const unknown = [];
|
|
11
11
|
for (const c of commits) {
|
|
12
|
-
const refs =
|
|
12
|
+
const refs = refsOf(`${c.subject}\n${c.body}`);
|
|
13
13
|
if (refs.length === 0) {
|
|
14
14
|
if (!isMerge(c))
|
|
15
15
|
orphans.push(c);
|
package/dist/news.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { parse, stringify } from 'yaml';
|
|
4
|
-
import { isDay } from './dates.js';
|
|
4
|
+
import { isDay, toStamp } from './dates.js';
|
|
5
|
+
import { addedTimes } from './git.js';
|
|
5
6
|
import { RafError } from './plan.js';
|
|
7
|
+
const CREATED_EMPTY = 'created vide — cadence news stamp';
|
|
8
|
+
const CREATED = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
9
|
+
const OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
6
10
|
const CAPTURE = /^[\w./-]+\.(?:png|jpe?g|webp|gif)$/i;
|
|
7
11
|
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
12
|
+
/** Bien formée ET réelle : Date.parse accepte le 30 février (et rend NaN pour 25:70). */
|
|
13
|
+
function validStamp(s) {
|
|
14
|
+
const [y, mo, d, h, mi, sec = 0] = s.match(/\d+/g).slice(0, 6).map(Number);
|
|
15
|
+
const day = new Date(Date.UTC(y, mo - 1, d));
|
|
16
|
+
return (!Number.isNaN(Date.parse(s.replace(' ', 'T'))) && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && sec < 60);
|
|
17
|
+
}
|
|
8
18
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
9
19
|
export function parseEntry(file, text) {
|
|
10
20
|
const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
|
|
@@ -23,6 +33,20 @@ export function parseEntry(file, text) {
|
|
|
23
33
|
}
|
|
24
34
|
entry.title = String(head.title ?? '').trim();
|
|
25
35
|
entry.date = String(head.date ?? '');
|
|
36
|
+
if ('created' in head) {
|
|
37
|
+
const created = String(head.created ?? '').trim();
|
|
38
|
+
if (!created)
|
|
39
|
+
entry.problems.push(CREATED_EMPTY);
|
|
40
|
+
else if (!CREATED.test(created))
|
|
41
|
+
entry.problems.push(`created « ${created} » n'est pas une heure AAAA-MM-JJTHH:MM±hh:mm`);
|
|
42
|
+
// Sans fuseau, l'heure serait lue dans celui de la machine : l'ordre changerait entre le poste et la CI.
|
|
43
|
+
else if (!OFFSET.test(created))
|
|
44
|
+
entry.problems.push(`created « ${created} » sans fuseau : ajouter ±hh:mm ou Z`);
|
|
45
|
+
else if (!validStamp(created))
|
|
46
|
+
entry.problems.push(`created « ${created} » n'est pas une heure valide`);
|
|
47
|
+
else
|
|
48
|
+
entry.created = created.replace(' ', 'T');
|
|
49
|
+
}
|
|
26
50
|
entry.lots = list(head.lots);
|
|
27
51
|
entry.captures = list(head.captures);
|
|
28
52
|
if (head.nocapture != null && String(head.nocapture).trim())
|
|
@@ -42,14 +66,29 @@ export function parseEntry(file, text) {
|
|
|
42
66
|
entry.problems.push(`date « ${entry.date} » n'est pas une date AAAA-MM-JJ`);
|
|
43
67
|
return entry;
|
|
44
68
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Entrées du dossier, plus récentes d'abord : par date, puis à date égale par instant de création
|
|
71
|
+
* (`created` de l'en-tête, sinon date d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage
|
|
72
|
+
* compte comme un ajout — ; pas encore commité = le plus récent), puis par nom de fichier décroissant.
|
|
73
|
+
* `added` (historique git) n'est appelé que si une entrée n'a pas de `created`.
|
|
74
|
+
*/
|
|
75
|
+
export function loadEntries(dir, added = () => addedTimes(dir)) {
|
|
47
76
|
if (!existsSync(dir))
|
|
48
77
|
return [];
|
|
49
|
-
|
|
78
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
50
79
|
.filter((f) => f.isFile() && f.name.endsWith('.md') && f.name.toLowerCase() !== 'readme.md')
|
|
51
|
-
.map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')))
|
|
52
|
-
|
|
80
|
+
.map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')));
|
|
81
|
+
const history = entries.some((e) => !e.created) ? added() : new Map();
|
|
82
|
+
const created = (e) => (e.created ? Date.parse(e.created) : history.get(e.file) ?? Infinity);
|
|
83
|
+
return entries.sort((a, b) => {
|
|
84
|
+
if (a.date !== b.date)
|
|
85
|
+
return b.date.localeCompare(a.date);
|
|
86
|
+
const ta = created(a);
|
|
87
|
+
const tb = created(b);
|
|
88
|
+
if (ta !== tb)
|
|
89
|
+
return ta < tb ? 1 : -1;
|
|
90
|
+
return b.file.localeCompare(a.file);
|
|
91
|
+
});
|
|
53
92
|
}
|
|
54
93
|
export function newsIssues(lots, entries, dir) {
|
|
55
94
|
const issues = [];
|
|
@@ -66,6 +105,9 @@ export function newsIssues(lots, entries, dir) {
|
|
|
66
105
|
if (!existsSync(path) || !statSync(path).isFile())
|
|
67
106
|
issues.push({ kind: 'missing-capture', message: `${e.file} : capture absente ${c}` });
|
|
68
107
|
}
|
|
108
|
+
if (e.problems.length === 0 && !e.created) {
|
|
109
|
+
issues.push({ kind: 'no-time', message: `${e.file} sans heure de création (created: AAAA-MM-JJTHH:MM±hh:mm) — cadence news stamp` });
|
|
110
|
+
}
|
|
69
111
|
if (e.problems.length === 0 && e.captures.length === 0 && !e.nocapture) {
|
|
70
112
|
issues.push({ kind: 'no-capture', message: `${e.file} sans capture (ou « nocapture: raison »)` });
|
|
71
113
|
}
|
|
@@ -87,16 +129,47 @@ export function slugify(title) {
|
|
|
87
129
|
.slice(0, 60)
|
|
88
130
|
.replace(/-+$/, '') || 'entree');
|
|
89
131
|
}
|
|
90
|
-
/** Crée le squelette d'une entrée ; refuse d'écraser un fichier existant. */
|
|
91
|
-
export function newEntry(dir, lots, rawTitle, today) {
|
|
132
|
+
/** Crée le squelette d'une entrée, horodatée de `now` ; refuse d'écraser un fichier existant. */
|
|
133
|
+
export function newEntry(dir, lots, rawTitle, today, now) {
|
|
92
134
|
const title = rawTitle.replace(/\s+/g, ' ').trim(); // un saut de ligne casserait l'en-tête
|
|
93
135
|
const path = join(dir, `${today}-${slugify(title)}.md`);
|
|
94
136
|
if (existsSync(path))
|
|
95
137
|
throw new RafError(`${path} existe déjà`);
|
|
96
138
|
mkdirSync(dir, { recursive: true });
|
|
97
|
-
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
139
|
+
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
98
140
|
return path;
|
|
99
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Migration : écrit `created:` dans les entrées lisibles qui n'en ont pas (ou l'ont vide), d'après la date
|
|
144
|
+
* d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage n'est pas suivi — (`now` s'il n'est pas
|
|
145
|
+
* encore commité). Le reste du fichier est laissé tel quel.
|
|
146
|
+
*/
|
|
147
|
+
export function stampEntries(dir, now) {
|
|
148
|
+
let history;
|
|
149
|
+
const added = () => (history ??= addedTimes(dir)); // un seul git log, et seulement s'il sert
|
|
150
|
+
const done = [];
|
|
151
|
+
for (const e of loadEntries(dir, added)) {
|
|
152
|
+
// Seule une clé created vide est réparée ici ; une heure fausse reste à corriger à la main.
|
|
153
|
+
if (e.created || !e.problems.every((p) => p === CREATED_EMPTY))
|
|
154
|
+
continue;
|
|
155
|
+
const created = toStamp(new Date(added().get(e.file) ?? now.getTime()));
|
|
156
|
+
const path = join(dir, e.file);
|
|
157
|
+
const text = readFileSync(path, 'utf8');
|
|
158
|
+
const lines = text.split('\n');
|
|
159
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trimEnd() === '---');
|
|
160
|
+
const key = (name) => lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${name}\\s*:`).test(l));
|
|
161
|
+
const existing = key('created');
|
|
162
|
+
const dateLine = key('date');
|
|
163
|
+
const eol = lines[dateLine >= 0 ? dateLine : 0].endsWith('\r') ? '\r' : '';
|
|
164
|
+
if (existing >= 0)
|
|
165
|
+
lines[existing] = `created: ${created}${eol}`; // clé vide : remplacée, jamais dupliquée
|
|
166
|
+
else
|
|
167
|
+
lines.splice(dateLine >= 0 ? dateLine + 1 : end, 0, `created: ${created}${eol}`);
|
|
168
|
+
writeFileSync(path, lines.join('\n'));
|
|
169
|
+
done.push({ file: e.file, created });
|
|
170
|
+
}
|
|
171
|
+
return done;
|
|
172
|
+
}
|
|
100
173
|
function escapeText(s) {
|
|
101
174
|
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
102
175
|
}
|
package/dist/plan.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isDay, toDay } from './dates.js';
|
|
|
5
5
|
export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
|
|
6
6
|
export class RafError extends Error {
|
|
7
7
|
}
|
|
8
|
+
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'after', 'created', 'started', 'finished', 'notes', 'parent'];
|
|
8
9
|
export function isOpen(status) {
|
|
9
10
|
return status === 'todo' || status === 'doing';
|
|
10
11
|
}
|
|
@@ -33,12 +34,24 @@ export class Plan {
|
|
|
33
34
|
path;
|
|
34
35
|
doc;
|
|
35
36
|
indentSeq;
|
|
37
|
+
settings;
|
|
36
38
|
constructor(path, doc,
|
|
37
39
|
/** false quand le fichier écrit à la main met les « - » en colonne de la clé parente. */
|
|
38
|
-
indentSeq) {
|
|
40
|
+
indentSeq, settings = {}) {
|
|
39
41
|
this.path = path;
|
|
40
42
|
this.doc = doc;
|
|
41
43
|
this.indentSeq = indentSeq;
|
|
44
|
+
this.settings = settings;
|
|
45
|
+
}
|
|
46
|
+
/** Un plan lu dans un autre format ne change pas : sa lecture et le motif de ses références sont gardés. */
|
|
47
|
+
foreign;
|
|
48
|
+
get readonly() {
|
|
49
|
+
return !!this.settings.format;
|
|
50
|
+
}
|
|
51
|
+
writable() {
|
|
52
|
+
if (this.readonly) {
|
|
53
|
+
throw new RafError(`plan en lecture seule : ${this.path} est tenu par un autre outil (cadence.yaml : plan) — le modifier avec l'outil du projet`);
|
|
54
|
+
}
|
|
42
55
|
}
|
|
43
56
|
static create(path, project, prefix = 'L', since) {
|
|
44
57
|
if (existsSync(path))
|
|
@@ -49,7 +62,7 @@ export class Plan {
|
|
|
49
62
|
writeFileSync(path, initialContent(project, prefix, since ?? toDay(new Date())));
|
|
50
63
|
return Plan.load(path);
|
|
51
64
|
}
|
|
52
|
-
static load(path) {
|
|
65
|
+
static load(path, settings = {}) {
|
|
53
66
|
if (!existsSync(path))
|
|
54
67
|
throw new RafError(`pas de plan à ${path} — lancer « raf init »`);
|
|
55
68
|
const text = readFileSync(path, 'utf8');
|
|
@@ -58,27 +71,90 @@ export class Plan {
|
|
|
58
71
|
throw new RafError(`${path} : ${doc.errors[0].message}`);
|
|
59
72
|
if (!isMap(doc.contents))
|
|
60
73
|
throw new RafError(`${path} : la racine doit être un objet`);
|
|
74
|
+
if (settings.format) {
|
|
75
|
+
const key = settings.format.lots;
|
|
76
|
+
if (!isSeq(doc.get(key)))
|
|
77
|
+
throw new RafError(`${path} : la clé « ${key} » doit porter la liste des lots (cadence.yaml : plan.lots)`);
|
|
78
|
+
return new Plan(path, doc, true, settings);
|
|
79
|
+
}
|
|
61
80
|
const lots = doc.get('lots');
|
|
62
81
|
if (!isSeq(lots))
|
|
63
82
|
doc.set('lots', doc.createNode([]));
|
|
64
83
|
else
|
|
65
84
|
lots.flow = false; // « lots: [] » écrit par init : passer en style bloc
|
|
66
|
-
return new Plan(path, doc, !/^lots:[^\n]*\n(?:#[^\n]*\n)*- /m.test(text));
|
|
85
|
+
return new Plan(path, doc, !/^lots:[^\n]*\n(?:#[^\n]*\n)*- /m.test(text), settings);
|
|
67
86
|
}
|
|
68
87
|
save() {
|
|
88
|
+
this.writable();
|
|
69
89
|
writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
|
|
70
90
|
}
|
|
71
91
|
get project() {
|
|
72
|
-
return String(this.doc.get('project') ?? '');
|
|
92
|
+
return String(this.doc.get('project') ?? this.settings.project ?? '');
|
|
73
93
|
}
|
|
74
94
|
get prefix() {
|
|
75
95
|
return String(this.doc.get('prefix') ?? 'L');
|
|
76
96
|
}
|
|
77
97
|
/** Date d'adoption de raf : les commits plus anciens ne sont pas audités. */
|
|
78
98
|
get since() {
|
|
79
|
-
const v = this.doc.get('since');
|
|
99
|
+
const v = this.doc.get('since') ?? this.settings.since;
|
|
80
100
|
return v == null ? undefined : String(v);
|
|
81
101
|
}
|
|
102
|
+
/** Motifs (expressions régulières sur le sujet) des commits automatiques à ne pas auditer. */
|
|
103
|
+
get ignore() {
|
|
104
|
+
const raw = this.doc.get('ignore');
|
|
105
|
+
const own = isSeq(raw) ? raw.toJSON().map(String) : raw == null ? [] : [String(raw)];
|
|
106
|
+
const list = [...own, ...(this.settings.ignore ?? [])];
|
|
107
|
+
const patterns = [];
|
|
108
|
+
const invalid = [];
|
|
109
|
+
for (const src of list) {
|
|
110
|
+
try {
|
|
111
|
+
patterns.push(new RegExp(src));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
invalid.push(src);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { patterns, invalid };
|
|
118
|
+
}
|
|
119
|
+
/** Fichiers tenus avec le plan, relatifs à la racine du dépôt. */
|
|
120
|
+
get files() {
|
|
121
|
+
return this.settings.files ?? [];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
|
|
125
|
+
* les identifiants du plan eux-mêmes, quelle que soit leur forme (`E-A2`, `NC2.4`, `B33/t1-fusion`).
|
|
126
|
+
*/
|
|
127
|
+
refs = (text) => {
|
|
128
|
+
if (!this.settings.format)
|
|
129
|
+
return extractRefs(text, this.prefix);
|
|
130
|
+
const { refs, tasks, ids } = this.read();
|
|
131
|
+
if (!refs)
|
|
132
|
+
return [];
|
|
133
|
+
return [...text.matchAll(refs)].flatMap((m) => {
|
|
134
|
+
if (!m[2])
|
|
135
|
+
return [{ lot: m[1] }];
|
|
136
|
+
if (tasks.has(`${m[1]}/${m[2]}`))
|
|
137
|
+
return [{ lot: m[1], task: m[2] }];
|
|
138
|
+
// « B33/E-A2 » : deux lots séparés par une barre, pas une sous-tâche.
|
|
139
|
+
return ids.has(m[2]) ? [{ lot: m[1] }, { lot: m[2] }] : [{ lot: m[1] }];
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
read() {
|
|
143
|
+
if (!this.foreign) {
|
|
144
|
+
const raw = this.doc.get(this.settings.format.lots).toJSON();
|
|
145
|
+
const lots = foreignLots(raw, this.settings.format);
|
|
146
|
+
const ids = lots.map((l) => l.id).sort((a, b) => b.length - a.length);
|
|
147
|
+
this.foreign = {
|
|
148
|
+
lots,
|
|
149
|
+
ids: new Set(ids),
|
|
150
|
+
tasks: new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`))),
|
|
151
|
+
refs: ids.length
|
|
152
|
+
? new RegExp(`(?<![\\w/.-])(${ids.map(escapeRe).join('|')})(?:/([\\w-]+(?:\\.[\\w-]+)*))?(?![\\w-]|\\.\\w)`, 'g')
|
|
153
|
+
: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return this.foreign;
|
|
157
|
+
}
|
|
82
158
|
/** Date d'activation de la revue UX obligatoire des lots visibles ; absente = règle inactive. */
|
|
83
159
|
get uxSince() {
|
|
84
160
|
const v = this.doc.get('uxSince');
|
|
@@ -86,27 +162,53 @@ export class Plan {
|
|
|
86
162
|
}
|
|
87
163
|
/** Active la revue UX ; false si elle l'était déjà. */
|
|
88
164
|
enableUx(today) {
|
|
89
|
-
|
|
165
|
+
return this.enableGate('uxSince', today);
|
|
166
|
+
}
|
|
167
|
+
recordUx(lotId, verdict, today) {
|
|
168
|
+
this.recordVerdict('ux', 'la revue UX', lotId, { date: today, verdict });
|
|
169
|
+
}
|
|
170
|
+
/** Date d'activation de la revue de code obligatoire des lots à commits ; absente = règle inactive. */
|
|
171
|
+
get reviewSince() {
|
|
172
|
+
const v = this.doc.get('reviewSince');
|
|
173
|
+
return v == null ? undefined : String(v);
|
|
174
|
+
}
|
|
175
|
+
/** Active la revue de code ; false si elle l'était déjà. */
|
|
176
|
+
enableReview(today) {
|
|
177
|
+
return this.enableGate('reviewSince', today);
|
|
178
|
+
}
|
|
179
|
+
/** `commit` : sha du dernier commit compté du lot (le plan ne lit pas git), null s'il n'en a aucun. */
|
|
180
|
+
recordReview(lotId, verdict, today, commit) {
|
|
181
|
+
this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit });
|
|
182
|
+
}
|
|
183
|
+
enableGate(key, today) {
|
|
184
|
+
this.writable();
|
|
185
|
+
if (this[key])
|
|
90
186
|
return false;
|
|
91
|
-
this.doc.set(
|
|
187
|
+
this.doc.set(key, today);
|
|
92
188
|
// Placer la clé avant « lots » pour garder les réglages groupés en tête.
|
|
93
189
|
const map = this.doc.contents;
|
|
94
|
-
const idx = map.items.findIndex((p) => String(p.key?.value ?? p.key) ===
|
|
190
|
+
const idx = map.items.findIndex((p) => String(p.key?.value ?? p.key) === key);
|
|
95
191
|
const lotsIdx = map.items.findIndex((p) => String(p.key?.value ?? p.key) === 'lots');
|
|
96
192
|
if (idx > lotsIdx && lotsIdx >= 0)
|
|
97
193
|
map.items.splice(lotsIdx, 0, ...map.items.splice(idx, 1));
|
|
98
194
|
return true;
|
|
99
195
|
}
|
|
100
|
-
|
|
196
|
+
recordVerdict(key, label, lotId, entry) {
|
|
197
|
+
this.writable();
|
|
101
198
|
if (lotId.includes('/'))
|
|
102
|
-
throw new RafError(
|
|
103
|
-
|
|
199
|
+
throw new RafError(`${label} se note sur un lot, pas une sous-tâche`);
|
|
200
|
+
// Un verdict vide ouvrirait la porte sans rien dire de la revue.
|
|
201
|
+
if (entry.verdict.trim() === '')
|
|
202
|
+
throw new RafError(`verdict vide : ${label} attend son verdict — raf ${key} ${lotId} "verdict"`);
|
|
203
|
+
const node = this.doc.createNode(entry);
|
|
104
204
|
node.flow = true;
|
|
105
|
-
this.lotNode(lotId).set(
|
|
205
|
+
this.lotNode(lotId).set(key, node);
|
|
106
206
|
}
|
|
107
207
|
lots() {
|
|
208
|
+
if (this.settings.format)
|
|
209
|
+
return this.read().lots;
|
|
108
210
|
const raw = this.doc.get('lots').toJSON();
|
|
109
|
-
return raw.map(normalizeLot);
|
|
211
|
+
return raw.map((r) => normalizeLot(r));
|
|
110
212
|
}
|
|
111
213
|
lot(id) {
|
|
112
214
|
const found = this.lots().find((l) => l.id === id);
|
|
@@ -129,6 +231,7 @@ export class Plan {
|
|
|
129
231
|
return node;
|
|
130
232
|
}
|
|
131
233
|
add(title, today, opts = {}) {
|
|
234
|
+
this.writable();
|
|
132
235
|
const known = new Set(this.lots().map((l) => l.id));
|
|
133
236
|
for (const dep of opts.after ?? [])
|
|
134
237
|
if (!known.has(dep))
|
|
@@ -152,6 +255,7 @@ export class Plan {
|
|
|
152
255
|
return id;
|
|
153
256
|
}
|
|
154
257
|
addTask(lotId, title) {
|
|
258
|
+
this.writable();
|
|
155
259
|
const lot = this.lotNode(lotId);
|
|
156
260
|
let tasks = lot.get('tasks');
|
|
157
261
|
if (!isSeq(tasks)) {
|
|
@@ -166,8 +270,13 @@ export class Plan {
|
|
|
166
270
|
seq.add(node);
|
|
167
271
|
return `${lotId}/${id}`;
|
|
168
272
|
}
|
|
169
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* `ref` is `L3` or `L3/t1`. `unreviewed` : nombre de commits du lot que sa revue de code ne couvre
|
|
275
|
+
* pas (le plan ne lit pas git) — tous sans verdict, ceux postérieurs au commit relu sinon ; sans lui,
|
|
276
|
+
* la revue de code n'est pas exigée.
|
|
277
|
+
*/
|
|
170
278
|
setStatus(ref, status, today, opts = {}) {
|
|
279
|
+
this.writable();
|
|
171
280
|
const [lotId, taskId] = ref.split('/');
|
|
172
281
|
if (taskId) {
|
|
173
282
|
const task = this.taskNode(lotId, taskId);
|
|
@@ -191,6 +300,11 @@ export class Plan {
|
|
|
191
300
|
if (this.uxSince && lot.visible && !lot.ux && !opts.force) {
|
|
192
301
|
throw new RafError(`${lotId} est visible : revue UX attendue avant done — raf ux ${lotId} "verdict" (--force pour passer outre)`);
|
|
193
302
|
}
|
|
303
|
+
if (this.reviewSince && opts.unreviewed && !opts.force) {
|
|
304
|
+
throw new RafError(lot.review
|
|
305
|
+
? `${lotId} : la revue de code précède ${opts.unreviewed} commit(s) du lot, elle est à refaire — raf review ${lotId} "verdict" (--force pour passer outre)`
|
|
306
|
+
: `${lotId} a ${opts.unreviewed} commit(s) : revue de code attendue avant done — raf review ${lotId} "verdict" (--force pour passer outre)`);
|
|
307
|
+
}
|
|
194
308
|
}
|
|
195
309
|
node.set('status', status);
|
|
196
310
|
if (!lot.started && status !== 'dropped')
|
|
@@ -199,6 +313,7 @@ export class Plan {
|
|
|
199
313
|
node.set('finished', today);
|
|
200
314
|
}
|
|
201
315
|
note(ref, text, today) {
|
|
316
|
+
this.writable();
|
|
202
317
|
const [lotId, taskId] = ref.split('/');
|
|
203
318
|
const lot = this.lotNode(lotId);
|
|
204
319
|
if (taskId)
|
|
@@ -213,9 +328,69 @@ export class Plan {
|
|
|
213
328
|
notes.add(entry);
|
|
214
329
|
}
|
|
215
330
|
}
|
|
216
|
-
|
|
331
|
+
/** Lit une liste écrite dans un autre format : champs traduits, entrées à parent repliées en sous-tâches. */
|
|
332
|
+
function foreignLots(raw, format) {
|
|
333
|
+
const entries = raw.map((r, i) => {
|
|
334
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
335
|
+
throw new RafError(`entrée n° ${i + 1} du plan : un objet est attendu`);
|
|
336
|
+
const pick = (field) => {
|
|
337
|
+
for (const key of format.fields[field] ?? [field])
|
|
338
|
+
if (r[key] != null)
|
|
339
|
+
return r[key];
|
|
340
|
+
return undefined;
|
|
341
|
+
};
|
|
342
|
+
// Un horodatage vaut pour son jour.
|
|
343
|
+
const day = (field) => {
|
|
344
|
+
const v = pick(field);
|
|
345
|
+
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v;
|
|
346
|
+
};
|
|
347
|
+
const id = pick('id');
|
|
348
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || String(id).trim() === '') {
|
|
349
|
+
throw new RafError(`entrée n° ${i + 1} du plan : identifiant absent`);
|
|
350
|
+
}
|
|
351
|
+
const problems = [];
|
|
352
|
+
const theirs = pick('status');
|
|
353
|
+
const mapped = Object.keys(format.statuses).length > 0;
|
|
354
|
+
if (mapped && theirs != null && !Object.hasOwn(format.statuses, String(theirs))) {
|
|
355
|
+
problems.push(`état « ${String(theirs)} » sans correspondance (cadence.yaml : plan.statuses)`);
|
|
356
|
+
}
|
|
357
|
+
const effort = pick('estimate');
|
|
358
|
+
if (typeof effort === 'string' && !(effort in format.estimates)) {
|
|
359
|
+
problems.push(`effort « ${effort} » sans correspondance (cadence.yaml : plan.estimates)`);
|
|
360
|
+
}
|
|
361
|
+
const notes = pick('notes');
|
|
362
|
+
const lot = normalizeLot({
|
|
363
|
+
id,
|
|
364
|
+
title: pick('title'),
|
|
365
|
+
status: mapped ? (Object.hasOwn(format.statuses, String(theirs)) ? format.statuses[String(theirs)] : undefined) : theirs,
|
|
366
|
+
estimate: typeof effort === 'string' ? format.estimates[effort] : effort,
|
|
367
|
+
quickwin: pick('quickwin'),
|
|
368
|
+
visible: pick('visible'),
|
|
369
|
+
after: pick('after'),
|
|
370
|
+
created: day('created'),
|
|
371
|
+
started: day('started'),
|
|
372
|
+
finished: day('finished'),
|
|
373
|
+
notes: typeof notes === 'string' ? [{ text: notes }] : notes,
|
|
374
|
+
}, problems);
|
|
375
|
+
const parent = pick('parent');
|
|
376
|
+
return { lot, parent: parent == null ? undefined : String(parent) };
|
|
377
|
+
});
|
|
378
|
+
const top = new Map(entries.filter((e) => !e.parent).map((e) => [e.lot.id, e.lot]));
|
|
379
|
+
const lots = [];
|
|
380
|
+
for (const { lot, parent } of entries) {
|
|
381
|
+
const into = parent ? top.get(parent) : undefined;
|
|
382
|
+
if (into && lot.id.startsWith(`${into.id}/`)) {
|
|
383
|
+
into.tasks.push({ id: lot.id.slice(into.id.length + 1), title: lot.title, status: lot.status });
|
|
384
|
+
into.problems.push(...lot.problems.map((p) => `${lot.id} : ${p}`));
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
lots.push(lot);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return lots;
|
|
391
|
+
}
|
|
392
|
+
function normalizeLot(raw, problems = []) {
|
|
217
393
|
const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
|
|
218
|
-
const problems = [];
|
|
219
394
|
const asDay = (field) => {
|
|
220
395
|
const v = raw[field];
|
|
221
396
|
if (v == null)
|
|
@@ -247,9 +422,18 @@ function normalizeLot(raw) {
|
|
|
247
422
|
status: STATUSES.includes(t.status) ? t.status : 'todo',
|
|
248
423
|
}))
|
|
249
424
|
: [],
|
|
250
|
-
...(raw.ux
|
|
251
|
-
|
|
252
|
-
: {}),
|
|
425
|
+
...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
|
|
426
|
+
...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
|
|
253
427
|
problems,
|
|
254
428
|
};
|
|
255
429
|
}
|
|
430
|
+
function asVerdict(raw) {
|
|
431
|
+
if (!raw || typeof raw !== 'object')
|
|
432
|
+
return undefined;
|
|
433
|
+
const v = raw;
|
|
434
|
+
const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict ?? '') };
|
|
435
|
+
// Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
|
|
436
|
+
if ('commit' in v)
|
|
437
|
+
verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
|
|
438
|
+
return verdict;
|
|
439
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { audit, exemptPlanOnly, nextUp, planCommits } from './audit.js';
|
|
2
3
|
import { diffDays, maxDay } from './dates.js';
|
|
3
|
-
import {
|
|
4
|
+
import { repoStatus } from './git.js';
|
|
4
5
|
import { linkCommits } from './link.js';
|
|
5
6
|
import { lockAlive, readLock, readNext } from './state.js';
|
|
7
|
+
const FACTS_TIMEOUT = 120_000;
|
|
8
|
+
/**
|
|
9
|
+
* Sortie de la commande du projet, ligne à ligne. Un échec se dit et ne bloque rien : ce sont des
|
|
10
|
+
* faits en plus, pas une condition de la session.
|
|
11
|
+
*/
|
|
12
|
+
function projectFacts(ctx, since) {
|
|
13
|
+
if (!ctx.facts)
|
|
14
|
+
return [];
|
|
15
|
+
const r = spawnSync('sh', ['-c', ctx.facts], {
|
|
16
|
+
cwd: ctx.root,
|
|
17
|
+
env: { ...process.env, CADENCE_SINCE: since, CADENCE_TODAY: ctx.today },
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
timeout: FACTS_TIMEOUT,
|
|
21
|
+
killSignal: 'SIGKILL',
|
|
22
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
const lines = `${r.stdout ?? ''}${r.stderr ?? ''}`.replace(/\s+$/, '').split('\n').filter((l, i, all) => l !== '' || all.length > 1);
|
|
25
|
+
if (r.error || r.signal)
|
|
26
|
+
lines.push(`✗ commande interrompue (${r.signal ? `délai de ${FACTS_TIMEOUT / 1000} s dépassé` : r.error.message}) : ${ctx.facts}`);
|
|
27
|
+
else if (r.status !== 0)
|
|
28
|
+
lines.push(`✗ commande en échec (code ${r.status})`);
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
6
31
|
const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
7
32
|
/** Commits de la fenêtre reliés aux lots, sans compter comme « sans lot » ceux qui ne touchent que le plan. */
|
|
8
33
|
function period(ctx, since) {
|
|
9
|
-
const commits =
|
|
10
|
-
|
|
34
|
+
const commits = planCommits(ctx.plan, ctx.root, { since });
|
|
35
|
+
const linked = exemptPlanOnly(linkCommits(ctx.plan.lots(), commits, ctx.plan.refs), ctx.plan, ctx.root);
|
|
36
|
+
// Comme raf check : un commit antérieur à l'adoption du plan n'avait pas à citer de lot.
|
|
37
|
+
const adopted = ctx.plan.since;
|
|
38
|
+
return adopted ? { ...linked, orphans: linked.orphans.filter((c) => c.day >= adopted) } : linked;
|
|
11
39
|
}
|
|
12
40
|
function byLotLines(linked) {
|
|
13
41
|
return [...linked.byLot].map(([id, cs]) => {
|
|
@@ -54,7 +82,7 @@ function lastActivity(lot, commits) {
|
|
|
54
82
|
export function sessionStart(ctx, opts) {
|
|
55
83
|
const { plan, out, today } = ctx;
|
|
56
84
|
const lots = plan.lots();
|
|
57
|
-
const all = linkCommits(lots,
|
|
85
|
+
const all = linkCommits(lots, planCommits(plan, ctx.root), plan.refs);
|
|
58
86
|
out(`${plan.project} — reprise du ${today}`);
|
|
59
87
|
const next = readNext(ctx.state);
|
|
60
88
|
if (next)
|
|
@@ -79,6 +107,7 @@ export function sessionStart(ctx, opts) {
|
|
|
79
107
|
section(out, `Fait depuis ${opts.since}`, done);
|
|
80
108
|
section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
|
|
81
109
|
section(out, 'Dépôt', [repoLine(ctx.root).line]);
|
|
110
|
+
section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
|
|
82
111
|
const proposals = [
|
|
83
112
|
...doing.map((l) => ({ l, why: 'en cours' })),
|
|
84
113
|
...ready.map((l) => ({ l, why: l.quickwin ? 'gain rapide prêt' : 'prêt' })),
|
|
@@ -96,12 +125,13 @@ export function sessionClose(ctx, opts) {
|
|
|
96
125
|
}
|
|
97
126
|
section(out, 'Commits de la période', commits.length ? commits : ['(aucun)']);
|
|
98
127
|
const quiet = plan.lots().filter((l) => l.status === 'doing' && !recent.byLot.has(l.id));
|
|
99
|
-
section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : raf done ou raf note`));
|
|
128
|
+
section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : ${plan.readonly ? "le fermer ou l'annoter avec l'outil du projet" : 'raf done ou raf note'}`));
|
|
100
129
|
const issues = audit(plan, ctx.root, ctx.newsDir, today);
|
|
101
130
|
section(out, 'Écarts (raf check)', issues.map((i) => `✗ ${i.message}`));
|
|
102
131
|
const repo = repoLine(ctx.root);
|
|
103
132
|
const lock = lockStatus(ctx.shared);
|
|
104
133
|
section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
|
|
134
|
+
section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
|
|
105
135
|
const open = issues.length + repo.open + (lock?.live ? 1 : 0);
|
|
106
136
|
out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
|
|
107
137
|
return open === 0 ? 0 : 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"build": "tsc -p tsconfig.build.json",
|
|
16
16
|
"test": "vitest run",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
|
-
"prepare": "npm run build"
|
|
18
|
+
"prepare": "npm run build",
|
|
19
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"yaml": "^2.5.0"
|