@sylad/cadence 1.9.0 → 1.10.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 +1 -1
- package/README.md +24 -67
- package/agents/doc-keeper.md +2 -2
- package/agents/precheck-reader.md +1 -1
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/dist/audit.js +3 -1
- package/dist/check.js +19 -3
- package/dist/cli.js +28 -5
- package/dist/corrections.js +2 -1
- package/dist/deliver.js +47 -16
- package/dist/lead.js +8 -0
- package/dist/orchestrate/briefs.js +19 -1
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +108 -60
- package/dist/orchestrate/cycle.js +226 -36
- package/dist/orchestrate/integrate.js +52 -8
- package/dist/orchestrate/launch.js +14 -4
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/registry.js +23 -0
- package/dist/orchestrate/snapshot.js +29 -1
- package/dist/orchestrate/state.js +14 -9
- package/dist/orchestrate/table.js +13 -1
- package/dist/perf.js +109 -7
- package/dist/plan.js +20 -1
- package/dist/proc.js +6 -0
- package/dist/ref/add.js +24 -11
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/command.js +15 -1
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +2 -2
- package/dist/ref/figures.js +86 -13
- package/dist/ref/pdf.js +20 -8
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/state.js +17 -2
- package/docs/deliver.md +19 -3
- package/docs/fonctionnement.md +3 -3
- package/docs/lead.md +8 -4
- package/docs/news.md +6 -0
- package/docs/orchestrate.md +71 -40
- package/docs/overview.md +65 -1
- package/docs/perf.md +3 -2
- package/docs/raf.md +2 -1
- package/docs/ref.md +102 -3
- package/docs/releasing.md +3 -3
- package/docs/skills.md +12 -0
- package/docs/spec.md +1 -1
- package/docs/whats-new.md +28 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +6 -6
- package/templates/orchestrate/fix-docs.md +2 -2
- package/templates/orchestrate/implement.md +1 -1
- package/templates/orchestrate/precheck.md +1 -1
|
@@ -16,7 +16,8 @@ import { isOpen } from '../plan.js';
|
|
|
16
16
|
import { isPlanOnly } from '../audit.js';
|
|
17
17
|
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
18
18
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
19
|
-
import { capturesText, headline, integrationText, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
19
|
+
import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
20
|
+
import { SlotAbandoned } from './registry.js';
|
|
20
21
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
21
22
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
22
23
|
import { sumTokens } from './result.js';
|
|
@@ -27,7 +28,7 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
|
|
|
27
28
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
28
29
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
29
30
|
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
30
|
-
import { PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
31
|
+
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
31
32
|
import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
32
33
|
import { mutexFor } from './mutex.js';
|
|
33
34
|
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
@@ -136,6 +137,10 @@ async function baselineFailures(c, command) {
|
|
|
136
137
|
try {
|
|
137
138
|
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
138
139
|
links.push(...linkIgnored(l.repo, dir));
|
|
140
|
+
// Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
|
|
141
|
+
// ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
|
|
142
|
+
if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
|
|
143
|
+
return null;
|
|
139
144
|
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
140
145
|
const failures = r.code === 0 ? [] : failedTests(r.output);
|
|
141
146
|
l.baseline = { sha, command, failures, verified: true };
|
|
@@ -293,10 +298,19 @@ function checksText(c) {
|
|
|
293
298
|
}
|
|
294
299
|
/** La clé `docs` du cadence.yaml du projet de ce lot. */
|
|
295
300
|
const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
|
|
296
|
-
/** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. */
|
|
301
|
+
/** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. Un écart acquitté et motivé (`raf docs-ok`, L317) n'en est plus un. */
|
|
297
302
|
function docGaps(c) {
|
|
298
303
|
const { sync } = docsConfig(c);
|
|
299
|
-
|
|
304
|
+
const plan = histPlan(c);
|
|
305
|
+
const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
|
|
306
|
+
return { sync, gaps: sync.length && !acknowledged ? docSyncGaps(sync, filesOf(wd(c), lotWork(plan, wd(c), c.lot.lot))) : [] };
|
|
307
|
+
}
|
|
308
|
+
/** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
|
|
309
|
+
function openDocGap(c) {
|
|
310
|
+
const { gaps } = docGaps(c);
|
|
311
|
+
if (!gaps.length)
|
|
312
|
+
return null;
|
|
313
|
+
return `documentation en retard (docs.sync) : ${gaps.flatMap((g) => g.touched).join(', ')} sans la documentation attendue, et aucun écart acquitté — écrire la documentation, ou \`raf docs-ok ${c.lot.lot} --reason "…"\` si elle reste vraie`;
|
|
300
314
|
}
|
|
301
315
|
/** Consigne docs.sync du brief de revue (L143). */
|
|
302
316
|
function docsText(c) {
|
|
@@ -384,7 +398,7 @@ function avalOf(c, kind) {
|
|
|
384
398
|
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
385
399
|
return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
386
400
|
}
|
|
387
|
-
function briefFor(c, kind) {
|
|
401
|
+
function briefFor(c, kind, local = false) {
|
|
388
402
|
const plan = c.loadPlan();
|
|
389
403
|
const l = c.lot;
|
|
390
404
|
const lot = plan.lot(l.lot);
|
|
@@ -398,6 +412,8 @@ function briefFor(c, kind) {
|
|
|
398
412
|
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
399
413
|
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
400
414
|
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
415
|
+
if (kind === 'precheck' && local)
|
|
416
|
+
vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
|
|
401
417
|
if (kind === 'spec') {
|
|
402
418
|
const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
|
|
403
419
|
const written = ficheOf(c);
|
|
@@ -441,8 +457,12 @@ function briefFor(c, kind) {
|
|
|
441
457
|
if (kind === 'fix') {
|
|
442
458
|
vars.commits = lotCommitLines(c);
|
|
443
459
|
vars.constats = constatLines(l.constats);
|
|
444
|
-
if (l.docsFix)
|
|
460
|
+
if (l.docsFix) {
|
|
445
461
|
vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
|
|
462
|
+
vars.ack = wd(c) === l.repo
|
|
463
|
+
? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
|
|
464
|
+
: `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
|
|
465
|
+
}
|
|
446
466
|
}
|
|
447
467
|
else if (interrupted) {
|
|
448
468
|
const mine = lotCommitLines(c);
|
|
@@ -493,6 +513,8 @@ export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
|
493
513
|
/** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
|
|
494
514
|
const dropped = (c) => c.wave.store.control().drops.includes(lotKey(c.lot.project, c.lot.lot));
|
|
495
515
|
export const DROPPED = 'retiré de la vague (--drop)';
|
|
516
|
+
/** Mention de la réponse du lead restée en attente : un lot rendu sans que la session l'ait lue le dit (comme overBudget). */
|
|
517
|
+
const unplayedAnswer = (l) => (l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '');
|
|
496
518
|
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
497
519
|
function halted(w, lot) {
|
|
498
520
|
if (w.store.control().stopAfterCurrent)
|
|
@@ -519,14 +541,17 @@ function reviewModel(c, kind) {
|
|
|
519
541
|
const { light, full } = c.config.review;
|
|
520
542
|
return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
|
|
521
543
|
}
|
|
522
|
-
/**
|
|
544
|
+
/** Écarte le modèle local pour le reste de la vague (L322) : le premier échec suffit, les lots suivants n'attendent pas de le constater à leur tour. */
|
|
545
|
+
function benchLocal(c, cause) {
|
|
546
|
+
c.wave.localOut ??= cause.slice(0, 200);
|
|
547
|
+
}
|
|
523
548
|
/** Rendu par `session(…, true)` quand l'étape locale n'a rien donné (Ollama éteint, délai, rapport sans structure) : l'appelant se replie sur Sonnet (L146). */
|
|
524
549
|
const LOCAL_FAILED = Symbol('local-failed');
|
|
525
550
|
async function session(c, kind, local = false) {
|
|
526
551
|
const w = c.wave;
|
|
527
552
|
const l = c.lot;
|
|
528
553
|
if (dropped(c))
|
|
529
|
-
return stop(c, 'handed-back', DROPPED);
|
|
554
|
+
return stop(c, 'handed-back', DROPPED + unplayedAnswer(l));
|
|
530
555
|
const halt = halted(w, l);
|
|
531
556
|
if (halt)
|
|
532
557
|
return suspend(c, halt);
|
|
@@ -548,7 +573,7 @@ async function session(c, kind, local = false) {
|
|
|
548
573
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
|
|
549
574
|
let brief;
|
|
550
575
|
try {
|
|
551
|
-
brief = briefFor(c, kind);
|
|
576
|
+
brief = briefFor(c, kind, local);
|
|
552
577
|
}
|
|
553
578
|
catch (e) {
|
|
554
579
|
step.status = 'failed';
|
|
@@ -559,6 +584,7 @@ async function session(c, kind, local = false) {
|
|
|
559
584
|
const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
560
585
|
if (playwright)
|
|
561
586
|
l.uxCaptures = join(dirname(mcpConfig), 'playwright');
|
|
587
|
+
const answerInBrief = l.pendingAnswer; // gardée pour la rendre si la session ne part jamais (créneau abandonné, L332)
|
|
562
588
|
l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
|
|
563
589
|
const tmpDir = w.store.tmpDir(l.project, l.lot); // TMPDIR de la session, aussi en --add-dir : hors du cwd, un Write y serait refusé en `claude -p`
|
|
564
590
|
// Délai (L298) : l'étape d'écriture est proportionnée à l'estimate et à la fiche du lot, puis prolongée à chaque échéance où elle a commité.
|
|
@@ -617,7 +643,55 @@ async function session(c, kind, local = false) {
|
|
|
617
643
|
step.sessionId = id;
|
|
618
644
|
save(c);
|
|
619
645
|
},
|
|
646
|
+
}).catch((e) => {
|
|
647
|
+
if (e instanceof SlotAbandoned)
|
|
648
|
+
return e;
|
|
649
|
+
throw e;
|
|
620
650
|
});
|
|
651
|
+
if (outcome instanceof SlotAbandoned) {
|
|
652
|
+
// L332 : la demande est arrivée pendant l'attente d'un créneau. Une étape qui n'a pas démarré (aucun pid) n'a pas eu lieu : elle ne figure pas à l'état.
|
|
653
|
+
// Rien de ce que le brief a consommé n'a servi : la réponse du lead (retirée de pendingAnswer avant le lancement) revient, la reprise rejoue l'étape avec elle.
|
|
654
|
+
if (step.pid === undefined) {
|
|
655
|
+
l.steps.pop();
|
|
656
|
+
l.pendingAnswer = answerInBrief;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
// Une session de l'étape a déjà tourné (le pid est le sien) et c'est son lancement SUIVANT qui attendait un créneau (relance d'une session coupée, relance de mise en forme) :
|
|
660
|
+
// sa sortie, son identifiant et ses jetons sont gardés, la cause dit ce qui n'a pas eu lieu.
|
|
661
|
+
const dir = w.store.lotDir(l.project, l.lot);
|
|
662
|
+
const base = `${n}-${kind}`;
|
|
663
|
+
const after = outcome.after;
|
|
664
|
+
const spentOf = (sessionId) => (w.claudeHome ? (journalTokens(w.claudeHome, wd(c), sessionId)?.tokens ?? null) : null);
|
|
665
|
+
let tokens = null;
|
|
666
|
+
let cause = outcome.message;
|
|
667
|
+
if (after?.relaunched) {
|
|
668
|
+
const cutRun = after.relaunched;
|
|
669
|
+
tokens = spentOf(cutRun.sessionId);
|
|
670
|
+
step.sessionId = cutRun.sessionId; // la session neuve n'a jamais démarré : l'étape est celle de la session coupée
|
|
671
|
+
writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
|
|
672
|
+
cause = `coupée (${cutRun.cause}), relance en session neuve non faite : ${outcome.message}`;
|
|
673
|
+
l.warnings.push(`${kind} coupée (${cutRun.cause}) : sa relance en session neuve n'a pas eu lieu, ${outcome.message}`);
|
|
674
|
+
}
|
|
675
|
+
else if (after?.formatting) {
|
|
676
|
+
const first = after.formatting;
|
|
677
|
+
tokens = first.tokens ?? (first.sessionId ? spentOf(first.sessionId) : null);
|
|
678
|
+
if (first.sessionId)
|
|
679
|
+
step.sessionId = first.sessionId;
|
|
680
|
+
writeFileSync(join(dir, `${base}.first.json`), first.stdout);
|
|
681
|
+
writeFileSync(join(dir, `${base}.first.err`), first.stderr);
|
|
682
|
+
cause = `sortie sans structured_output, relance de mise en forme non faite : ${outcome.message}`;
|
|
683
|
+
}
|
|
684
|
+
if (tokens && !local) {
|
|
685
|
+
step.tokens = tokens;
|
|
686
|
+
w.budget.add(tokens);
|
|
687
|
+
}
|
|
688
|
+
step.status = 'failed';
|
|
689
|
+
step.cause = cause;
|
|
690
|
+
step.ended = new Date().toISOString();
|
|
691
|
+
w.saveWave();
|
|
692
|
+
}
|
|
693
|
+
return outcome.reason === DROPPED ? stop(c, 'handed-back', DROPPED + unplayedAnswer(l)) : suspend(c, outcome.reason);
|
|
694
|
+
}
|
|
621
695
|
step.ended = new Date().toISOString();
|
|
622
696
|
const dir = w.store.lotDir(l.project, l.lot);
|
|
623
697
|
const base = `${n}-${kind}`;
|
|
@@ -655,7 +729,9 @@ async function session(c, kind, local = false) {
|
|
|
655
729
|
}
|
|
656
730
|
if (local && outcome.kind !== 'ok') {
|
|
657
731
|
// Hors quota Anthropic : rien au budget (ni sortie, ni journal), et jamais d'arrêt du lot — Sonnet reprend le contrôle.
|
|
658
|
-
|
|
732
|
+
// Un appel d'outil écrit en texte (`<function=Bash>`) est une sortie sans structured_output : le local n'a pas tenu l'appel d'outils, inutile d'attendre un second tour.
|
|
733
|
+
const textCall = outcome.kind === 'failed' && outcome.stdout.includes('<function=');
|
|
734
|
+
const cause = textCall ? 'appel d\'outil écrit en texte, pas de structured_output' : outcome.kind === 'failed' ? outcome.cause : outcome.message;
|
|
659
735
|
writeFileSync(join(dir, `${base}.json`), outcome.kind === 'failed' ? outcome.stdout : '');
|
|
660
736
|
if (outcome.kind === 'failed')
|
|
661
737
|
writeFileSync(join(dir, `${base}.err`), outcome.stderr);
|
|
@@ -664,6 +740,7 @@ async function session(c, kind, local = false) {
|
|
|
664
740
|
step.report = `${base}.json`;
|
|
665
741
|
delete step.tokens;
|
|
666
742
|
l.warnings.push(`contrôle préalable : modèle local sans résultat (${cause.slice(0, 200)}), repli sur Sonnet`);
|
|
743
|
+
benchLocal(c, cause);
|
|
667
744
|
save(c);
|
|
668
745
|
return LOCAL_FAILED;
|
|
669
746
|
}
|
|
@@ -944,9 +1021,9 @@ function overBudget(c, reserve = false) {
|
|
|
944
1021
|
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
|
|
945
1022
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
946
1023
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
947
|
-
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
|
|
1024
|
+
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
|
|
948
1025
|
const played = l.pass - 1;
|
|
949
|
-
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
|
|
1026
|
+
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : l.resumedFrom && l.resumedReviewed ? 'la dernière revue de la branche reprise' : "l'implémentation";
|
|
950
1027
|
const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
|
|
951
1028
|
const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
|
|
952
1029
|
const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
|
|
@@ -1001,18 +1078,28 @@ const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-]
|
|
|
1001
1078
|
function citesPriorWork(text, lot, ids) {
|
|
1002
1079
|
if (CITES_FINDING.test(text))
|
|
1003
1080
|
return true;
|
|
1004
|
-
|
|
1005
|
-
|
|
1081
|
+
const { ids: cited, foreign } = citedLots(text, lot, ids);
|
|
1082
|
+
return foreign || cited.length > 0;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
|
|
1086
|
+
* (`cadence:L68`, dont on ne lit pas les commits ici).
|
|
1087
|
+
*/
|
|
1088
|
+
function citedLots(text, lot, ids) {
|
|
1089
|
+
const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
|
|
1006
1090
|
const own = /^L(\d+)$/.exec(lot);
|
|
1007
1091
|
const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
|
|
1008
|
-
return
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1092
|
+
return {
|
|
1093
|
+
foreign,
|
|
1094
|
+
ids: ids.filter((id) => {
|
|
1095
|
+
if (id === lot)
|
|
1096
|
+
return false;
|
|
1097
|
+
const n = /^L(\d+)$/.exec(id);
|
|
1098
|
+
if (own && n)
|
|
1099
|
+
return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
|
|
1100
|
+
return cited(id);
|
|
1101
|
+
}),
|
|
1102
|
+
};
|
|
1016
1103
|
}
|
|
1017
1104
|
/**
|
|
1018
1105
|
* Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
|
|
@@ -1030,6 +1117,44 @@ export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
|
|
|
1030
1117
|
const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
|
|
1031
1118
|
return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
|
|
1032
1119
|
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
|
|
1122
|
+
* Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
|
|
1123
|
+
* migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
|
|
1124
|
+
* qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
|
|
1125
|
+
* Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
|
|
1126
|
+
* juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
|
|
1127
|
+
* dont 2 avec un vrai « partiel »).
|
|
1128
|
+
*/
|
|
1129
|
+
export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
|
|
1130
|
+
if (repos.length)
|
|
1131
|
+
return null;
|
|
1132
|
+
try {
|
|
1133
|
+
const rel = relative(root, plan.path);
|
|
1134
|
+
const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
|
|
1135
|
+
// Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
|
|
1136
|
+
const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
|
|
1137
|
+
if (!added)
|
|
1138
|
+
return null;
|
|
1139
|
+
const created = plan.lot(lot).created;
|
|
1140
|
+
const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
|
|
1141
|
+
const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
|
|
1142
|
+
const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
|
|
1143
|
+
const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
|
|
1144
|
+
if (!code)
|
|
1145
|
+
return null;
|
|
1146
|
+
if (createdAt <= Number(code[1]))
|
|
1147
|
+
return null;
|
|
1148
|
+
const l = plan.lot(lot);
|
|
1149
|
+
const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
|
|
1150
|
+
if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
|
|
1151
|
+
return null;
|
|
1152
|
+
return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1033
1158
|
/**
|
|
1034
1159
|
* Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
|
|
1035
1160
|
* autre lot). « oui » rend le lot sans ouvrir d'implémentation ; « partiel » joint le constat au brief de l'implémentation.
|
|
@@ -1039,7 +1164,10 @@ async function precheck(c) {
|
|
|
1039
1164
|
const l = c.lot;
|
|
1040
1165
|
let rep = null;
|
|
1041
1166
|
let localYes = false;
|
|
1042
|
-
if (c.config.precheckLocal) {
|
|
1167
|
+
if (c.config.precheckLocal && c.wave.localOut) {
|
|
1168
|
+
l.warnings.push(`contrôle préalable : modèle local écarté pour la vague (${c.wave.localOut}), Sonnet directement`);
|
|
1169
|
+
}
|
|
1170
|
+
else if (c.config.precheckLocal) {
|
|
1043
1171
|
// Modèle local (L146) : un échec, un rapport illisible ou un « oui » (qui rendrait le lot sans implémentation) rend la main à Sonnet.
|
|
1044
1172
|
const done = await session(c, 'precheck', true);
|
|
1045
1173
|
if (!done)
|
|
@@ -1049,7 +1177,8 @@ async function precheck(c) {
|
|
|
1049
1177
|
rep = checkShape(done.report, PRECHECK_SCHEMA);
|
|
1050
1178
|
}
|
|
1051
1179
|
catch (e) {
|
|
1052
|
-
l.warnings.push(
|
|
1180
|
+
l.warnings.push(`${LOCAL_PRECHECK_UNREADABLE} (${e.message}), repli sur Sonnet`);
|
|
1181
|
+
benchLocal(c, `rapport illisible : ${e.message}`);
|
|
1053
1182
|
}
|
|
1054
1183
|
if (rep?.dejaPresent === 'oui') {
|
|
1055
1184
|
localYes = true;
|
|
@@ -1093,6 +1222,16 @@ async function precheck(c) {
|
|
|
1093
1222
|
l.next = firstWrite(c);
|
|
1094
1223
|
save(c);
|
|
1095
1224
|
}
|
|
1225
|
+
/** Le lot passe-t-il par le contrôle préalable ? Un lot qui n'a rien à retrouver (L322) saute l'étape, et le journal du lot le dit. */
|
|
1226
|
+
function precheckOrSkip(c) {
|
|
1227
|
+
const plan = c.loadPlan();
|
|
1228
|
+
if (!needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
|
|
1229
|
+
return false;
|
|
1230
|
+
const nothing = precheckNothingToFind(histPlan(c), wd(c), c.lot.lot, c.lot.repos, c.lot.goal);
|
|
1231
|
+
if (nothing)
|
|
1232
|
+
c.lot.warnings.push(`contrôle préalable sauté : ${nothing}`);
|
|
1233
|
+
return !nothing;
|
|
1234
|
+
}
|
|
1096
1235
|
/** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
|
|
1097
1236
|
function firstWrite(c) {
|
|
1098
1237
|
const l = c.lot;
|
|
@@ -1671,12 +1810,12 @@ async function acceptGreen(c) {
|
|
|
1671
1810
|
return null;
|
|
1672
1811
|
}
|
|
1673
1812
|
/**
|
|
1674
|
-
* Les contrôles du programme (`orchestrate.
|
|
1675
|
-
* (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
|
|
1813
|
+
* Les contrôles du programme (`orchestrate.build` puis `orchestrate.test`, L333 : les tests e2e lisent le `dist` que seul le build produit), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
|
|
1814
|
+
* (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
|
|
1676
1815
|
*/
|
|
1677
1816
|
async function programChecks(c, red, runs) {
|
|
1678
1817
|
const l = c.lot;
|
|
1679
|
-
for (const [label, command] of [['
|
|
1818
|
+
for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
|
|
1680
1819
|
if (!command)
|
|
1681
1820
|
continue;
|
|
1682
1821
|
const r = await sh(c, command);
|
|
@@ -1700,6 +1839,37 @@ async function programChecks(c, red, runs) {
|
|
|
1700
1839
|
return;
|
|
1701
1840
|
}
|
|
1702
1841
|
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
|
|
1844
|
+
* temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
|
|
1845
|
+
*/
|
|
1846
|
+
async function integratedChecks(c, key) {
|
|
1847
|
+
const first = [];
|
|
1848
|
+
let runs = [];
|
|
1849
|
+
await programChecks(c, first, runs);
|
|
1850
|
+
if (!first.length)
|
|
1851
|
+
return { red: first, runs };
|
|
1852
|
+
c.wave.log(`${key} tests rouges au sommet intégré, relancés une fois (${first.map(redExcerpt).join(' ; ')})`);
|
|
1853
|
+
const red = [];
|
|
1854
|
+
runs = [];
|
|
1855
|
+
await programChecks(c, red, runs);
|
|
1856
|
+
if (!red.length)
|
|
1857
|
+
c.lot.warnings.push(`rouge au sommet intégré, vert à la relance (charge de la machine ?) : ${first.map(redExcerpt).join(' ; ')}`);
|
|
1858
|
+
return { red, runs };
|
|
1859
|
+
}
|
|
1860
|
+
const EXCERPT_LINES = 10;
|
|
1861
|
+
/**
|
|
1862
|
+
* Le constat rouge d'un contrôle, en une ligne pour le motif d'un refus (L328) : la commande, puis les tests rouges nommés quand le constat les liste (« - nom »),
|
|
1863
|
+
* sinon la fin de la sortie. La première ligne seule laissait « npx vitest run en échec (code 1) : » sans un nom de test.
|
|
1864
|
+
*/
|
|
1865
|
+
export function redExcerpt(k) {
|
|
1866
|
+
const [head, ...rest] = k.texte.split('\n');
|
|
1867
|
+
const lines = rest.map((x) => x.trim()).filter(Boolean);
|
|
1868
|
+
const named = lines.filter((x) => x.startsWith('- '));
|
|
1869
|
+
const shown = named.length ? named.slice(0, EXCERPT_LINES) : lines.slice(-EXCERPT_LINES);
|
|
1870
|
+
const more = named.length > EXCERPT_LINES ? [`… (+${named.length - EXCERPT_LINES})`] : [];
|
|
1871
|
+
return [head.replace(/\s*:$/, ''), ...shown, ...more].join(' ; ');
|
|
1872
|
+
}
|
|
1703
1873
|
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
1704
1874
|
async function work(c, kind) {
|
|
1705
1875
|
const l = c.lot;
|
|
@@ -1790,13 +1960,13 @@ async function work(c, kind) {
|
|
|
1790
1960
|
}
|
|
1791
1961
|
// Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
|
|
1792
1962
|
// Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
|
|
1793
|
-
let
|
|
1963
|
+
let interruptedAt;
|
|
1794
1964
|
for (const s of l.steps.slice(0, -1).reverse()) {
|
|
1795
1965
|
if (s.kind !== 'fix' || s.status !== 'interrupted')
|
|
1796
1966
|
break;
|
|
1797
|
-
|
|
1967
|
+
interruptedAt = s.headBefore;
|
|
1798
1968
|
}
|
|
1799
|
-
const headMoved = kind === 'fix' && made === 0 && !!
|
|
1969
|
+
const headMoved = kind === 'fix' && made === 0 && !!interruptedAt && git(wd(c), 'rev-parse', 'HEAD') !== interruptedAt;
|
|
1800
1970
|
if (minorsPass && made === 0 && !headMoved) {
|
|
1801
1971
|
// Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
|
|
1802
1972
|
for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
|
|
@@ -1821,6 +1991,14 @@ async function work(c, kind) {
|
|
|
1821
1991
|
stop(c, 'handed-back', `${kind} sans commit`);
|
|
1822
1992
|
return;
|
|
1823
1993
|
}
|
|
1994
|
+
// L317 : la passe du bibliothécaire qui laisse l'écart ouvert (rien commité, ou autre chose que la documentation) ne passe pas à la revue : le lot ne finirait pas prêt, `raf check` resterait rouge.
|
|
1995
|
+
if (docsPass) {
|
|
1996
|
+
const open = openDocGap(c);
|
|
1997
|
+
if (open) {
|
|
1998
|
+
stop(c, 'handed-back', open);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
1824
2002
|
if (headMoved)
|
|
1825
2003
|
l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
|
|
1826
2004
|
else if (made === 0)
|
|
@@ -2107,6 +2285,12 @@ async function conclude(c, code, minorNote = '') {
|
|
|
2107
2285
|
const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
|
|
2108
2286
|
l.verdict = verdict;
|
|
2109
2287
|
const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
|
|
2288
|
+
// L317 : la porte du bibliothécaire peut ne pas avoir été jouée (passes ou budget épuisés) ; un écart docs.sync ouvert ne finit jamais prêt.
|
|
2289
|
+
const gap = openDocGap(c);
|
|
2290
|
+
if (gap) {
|
|
2291
|
+
stop(c, 'handed-back', gap);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2110
2294
|
const moved = (code.head && newer !== null && git(wd(c), 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
|
|
2111
2295
|
if (moved.length) {
|
|
2112
2296
|
stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
|
|
@@ -2286,16 +2470,16 @@ async function integrateLot(c) {
|
|
|
2286
2470
|
if (rebased.kind === 'conflict') {
|
|
2287
2471
|
return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
|
|
2288
2472
|
}
|
|
2473
|
+
if (rebased.kind === 'rebased' && rebased.changelog > 0)
|
|
2474
|
+
c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
|
|
2289
2475
|
const tip = git(t.dir, 'rev-parse', 'HEAD');
|
|
2290
2476
|
if (tip !== reviewed) {
|
|
2291
2477
|
// Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
|
|
2292
|
-
const red =
|
|
2293
|
-
const runs = [];
|
|
2294
|
-
await programChecks(c, red, runs);
|
|
2478
|
+
const { red, runs } = await integratedChecks(c, key);
|
|
2295
2479
|
l.checks = runs.length ? { head: tip, runs } : undefined;
|
|
2296
2480
|
save(c);
|
|
2297
2481
|
if (red.length)
|
|
2298
|
-
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(
|
|
2482
|
+
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
|
|
2299
2483
|
const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
|
|
2300
2484
|
const after = patchId(t.dir, t.base, 'HEAD');
|
|
2301
2485
|
if (before !== '' && before === after) {
|
|
@@ -2500,8 +2684,14 @@ export async function runLot(c) {
|
|
|
2500
2684
|
if (!(await ensureTree(c)))
|
|
2501
2685
|
return;
|
|
2502
2686
|
l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
|
|
2503
|
-
|
|
2504
|
-
|
|
2687
|
+
// Un lot repris de sa branche conservée (L311) n'a rien à faire contrôler : ses commits sont les siens. Il repart à la correction des constats de sa dernière revue, à la revue s'il en a passé une sans constat, à l'écriture s'il n'a jamais été revu.
|
|
2688
|
+
// La correction de départ est une passe comme les autres (toFix : l.pass, verdict, tableau, MAX_PASSES bornent le total, reprise comprise).
|
|
2689
|
+
if (l.resumedFrom && l.constats.length)
|
|
2690
|
+
toFix(c, l.constats, 'reprise de la branche');
|
|
2691
|
+
else {
|
|
2692
|
+
l.next = l.resumedFrom ? (l.resumedReviewed ? firstReview(c) : firstWrite(c)) : precheckOrSkip(c) ? 'precheck' : firstWrite(c);
|
|
2693
|
+
save(c);
|
|
2694
|
+
}
|
|
2505
2695
|
}
|
|
2506
2696
|
while (l.next && !TERMINAL.has(l.status)) {
|
|
2507
2697
|
const kind = l.next;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { gitCommonDir } from '../git.js';
|
|
4
5
|
import { sharedStateDir } from '../state.js';
|
|
5
6
|
import { activeLock, releaseLock, takeLock } from './lock.js';
|
|
7
|
+
import { mergeUnreleased } from './changelog-merge.js';
|
|
6
8
|
import { mutexFor } from './mutex.js';
|
|
7
9
|
import { git, gitFailure, tryGit } from './worktree.js';
|
|
8
10
|
/** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
|
|
@@ -50,24 +52,66 @@ export async function takeRepoWrite(repo, who, o = {}) {
|
|
|
50
52
|
export const repoBranch = (repo) => tryGit(repo, 'symbolic-ref', '--short', '-q', 'HEAD') || null;
|
|
51
53
|
/** Point de départ de `rev` par rapport à la branche `base` : le dernier ancêtre commun. */
|
|
52
54
|
export const mergeBase = (cwd, rev, base) => git(cwd, 'merge-base', rev, `refs/heads/${base}`);
|
|
55
|
+
const CHANGELOG = 'CHANGELOG.md';
|
|
56
|
+
const REBASE_ENV = { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' };
|
|
57
|
+
const run = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: REBASE_ENV });
|
|
58
|
+
/**
|
|
59
|
+
* Résout le conflit en cours quand c'est le CHANGELOG seul et de l'ajout pur sous `[Unreleased]` (L314) : écrit l'union, l'ajoute à l'index. Faux, sans rien
|
|
60
|
+
* toucher, pour tout autre conflit (un autre fichier avec lui, une entrée de base modifiée, une étape de base absente). `start` : le CHANGELOG au point de
|
|
61
|
+
* départ de la branche (L323), pour qu'un commit puisse réécrire l'entrée qu'un commit précédent du lot a ajoutée.
|
|
62
|
+
*/
|
|
63
|
+
function resolveChangelog(work, start) {
|
|
64
|
+
const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
|
|
65
|
+
if (files.length !== 1 || files[0] !== CHANGELOG)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
// Pendant un rebase, l'étape 2 est la branche du dépôt (« ours »), l'étape 3 le commit du lot rejoué (« theirs »).
|
|
69
|
+
const merged = mergeUnreleased(run(work, 'show', `:1:${CHANGELOG}`), run(work, 'show', `:2:${CHANGELOG}`), run(work, 'show', `:3:${CHANGELOG}`), start);
|
|
70
|
+
if (merged === null)
|
|
71
|
+
return false;
|
|
72
|
+
writeFileSync(join(work, CHANGELOG), merged);
|
|
73
|
+
run(work, 'add', '--', CHANGELOG);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
/**
|
|
54
81
|
* Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
|
|
55
|
-
* change). Un conflit
|
|
56
|
-
* branche du
|
|
82
|
+
* change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
|
|
83
|
+
* tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la branche du dépôt n'a pas été
|
|
84
|
+
* touchée, personne ne résout le conflit à l'aveugle.
|
|
57
85
|
*/
|
|
58
86
|
export function rebaseOnto(work, base) {
|
|
59
87
|
const before = git(work, 'rev-parse', 'HEAD');
|
|
60
88
|
if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
|
|
61
89
|
return { kind: 'forwarded' };
|
|
90
|
+
let changelog = 0;
|
|
91
|
+
const start = tryGit(work, 'show', `${mergeBase(work, 'HEAD', base)}:${CHANGELOG}`) ?? undefined;
|
|
62
92
|
try {
|
|
63
|
-
|
|
93
|
+
run(work, 'rebase', `refs/heads/${base}`);
|
|
64
94
|
}
|
|
65
|
-
catch (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
catch (first) {
|
|
96
|
+
let failure = first;
|
|
97
|
+
// Chaque tour règle le commit sur lequel le rebase s'est arrêté ; un lot ne porte pas des centaines de commits, la borne évite seulement une boucle sans fin.
|
|
98
|
+
for (let turn = 0;; turn++) {
|
|
99
|
+
if (turn >= 200 || !resolveChangelog(work, start)) {
|
|
100
|
+
const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
|
|
101
|
+
tryGit(work, 'rebase', '--abort');
|
|
102
|
+
return { kind: 'conflict', files, detail: gitFailure(failure) };
|
|
103
|
+
}
|
|
104
|
+
changelog++;
|
|
105
|
+
try {
|
|
106
|
+
run(work, 'rebase', '--continue');
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
failure = e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
69
113
|
}
|
|
70
|
-
return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD') };
|
|
114
|
+
return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
|
|
71
115
|
}
|
|
72
116
|
/**
|
|
73
117
|
* Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
|
|
@@ -5,6 +5,7 @@ import { delimiter, join, resolve } from 'node:path';
|
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { RafError } from '../plan.js';
|
|
7
7
|
import { sandboxEnv } from './sandbox.js';
|
|
8
|
+
import { SlotAbandoned } from './registry.js';
|
|
8
9
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
9
10
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
10
11
|
/**
|
|
@@ -105,8 +106,8 @@ export function readAgents(dir) {
|
|
|
105
106
|
}
|
|
106
107
|
return out;
|
|
107
108
|
}
|
|
108
|
-
/** Étapes en lecture seule
|
|
109
|
-
const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche']);
|
|
109
|
+
/** Étapes relancées à neuf après une coupure : celles en lecture seule, et `ref`, qui n'écrit que sa lecture (`lectures/a.json` ou `b.json`, le troisième regard n'écrit rien), que le programme valide ensuite (une étape d'écriture ordinaire a pu laisser un travail à moitié fait). */
|
|
110
|
+
const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche', 'ref']);
|
|
110
111
|
/** Session tuée par un signal (code 128 + n : 143 SIGTERM, 137 SIGKILL…) sans rien écrire : la coupure, ni un quota (un message) ni un échec (une sortie). */
|
|
111
112
|
const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() === '';
|
|
112
113
|
/** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
|
|
@@ -149,7 +150,12 @@ export async function runSession(spec, deps) {
|
|
|
149
150
|
const relaunched = { sessionId: spec.sessionId, cause: `code ${out.code}, sortie vide`, stderr: out.stderr };
|
|
150
151
|
const fresh = randomUUID();
|
|
151
152
|
deps.onRelaunch?.(fresh); // l'appelant l'enregistre AVANT le lancement : un signal pendant la relance retrouve la session neuve
|
|
152
|
-
|
|
153
|
+
// Relance qui doit attendre un créneau et voit une demande d'arrêt (L332) : la session coupée ne se perd pas avec l'abandon.
|
|
154
|
+
const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents)).catch((e) => {
|
|
155
|
+
if (e instanceof SlotAbandoned)
|
|
156
|
+
e.after = { relaunched };
|
|
157
|
+
throw e;
|
|
158
|
+
});
|
|
153
159
|
const next = classify(again);
|
|
154
160
|
if (next.kind === 'ok')
|
|
155
161
|
return { ...next, relaunched };
|
|
@@ -160,7 +166,11 @@ export async function runSession(spec, deps) {
|
|
|
160
166
|
if (!missing)
|
|
161
167
|
return first;
|
|
162
168
|
const spentFirst = first.kind === 'failed' ? first.tokens : undefined;
|
|
163
|
-
const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)))
|
|
169
|
+
const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)).catch((e) => {
|
|
170
|
+
if (e instanceof SlotAbandoned)
|
|
171
|
+
e.after = { formatting: { stdout: out.stdout, stderr: out.stderr, ...(spentFirst ? { tokens: spentFirst } : {}), sessionId: missing.sessionId } };
|
|
172
|
+
throw e;
|
|
173
|
+
}));
|
|
164
174
|
const total = (t) => (spentFirst && t ? sumTokens(spentFirst, t) : (t ?? spentFirst));
|
|
165
175
|
if (second.kind === 'ok')
|
|
166
176
|
return { kind: 'ok', result: { ...second.result, tokens: total(second.result.tokens), formattingRetry: true }, firstStdout: out.stdout };
|