@sylad/cadence 1.41.0 → 1.42.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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command.js +18 -17
  24. package/dist/orchestrate/continue.js +2 -12
  25. package/dist/orchestrate/cycle.js +5 -5
  26. package/dist/orchestrate/integration-vivante.js +138 -0
  27. package/dist/orchestrate/launch.js +6 -18
  28. package/dist/orchestrate/lock.js +3 -20
  29. package/dist/orchestrate/registry.js +8 -1
  30. package/dist/orchestrate/schemas.js +5 -1
  31. package/dist/orchestrate/snapshot.js +13 -0
  32. package/dist/orchestrate/state.js +17 -0
  33. package/dist/plan-extras.js +3 -2
  34. package/dist/plan-ids.js +95 -0
  35. package/dist/plan-read.js +2 -0
  36. package/dist/plan.js +29 -34
  37. package/dist/ref/command.js +2 -3
  38. package/dist/resources.js +3 -9
  39. package/dist/state.js +8 -8
  40. package/dist/verify-gate.js +33 -2
  41. package/dist/yaml-keys.js +19 -0
  42. package/docs/cadence-hud.md +1 -1
  43. package/docs/deliver.md +1 -1
  44. package/docs/lead.md +2 -2
  45. package/docs/news.md +2 -0
  46. package/docs/orchestrate.md +6 -2
  47. package/docs/qualite.md +1 -1
  48. package/docs/raf.md +22 -4
  49. package/docs/whats-new.md +6 -0
  50. package/package.json +1 -1
  51. package/skills/lead/SKILL.md +1 -1
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
8
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
9
9
  import { planLoader, readDocsConfig, readPlanConfig } from '../config.js';
10
10
  import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
11
- import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
11
+ import { articleRepoDir, articlesBrief, docSyncBrief, filesOf, gapMessage, lotDocSyncGaps } from '../docsync.js';
12
12
  import { testGateMissing } from '../testgate.js';
13
13
  import { citesPriorWork, keepPrecheckReading, localYesNote, precheckNothingToFind } from './precheck.js';
14
14
  import { PROBE_FAILED, PROBE_RESERVE, probeStep, probeVars, scheduleProbe } from './probe.js';
@@ -30,7 +30,7 @@ import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, t
30
30
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
31
31
  import { sumTokens } from './result.js';
32
32
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
33
- import { ACCEPT_SCHEMA, agentOf, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
33
+ import { ACCEPT_SCHEMA, checkShape, effortKey, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, stepAgent, WORK_SCHEMA } from './schemas.js';
34
34
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
35
35
  import { lotIds, referenceIdsText } from '../ref/coverage.js';
36
36
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
@@ -413,7 +413,7 @@ function docGaps(c) {
413
413
  const { sync } = docsConfig(c);
414
414
  const plan = histPlan(c);
415
415
  const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
416
- return { sync, gaps: sync.length && !acknowledged ? docSyncGaps(sync, filesOf(wd(c), lotWork(plan, wd(c), c.lot.lot))) : [] };
416
+ return { sync, gaps: sync.length && !acknowledged ? lotDocSyncGaps(sync, wd(c), lotWork(plan, wd(c), c.lot.lot)) : [] };
417
417
  }
418
418
  /** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
419
419
  function openDocGap(c) {
@@ -699,7 +699,7 @@ async function session(c, kind, local = false) {
699
699
  if (WRITE_STEPS.includes(kind) && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
700
700
  return overBudget(c, kind === 'fix' && !lotOver(l));
701
701
  const model = ['spec', 'accept', 'fiche', 'probe'].includes(kind) ? 'sonnet' : write ? (kind === 'fix' && l.escalation?.stage === 'fix' ? l.escalation.model : l.model) : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
702
- const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
702
+ const effort = c.config.effort[effortKey(kind)];
703
703
  const before = await snapshot(wd(c));
704
704
  const neighbours = l.repos ?? [];
705
705
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
@@ -759,7 +759,7 @@ async function session(c, kind, local = false) {
759
759
  effort,
760
760
  local,
761
761
  schema: schemaFor(kind),
762
- agent: kind === 'fix' && l.docsFix ? 'doc-keeper' : agentOf(kind),
762
+ agent: stepAgent(kind, l.docsFix),
763
763
  cwd: wd(c),
764
764
  wave: w.id,
765
765
  lot: lotKey(l.project, l.lot),
@@ -0,0 +1,138 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { planLoader } from '../config.js';
4
+ import { leadReviewedHandBack } from './cycle.js';
5
+ import { trackedDirty } from './depot.js';
6
+ import { cadenceHome, liveWaves } from './registry.js';
7
+ import { waveReadsIntegrateQueue } from './snapshot.js';
8
+ import { lotKey } from './state.js';
9
+ import { resumeCommand } from './table.js';
10
+ import { lotTreeDir } from './worktree.js';
11
+ /**
12
+ * Un lot rendu que `--integrate` peut reprendre (L279, L370) : rendu à l'étape d'intégration (`next: integrate`), ou rendu ailleurs puis corrigé et revu conforme par le lead dans son worktree (la revue porte le dernier commit).
13
+ * Rend le verdict du lead dans ce second cas (`lead`), `{ lead: null }` dans le premier, null si le lot n'est pas reprenable.
14
+ */
15
+ export function integrationHandBack(store, l) {
16
+ if (l.status !== 'handed-back')
17
+ return null;
18
+ if (l.next === 'integrate')
19
+ return { lead: null };
20
+ // La revue du lead est écrite par `raf review` lancé dans le worktree : c'est le plan du worktree qui la porte, pas celui du dépôt principal.
21
+ const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
22
+ const lead = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
23
+ return lead ? { lead } : null;
24
+ }
25
+ /** La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé. */
26
+ export function adoptLeadReview(l, lead) {
27
+ l.next = 'integrate';
28
+ l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
29
+ l.verdict = lead.verdict;
30
+ l.constats = [];
31
+ }
32
+ /** La ligne de journal d'une intégration reprise (à la reprise comme sur la vague vivante). */
33
+ export const integrationText = (lead) => (lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : 'intégration reprise (--resume --integrate)');
34
+ /** Le contrôle d'une cible de `--integrate` : sa clé `projet:lot`, ou le refus. */
35
+ function checkTarget(store, t, seen) {
36
+ const matches = store.lots().filter((l) => l.lot === t.lot && (t.project ? l.project === t.project : true));
37
+ const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
38
+ if (matches.length !== 1)
39
+ return { refusal: matches.length ? `--integrate ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--integrate ${where} : lot inconnu dans la vague ${store.id}` };
40
+ const l = matches[0];
41
+ const key = lotKey(l.project, l.lot);
42
+ if (seen.includes(key))
43
+ return { refusal: `--integrate ${key} : lot visé deux fois` };
44
+ if (!integrationHandBack(store, l))
45
+ return { refusal: `--integrate ${key} : le lot n'est pas rendu à l'étape d'intégration (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})` };
46
+ return { key };
47
+ }
48
+ /**
49
+ * `--resume --integrate` sur une vague VIVANTE (L532) : l'intégration est déposée dans la file de la vague (`integrates.log`), qui la reprend entre deux étapes, comme un `--add`.
50
+ * Sans cela le lead attendait la fin de la vague (qui dure des heures sous `--continue`). Les lots visés sont contrôlés comme à la reprise : connus, rendus à l'intégration ou revus par le lead.
51
+ */
52
+ export function depositIntegrations(targets, io, store) {
53
+ const refusals = [];
54
+ const keys = [];
55
+ for (const t of targets) {
56
+ const r = checkTarget(store, t, keys);
57
+ if ('key' in r)
58
+ keys.push(r.key);
59
+ else
60
+ refusals.push(r.refusal);
61
+ }
62
+ if (!waveReadsIntegrateQueue(store.dir))
63
+ refusals.push(`la vague ${store.id} ne lit pas la file d'intégrations (lancée avec une version de cadence d'avant L532) : attendre sa fin, puis ${resumeCommand(store.id, '--integrate', '<projet>:<lot>')}`);
64
+ if (refusals.length) {
65
+ for (const r of refusals)
66
+ io.err(`orchestrate : ${r}`);
67
+ return 2;
68
+ }
69
+ for (const key of keys) {
70
+ const id = store.requestIntegrate(key);
71
+ // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la demande est retirée et le lead reprend à la main.
72
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
73
+ store.markIntegrateTaken(id);
74
+ io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, intégration non prise : ${resumeCommand(store.id, '--integrate', key)}`);
75
+ return 2;
76
+ }
77
+ store.journal(`demande : intégration du lot ${key}`);
78
+ io.out(`${key} : intégration déposée — la vague ${store.id} la reprend entre deux étapes dès qu'un créneau est libre`);
79
+ }
80
+ return 0;
81
+ }
82
+ /** Pourquoi la demande ne peut plus être jouée (lot inconnu, déjà repris, revue du lead perdue), ou le lot et ce qui le reprend. */
83
+ function eligible(store, ctxs, lot) {
84
+ const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === lot);
85
+ if (!c)
86
+ return { why: 'lot inconnu' };
87
+ if (c.lot.status !== 'handed-back')
88
+ return { why: `le lot n'est plus rendu (${c.lot.status})` };
89
+ const back = integrationHandBack(store, c.lot);
90
+ return back ? { c, back } : { why: "le lot n'est pas rendu à l'étape d'intégration ni revu par le lead au dernier commit de son worktree" };
91
+ }
92
+ /**
93
+ * La vague vivante prend les intégrations déposées (L532) : le lot rendu repart à l'étape `integrate` comme sous `--resume --integrate`, quand `canRun` l'accepte ; une demande dont le lot ne qualifie plus
94
+ * (déjà repris, commit après la revue du lead, fichier suivi modifié) est écartée et dite au journal. Rend les lots à jouer ; `settled` est la liste des lots qui ont fini de jouer.
95
+ */
96
+ export async function takeIntegrations(w, canRun) {
97
+ const { store, wctx } = w;
98
+ const taken = [];
99
+ const drop = (id, lot, why) => {
100
+ wctx.log(`${lot} : intégration écartée (${why})`);
101
+ store.markIntegrateTaken(id);
102
+ };
103
+ for (const e of store.integrateQueue()) {
104
+ const r = eligible(store, w.ctxs, e.lot);
105
+ if ('why' in r) {
106
+ drop(e.id, e.lot, r.why);
107
+ continue;
108
+ }
109
+ const { c, back } = r;
110
+ if (!canRun(c))
111
+ continue;
112
+ const dirty = await trackedDirty(lotTreeDir(store.dir, c.lot.project, c.lot.lot, c.lot.repo));
113
+ if (dirty.length) {
114
+ drop(e.id, e.lot, `arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
115
+ continue;
116
+ }
117
+ if (back.lead)
118
+ adoptLeadReview(c.lot, back.lead);
119
+ c.lot.next = 'integrate';
120
+ c.lot.status = 'suspended';
121
+ c.lot.outcome = null;
122
+ store.writeLot(c.lot);
123
+ store.markIntegrateTaken(e.id);
124
+ wctx.log(`${e.lot} → suspended — ${integrationText(back.lead)}`);
125
+ // Le lot avait fini de jouer (`settled`) : il rejoue, il tient de nouveau un créneau (L433).
126
+ w.settled.delete(c);
127
+ taken.push(c);
128
+ }
129
+ return taken;
130
+ }
131
+ /** `--resume --integrate` seul (sans --replay, --answer, --budget, --drop ni --continue) sur une vague qui tourne encore : l'intégration est déposée, pas refusée. */
132
+ export function depositsOnly(a, live) {
133
+ return live && a.integrate.length > 0 && a.integrate.every((t) => !t.replay) && !a.answers.length && !a.lotBudgets.length && !a.drop.length && a.budget === undefined && !a.continue;
134
+ }
135
+ /** La vague tourne dans un autre processus de ce dossier, et joue (« running ») : une reprise qui attend le dépôt (L443) est inscrite vivante sans rien jouer encore, elle ne lit pas la file. */
136
+ export function runsElsewhere(store, launch) {
137
+ return store.readWave()?.status === 'running' && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid);
138
+ }
@@ -88,14 +88,15 @@ export function enabledPlugins(files) {
88
88
  * En argument, pas dans un fichier : des identifiants seulement (`nom@marketplace`, aucun secret), 1 000 plugins pèsent 50 ko sous la limite d'un argument Linux (128 ko).
89
89
  */
90
90
  const settingsArgs = (spec) => (spec.disablePlugins?.length ? ['--settings', JSON.stringify({ enabledPlugins: Object.fromEntries(spec.disablePlugins.map((id) => [id, false])) })] : []);
91
- export function buildArgs(spec, agents) {
92
- const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
91
+ /** Le corps commun d'une session `claude -p` et de sa relance : prompt, schéma, modèle, effort, agent restreint, puis (après `session`) mode de permission, dossiers, plugins désactivés, interdits et MCP. `session` : `--session-id <id>` ou `--resume <id>`. */
92
+ function claudeArgs(spec, agents, prompt, session) {
93
+ const args = ['-p', prompt, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
93
94
  if (spec.agent) {
94
95
  if (!agents[spec.agent])
95
96
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
96
97
  args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
97
98
  }
98
- args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
99
+ args.push(...session, '--permission-mode', spec.permissionMode);
99
100
  for (const d of spec.addDirs)
100
101
  args.push('--add-dir', d);
101
102
  args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
@@ -103,6 +104,7 @@ export function buildArgs(spec, agents) {
103
104
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
104
105
  return args;
105
106
  }
107
+ export const buildArgs = (spec, agents) => claudeArgs(spec, agents, spec.brief, ['--session-id', spec.sessionId]);
106
108
  /**
107
109
  * Arguments de `claude-local` (L146) : le prompt vient en premier, le script ajoute lui-même `--bare`, `--model` et le
108
110
  * serveur Ollama. Le harnais `--bare` ne charge pas `--agents` : le prompt de l'agent est joint au brief et ses outils
@@ -150,21 +152,7 @@ const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() ===
150
152
  /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
151
153
  export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
152
154
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
153
- export function buildRetryArgs(spec, sessionId, agents) {
154
- const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
155
- if (spec.agent) {
156
- if (!agents[spec.agent])
157
- throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
158
- args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
159
- }
160
- args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
161
- for (const d of spec.addDirs)
162
- args.push('--add-dir', d);
163
- args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
164
- if (spec.mcpConfig)
165
- args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
166
- return args;
167
- }
155
+ export const buildRetryArgs = (spec, sessionId, agents) => claudeArgs(spec, agents, FORMAT_RETRY_PROMPT, ['--resume', sessionId]);
168
156
  /**
169
157
  * Lance une session et range son issue : ok, quota atteint, ou échec nommé (jamais de nouvel essai ici), à une
170
158
  * exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
@@ -1,7 +1,7 @@
1
- import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { linkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { processStart } from '../proc.js';
4
- import { pidAlive } from '../state.js';
4
+ import { pidAlive, readLockJson } from '../state.js';
5
5
  /**
6
6
  * Le porteur vit encore : son pid existe ET, quand l'heure de démarrage est connue des deux côtés, c'est la même.
7
7
  * Un pid repris par un autre processus est un porteur mort.
@@ -18,24 +18,7 @@ export function holderAlive(pid, start) {
18
18
  export const REPO_LOCK = 'orchestrate.lock';
19
19
  /** Un champ facultatif du verrou, tel qu'écrit ; absent : absent. */
20
20
  const optional = (v) => (v === undefined ? undefined : String(v));
21
- function read(file) {
22
- let text;
23
- let ageMs;
24
- try {
25
- ageMs = Date.now() - statSync(file).mtimeMs;
26
- text = readFileSync(file, 'utf8');
27
- }
28
- catch {
29
- return null;
30
- }
31
- try {
32
- const raw = JSON.parse(text);
33
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until), ageMs };
34
- }
35
- catch {
36
- return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
37
- }
38
- }
21
+ const read = (file) => readLockJson(file, (raw) => ({ pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until) }), { pid: 0, wave: '?', started: '?' });
39
22
  const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : l.until !== undefined || holderAlive(l.pid, l.start));
40
23
  /** Un verrou déclaré dont l'échéance est passée à `now` ; faux pour un verrou de processus (il n'a pas d'échéance). */
41
24
  export const lockOverdue = (l, now) => l.until !== undefined && Date.parse(l.until) <= now.getTime();
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
5
5
  import { gitCommonDir } from '../git.js';
6
6
  import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
7
+ import { hasRoom, whileWaiting } from './attente.js';
7
8
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
8
9
  /**
9
10
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
@@ -198,6 +199,9 @@ export class SlotAbandoned extends Error {
198
199
  * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
199
200
  * le créneau pris, il est rendu et l'attente reprend.
200
201
  *
202
+ * `fresh`/`rank` (L531) : quand plusieurs sessions attendent, le créneau qui se libère va à l'étape suivante d'un lot déjà en cours (`fresh` faux, défaut) avant un nouveau tirage (`fresh`),
203
+ * puis au projet le plus haut dans `priority:` (`rank` le plus petit), puis à l'attente la plus ancienne, toutes vagues confondues ; une session ne prend un créneau que si aucune attente vivante ne la précède (`attente.ts`).
204
+ *
201
205
  * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
202
206
  * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
203
207
  * `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
@@ -206,6 +210,9 @@ export class SlotAbandoned extends Error {
206
210
  * la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
207
211
  */
208
212
  export async function acquireSlot(home, cap, opts) {
213
+ return whileWaiting(home, opts, (myTurn) => takeSlot(home, cap, opts, myTurn));
214
+ }
215
+ async function takeSlot(home, cap, opts, myTurn) {
209
216
  mkdirSync(slotsDir(home), { recursive: true });
210
217
  const now = opts.now ?? Date.now; // injectable : les tests pilotent l'horloge
211
218
  const t0 = now();
@@ -223,7 +230,7 @@ export async function acquireSlot(home, cap, opts) {
223
230
  checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
224
231
  // Les sessions du lead (`lead hold`) tournent hors de toute vague mais prennent un créneau : relues à chaque comptage (L424).
225
232
  const taken = () => live(home).length + liveHolds(home, new Date()).length;
226
- if (taken() < limit) {
233
+ if (hasRoom(taken, limit, myTurn)) {
227
234
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
228
235
  const file = join(slotsDir(home), `slot-${k}.lock`);
229
236
  const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
@@ -63,7 +63,11 @@ export const PROBE_SCHEMA = {
63
63
  export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'probe' ? PROBE_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
64
64
  /** L'agent du paquet de chaque étape qui en a un, ou `undefined` pour une écriture sans agent ; les revues et la fiche n'y sont pas : `code-reviewer`. */
65
65
  const STEP_AGENT = { implement: undefined, fix: undefined, probe: undefined, spec: 'spec-writer', accept: 'acceptance-writer', ux: 'ux-reviewer', maquette: 'ux-reviewer', precheck: 'precheck-reader' };
66
- export const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
66
+ const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
67
+ /** L'agent d'une étape, la passe du bibliothécaire (`fix` de documentation) comprise : un seul choix pour la session réelle et pour le `--dry-run`. */
68
+ export const stepAgent = (kind, docsFix) => (kind === 'fix' && docsFix ? 'doc-keeper' : agentOf(kind));
69
+ /** La clé de `effort` d'une étape : la revue d'un petit lot visible (`review-small`) prend l'effort de `review`. */
70
+ export const effortKey = (kind) => (kind === 'review-small' ? 'review' : kind);
67
71
  /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
68
72
  export function checkShape(value, schema) {
69
73
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -130,6 +130,19 @@ export function waveReadsAnswerQueue(waveDir) {
130
130
  return false;
131
131
  }
132
132
  }
133
+ /**
134
+ * La vague lit-elle la file d'intégrations `integrates.log` (L532, `--resume --integrate` sur une vague vivante) ? Une vague lancée avant L532 ne la lirait jamais : la demande y dormirait. Sans instantané : on suppose que oui.
135
+ */
136
+ export function waveReadsIntegrateQueue(waveDir) {
137
+ if (!snapshotExists(waveDir))
138
+ return true;
139
+ try {
140
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('integrates.log');
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
133
146
  /**
134
147
  * La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
135
148
  */
@@ -219,6 +219,23 @@ export class RunStore {
219
219
  markAnswerTaken(id) {
220
220
  appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
221
221
  }
222
+ /**
223
+ * File des intégrations demandées à une vague vivante (L532, `--resume --integrate` sur une vague qui tourne encore) : `integrates.log`, une ligne JSON par demande (`{id, lot}`, le lot écrit `projet:lot`).
224
+ * La vague la relit entre deux étapes, comme celle des réponses.
225
+ */
226
+ integrateQueue() {
227
+ return this.queue('integrates.log', (o) => (typeof o.lot === 'string' ? { lot: o.lot } : null));
228
+ }
229
+ /** Dépose une intégration dans la file ; rend son numéro. */
230
+ requestIntegrate(lot) {
231
+ const id = this.nextId('integrates.log');
232
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ id, lot })}\n`);
233
+ return id;
234
+ }
235
+ /** La demande `id` est prise (intégration reprise ou écartée) : elle ne sera plus relue. */
236
+ markIntegrateTaken(id) {
237
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ taken: id })}\n`);
238
+ }
222
239
  /**
223
240
  * File des lots à ajouter à une vague vivante (L421, `--add`) : `adds.log`, une ligne JSON par demande (`{id, lot, project?, model?}`), le projet étant écrit comme le lead l'a donné
224
241
  * (nom de dossier depuis le dossier de lancement). La vague la relit entre deux étapes, comme celle des réponses.
@@ -1,8 +1,9 @@
1
+ import { PLACE_KEY } from './archive.js';
1
2
  import { FIELDS, RafError } from './plan.js';
2
3
  const EXTRA_KEYS = ['enum', 'default'];
3
4
  const EXTRA_NAME = /^[a-z][a-z0-9_]*$/;
4
- /** Les clés que raf lit ou écrit sur un lot : un champ d'extension ne peut pas en prendre le nom. */
5
- const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before'];
5
+ /** Les clés que raf lit ou écrit sur un lot (`place` : le rang d'un lot archivé sous `plan.idPattern`, L536) : un champ d'extension ne peut pas en prendre le nom. */
6
+ const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before', PLACE_KEY];
6
7
  const nonEmptyText = (v) => typeof v === 'string' && v.trim() !== '';
7
8
  /** Une déclaration `plan.extras.<nom>` lue, ou l'erreur (message sans préfixe) qui la refuse. */
8
9
  function readDecl(name, decl) {
@@ -0,0 +1,95 @@
1
+ export function escapeRe(s) {
2
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
6
+ * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
7
+ * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
8
+ */
9
+ export const DOTTED = '\\.\\w';
10
+ /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
11
+ function refPattern(prefix) {
12
+ return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
13
+ }
14
+ export function extractRefs(text, prefix) {
15
+ return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
16
+ }
17
+ /**
18
+ * Le motif des références d'un plan natif qui déclare `plan.idPattern` : `<préfixe>\d+` ou le motif du projet, puis un sous-id `/slug` facultatif
19
+ * (`X7/t1-fusion`). Garde des `<préfixe>\d+` : celle du format de raf (pas collés à un mot ni à un chemin, pas suivis d'un point et d'un mot ; « feat(L3-L5) » cite L3 et L5) ; les ids du motif du projet, plus strictes, refusent aussi un `-` ou un `.` avant et un `-` après (« AB-1-x »). Le point de fin de phrase passe.
20
+ * Seul le groupe nommé `sub` est capturé, après le motif du projet : ses groupes numérotés (`([A-Z])\1\d+`) gardent leur numéro. L'id est le reste de la correspondance.
21
+ */
22
+ function idRefPattern(prefix, idPattern) {
23
+ const own = `(?<![\\w/])${escapeRe(prefix)}\\d+`;
24
+ const free = `(?<![\\w/.-])(?:${idPattern})(?=/|(?!-))`;
25
+ return new RegExp(`(?:${own}|${free})(?:/(?<sub>[\\w-]+(?:\\.[\\w-]+)*)(?![\\w-]|${DOTTED})|(?!\\w|${DOTTED}))`, 'g');
26
+ }
27
+ /** Références d'un message dans un plan natif qui déclare `plan.idPattern`. Un sous-id `t<n>` est une sous-tâche du lot ; tout autre (`t1-fusion`) n'est qu'une précision sur le lot cité. */
28
+ export function idRefs(text, prefix, idPattern) {
29
+ return [...text.matchAll(idRefPattern(prefix, idPattern))].map((m) => {
30
+ const sub = m.groups.sub;
31
+ return { lot: sub === undefined ? m[0] : m[0].slice(0, -(sub.length + 1)), ...(/^t\d+$/.test(sub ?? '') ? { task: sub } : {}) };
32
+ });
33
+ }
34
+ /** Ce que le motif `plan.idPattern` a de refusable (ancres, groupe nommé `id` ou `sub` réservé, id vide), null s'il est utilisable. Vérifié à la lecture de cadence.yaml. */
35
+ function idPatternProblem(idPattern) {
36
+ if (idPattern.trim() === '')
37
+ return 'est vide';
38
+ if (/^\^|(?<!\\)\$$/.test(idPattern))
39
+ return "ne prend pas d'ancre (^ ou $) : le motif est ancré par raf";
40
+ if (/\(\?<(?:id|sub)>/.test(idPattern))
41
+ return 'ne peut pas nommer un groupe « id » ou « sub » (noms réservés à raf)';
42
+ try {
43
+ idRefPattern('L', idPattern);
44
+ if (new RegExp(`^(?:${idPattern})$`).test(''))
45
+ return "accepte l'identifiant vide";
46
+ }
47
+ catch (e) {
48
+ return `n'est pas une expression régulière utilisable (${e.message.split('\n')[0]})`;
49
+ }
50
+ return null;
51
+ }
52
+ /** Pourquoi `id` ne peut pas être donné à `raf add --id` : sans `plan.idPattern`, avec un « / » (réservé aux sous-tâches), hors du motif, ou déjà pris (archivés compris). Null s'il est libre. */
53
+ function freeIdProblem(id, idPattern, known) {
54
+ if (idPattern === undefined)
55
+ return '--id exige plan.idPattern dans cadence.yaml : sans motif déclaré, les identifiants sont <préfixe><n> générés par raf';
56
+ if (id.includes('/'))
57
+ return `identifiant invalide : ${id} (« / » est réservé aux sous-tâches)`;
58
+ if (!new RegExp(`^(?:${idPattern})$`).test(id))
59
+ return `identifiant invalide : ${id === '' ? '(vide)' : id} ne suit pas plan.idPattern (${idPattern})`;
60
+ return known.has(id) ? `identifiant déjà pris : ${id}` : null;
61
+ }
62
+ /**
63
+ * L'identifiant d'un lot ajouté : celui que `raf add --id` donne (libre, voir `freeIdProblem`), sinon `<préfixe><n+1>` ; `under`, le lot parent
64
+ * d'un sous-lot, doit exister. `known` : tous les lots du plan, archivés compris — un identifiant n'est jamais réutilisé.
65
+ */
66
+ export function pickId(prefix, given, under, idPattern, known) {
67
+ if (under !== undefined && !known.has(under))
68
+ return { problem: `lot parent inconnu : ${under}` };
69
+ if (given !== undefined) {
70
+ const problem = freeIdProblem(given, idPattern, known);
71
+ return problem ? { problem } : { id: given };
72
+ }
73
+ const re = new RegExp(`^${escapeRe(prefix)}(\\d+)$`);
74
+ return { id: `${prefix}${Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0))) + 1}` };
75
+ }
76
+ /** Le `parent:` d'un lot à plat : un identifiant de lot ; toute autre forme est un problème du lot et ne désigne aucun parent. */
77
+ export function readParent(raw, problems) {
78
+ if (raw == null)
79
+ return {};
80
+ if (typeof raw === 'string' && raw.trim() !== '')
81
+ return { parent: raw.trim() };
82
+ problems.push(`parent : un identifiant de lot attendu (reçu « ${String(raw)} »)`);
83
+ return {};
84
+ }
85
+ /** `plan.idPattern` lu dans cadence.yaml : `{ idPattern }` (rien sans la clé), ou l'erreur que `bad` fabrique (qui nomme la clé). */
86
+ export function readIdPattern(raw, bad) {
87
+ if (raw == null)
88
+ return {};
89
+ if (typeof raw !== 'string')
90
+ throw bad('idPattern doit être une expression régulière (texte)');
91
+ const problem = idPatternProblem(raw);
92
+ if (problem)
93
+ throw bad(`idPattern ${problem}`);
94
+ return { idPattern: raw };
95
+ }
package/dist/plan-read.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isDay } from './dates.js';
2
2
  import { RESOURCE_NAME, RESOURCES_BAD_LIST, SCREENS, STATUSES } from './plan.js';
3
+ import { readParent } from './plan-ids.js';
3
4
  const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
4
5
  /** Lit `gate: { count: N, spec: true }` ou `{ count: N, screen: page }` ; toute autre forme est un problème du lot et une condition jamais remplie. */
5
6
  function readGate(raw, problems) {
@@ -68,6 +69,7 @@ export function normalizeLot(raw, problems = []) {
68
69
  const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
69
70
  return {
70
71
  id: String(raw.id),
72
+ ...readParent(raw.parent, problems),
71
73
  title: String(raw.title ?? ''),
72
74
  status,
73
75
  ...(typeof raw.state === 'string' && raw.state.trim() !== '' ? { state: raw.state.trim() } : {}),
package/dist/plan.js CHANGED
@@ -8,7 +8,8 @@ import { normalizeLot } from './plan-read.js';
8
8
  import { currentState, defaultState, rebase, stateProblems, transitionProblem } from './plan-states.js';
9
9
  import { DOCS_OK_FILE, readDocsOkFile } from './docs-ok-file.js';
10
10
  import { checkedExtras, withExtras } from './plan-extras.js';
11
- import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
11
+ import { DOTTED, escapeRe, extractRefs, idRefs, pickId } from './plan-ids.js';
12
+ import { ARCHIVE_DIR, PLACE_KEY, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, reopenArchived, selectArchivable } from './archive.js';
12
13
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
13
14
  const ON_WRITE_TIMEOUT_MS = 120_000;
14
15
  /**
@@ -42,28 +43,13 @@ function cleanUses(names) {
42
43
  return [...new Set(names)];
43
44
  }
44
45
  const trueFlags = (flags) => Object.fromEntries(Object.entries(flags).filter(([, on]) => on).map(([k]) => [k, true])); // les drapeaux vrais, chacun à `true` : les absents et les faux ne s'écrivent pas
46
+ export { escapeRe, extractRefs, readIdPattern } from './plan-ids.js';
45
47
  export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
46
48
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
47
49
  export const SCREENS = ['page', 'component', 'journey'];
48
50
  export function isOpen(status) {
49
51
  return status === 'todo' || status === 'doing';
50
52
  }
51
- export function escapeRe(s) {
52
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
- }
54
- /**
55
- * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
56
- * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
57
- * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
58
- */
59
- const DOTTED = '\\.\\w';
60
- /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
61
- function refPattern(prefix) {
62
- return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
63
- }
64
- export function extractRefs(text, prefix) {
65
- return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
66
- }
67
53
  function initialContent(project, prefix, since) {
68
54
  return `# Plan « reste à faire » tenu par raf (https://github.com/Sylad/cadence).
69
55
  # Édité par le CLI, mais les modifications à la main et les commentaires sont préservés.
@@ -305,7 +291,7 @@ export class Plan {
305
291
  */
306
292
  refs = (text) => {
307
293
  if (!this.settings.format)
308
- return extractRefs(text, this.prefix);
294
+ return this.settings.idPattern === undefined ? extractRefs(text, this.prefix) : idRefs(text, this.prefix, this.settings.idPattern);
309
295
  const { refs, tasks, ids } = this.read();
310
296
  if (!refs)
311
297
  return [];
@@ -395,7 +381,8 @@ export class Plan {
395
381
  // L396 : l'archive est un détail de stockage — les lots archivés reprennent leur place parmi ceux du fichier actif, comme un seul plan.
396
382
  const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
397
383
  const read = (r) => withExtras(normalizeLot(r), r, this.settings.extras);
398
- const lots = mergeArchived(archived.map(read), this.activeRaw().map(read));
384
+ const places = new Map(archived.flatMap((r) => (typeof r[PLACE_KEY] === 'string' || r[PLACE_KEY] === null ? [[String(r.id), r[PLACE_KEY] ?? '']] : [])));
385
+ const lots = mergeArchived(archived.map(read), this.activeRaw().map(read), places);
399
386
  const model = this.settings.states;
400
387
  return model ? lots.map((l) => ({ ...l, problems: [...l.problems, ...stateProblems(model, l)] })) : lots;
401
388
  }
@@ -472,20 +459,11 @@ export class Plan {
472
459
  }
473
460
  /**
474
461
  * Un lot archivé qui n'est plus clos (`raf todo` sur un lot done archivé) revient à la fin du fichier du plan à l'écriture :
475
- * l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert.
462
+ * l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert. Seul lui bouge :
463
+ * les lots archivés dont le `place:` le nommait (L536) sont re-pointés sur le lot qui le précédait, et gardent leur place dans le plan présenté.
476
464
  */
477
465
  reopened() {
478
- const active = this.doc.get('lots');
479
- for (const a of this.archives ?? []) {
480
- const seq = archiveLots(a.doc);
481
- const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
482
- if (back.length === 0)
483
- continue;
484
- seq.items = seq.items.filter((n) => !back.includes(n));
485
- for (const n of back)
486
- active.add(n);
487
- a.dirty = true;
488
- }
466
+ reopenArchived(this.archives ?? [], this.doc.get('lots'), () => this.lots().map((l) => l.id));
489
467
  }
490
468
  /**
491
469
  * `raf archive` : déplace les lots done et dropped du fichier du plan (terminés avant `before` s'il est donné) vers le fichier de leur année
@@ -506,8 +484,13 @@ export class Plan {
506
484
  // De quoi revenir en arrière en mémoire : les items du plan, ceux de chaque archive déjà chargée, et le nombre d'archives (les nouvelles s'ajoutent à la fin).
507
485
  const archives = this.loadArchives();
508
486
  const snapshot = { items: [...seq.items], archives: archives.map((a) => ({ a, items: [...archiveLots(a.doc).items], dirty: a.dirty })) };
487
+ // Sous plan.idPattern, le nombre final d'un id ne dit rien de l'ordre : chaque lot archivé garde le lot qui le précède dans le plan
488
+ // PRÉSENTÉ (archivés compris, ordre d'avant ce passage), pas dans le fichier seul — deux passages successifs gardent ainsi l'ordre du plan.
489
+ const merged = this.settings.idPattern === undefined ? [] : this.lots().map((l) => l.id);
509
490
  for (const n of nodes) {
510
491
  const lot = move.find((l) => l.id === String(n.get('id')));
492
+ if (this.settings.idPattern !== undefined)
493
+ n.set(PLACE_KEY, merged[merged.indexOf(lot.id) - 1] ?? '');
511
494
  const target = this.archiveFor(archiveYear(lot, today));
512
495
  archiveLots(target.doc).add(n);
513
496
  target.dirty = true;
@@ -563,15 +546,18 @@ export class Plan {
563
546
  for (const dep of opts.after ?? [])
564
547
  if (!known.has(dep))
565
548
  throw new RafError(`dépendance inconnue : ${dep}`);
566
- const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
567
- const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
568
- const id = `${this.prefix}${max + 1}`;
549
+ const picked = pickId(this.prefix, opts.id, opts.under, this.settings.idPattern, known);
550
+ if ('problem' in picked)
551
+ throw new RafError(picked.problem);
552
+ const { id } = picked;
569
553
  const pub = opts.public === undefined ? undefined : cleanPublic(opts.public);
570
554
  if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
571
555
  throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
572
556
  }
573
557
  const extras = checkedExtras(this.settings.extras, opts.extras);
574
558
  const entry = { id, title };
559
+ if (opts.under !== undefined)
560
+ entry.parent = opts.under;
575
561
  if (pub !== undefined)
576
562
  entry.public = pub;
577
563
  Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 }, trueFlags({ quickwin: opts.quickwin, visible: opts.visible }));
@@ -595,6 +581,15 @@ export class Plan {
595
581
  this.doc.get('lots').add(node);
596
582
  return id;
597
583
  }
584
+ /** Remplace l'estimation (en jours) d'un lot de tout statut, archivé compris ; jamais sur une sous-tâche. */
585
+ setEstimate(ref, days) {
586
+ this.writable();
587
+ if (ref.includes('/'))
588
+ throw new RafError(`l'estimation se pose sur un lot, pas une sous-tâche : ${ref}`);
589
+ if (!(Number.isFinite(days) && days > 0))
590
+ throw new RafError(`estimation invalide : ${days} (un nombre de jours > 0)`);
591
+ this.lotNode(ref).set('estimate', days);
592
+ }
598
593
  /** Déclare les dépôts voisins d'un lot (clé `repos:`) : un chemin seul, ou `{ path, cite }` quand le voisin est partagé. */
599
594
  setRepos(lotId, repos) {
600
595
  this.writable();