@sylad/cadence 1.23.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/code-reviewer.md +17 -0
- package/agents/qa-reviewer.md +17 -0
- package/dist/cli.js +69 -19
- package/dist/gate-record.js +34 -8
- package/dist/gate-verdict.js +46 -0
- package/dist/git.js +31 -1
- package/dist/lead.js +4 -1
- package/dist/orchestrate/command.js +224 -131
- package/dist/orchestrate/guard.js +34 -2
- package/dist/orchestrate/integrate.js +6 -9
- package/dist/orchestrate/registry.js +6 -4
- package/dist/orchestrate/table.js +60 -28
- package/dist/perf.js +24 -2
- package/dist/plan.js +25 -2
- package/dist/push-guard.js +50 -1
- package/dist/release.js +11 -0
- package/dist/verify-gate.js +32 -5
- package/docs/cadence-hud.md +8 -0
- package/docs/corrections.md +1 -0
- package/docs/deliver.md +12 -3
- package/docs/lead.md +11 -4
- package/docs/orchestrate.md +10 -8
- package/docs/perf.md +3 -1
- package/docs/raf.md +4 -2
- package/docs/releasing.md +9 -3
- package/docs/skills.md +6 -0
- package/docs/whats-new.md +9 -0
- package/package.json +1 -1
- package/skills/deliver/SKILL.md +8 -0
- package/skills/lead/SKILL.md +26 -1
- package/templates/orchestrate/implement.md +6 -1
|
@@ -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.
|
|
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.
|
|
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",
|
package/agents/code-reviewer.md
CHANGED
|
@@ -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
|
package/agents/qa-reviewer.md
CHANGED
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { basename, dirname, join, posix, relative, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
@@ -14,7 +14,7 @@ import { ganttData, renderGantt } from './gantt.js';
|
|
|
14
14
|
import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
|
|
15
15
|
import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
|
|
16
16
|
import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
|
|
17
|
-
import { pushRefusal } from './push-guard.js';
|
|
17
|
+
import { gateRefusal, pushRefusal, UNGATED_ENV } from './push-guard.js';
|
|
18
18
|
import { citedRefs, linkCommits } from './link.js';
|
|
19
19
|
import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
|
|
20
20
|
import { invalidResource, Plan, RafError, SCREENS, STATUSES, setPlanNotice } from './plan.js';
|
|
@@ -26,6 +26,7 @@ import { findProjects, readDepth, leadTour, readLeadUx, tourFreeLine, tourLine,
|
|
|
26
26
|
import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
|
|
27
27
|
import { addHold, releaseHold } from './orchestrate/registry.js';
|
|
28
28
|
import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
|
|
29
|
+
import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
|
|
29
30
|
import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
|
|
30
31
|
import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
|
|
31
32
|
import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
|
|
@@ -86,7 +87,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
86
87
|
cadence ref corpus <dossier> [--sans-rendu] écrit le corpus synthétique à vérité connue (7 pièges : texte ambigu levé par une figure, figure qui contredit, table en image, gabarit BUFR, contrôle translucide, page Proposal, scan bruité) : par cas document.html et attendu.json, puis document.pdf et page-<n>.png (1700×2200) rendus par Playwright (CADENCE_PLAYWRIGHT = dossier du paquet) ; dossier existant ou Playwright introuvable : code 2
|
|
87
88
|
cadence release patch|minor|major [--dry-run] prépare le commit de version (package.json, package-lock.json, plugin.json, marketplace.json, CHANGELOG.md) sans commiter ni publier ; imprime les étapes du lead et vérifie docs/whats-new.md : refus sur un arbre sale, un [Unreleased] vide ou une version déjà taguée
|
|
88
89
|
cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
|
|
89
|
-
cadence verify --gate --sha rév [--project dossier] porte du projet (orchestrate.build puis orchestrate.test) : reprend le verdict enregistré par la vague quand son sha est le même et son arbre identique, sans rien rejouer (la sortie dit « verdict repris de la porte de la vague ») ; sinon la rejoue (« verdict rejoué : raison ») au sha dans l'arbre principal s'il est propre au sha, sinon dans un worktree jetable aux dépendances copiées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
|
|
90
|
+
cadence verify --gate --sha rév [--project dossier] porte du projet (orchestrate.build puis orchestrate.test) : reprend le verdict enregistré par la vague quand son sha est le même et son arbre identique, sans rien rejouer (la sortie dit « verdict repris de la porte de la vague ») ; sinon la rejoue (« verdict rejoué : raison ») au sha dans l'arbre principal s'il est propre au sha, sinon dans un worktree jetable aux dépendances copiées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer ; le verdict (0 ou 1) est noté par sha et le hook pre-push refuse un push dont le sha n'a pas de verdict vert (CADENCE_PUSH_UNGATED=1 passe outre)
|
|
90
91
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
91
92
|
|
|
92
93
|
Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
|
|
@@ -414,6 +415,7 @@ function dispatch(argv, io) {
|
|
|
414
415
|
}
|
|
415
416
|
plan.save();
|
|
416
417
|
io.out(id);
|
|
418
|
+
warnIntegrating(root, io.err);
|
|
417
419
|
return 0;
|
|
418
420
|
}
|
|
419
421
|
case 'start':
|
|
@@ -455,6 +457,8 @@ function dispatch(argv, io) {
|
|
|
455
457
|
plan.note(rest[0], `abandonné : ${values.reason}`, today);
|
|
456
458
|
plan.save();
|
|
457
459
|
io.out(`${rest[0]} → ${status}`);
|
|
460
|
+
if (status === 'done')
|
|
461
|
+
warnIntegrating(root, io.err);
|
|
458
462
|
if (status === 'done' && !rest[0].includes('/')) {
|
|
459
463
|
const lot = plan.lot(rest[0]);
|
|
460
464
|
if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
|
|
@@ -509,6 +513,7 @@ function dispatch(argv, io) {
|
|
|
509
513
|
const plan = loadPlan();
|
|
510
514
|
plan.note(rest[0], rest.slice(1).join(' '), today);
|
|
511
515
|
plan.save();
|
|
516
|
+
warnIntegrating(root, io.err);
|
|
512
517
|
return 0;
|
|
513
518
|
}
|
|
514
519
|
case 'did': {
|
|
@@ -742,7 +747,7 @@ function dispatch(argv, io) {
|
|
|
742
747
|
if (rest[0] === 'pre-commit')
|
|
743
748
|
return preCommit(existsSync(planPath) ? loadPlan : null, readHookConfig(configPath).autostart, root, today, io);
|
|
744
749
|
if (rest[0] === 'pre-push')
|
|
745
|
-
return prePush(root, io, rest[1]);
|
|
750
|
+
return prePush(root, io, rest[1], configPath, planFileIn(root, planPath));
|
|
746
751
|
throw new RafError('usage : raf hook install|pre-commit|post-commit|pre-push');
|
|
747
752
|
}
|
|
748
753
|
case 'session': {
|
|
@@ -1123,15 +1128,17 @@ function morningEffects(configPath, root) {
|
|
|
1123
1128
|
return [`✗ ${e.message}`];
|
|
1124
1129
|
}
|
|
1125
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
|
+
}
|
|
1126
1135
|
/**
|
|
1127
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
|
|
1128
|
-
* du dépôt, ou dont un lot pas prêt a des commits illisibles (la garde de `deliver --sha`).
|
|
1129
|
-
* (
|
|
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
|
|
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
|
|
1139
|
+
* (avertissement), la première garde ne gêne jamais un push ; une config illisible laisse passer la seconde.
|
|
1130
1140
|
*/
|
|
1131
|
-
function prePush(root, io, remoteName) {
|
|
1132
|
-
const holding = waveHolding(root);
|
|
1133
|
-
if (!holding)
|
|
1134
|
-
return 0;
|
|
1141
|
+
function prePush(root, io, remoteName, configPath, planFile) {
|
|
1135
1142
|
let input = '';
|
|
1136
1143
|
try {
|
|
1137
1144
|
input = io.stdin ? io.stdin() : readFileSync(0, 'utf8');
|
|
@@ -1139,17 +1146,51 @@ function prePush(root, io, remoteName) {
|
|
|
1139
1146
|
catch {
|
|
1140
1147
|
return 0;
|
|
1141
1148
|
}
|
|
1142
|
-
const
|
|
1143
|
-
if (
|
|
1144
|
-
|
|
1149
|
+
const holding = waveHolding(root);
|
|
1150
|
+
if (holding) {
|
|
1151
|
+
const lots = waveLotsIn(cadenceHome(), holding.pid, root);
|
|
1152
|
+
if (lots === null) {
|
|
1153
|
+
io.err(`raf: vague ${holding.wave} : état illisible, push non vérifié`);
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
const refusal = pushRefusal(root, holding.wave, lots, input, remoteName || undefined);
|
|
1157
|
+
if (refusal) {
|
|
1158
|
+
io.err(`raf: ${refusal}`);
|
|
1159
|
+
return REFUSED;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// La porte du projet (L426) : une config illisible ne gêne pas un push, comme un état de vague illisible.
|
|
1164
|
+
let gate = false;
|
|
1165
|
+
try {
|
|
1166
|
+
const config = readOrchestrateConfig(configPath);
|
|
1167
|
+
gate = Boolean(config.test || config.build);
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1145
1170
|
return 0;
|
|
1146
1171
|
}
|
|
1147
|
-
const refusal =
|
|
1148
|
-
if (refusal)
|
|
1149
|
-
|
|
1150
|
-
|
|
1172
|
+
const refusal = gateRefusal(root, gate, input, planFile);
|
|
1173
|
+
if (!refusal)
|
|
1174
|
+
return 0;
|
|
1175
|
+
if (io.env[UNGATED_ENV] === '1') {
|
|
1176
|
+
io.err(`raf: ${refusal.replace(/^push refusé/, 'push passé outre')} — ${UNGATED_ENV}=1 est posée`);
|
|
1177
|
+
return 0;
|
|
1151
1178
|
}
|
|
1152
|
-
|
|
1179
|
+
io.err(`raf: ${refusal}`);
|
|
1180
|
+
return REFUSED;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Avertit (stderr, sans refuser) quand une intégration du dépôt est en cours (L425) : le plan écrit dans l'arbre principal est un fichier suivi modifié, et
|
|
1184
|
+
* l'intégration en cours le voit et refuse son lot (« arbre du dépôt modifié »). Seul l'arbre principal est concerné : dans le worktree d'un lot, l'écriture
|
|
1185
|
+
* ne change pas l'arbre que l'intégration contrôle.
|
|
1186
|
+
*/
|
|
1187
|
+
function warnIntegrating(root, err) {
|
|
1188
|
+
const top = gitRoot(root);
|
|
1189
|
+
if (!top || !statSync(join(top, '.git')).isDirectory())
|
|
1190
|
+
return;
|
|
1191
|
+
const holder = activeLock(join(sharedStateDir(root), INTEGRATION_LOCK));
|
|
1192
|
+
if (holder)
|
|
1193
|
+
err(`avertissement : intégration en cours (vague ${holder.wave}, pid ${holder.pid}) — ce plan est un fichier suivi modifié de l'arbre principal, l'intégration en cours peut refuser son lot (arbre du dépôt modifié) : committer le plan sans attendre`);
|
|
1153
1194
|
}
|
|
1154
1195
|
/** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
|
|
1155
1196
|
function waveHolding(root) {
|
|
@@ -1566,7 +1607,16 @@ async function verifyGateCommand(values, io) {
|
|
|
1566
1607
|
const sha = resolveCommit(top, values.sha);
|
|
1567
1608
|
if (!sha)
|
|
1568
1609
|
throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
|
|
1569
|
-
|
|
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 });
|
|
1570
1620
|
}
|
|
1571
1621
|
/** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
|
|
1572
1622
|
async function sessionAll(rest, values, io) {
|
package/dist/gate-record.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
78
|
+
if (checks.tree === undefined) {
|
|
53
79
|
reason = `arbre non enregistré (${where}, enregistrement antérieur à L397)`;
|
|
54
80
|
continue;
|
|
55
81
|
}
|
|
56
|
-
if (
|
|
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) => !
|
|
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:
|
|
92
|
+
return { found: { wave: store.id, lot: l.lot, tree, checked, runs: checks.runs } };
|
|
67
93
|
}
|
|
68
94
|
}
|
|
69
95
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sharedStateDir } from './state.js';
|
|
4
|
+
const FILE = 'gate-verdicts.json';
|
|
5
|
+
/** Verdicts gardés : les plus récents ; un push ne porte que des shas récents. */
|
|
6
|
+
const KEEP = 200;
|
|
7
|
+
const file = (repo) => join(sharedStateDir(repo), FILE);
|
|
8
|
+
/** Tout le fichier ; absent ou illisible (écriture coupée, édition à la main), il vaut « aucun verdict ». */
|
|
9
|
+
function readAll(repo) {
|
|
10
|
+
const path = file(repo);
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return {};
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
16
|
+
return {};
|
|
17
|
+
const all = {};
|
|
18
|
+
for (const [sha, v] of Object.entries(raw)) {
|
|
19
|
+
const { code, at } = v;
|
|
20
|
+
if ((code === 0 || code === 1) && typeof at === 'string')
|
|
21
|
+
all[sha] = { code, at };
|
|
22
|
+
}
|
|
23
|
+
return all;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Note le verdict de `cadence verify --gate` pour `sha` (L426), dans l'état partagé du dépôt (`.git/cadence/gate-verdicts.json`, commun à tous les worktrees).
|
|
31
|
+
* Le dernier verdict d'un sha remplace le précédent ; au-delà de `KEEP` shas, les plus anciens partent. Écriture atomique (fichier voisin renommé).
|
|
32
|
+
*/
|
|
33
|
+
export function recordVerdict(repo, sha, code, at) {
|
|
34
|
+
const all = readAll(repo);
|
|
35
|
+
delete all[sha]; // réinséré en dernier : l'ordre des clés est celui des écritures
|
|
36
|
+
all[sha] = { code, at: at.toISOString() };
|
|
37
|
+
const kept = Object.fromEntries(Object.entries(all).slice(-KEEP));
|
|
38
|
+
const path = file(repo);
|
|
39
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, `${JSON.stringify(kept, null, 2)}\n`);
|
|
41
|
+
renameSync(tmp, path);
|
|
42
|
+
}
|
|
43
|
+
/** Le dernier verdict noté pour `sha` ; null si la porte n'a jamais rendu de verdict pour ce sha. */
|
|
44
|
+
export function verdictFor(repo, sha) {
|
|
45
|
+
return readAll(repo)[sha] ?? null;
|
|
46
|
+
}
|
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;
|