@ingeniomaps/cauce 0.69.0 → 0.70.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/CHANGELOG.md +139 -0
- package/README.md +6 -6
- package/automatization/AGENTS.md +1 -1
- package/automatization/runners/antigravity/rules/cauce.md +1 -1
- package/automatization/runners/claude/CLAUDE.md +1 -1
- package/automatization/runners/codex/AGENTS.md +1 -1
- package/automatization/runners/gemini/GEMINI.md +1 -1
- package/automatization/shared/skills/autobuild/SKILL.md +1 -1
- package/automatization/workflows/autobuild.js +44 -8
- package/engine/cli/archive.js +87 -0
- package/engine/cli/args.js +6 -2
- package/engine/cli/catalog.js +9 -1
- package/engine/cli/claims.js +125 -0
- package/engine/cli/ops.js +15 -4
- package/engine/cli/planning.js +110 -107
- package/engine/cli/worktree.js +89 -0
- package/engine/core/ownership.js +13 -5
- package/engine/core/repos.js +67 -0
- package/engine/hooks/files.js +3 -2
- package/engine/planning/adoption.js +1 -1
- package/engine/planning/claims.js +153 -0
- package/engine/planning/contracts.js +43 -202
- package/engine/planning/parser.js +51 -12
- package/engine/planning/state.js +39 -6
- package/engine/planning/structure.js +220 -0
- package/package.json +1 -1
- package/template/.gitattributes +19 -0
- package/template/AGENTS.md +47 -5
- package/template/automatization/AGENTS.md +1 -1
- package/template/gitignore +9 -0
- package/template/planning/BACKLOG.md +5 -0
- package/template/planning/FLOW.md +1 -1
- package/template/planning/PROTOCOL.md +20 -8
- package/template/planning/README.md +3 -3
- package/template/planning/RECURRING.md +1 -1
- package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
- package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
- package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
- package/template/planning/claims/README.md +70 -0
- package/template/planning/delivery/README.md +1 -0
- package/template/planning/delivery/multi-repo.md +11 -0
- package/template/planning/delivery/teamwork.md +162 -0
- package/template/planning/done/README.md +41 -0
- package/template/planning/wip/README.md +49 -0
- package/template/planning/DONE.md +0 -13
- package/template/planning/WIP.md +0 -22
package/engine/cli/ops.js
CHANGED
|
@@ -9,6 +9,9 @@ const { FLAGS, parse } = require('./args')
|
|
|
9
9
|
const { fail } = require('./io')
|
|
10
10
|
const IN = require('./instance')
|
|
11
11
|
const PL = require('./planning')
|
|
12
|
+
const AR = require('./archive')
|
|
13
|
+
const CLM = require('./claims')
|
|
14
|
+
const WT = require('./worktree')
|
|
12
15
|
const CAT = require('./catalog')
|
|
13
16
|
const W = require('./wiring')
|
|
14
17
|
const BOOT = require('./bootstrap')
|
|
@@ -142,12 +145,16 @@ function usage() {
|
|
|
142
145
|
ops onboard [ops-root] [--json]
|
|
143
146
|
ops check <planning-dir> [--json]
|
|
144
147
|
ops tree <planning-dir> [--no-color] [--json]
|
|
145
|
-
ops context <planning-dir> [--json]
|
|
148
|
+
ops context <planning-dir> [--hito <slug>] [--json]
|
|
146
149
|
ops recurring <planning-dir> [--promote <qué>] [--json]
|
|
150
|
+
ops runners <planning-dir> [--json]
|
|
151
|
+
ops claim <planning-dir> <tarea>
|
|
152
|
+
ops release <planning-dir> <tarea>
|
|
153
|
+
ops worktree <planning-dir> <tarea> [--json]
|
|
147
154
|
ops evidence <planning-dir> [--task <slug>] [--json]
|
|
148
155
|
ops upgrade <ops-root> [--check] [--force]
|
|
149
156
|
ops destroy <ops-root> [--force]
|
|
150
|
-
ops archive <planning-dir>
|
|
157
|
+
ops archive <planning-dir> human-actions
|
|
151
158
|
ops adopt <planning-dir>
|
|
152
159
|
ops integration list <ops-root>
|
|
153
160
|
ops integration enable <ops-root> <provider>
|
|
@@ -198,12 +205,16 @@ async function run(cli) {
|
|
|
198
205
|
else if (command === 'tree') PL.tree(arg[1], cli)
|
|
199
206
|
else if (command === 'context') PL.context(arg[1], cli)
|
|
200
207
|
else if (command === 'recurring') PL.recurring(arg[1], cli)
|
|
208
|
+
else if (command === 'runners') CLM.runners(arg[1], cli)
|
|
209
|
+
else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
|
|
210
|
+
else if (command === 'release') CLM.release(arg[1], arg[2])
|
|
211
|
+
else if (command === 'worktree') WT.worktree(arg[1], arg[2], cli)
|
|
201
212
|
else if (command === 'evidence') PL.evidence(arg[1], cli)
|
|
202
213
|
else if (command === 'upgrade') IN.upgrade(arg[1], cli)
|
|
203
214
|
else if (command === 'destroy') IN.destroy(arg[1], cli)
|
|
204
215
|
else if (command === 'agents') CAT.agents(arg[1], arg[2], arg[3], cli)
|
|
205
|
-
else if (command === 'archive')
|
|
206
|
-
else if (command === 'adopt')
|
|
216
|
+
else if (command === 'archive') AR.archive(arg[1], arg[2])
|
|
217
|
+
else if (command === 'adopt') AR.adopt(arg[1])
|
|
207
218
|
else if (command === 'integration') {
|
|
208
219
|
await W.integration(arg[1], arg[2], arg[3], arg[4], cli)
|
|
209
220
|
}
|
package/engine/cli/planning.js
CHANGED
|
@@ -8,8 +8,11 @@ const path = require('node:path')
|
|
|
8
8
|
const P = require('../planning/parser')
|
|
9
9
|
const B = require('../planning/business-rules')
|
|
10
10
|
const PC = require('../planning/contracts')
|
|
11
|
+
const SR = require('../planning/structure')
|
|
11
12
|
const SZ = require('../planning/sizing')
|
|
12
13
|
const RC = require('../planning/recurring')
|
|
14
|
+
const CL = require('../planning/claims')
|
|
15
|
+
const R = require('../core/repos')
|
|
13
16
|
const ST = require('../planning/state')
|
|
14
17
|
const AD = require('../planning/adoption')
|
|
15
18
|
const AP = require('../hooks/approval')
|
|
@@ -20,7 +23,6 @@ const OB = require('../core/onboarding')
|
|
|
20
23
|
const C = require('../config/validate')
|
|
21
24
|
const CP = require('../config/paths')
|
|
22
25
|
const AG = require('../agents/catalog')
|
|
23
|
-
const F = require('../core/files')
|
|
24
26
|
const { fail } = require('./io')
|
|
25
27
|
|
|
26
28
|
// Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
|
|
@@ -38,7 +40,9 @@ function evidence(dir, cli) {
|
|
|
38
40
|
const opsDir = path.join(root, '..')
|
|
39
41
|
const entries = P.readDone(root).entries
|
|
40
42
|
const slug = cli.value('--task')
|
|
41
|
-
|
|
43
|
+
// Sin `--task`, la más reciente, y la decide `fecha:` — por qué ese campo existe lo dice el contrato.
|
|
44
|
+
const reciente = [...entries].sort((a, b) => (a.fecha || '').localeCompare(b.fecha || '')).pop()
|
|
45
|
+
const entry = slug ? entries.find((one) => one.slug === slug) : reciente
|
|
42
46
|
if (!entry) return fail(slug ? `DONE no tiene la entrada ${slug}` : 'DONE no tiene ninguna entrada', 2)
|
|
43
47
|
|
|
44
48
|
let config = {}
|
|
@@ -75,7 +79,21 @@ function check(dir, cli) {
|
|
|
75
79
|
const root = path.resolve(dir || '.')
|
|
76
80
|
const errors = []
|
|
77
81
|
const warnings = []
|
|
78
|
-
|
|
82
|
+
// El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
|
|
83
|
+
// error. Ausente se lee como IDLE, que es lo que significa.
|
|
84
|
+
const required = ['BACKLOG.md', 'INBOX.md', 'HUMAN_ACTIONS.md', 'PROTOCOL.md']
|
|
85
|
+
// `DONE.md` se retiró: la evidencia vive en un archivo por tarea. Un `DONE.md` que quede en disco
|
|
86
|
+
// ya no lo lee nadie, y eso no se nota — las épicas dejan de poder cerrar y sus historias figuran
|
|
87
|
+
// sin evidencia, que es lo mismo que se vería si nunca se hubieran hecho.
|
|
88
|
+
// `WIP.md` se retiró por lo mismo que `DONE.md`: era uno solo y lo escribían todos los que corren sobre
|
|
89
|
+
// una instancia sidecar. Uno que quede en disco ya no lo lee nadie, y su plan a medias se pierde sin
|
|
90
|
+
// que nada lo diga.
|
|
91
|
+
if (fs.existsSync(path.join(root, 'WIP.md'))) {
|
|
92
|
+
errors.push('WIP.md ya no se lee: el plan de cada runner vive en `wip/<runner>.md`; movelo y borralo')
|
|
93
|
+
}
|
|
94
|
+
if (fs.existsSync(path.join(root, 'DONE.md'))) {
|
|
95
|
+
errors.push('DONE.md ya no se lee: pasá cada entrada a su propio `done/<slug>.md` y borralo')
|
|
96
|
+
}
|
|
79
97
|
for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
|
|
80
98
|
|
|
81
99
|
const configPath = path.join(root, '..', 'ops.config.json')
|
|
@@ -113,26 +131,39 @@ function check(dir, cli) {
|
|
|
113
131
|
const milestones = P.readBacklog(root)
|
|
114
132
|
const done = P.readDone(root)
|
|
115
133
|
errors.push(...B.validate(path.join(root, 'business-rules')))
|
|
116
|
-
errors.push(...
|
|
117
|
-
errors.push(...
|
|
118
|
-
errors.push(...
|
|
119
|
-
errors.push(...
|
|
134
|
+
errors.push(...SR.validateRoadmapStructure(root))
|
|
135
|
+
errors.push(...SR.validateBacklogStructure(root))
|
|
136
|
+
errors.push(...SR.validateRules(root))
|
|
137
|
+
errors.push(...SR.validateAdr(root))
|
|
120
138
|
const backlog = milestones.flatMap((milestone) => milestone.tasks)
|
|
121
139
|
const backlogSlugs = new Set(backlog.map((task) => task.slug))
|
|
122
140
|
const epicNums = new Set()
|
|
123
141
|
const storySlugs = new Set()
|
|
124
142
|
|
|
125
143
|
const roles = new Set(AG.list(path.resolve(root, '..')).map((role) => role.slug))
|
|
126
|
-
const
|
|
144
|
+
const wips = P.readWips(root)
|
|
127
145
|
const adopted = AD.read(root)
|
|
128
146
|
errors.push(...SZ.oversizedUnits({ epics, milestones }))
|
|
129
147
|
errors.push(...PC.validateState({
|
|
130
|
-
epics, milestones, done,
|
|
148
|
+
epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
|
|
131
149
|
}))
|
|
132
150
|
warnings.push(...AD.report({ done, epics, adopted }))
|
|
133
151
|
// Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
|
|
134
152
|
// no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
|
|
135
153
|
// en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
|
|
154
|
+
// Un reclamo que nombra una tarea que no existe bloquea la cola sin que nada lo explique, y uno viejo
|
|
155
|
+
// la bloquea para siempre. Lo primero es error; lo segundo avisa, porque abandonar no es un defecto.
|
|
156
|
+
const claims = CL.read(root)
|
|
157
|
+
errors.push(...CL.validate({ claims, milestones, done }))
|
|
158
|
+
// Si la rama de cada tarea tomada se movió, que es lo único barato que distingue una tarea larga de
|
|
159
|
+
// una abandonada. Sin repositorio resoluble el mapa queda vacío y el aviso vuelve a mirar sólo la
|
|
160
|
+
// fecha, que es lo que había antes: degrada, no rompe.
|
|
161
|
+
const activity = new Map()
|
|
162
|
+
for (const claim of claims.filter((one) => !done.set.has(one.slug))) {
|
|
163
|
+
const at = R.lastCommit(R.repoOf(path.join(root, '..'), claim.service), CL.branchOf(claim.slug))
|
|
164
|
+
if (at) activity.set(claim.slug, at)
|
|
165
|
+
}
|
|
166
|
+
warnings.push(...CL.warnings({ claims, done, today: TODAY(), activity }))
|
|
136
167
|
const recurring = RC.read(root)
|
|
137
168
|
errors.push(...RC.validate(recurring))
|
|
138
169
|
warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
|
|
@@ -168,14 +199,14 @@ function check(dir, cli) {
|
|
|
168
199
|
errors.push(...integration.errors)
|
|
169
200
|
warnings.push(...integration.warnings)
|
|
170
201
|
|
|
171
|
-
warnings.push(...
|
|
202
|
+
warnings.push(...SR.competingSections(root))
|
|
172
203
|
// Sobrescribir una entrada de system/ es legítimo y esperado; lo que no puede pasar es que
|
|
173
204
|
// ocurra en silencio, porque esa entrada deja de recibir las mejoras del toolkit.
|
|
174
205
|
for (const override of O.overrides(path.resolve(root, '..'))) {
|
|
175
206
|
// Y con qué se queda el proyecto: un override sano redefine lo que reemplaza, y el que deja IDs
|
|
176
207
|
// afuera los retira sin decirlo. Nombrarlos es lo único que separa una decisión de un descuido.
|
|
177
208
|
const retired = override.collection === 'planning/rules'
|
|
178
|
-
?
|
|
209
|
+
? SR.retiredByOverride(root, override.project)
|
|
179
210
|
: []
|
|
180
211
|
warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
|
|
181
212
|
+ `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
|
|
@@ -193,7 +224,7 @@ function check(dir, cli) {
|
|
|
193
224
|
epics: epics.length,
|
|
194
225
|
queued: backlog.length,
|
|
195
226
|
done: done.entries.length,
|
|
196
|
-
|
|
227
|
+
wips: wips.map((one) => one.task),
|
|
197
228
|
errors,
|
|
198
229
|
warnings,
|
|
199
230
|
}))
|
|
@@ -211,7 +242,7 @@ function check(dir, cli) {
|
|
|
211
242
|
}
|
|
212
243
|
|
|
213
244
|
// Estado observable de planning sin mutar nada; base común de `tree` y de sus salidas.
|
|
214
|
-
function treeJson({ epics, milestones, done,
|
|
245
|
+
function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
|
|
215
246
|
const state = (slug) => done.set.has(slug) ? 'done' : queued.has(slug) ? 'queued' : 'pending'
|
|
216
247
|
console.log(JSON.stringify({
|
|
217
248
|
roadmap: epics.map((epic) => ({
|
|
@@ -224,8 +255,11 @@ function treeJson({ epics, milestones, done, wip, inbox, queued }) {
|
|
|
224
255
|
slug: milestone.slug,
|
|
225
256
|
tasks: milestone.tasks.map((task) => ({ slug: task.slug, tier: task.tier || '' })),
|
|
226
257
|
})),
|
|
227
|
-
wip:
|
|
258
|
+
wip: wips.map((one) => ({
|
|
259
|
+
task: one.task, runner: one.runner, phase: one.phase, complete: one.complete, pending: one.pending,
|
|
260
|
+
})),
|
|
228
261
|
inbox: { deuda: inbox.deuda, ideas: inbox.ideas, propuestas: inbox.propuestas, lecciones: inbox.lecciones },
|
|
262
|
+
claims: claims.map((one) => ({ slug: one.slug, owner: one.owner, started: one.started })),
|
|
229
263
|
done: done.entries.length,
|
|
230
264
|
}))
|
|
231
265
|
}
|
|
@@ -234,7 +268,7 @@ function tree(dir, cli) {
|
|
|
234
268
|
const root = path.resolve(dir || '.')
|
|
235
269
|
const state = ST.snapshot(root)
|
|
236
270
|
if (cli.has('--json')) return treeJson(state)
|
|
237
|
-
const { epics, milestones, done,
|
|
271
|
+
const { epics, milestones, done, wips, inbox, queued, claims } = state
|
|
238
272
|
const color = process.stdout.isTTY && !cli.has('--no-color')
|
|
239
273
|
const paint = (code, text) => color ? `\x1b[${code}m${text}\x1b[0m` : text
|
|
240
274
|
console.log(`\n${paint('1', 'CAUCE')}\n`)
|
|
@@ -254,8 +288,8 @@ function tree(dir, cli) {
|
|
|
254
288
|
console.log(` ${milestone.heading}`)
|
|
255
289
|
for (const task of milestone.tasks) console.log(` ☐ ${task.slug}${task.tier ? ` [${task.tier}]` : ''}`)
|
|
256
290
|
}
|
|
257
|
-
const wipText =
|
|
258
|
-
? `▶ ${
|
|
291
|
+
const wipText = wips.length
|
|
292
|
+
? wips.map((one) => `▶ ${one.task} · ${one.phase} · ${one.complete}✓/${one.pending}○ (${one.runner})`).join(' ')
|
|
259
293
|
: 'idle'
|
|
260
294
|
console.log(`\n${paint('1', 'WIP')} ${wipText}`)
|
|
261
295
|
console.log(
|
|
@@ -264,6 +298,9 @@ function tree(dir, cli) {
|
|
|
264
298
|
// Sin esto, doce viñetas sin nombre se veían como un inbox vacío y nadie se enteraba.
|
|
265
299
|
(inbox.skipped ? ` (${inbox.skipped} sin contar: falta el nombre en **negrita**)` : ''),
|
|
266
300
|
)
|
|
301
|
+
if (claims.length) {
|
|
302
|
+
console.log(`${paint('1', 'CLAIM')} ${claims.map((one) => `${one.slug} · ${one.owner}`).join(' ')}`)
|
|
303
|
+
}
|
|
267
304
|
console.log(`${paint('1', 'DONE')} ${done.entries.length} tareas\n`)
|
|
268
305
|
}
|
|
269
306
|
|
|
@@ -271,9 +308,32 @@ function tree(dir, cli) {
|
|
|
271
308
|
function context(dir, cli) {
|
|
272
309
|
const root = path.resolve(dir || '.')
|
|
273
310
|
const state = ST.snapshot(root)
|
|
311
|
+
// Acotar la cola a un hito es como un equipo se reparte trabajo sin coordinarse: dos personas en hitos
|
|
312
|
+
// distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo que se acota es qué se
|
|
313
|
+
// ofrece, no qué se sabe: `done` sigue siendo global, así que una dependencia que vive en otro hito se
|
|
314
|
+
// juzga igual de bien.
|
|
315
|
+
const from = CL.runner()
|
|
316
|
+
const mio = state.wips.find((one) => one.runner === P.wipName(from)) || null
|
|
317
|
+
const hito = cli.value('--hito')
|
|
318
|
+
// Un filtro elige dónde buscar trabajo **nuevo**; no puede esconder el que ya tenés. Sin esto, pedir
|
|
319
|
+
// otro hito mientras sostenías una tarea ofrecía una segunda que `claim` después se niega a dar: el
|
|
320
|
+
// comando que dice qué hacer y el que lo autoriza contestaban distinto, y sólo se veía al reclamar.
|
|
321
|
+
const propio = state.claims.find((one) => one.runner === from && !state.done.set.has(one.slug))
|
|
322
|
+
let hitoOmitido = ''
|
|
323
|
+
if (hito) {
|
|
324
|
+
const existe = state.milestones.some((one) => one.slug === hito)
|
|
325
|
+
// Un hito mal escrito devolvería «sin tarea disponible», que es indistinguible de un hito terminado.
|
|
326
|
+
if (!existe) {
|
|
327
|
+
const hay = state.milestones.map((one) => one.slug).join(', ') || '(ninguno)'
|
|
328
|
+
return fail(`el hito ${hito} no existe. Hay: ${hay}`, 2)
|
|
329
|
+
}
|
|
330
|
+
if (propio) hitoOmitido = `${hito} no se aplica: ya tenés ${propio.slug} tomada`
|
|
331
|
+
else state.milestones = state.milestones.filter((one) => one.slug === hito)
|
|
332
|
+
}
|
|
274
333
|
const gate = path.join(root, 'AWAITING_REVIEW.md')
|
|
275
334
|
const humanActions = ST.pendingHumanActions(root)
|
|
276
|
-
const
|
|
335
|
+
const me = CL.owner(root)
|
|
336
|
+
const { task, skipped, claimed, taken, waiting } = ST.currentTask(state, humanActions, from)
|
|
277
337
|
const epic = task ? state.epics.find((candidate) => candidate.num === task.epic) : null
|
|
278
338
|
const criteria = epic ? epic.criteria.filter((criterion) => task.criteria.includes(criterion.id)) : []
|
|
279
339
|
const report = {
|
|
@@ -291,10 +351,22 @@ function context(dir, cli) {
|
|
|
291
351
|
// un criterio de cumplir su letra. Viaja acá porque el ejecutor tiene prohibido ir a buscarlo:
|
|
292
352
|
// `autobuild` le dice que lea cuatro archivos una sola vez y nada más, y el roadmap no es ninguno.
|
|
293
353
|
epic: epic ? { num: epic.num, title: epic.title, status: epic.status, context: epic.context } : null,
|
|
294
|
-
|
|
354
|
+
// El WIP que este runner tiene, no el de la instancia: es lo único que le corresponde continuar.
|
|
355
|
+
wip: mio ? { phase: mio.phase, complete: mio.complete, pending: mio.pending } : null,
|
|
356
|
+
// Dónde va su plan. Lo dice el motor porque el nombre sale del id del runner, y quien escribe el
|
|
357
|
+
// plan —un workflow— no tiene por qué saber cómo se deriva.
|
|
358
|
+
wipFile: `wip/${P.wipName(from)}.md`,
|
|
295
359
|
queued: state.milestones.reduce((total, milestone) => total + milestone.tasks.length, 0),
|
|
296
360
|
blockedTasks: skipped,
|
|
297
361
|
humanActions,
|
|
362
|
+
owner: me,
|
|
363
|
+
// El día de hoy, para quien no tiene reloj. Un workflow no puede llamar a `new Date` —una puerta se
|
|
364
|
+
// lo impide, porque su salida dejaría de ser reproducible— y necesita la fecha para cerrar una tarea.
|
|
365
|
+
// Sale de acá y no del modelo: es un dato mecánico, y pedírselo a un agente es invitarlo a inventarlo.
|
|
366
|
+
today: TODAY(),
|
|
367
|
+
claimed,
|
|
368
|
+
taken,
|
|
369
|
+
waiting,
|
|
298
370
|
// Sólo las vencidas: la fila que todavía no vence no tiene nada que decirle a quien va a tomar una
|
|
299
371
|
// tarea, y una recurrencia que hablara siempre sería ruido en el único comando que se corre en cada
|
|
300
372
|
// vuelta. Que aparezca es la señal.
|
|
@@ -315,6 +387,14 @@ function context(dir, cli) {
|
|
|
315
387
|
// que una instancia recién arrancada —`onboard` deja filas pendientes y ninguna tarea todavía—
|
|
316
388
|
// respondía «sin tarea disponible» y se tragaba las siete cosas que una persona tenía que desbloquear.
|
|
317
389
|
// Es el comando que existe para decir qué toca ahora, contestando «nada» cuando lo que toca es eso.
|
|
390
|
+
// Tu propio nombre en una tarea «ajena» es la señal de que sos vos desde otro runner, y sin decirlo se
|
|
391
|
+
// lee como que alguien te ganó la tarea.
|
|
392
|
+
const dueño = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
|
|
393
|
+
const espera = () => {
|
|
394
|
+
for (const one of report.waiting) {
|
|
395
|
+
console.log(`WAIT ${one.slug}: espera a ${one.dep}${one.owner ? ` (${one.owner})` : ''}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
318
398
|
const due = () => {
|
|
319
399
|
for (const one of report.recurring) {
|
|
320
400
|
const when = one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`
|
|
@@ -323,6 +403,10 @@ function context(dir, cli) {
|
|
|
323
403
|
}
|
|
324
404
|
if (!report.task) {
|
|
325
405
|
console.log('TASK (sin tarea disponible)')
|
|
406
|
+
// Mismo motivo que `blocked` arriba, con otra causa: acá la cola no la traba una persona, la tiene
|
|
407
|
+
// el equipo, y lo que corresponde es hablar con quien la tiene.
|
|
408
|
+
for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
|
|
409
|
+
espera()
|
|
326
410
|
for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
|
|
327
411
|
due()
|
|
328
412
|
return
|
|
@@ -345,6 +429,12 @@ function context(dir, cli) {
|
|
|
345
429
|
for (const criterion of criteria) console.log(`${criterion.id.padEnd(6)} ${criterion.text}`)
|
|
346
430
|
const wip = report.wip ? `${report.wip.phase} · ${report.wip.complete}✓/${report.wip.pending}○` : 'idle'
|
|
347
431
|
console.log(`WIP ${wip}`)
|
|
432
|
+
if (hitoOmitido) console.log(`HITO ${hitoOmitido}`)
|
|
433
|
+
console.log(report.claimed
|
|
434
|
+
? `CLAIM tuya desde el reclamo (${report.owner})`
|
|
435
|
+
: `CLAIM libre — tomala con \`ops claim <planning> ${report.task.slug}\``)
|
|
436
|
+
for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
|
|
437
|
+
espera()
|
|
348
438
|
if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
|
|
349
439
|
for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
|
|
350
440
|
due()
|
|
@@ -379,91 +469,4 @@ function recurring(dir, cli) {
|
|
|
379
469
|
}
|
|
380
470
|
}
|
|
381
471
|
|
|
382
|
-
|
|
383
|
-
// a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
|
|
384
|
-
// cerró ninguna —que es justo cuando el archivo se vuelve ilegible—.
|
|
385
|
-
// Adoptar es declarar de una vez qué historia llegó con el proyecto. Se genera con lo que hoy no cumple
|
|
386
|
-
// y no se vuelve a correr: un baseline que se regenera perdona de nuevo lo que alguien ya se tomó el
|
|
387
|
-
// trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
|
|
388
|
-
// Achicarlo sí es a mano, borrando el renglón que `check` señala.
|
|
389
|
-
function adopt(dir) {
|
|
390
|
-
const root = path.resolve(dir || '.')
|
|
391
|
-
const target = path.join(root, AD.BASELINE)
|
|
392
|
-
if (fs.existsSync(target)) {
|
|
393
|
-
// Un baseline que ya trae huella no se toca: regenerarlo es exactamente lo que la huella impide.
|
|
394
|
-
// Uno sin huella lo generó una versión anterior, y sellarlo no es regenerar nada — se calcula sobre
|
|
395
|
-
// lo que ya está—, así que es la única salida de un aviso que si no no tendría ninguna.
|
|
396
|
-
const existing = fs.readFileSync(target, 'utf8')
|
|
397
|
-
if (!AD.sealWarnings(root).some((one) => /sin huella/.test(one))) {
|
|
398
|
-
fail(`${AD.BASELINE} ya existe: se genera una vez. Para retirar un renglón, ponele \`#~\` `
|
|
399
|
-
+ 'delante; `check` marca los que ya cumplen.')
|
|
400
|
-
}
|
|
401
|
-
const slugs = AD.declared(existing)
|
|
402
|
-
F.atomicWrite(target, existing.replace(/\n?$/, `\n# huella: ${slugs.length} entradas · `
|
|
403
|
-
+ `sha256:${AD.digest(slugs)}\n`))
|
|
404
|
-
return console.log(`✓ ${AD.BASELINE} sellado con ${slugs.length} entrada(s); la lista no cambió`)
|
|
405
|
-
}
|
|
406
|
-
const epics = P.readEpics(root)
|
|
407
|
-
const pending = P.readDone(root).entries.filter((entry) => PC.doneEntryErrors(entry, epics).length)
|
|
408
|
-
if (!pending.length) {
|
|
409
|
-
return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
|
|
410
|
-
}
|
|
411
|
-
const today = TODAY()
|
|
412
|
-
const slugs = pending.map((entry) => entry.slug)
|
|
413
|
-
F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
|
|
414
|
-
+ '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
|
|
415
|
-
+ '# cumplirlo para que le pongas `#~` delante y quede retirada.\n'
|
|
416
|
-
+ `# huella: ${slugs.length} entradas · sha256:${AD.digest(slugs)}\n`
|
|
417
|
-
+ `${slugs.join('\n')}\n`)
|
|
418
|
-
console.log(`✓ ${pending.length} entrada(s) exentas en ${AD.BASELINE}`)
|
|
419
|
-
return console.log(' revisá la lista: lo que sí cumple el contrato no tiene por qué estar ahí')
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function archiveHumanActions(root) {
|
|
423
|
-
const source = path.join(root, 'HUMAN_ACTIONS.md')
|
|
424
|
-
const rows = P.readHumanActions(root).filter((row) => row.resolved)
|
|
425
|
-
if (!rows.length) return console.log('= no hay filas resueltas')
|
|
426
|
-
const target = path.join(root, 'done', 'human-actions.md')
|
|
427
|
-
const header = '| Tarea | Estado | Origen | Acción concreta y condición de desbloqueo |\n|---|---|---|---|'
|
|
428
|
-
const previous = P.read(target).trimEnd()
|
|
429
|
-
const head = previous || `---\nstatus: archived\n---\n\n# Acciones humanas resueltas\n\n${header}`
|
|
430
|
-
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
431
|
-
F.atomicWrite(target, `${head}\n${rows.map((row) => row.raw).join('\n')}\n`)
|
|
432
|
-
const drop = new Set(rows.map((row) => row.raw))
|
|
433
|
-
const kept = P.read(source).split('\n').filter((line) => !drop.has(line))
|
|
434
|
-
F.atomicWrite(source, `${kept.join('\n').trimEnd()}\n`)
|
|
435
|
-
return console.log(`✓ ${rows.length} fila(s) archivadas`)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function archive(dir, rawNum) {
|
|
439
|
-
const root = path.resolve(dir || '.')
|
|
440
|
-
if (String(rawNum || '') === 'human-actions') return archiveHumanActions(root)
|
|
441
|
-
const num = String(rawNum || '').padStart(3, '0')
|
|
442
|
-
if (!/^\d{3}$/.test(num)) fail('La épica debe ser NNN, o human-actions.', 2)
|
|
443
|
-
const epic = P.readEpics(root).find((candidate) => candidate.num === num)
|
|
444
|
-
if (!epic) fail(`No existe epic-${num}.`, 2)
|
|
445
|
-
if (epic.status !== 'closed') fail(`epic-${num} no está cerrada (status: ${epic.status}).`)
|
|
446
|
-
const target = path.join(root, 'done', `epic-${num}.md`)
|
|
447
|
-
const source = path.join(root, 'DONE.md')
|
|
448
|
-
const content = P.read(source)
|
|
449
|
-
const slugs = new Set(epic.stories.map((story) => story.slug))
|
|
450
|
-
const entries = P.readDone(root).entries.filter((entry) => entry.source === 'DONE.md' && slugs.has(entry.slug))
|
|
451
|
-
if (!entries.length) {
|
|
452
|
-
if (fs.existsSync(target)) return console.log(`= epic-${num} ya estaba archivada`)
|
|
453
|
-
fail(`No hay entradas de epic-${num} en DONE.md.`)
|
|
454
|
-
}
|
|
455
|
-
let updated = content
|
|
456
|
-
for (const entry of entries) updated = updated.replace(entry.raw, '').replace(/\n{3,}/g, '\n\n')
|
|
457
|
-
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
458
|
-
if (!fs.existsSync(target)) {
|
|
459
|
-
F.atomicWrite(
|
|
460
|
-
target,
|
|
461
|
-
`---\nepic: ${num}\nstatus: archived\n---\n\n# DONE — ${epic.title}\n\n` +
|
|
462
|
-
`${entries.map((entry) => entry.raw).join('\n\n')}\n`,
|
|
463
|
-
)
|
|
464
|
-
}
|
|
465
|
-
F.atomicWrite(source, `${updated.trimEnd()}\n`)
|
|
466
|
-
console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
module.exports = { check, evidence, tree, context, archive, adopt, recurring }
|
|
472
|
+
module.exports = { check, evidence, tree, context, recurring }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Dónde trabaja un agente. Prepara un árbol de trabajo por tarea con `git worktree`, que es git de base:
|
|
4
|
+
// no hace falta instalar nada, y sobre todo **no clona el repositorio**. Un worktree comparte el mismo
|
|
5
|
+
// `.git`, el mismo historial y los mismos objetos; lo único que materializa es un segundo directorio de
|
|
6
|
+
// archivos, fijado a su rama.
|
|
7
|
+
//
|
|
8
|
+
// Esa es la propiedad que importa con varios agentes: cada uno queda en su rama y **nadie hace checkout
|
|
9
|
+
// nunca**, que es lo que pisaría el trabajo del otro dentro de un único directorio compartido.
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs')
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
const { spawnSync } = require('node:child_process')
|
|
14
|
+
const ST = require('../planning/state')
|
|
15
|
+
const CL = require('../planning/claims')
|
|
16
|
+
const R = require('../core/repos')
|
|
17
|
+
const O = require('../core/ownership')
|
|
18
|
+
const { fail } = require('./io')
|
|
19
|
+
|
|
20
|
+
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
21
|
+
|
|
22
|
+
// El árbol que ya existe para esa rama, si existe. `--porcelain` lista bloques de `worktree <ruta>` y
|
|
23
|
+
// `branch refs/heads/<nombre>`, y se lee así para no depender del formato humano, que cambia.
|
|
24
|
+
function existing(repo, branch) {
|
|
25
|
+
const listed = git(repo, 'worktree', 'list', '--porcelain')
|
|
26
|
+
if (listed.status !== 0) return ''
|
|
27
|
+
let current = ''
|
|
28
|
+
for (const line of listed.stdout.split('\n')) {
|
|
29
|
+
if (line.startsWith('worktree ')) current = line.slice(9).trim()
|
|
30
|
+
if (line.trim() === `branch refs/heads/${branch}`) return current
|
|
31
|
+
}
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function worktree(dir, slug, cli) {
|
|
36
|
+
const root = path.resolve(dir || '.')
|
|
37
|
+
if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
|
|
38
|
+
const state = ST.snapshot(root)
|
|
39
|
+
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
|
40
|
+
if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, 2)
|
|
41
|
+
|
|
42
|
+
// No reserva —eso es `claim`— pero se niega a montar sobre lo de otro: preparar un árbol para una
|
|
43
|
+
// tarea ajena es trabajo que se va a tirar, y el aviso cuesta menos que descubrirlo después.
|
|
44
|
+
const taken = state.claims.find((one) => one.slug === slug)
|
|
45
|
+
if (taken && taken.runner !== CL.runner()) {
|
|
46
|
+
return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const candidatos = R.reposFor(path.join(root, '..'), task.service)
|
|
50
|
+
if (candidatos.length > 1) {
|
|
51
|
+
return fail(`${task.service || '.'} existe en más de un repositorio (${candidatos.join(', ')}), así que `
|
|
52
|
+
+ 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', 2)
|
|
53
|
+
}
|
|
54
|
+
const repo = candidatos[0]
|
|
55
|
+
if (!repo) {
|
|
56
|
+
return fail(`no encontré el repositorio de ${task.service || '(sin service)'}: revisá workspaceRoots `
|
|
57
|
+
+ 'en ops.config.json y que la ruta del servicio exista.', 2)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const branch = CL.branchOf(slug)
|
|
61
|
+
const already = existing(repo, branch)
|
|
62
|
+
const target = already || path.join(path.dirname(repo), `${path.basename(repo)}-${slug}`)
|
|
63
|
+
if (!already) {
|
|
64
|
+
if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`)
|
|
65
|
+
const hasBranch = git(repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).status === 0
|
|
66
|
+
const args = hasBranch ? ['worktree', 'add', target, branch] : ['worktree', 'add', '-b', branch, target]
|
|
67
|
+
const added = git(repo, ...args)
|
|
68
|
+
if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (cli.has('--json')) {
|
|
72
|
+
return console.log(JSON.stringify({ path: target, branch, repo, runner: target, reused: Boolean(already) }))
|
|
73
|
+
}
|
|
74
|
+
console.log(`${already ? '=' : '✓'} ${target} (${branch})`)
|
|
75
|
+
// En `embedded` la instancia vive dentro del repo, así que cada árbol se lleva su propia copia de
|
|
76
|
+
// `planning/` — o ninguna, si todavía no se commiteó—. Para un agente solo eso funciona; para varios
|
|
77
|
+
// deja de haber coordinación, porque los reclamos de uno no los ve el otro hasta mergear. Se avisa y no
|
|
78
|
+
// se frena: usar un árbol por rama sin equipo es legítimo.
|
|
79
|
+
if (O.mode(path.join(root, '..')) === 'embedded') {
|
|
80
|
+
console.log(' ⚠ la instancia vive dentro del repo, así que este árbol lleva su propia copia de '
|
|
81
|
+
+ 'planning/: los reclamos no se ven entre árboles hasta mergear. Para varios agentes, mode: sidecar.')
|
|
82
|
+
}
|
|
83
|
+
// El id del runner y la ruta son la misma cosa a propósito: el árbol es lo que distingue a un agente
|
|
84
|
+
// de otro en una máquina, así que darlo hecho evita el modo de fallo que deja a los dos con el mismo.
|
|
85
|
+
console.log(` export CAUCE_RUNNER=${target}`)
|
|
86
|
+
console.log(` node tools/ops.js claim ${path.relative(process.cwd(), root) || '.'} ${slug}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { worktree }
|
package/engine/core/ownership.js
CHANGED
|
@@ -20,6 +20,9 @@ const SYSTEM_FILES = [
|
|
|
20
20
|
'planning/business-rules/000-template.md',
|
|
21
21
|
'planning/business-rules/README.md',
|
|
22
22
|
'planning/rules/README.md',
|
|
23
|
+
'planning/claims/README.md',
|
|
24
|
+
'planning/done/README.md',
|
|
25
|
+
'planning/wip/README.md',
|
|
23
26
|
'planning/roadmap/README.md',
|
|
24
27
|
'planning/roadmap/epic-000-template.md',
|
|
25
28
|
// La guía de entrega no tiene una línea de la empresa: describe el camino que Cauce recomienda, y lo
|
|
@@ -32,6 +35,7 @@ const SYSTEM_FILES = [
|
|
|
32
35
|
'planning/delivery/environments.md',
|
|
33
36
|
'planning/delivery/flags.md',
|
|
34
37
|
'planning/delivery/multi-repo.md',
|
|
38
|
+
'planning/delivery/teamwork.md',
|
|
35
39
|
'organization/roles/README.md',
|
|
36
40
|
'flows/000-template.md',
|
|
37
41
|
'flows/README.md',
|
|
@@ -75,9 +79,12 @@ const TEMPLATE_PREFIXES = [
|
|
|
75
79
|
'tools/',
|
|
76
80
|
]
|
|
77
81
|
|
|
78
|
-
// Archivos que la instancia recibe en su raíz y
|
|
79
|
-
//
|
|
80
|
-
|
|
82
|
+
// Archivos que la instancia recibe en su raíz y cuyo original vive en `template/`. Dos de ellos el
|
|
83
|
+
// paquete los tiene por duplicado —el propio del toolkit y el de la plantilla— y gana el de la
|
|
84
|
+
// plantilla, que es el que le habla a la instancia; `.gitattributes` existe sólo del lado del molde y
|
|
85
|
+
// entra por la misma puerta, porque sin esto `sourceOf` lo busca en la raíz del paquete y `upgrade` lo
|
|
86
|
+
// saltea en silencio.
|
|
87
|
+
const TEMPLATE_FILES = new Set(['AGENTS.md', 'Makefile', '.gitattributes'])
|
|
81
88
|
|
|
82
89
|
function sourceOf(relative) {
|
|
83
90
|
if (TEMPLATE_FILES.has(relative)) return path.join('template', relative)
|
|
@@ -186,6 +193,9 @@ function overrides(root) {
|
|
|
186
193
|
// decidir acá. Una entrada pasa de `upgrade` a `init` cuando ninguna versión soportada puede no
|
|
187
194
|
// tenerlo; que se quede de más no rompe nada, porque el archivo ya está y se conserva.
|
|
188
195
|
const TEMPLATE_OWN = {
|
|
196
|
+
// 0.70.0. Le dice a git que los dos archivos que sólo crecen se concatenan en vez de
|
|
197
|
+
// conflictuar, así que hace falta en la instancia que ya existe y no sólo en la nueva.
|
|
198
|
+
'.gitattributes': 'upgrade',
|
|
189
199
|
'README.md': 'init',
|
|
190
200
|
'gitignore': 'init',
|
|
191
201
|
'integrations/config.json': 'init',
|
|
@@ -202,13 +212,11 @@ const TEMPLATE_OWN = {
|
|
|
202
212
|
// una instancia que actualiza queda leyendo una instrucción que apunta a un archivo que no tiene.
|
|
203
213
|
'organization/workspace.md': 'upgrade',
|
|
204
214
|
'planning/BACKLOG.md': 'init',
|
|
205
|
-
'planning/DONE.md': 'init',
|
|
206
215
|
'planning/HUMAN_ACTIONS.md': 'init',
|
|
207
216
|
'planning/INBOX.md': 'init',
|
|
208
217
|
// 0.69.0. El contrato nace con esta versión, así que ninguna instancia anterior lo tiene: por `init`
|
|
209
218
|
// no llegaría nunca a la que ya existe, que es justo la que iba a usarlo.
|
|
210
219
|
'planning/RECURRING.md': 'upgrade',
|
|
211
|
-
'planning/WIP.md': 'init',
|
|
212
220
|
'planning/delivery/project.md': 'init',
|
|
213
221
|
'planning/done/.gitkeep': 'init',
|
|
214
222
|
'planning/reports/README.md': 'init',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// En qué repositorio vive un servicio, y cuándo se movió por última vez una rama. Vive acá porque lo
|
|
4
|
+
// preguntan dos cosas que no se conocen entre sí —preparar un árbol de trabajo y juzgar si un reclamo
|
|
5
|
+
// sigue vivo— y la resolución tiene que ser la misma en las dos: escrita dos veces, una copia envejece
|
|
6
|
+
// y las dos respuestas dejan de coincidir sin que nada falle.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
const { spawnSync } = require('node:child_process')
|
|
11
|
+
|
|
12
|
+
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
13
|
+
|
|
14
|
+
// Los repositorios cuyo árbol contiene el servicio, resuelto como lo resuelve `check` para juzgar si
|
|
15
|
+
// existe. Devuelve la raíz git de cada uno, que no siempre es la raíz declarada: `workspaceRoots` puede
|
|
16
|
+
// apuntar a un subdirectorio.
|
|
17
|
+
//
|
|
18
|
+
// Devuelve una lista y no el primero porque con varias raíces la respuesta puede ser ambigua: un
|
|
19
|
+
// `service: .` existe en todas, y un `src` puede existir en dos. Elegir el primero da una respuesta
|
|
20
|
+
// plausible y equivocada —un árbol de trabajo en el repositorio que no era— sin que nada lo diga.
|
|
21
|
+
function reposFor(opsRoot, service) {
|
|
22
|
+
let config = {}
|
|
23
|
+
try {
|
|
24
|
+
config = JSON.parse(fs.readFileSync(path.join(opsRoot, 'ops.config.json'), 'utf8'))
|
|
25
|
+
} catch { return [] }
|
|
26
|
+
return (Array.isArray(config.workspaceRoots) ? config.workspaceRoots : [])
|
|
27
|
+
.filter((one) => one && one.path)
|
|
28
|
+
.map((one) => path.resolve(opsRoot, one.path))
|
|
29
|
+
.filter((root) => fs.existsSync(path.join(root, service || '.')))
|
|
30
|
+
.map((root) => {
|
|
31
|
+
const top = git(root, 'rev-parse', '--show-toplevel')
|
|
32
|
+
return top.status === 0 ? top.stdout.trim() : ''
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
// Dos raíces del mismo repositorio son un solo repositorio: lo ambiguo es a cuál pertenece el
|
|
36
|
+
// servicio, no cuántas rutas lo contienen.
|
|
37
|
+
.filter((repo, index, todos) => todos.indexOf(repo) === index)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// El repositorio del servicio cuando no hay duda. Sin ninguno o con varios devuelve vacío, y quien
|
|
41
|
+
// pregunta decide qué decir: para `check` es la degradación ya declarada —mirar sólo la fecha—, y para
|
|
42
|
+
// `worktree` es un error que tiene que nombrar los candidatos.
|
|
43
|
+
function repoOf(opsRoot, service) {
|
|
44
|
+
const repos = reposFor(opsRoot, service)
|
|
45
|
+
return repos.length === 1 ? repos[0] : ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// La fecha del último commit **propio** de una rama, en AAAA-MM-DD, o vacío si no tiene ninguno. Vacío no
|
|
49
|
+
// es un error: una tarea recién tomada todavía no tiene rama, la rama recién creada no tiene commits, y
|
|
50
|
+
// un proyecto puede nombrar sus ramas de otra forma. Quien pregunta decide qué hacer con la ausencia.
|
|
51
|
+
//
|
|
52
|
+
// Lo que hay que excluir es el tronco. Una rama nueva hereda su historia entera, así que preguntar por su
|
|
53
|
+
// último commit a secas devuelve el del tronco y **toda rama parece haber avanzado el día que se creó** —
|
|
54
|
+
// que es justo lo contrario de lo que esta función existe para medir. El tronco es la rama en la que está
|
|
55
|
+
// el árbol principal: los worktrees se crean desde ahí y ahí se queda.
|
|
56
|
+
function lastCommit(repo, branch) {
|
|
57
|
+
if (!repo) return ''
|
|
58
|
+
const actual = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
|
|
59
|
+
const tronco = actual.status === 0 ? actual.stdout.trim() : ''
|
|
60
|
+
const args = tronco && tronco !== branch
|
|
61
|
+
? ['log', '-1', '--format=%cs', branch, '--not', tronco, '--']
|
|
62
|
+
: ['log', '-1', '--format=%cs', branch, '--']
|
|
63
|
+
const shown = git(repo, ...args)
|
|
64
|
+
return shown.status === 0 ? shown.stdout.trim() : ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { reposFor, repoOf, lastCommit }
|
package/engine/hooks/files.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
} = require('./input')
|
|
13
13
|
const AP = require('./approval')
|
|
14
14
|
const { readWip } = require('../planning/parser')
|
|
15
|
+
const { runner } = require('../planning/claims')
|
|
15
16
|
const { hasTasks } = require('../planning/state')
|
|
16
17
|
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
17
18
|
|
|
@@ -138,7 +139,7 @@ function planFirst(input) {
|
|
|
138
139
|
const root = opsRoot(input)
|
|
139
140
|
if (!root) return
|
|
140
141
|
const planning = path.join(root, 'planning')
|
|
141
|
-
const wip = readWip(planning)
|
|
142
|
+
const wip = readWip(planning, runner())
|
|
142
143
|
if (wip && wip.complete + wip.pending > 0) return
|
|
143
144
|
// Una instancia recién creada no tiene de dónde sacar una tarea: `onboard` deja el roadmap vacío y
|
|
144
145
|
// dice que alguien lo llene. Exigir el plan ahí es un candado delante de la puerta, y la salida que
|
|
@@ -149,7 +150,7 @@ function planFirst(input) {
|
|
|
149
150
|
if (!hasTasks(planning)) return
|
|
150
151
|
const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
|
|
151
152
|
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
152
|
-
+ 'Escribí en planning/
|
|
153
|
+
+ 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
153
154
|
+ 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
|
|
154
155
|
+ AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
|
|
155
156
|
for (const raw of filesOf(input)) {
|