@sylad/cadence 1.24.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.24.0",
11
+ "version": "1.25.1",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.24.0",
4
+ "version": "1.25.1",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
@@ -91,6 +91,23 @@ A short report:
91
91
  in that one line: an empty list of findings is a valid review. It is the last line of the
92
92
  review; extra sections a caller asks for come after it.
93
93
 
94
+ ## Short reply
95
+
96
+ Only when the brief carries a line `Report file: <absolute path>` (the lead, which keeps its context
97
+ small by reading a report only when it needs to). That line is the only trigger; an orchestrated
98
+ review session and a review started by hand have none.
99
+
100
+ - The full report, as the Output section above describes it, is written to that file with Bash. Use the
101
+ path the brief gives and no other: it is outside the repository, so the working tree stays as you
102
+ found it. The verdict is the last line of the file. Write the file before you reply.
103
+ - Reply to the caller with 5 lines at most, and nothing before or after them:
104
+ `Verdict: <the one-line verdict>`, `Blocking: <n> — <one short phrase per blocking finding>` (`Blocking: 0` when there is none),
105
+ `Follow-ups: <n> — <the lines to add to the plan, by their first words>` (`Follow-ups: 0` when there is none),
106
+ `Report: <the path>`. Add a fifth line, `Not verified: <n>`, only when something could not be run.
107
+ - If you cannot write the file, say so on the first line of the reply and give the whole report in the reply, as if
108
+ the brief had no report file: a verdict whose evidence nobody can read is lost.
109
+ - With no `Report file:` line in the brief, give the complete report in your reply.
110
+
94
111
  ## Do not
95
112
 
96
113
  - Judge on taste: naming, formatting or structure you would have written differently is not a
@@ -169,6 +169,23 @@ Give screenshots and snapshots the absolute path of the capture directory your b
169
169
  the caller names; remove them, or list their paths in the report — except the QA browser profile, which is kept for the next pass. The working tree is left as you
170
170
  found it.
171
171
 
172
+ ## Short reply
173
+
174
+ Only when the brief carries a line `Report file: <absolute path>` (the lead, which keeps its context
175
+ small by reading a report only when it needs to). That line is the only trigger; an orchestrated
176
+ review session and a review started by hand have none.
177
+
178
+ - The full report, as the Output section above describes it, is written to that file with Bash. Use the
179
+ path the brief gives and no other: it is outside the repository, so the working tree stays as you
180
+ found it. The verdict is the last line of the file. Write the file before you reply.
181
+ - Reply to the caller with 5 lines at most, and nothing before or after them:
182
+ `Verdict: <the one-line verdict>`, `Blocking: <n> — <one short phrase per blocking finding>` (`Blocking: 0` when there is none),
183
+ `Follow-ups: <n> — <the lines to add to the plan, by their first words>` (`Follow-ups: 0` when there is none),
184
+ `Report: <the path>`. Add a fifth line, `Not walked: <n> pages`, only when pages were not reached.
185
+ - If you cannot write the file, say so on the first line of the reply and give the whole report in the reply, as if
186
+ the brief had no report file: a verdict whose evidence nobody can read is lost.
187
+ - With no `Report file:` line in the brief, give the complete report in your reply.
188
+
172
189
  ## Do not
173
190
 
174
191
  - Report an impression: a finding you have not measured in the browser is not a finding.
package/dist/cli.js CHANGED
@@ -747,7 +747,7 @@ function dispatch(argv, io) {
747
747
  if (rest[0] === 'pre-commit')
748
748
  return preCommit(existsSync(planPath) ? loadPlan : null, readHookConfig(configPath).autostart, root, today, io);
749
749
  if (rest[0] === 'pre-push')
750
- return prePush(root, io, rest[1], configPath);
750
+ return prePush(root, io, rest[1], configPath, planFileIn(root, planPath));
751
751
  throw new RafError('usage : raf hook install|pre-commit|post-commit|pre-push');
752
752
  }
753
753
  case 'session': {
@@ -1128,13 +1128,17 @@ function morningEffects(configPath, root) {
1128
1128
  return [`✗ ${e.message}`];
1129
1129
  }
1130
1130
  }
1131
+ /** Le plan, relatif à la racine du dépôt git (comme les fichiers d'un commit), séparateurs `/`. */
1132
+ function planFileIn(root, planPath) {
1133
+ return relative(gitRoot(root) ?? root, planPath).split('\\').join('/');
1134
+ }
1131
1135
  /**
1132
1136
  * `raf hook pre-push` (L405), appelé par le hook que la vague pose : refuse (code 3) un push qui emporte un commit d'un lot pas prêt de la vague vivante
1133
1137
  * du dépôt, ou dont un lot pas prêt a des commits illisibles (la garde de `deliver --sha`). Puis (L426), quand le projet déclare une porte, refuse un push dont
1134
- * le sha de tête n'a pas de verdict vert de `cadence verify --gate` (`CADENCE_PUSH_UNGATED=1` passe outre, et le dit). Sans vague qui tient le dépôt, ou avec un état de vague illisible
1138
+ * le sha de tête n'a pas de verdict vert de `cadence verify --gate` (`CADENCE_PUSH_UNGATED=1` passe outre, et le dit) ; un commit qui ne change que le plan reprend le verdict de son parent (L432). Sans vague qui tient le dépôt, ou avec un état de vague illisible
1135
1139
  * (avertissement), la première garde ne gêne jamais un push ; une config illisible laisse passer la seconde.
1136
1140
  */
1137
- function prePush(root, io, remoteName, configPath) {
1141
+ function prePush(root, io, remoteName, configPath, planFile) {
1138
1142
  let input = '';
1139
1143
  try {
1140
1144
  input = io.stdin ? io.stdin() : readFileSync(0, 'utf8');
@@ -1165,7 +1169,7 @@ function prePush(root, io, remoteName, configPath) {
1165
1169
  catch {
1166
1170
  return 0;
1167
1171
  }
1168
- const refusal = gateRefusal(root, gate, input);
1172
+ const refusal = gateRefusal(root, gate, input, planFile);
1169
1173
  if (!refusal)
1170
1174
  return 0;
1171
1175
  if (io.env[UNGATED_ENV] === '1') {
@@ -1603,7 +1607,16 @@ async function verifyGateCommand(values, io) {
1603
1607
  const sha = resolveCommit(top, values.sha);
1604
1608
  if (!sha)
1605
1609
  throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
1606
- return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], now: io.now, out: io.out });
1610
+ // Le plan du projet (L444) : les commits qui ne changent que lui au-dessus du sha reprennent la porte du sha dessous. Illisible, il n'est pas cherché : la porte se rejoue.
1611
+ let planFile;
1612
+ try {
1613
+ const configured = readPlanConfig(configPath, top)?.path;
1614
+ planFile = planFileIn(top, configured !== undefined ? resolve(top, configured) : defaultPlan(top).path);
1615
+ }
1616
+ catch {
1617
+ planFile = undefined;
1618
+ }
1619
+ return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], planFile, now: io.now, out: io.out });
1607
1620
  }
1608
1621
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
1609
1622
  async function sessionAll(rest, values, io) {
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { changedFiles, tryGit } from './git.js';
2
3
  import { git } from './orchestrate/worktree.js';
3
4
  import { RunStore } from './orchestrate/state.js';
4
5
  const realOrSelf = (p) => {
@@ -9,14 +10,36 @@ const realOrSelf = (p) => {
9
10
  return p;
10
11
  }
11
12
  };
13
+ const PLAN_HOPS = 50;
14
+ /**
15
+ * Les shas dont la porte vaut pour `sha` : `sha` lui-même, puis, tant que le commit ne change que le plan (`planFile`, relatif à la racine du dépôt) et n'a qu'un parent, son parent, de proche en proche
16
+ * (même règle que le hook pre-push, L432 : le commit de revue et celui de `raf done` posés au-dessus d'un lot intégré ne changent pas ce que la porte a contrôlé).
17
+ * Une fusion, un commit racine, un commit qui touche autre chose que le plan ou qui ne touche rien est le dernier de la liste.
18
+ */
19
+ function planChain(repo, sha, planFile) {
20
+ const chain = [sha];
21
+ if (planFile === undefined)
22
+ return chain;
23
+ for (let hop = 0; hop < PLAN_HOPS; hop++) {
24
+ const at = chain[chain.length - 1];
25
+ const parents = (tryGit(repo, ['rev-list', '--parents', '-n1', at]) ?? '').trim().split(/\s+/).slice(1);
26
+ const files = parents.length === 1 ? changedFiles(repo, at) : [];
27
+ if (files.length !== 1 || files[0] !== planFile)
28
+ break;
29
+ chain.push(parents[0]);
30
+ }
31
+ return chain;
32
+ }
12
33
  /**
13
34
  * La porte que le programme a déjà jouée pour `sha` (L397), pour que `verify --gate` la lise au lieu de la rejouer (≈ 5 min par lot prêt).
14
35
  * Une vague range l'état de ses lots sous `<dossier de lancement>/.cadence/runs/<vague>` : `launchDirs` sont ceux à regarder (dossier courant, racine du dépôt, son parent), les vagues les plus récentes d'abord.
15
36
  * Un enregistrement est repris quand ses contrôles ont tourné AU sha (même commit), que l'arbre de ce commit est celui enregistré, et qu'il couvre chaque étape de `steps`
16
- * (même étiquette, même commande, code 0 : une étape rouge, absente ou rejouée avec une autre commande ne prouve rien). Sinon `reason` dit pourquoi la porte est rejouée.
37
+ * (même étiquette, même commande, code 0 : une étape rouge, absente ou rejouée avec une autre commande ne prouve rien). `planFile` : les commits qui ne changent que le plan au-dessus de `sha` sont traversés (`planBase`, L444). Sinon `reason` dit pourquoi la porte est rejouée.
17
38
  */
18
- export function recordedGate(launchDirs, repo, sha, steps) {
19
- const tree = git(repo, 'rev-parse', `${sha}^{tree}`);
39
+ export function recordedGate(launchDirs, repo, sha, steps, planFile) {
40
+ const chain = planChain(repo, sha, planFile);
41
+ const trees = new Map();
42
+ const treeOf = (c) => trees.get(c) ?? trees.set(c, git(repo, 'rev-parse', `${c}^{tree}`)).get(c);
20
43
  const repoReal = realOrSelf(repo);
21
44
  let reason = 'aucune porte de vague enregistrée pour ce sha';
22
45
  const seen = new Set();
@@ -46,24 +69,27 @@ export function recordedGate(launchDirs, repo, sha, steps) {
46
69
  continue; // état illisible (écriture en cours) : cette vague ne fournit rien
47
70
  }
48
71
  for (const l of lots) {
49
- if (l.checks?.head !== sha || realOrSelf(l.repo) !== repoReal)
72
+ const checks = l.checks;
73
+ const checked = checks === undefined ? undefined : chain.find((c) => c === checks.head);
74
+ if (checks === undefined || checked === undefined || realOrSelf(l.repo) !== repoReal)
50
75
  continue;
76
+ const tree = treeOf(checked);
51
77
  const where = `vague ${store.id}, lot ${l.lot}`;
52
- if (l.checks.tree === undefined) {
78
+ if (checks.tree === undefined) {
53
79
  reason = `arbre non enregistré (${where}, enregistrement antérieur à L397)`;
54
80
  continue;
55
81
  }
56
- if (l.checks.tree !== tree) {
82
+ if (checks.tree !== tree) {
57
83
  reason = `arbre différent de celui contrôlé (${where})`;
58
84
  continue;
59
85
  }
60
86
  const label = (name) => (name === 'build' ? 'build' : 'tests');
61
- const missing = steps.filter((s) => !l.checks.runs.some((r) => r.label === label(s.name) && r.command === s.cmd && r.code === 0));
87
+ const missing = steps.filter((s) => !checks.runs.some((r) => r.label === label(s.name) && r.command === s.cmd && r.code === 0));
62
88
  if (missing.length) {
63
89
  reason = `l'enregistrement (${where}) ne couvre pas en vert ${missing.map((s) => `${s.name} (${s.cmd})`).join(', ')}`;
64
90
  continue;
65
91
  }
66
- return { found: { wave: store.id, lot: l.lot, tree, runs: l.checks.runs } };
92
+ return { found: { wave: store.id, lot: l.lot, tree, checked, runs: checks.runs } };
67
93
  }
68
94
  }
69
95
  }
package/dist/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { rmSync, writeFileSync } from 'node:fs';
2
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
4
  function git(cwd, args) {
5
5
  // Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
@@ -248,6 +248,36 @@ export function dirtyPaths(root, paths) {
248
248
  const out = git(root, ['-c', 'core.quotepath=off', 'status', '--porcelain', '-z', '--', ...paths]);
249
249
  return out.split('\0').filter(Boolean).map((l) => l.slice(3));
250
250
  }
251
+ /**
252
+ * L'état des fichiers que git voit modifiés, stagés ou non suivis dans tout le dépôt : chemin (relatif à la racine) → contenu.
253
+ * Deux états se comparent pour savoir ce qu'une commande a écrit entre les deux. Vide hors d'un dépôt git.
254
+ */
255
+ export function dirtyContents(root) {
256
+ const state = new Map();
257
+ let out;
258
+ try {
259
+ out = git(root, ['-c', 'core.quotepath=off', 'status', '--porcelain', '-z', '-uall']);
260
+ }
261
+ catch {
262
+ return state;
263
+ }
264
+ const top = tryGit(root, ['rev-parse', '--show-toplevel'])?.trim() ?? root;
265
+ const entries = out.split('\0').filter(Boolean);
266
+ for (let i = 0; i < entries.length; i++) {
267
+ const file = entries[i].slice(3);
268
+ if (entries[i][0] === 'R' || entries[i][0] === 'C')
269
+ i++; // un renommage porte l'ancien chemin dans l'entrée suivante
270
+ let content = '';
271
+ try {
272
+ content = readFileSync(resolve(top, file)).toString('base64');
273
+ }
274
+ catch {
275
+ content = '(absent)';
276
+ }
277
+ state.set(file, content);
278
+ }
279
+ return state;
280
+ }
251
281
  /** Commit de `paths` seuls (à chemin explicite : le reste de l'index n'y entre pas). Rend le sha court. Lève l'erreur de git (hook refusé compris). */
252
282
  export function commitPaths(root, paths, message) {
253
283
  git(root, ['add', '--', ...paths]);
package/dist/lead.js CHANGED
@@ -71,7 +71,7 @@ export function openLots(lots) {
71
71
  return lots
72
72
  .filter((l) => isOpen(l.status) && !isRecurring(l))
73
73
  .flatMap((l) => [
74
- { id: l.id, title: l.title, status: l.status, after: l.after, ...(l.parent ? { parent: l.parent } : {}) },
74
+ { id: l.id, title: l.title, status: l.status, after: l.after, ...(l.parent ? { parent: l.parent } : {}), ...(l.review ? { reviewed: true } : {}) },
75
75
  ...l.tasks.filter((t) => isOpen(t.status)).map((t) => ({ id: `${l.id}/${t.id}`, title: t.title, status: t.status, after: [], parent: l.id })),
76
76
  ]);
77
77
  }
@@ -146,6 +146,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
146
146
  added7: counted.filter((l) => l.created !== undefined && diffDays(l.created, today) >= 0 && diffDays(l.created, today) < ADDED_DAYS).length,
147
147
  };
148
148
  row.lots = openLots(lots);
149
+ const closed = lots.filter((l) => l.status === 'done' && l.every === undefined && l.finished !== undefined).map((l) => ({ id: l.id, finished: l.finished }));
150
+ if (closed.length)
151
+ row.closed = closed;
149
152
  const waiting = gateWaiting(lots, blocked).map((g) => g.lot.id);
150
153
  if (waiting.length)
151
154
  row.waiting = waiting;