@ancleto/spec 0.6.37 → 0.7.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/README.md CHANGED
@@ -333,7 +333,7 @@ Funciona en tres piezas que comparten el mismo archivo:
333
333
  | `recordRule` | Guarda una regla o restricción permanente. |
334
334
  | `recordDecision` | Registra el *por qué* de una decisión. |
335
335
 
336
- Cada entrada tiene un `scope`: `project` (default, entra en `<ProjectMemoryRules>`), `feature` o `task`. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
336
+ Cada entrada tiene un `scope`: `project` (default), `feature` o `task`. Solo las **reglas** activas con scope `project` entran en `<ProjectMemoryRules>`; las **decisiones** con scope `project` se recuperan reactivamente con `searchMemory` y no se inyectan de forma proactiva. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
337
337
 
338
338
  > El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera solo en `init`, `install --project` y `upgrade`.
339
339
 
@@ -94,7 +94,7 @@ For `STALE`, delegate `regenerate` to `@technical-seed-writer` only after explic
94
94
  - **MUST STOP** after `@spec-writer` and wait for explicit user approval
95
95
  - **MUST REPORT** subagent failures and wait for instructions
96
96
  - **MUST STOP** if `@tester` reports failed verification
97
- - **MUST STOP** if `@reviewer` reports critical issues
97
+ - **MUST STOP** if `@reviewer` reports critical issues (including a `GUARANTEE NOT SUSTAINED` finding) and do not advance toward archive while it is present
98
98
  - **MUST DELEGATE** all implementation to appropriate subagents
99
99
 
100
100
  ## Active Change Check
@@ -268,7 +268,7 @@ If the runtime is read-only or plan-only:
268
268
  3. **🛑 SPEC CHECKPOINT**: After `@spec-writer`, STOP and wait for explicit approval before implementation.
269
269
  4. **Implementation**: Delegate to **`@coder`** only - no direct implementation. Include the complete Resolved Context Envelope.
270
270
  5. **Testing**: Delegate to **`@tester`** to create or update unit tests and perform final validation against the approved scope, including a non-writing format check and one lint pass after all edits. Include the complete Resolved Context Envelope, the coder's task-owned files, and its reported risks. `@tester` returns the final union of task-owned files and the Validation Ledger. A format failure in a task-owned file is failed verification and must not be fixed silently by `@tester`.
271
- 6. **Review**: Delegate to **`@reviewer`** when an independent correctness or scope review is appropriate. Include the complete Resolved Context Envelope, the tester's final task-owned file union, and the full Validation Ledger.
271
+ 6. **Review**: Delegate to **`@reviewer`** when an independent correctness or scope review is appropriate. Include the complete Resolved Context Envelope, the tester's final task-owned file union, and the full Validation Ledger. When the change declares effect guarantees, additionally include, as separate fields, the guarantee extracted from the proposal, the protected state, and the affected capability
272
272
  7. **🛑 FINAL ARCHIVE CHECKPOINT**: If implementation, validation, and any required review are complete with no blocking issues, stop and explicitly ask the user whether to keep iterating on the same change or finalize and archive it
273
273
  8. **Finalization / Archive**: Before archiving, verify the delta specs reflect the final implementation — if iteration changed behavior or scope, re-delegate to **`@spec-writer`** to update the delta specs first, so the merge to source-of-truth is accurate. Check the seed freshness before closing: delegate the read-only state check to **`@technical-discovery`** and, when the state is `STALE` with `impact: material`, warn and offer regeneration via **`@technical-seed-writer`** with explicit user approval — never automatic; with `impact: minor`, continue the archive normally and note the drift in the summary. This closure check protects future sessions from a silently aging seed. Then delegate to **`@documenter`** only after explicit user approval to archive
274
274
  9. **Memory Record**: After a successful archive, delegate to **`@memory-keeper`** in Automatic Record mode when a concrete, plausible candidate exists, with the factual completed-work summary and validation evidence supporting it. If no candidate exists, delegate Optional Draft with the factual completed-work summary, `no-automatic-candidate` classification, and the reason no candidate was identified. When Automatic Record returns `no-entry-warranted`, use its draft or delegate Optional Draft with that classification and reason if it could not provide one. Before asking, show the final work summary, classification, reason, and exact draft, then stop for explicit user approval. If no safe draft is available, report that outcome and finish without asking or storing. On approval, delegate User-Approved Record with the unchanged draft, the factual summary and validation evidence used to compose it, archived change name, and card context. On rejection, do not store.
@@ -306,7 +306,7 @@ For each validation command in the tester result, including format check and lin
306
306
 
307
307
  A subagent return is a payload to distill, not a message to forward. Before the next stage:
308
308
 
309
- - Extract only the fields the next stage needs: `task-owned files`, Validation Ledger entries, flags (`SPEC UPDATE RECOMMENDED`, `SEED_ACTION_REQUIRED`), verdicts, blockers, and card context when relevant.
309
+ - Extract only the fields the next stage needs: `task-owned files`, Validation Ledger entries, flags (`SPEC UPDATE RECOMMENDED`, `SEED_ACTION_REQUIRED`, `GUARANTEE NOT SUSTAINED`), verdicts, blockers, and card context when relevant.
310
310
  - Never copy a subagent's raw return — prose, logs, diffs, or command output — into the next delegation or the final report; pass distilled fields only.
311
311
  - The caps declared in each agent's output section bound its return. If a return exceeds them, summarize it and keep the detail only for the current stage; offer the full detail only if the user asks.
312
312
  - Evidence survives as fields, not as text: `file:line` for code, ledger entries for commands, flags for decisions.
@@ -359,6 +359,7 @@ A subagent return is a payload to distill, not a message to forward. Before the
359
359
  - Warnings
360
360
  - Suggestions
361
361
  - `SPEC UPDATE RECOMMENDED` flag for `direct-implementation` changes that modified documented behavior, when applicable
362
+ - `GUARANTEE NOT SUSTAINED` flag when the proposal's guarantee does not hold under the real model (`aspec Change` mode only), naming the guarantee, the falsifying operation, and the protected state
362
363
  - A concrete, plausible lesson for the team memory that could save future investigation, including a command alternative, runtime constraint, verified workaround, or confirmed assumption — or explicitly none with the reason automatic storage is not recommended after reviewing warnings, suggestions, and validation results
363
364
  - Overall review verdict
364
365
  - Any missing or inconclusive command evidence that requires a focused `@tester` verification
@@ -62,10 +62,39 @@ If `@orchestrator` classified the task as `direct-test-only`, review against:
62
62
 
63
63
  Do not assume an aspec change exists for this mode. Do not raise `SPEC UPDATE RECOMMENDED` for test-only work.
64
64
 
65
+ ## Guarantee Verification Under the Real Model
66
+
67
+ This section applies **only** in `aspec Change` mode. Do not run it for `direct-implementation` or `direct-test-only` work.
68
+
69
+ ### Mandate
70
+
71
+ For a change that declares guarantees about the effects of operations on state, verify that the proposal's guarantee holds under the **real model**: the set of operations that can mutate the protected state, **including the operations the change does NOT introduce** (supersession of the same identity, operations in other modules, indirect effects). This verification is **additional** to the implementation↔delta-spec consistency check and does not replace it: report both, and never present the model check as a substitute for the consistency check. Conclude explicitly whether the guarantee holds under the real model.
72
+
73
+ ### Applicability signal: `S1 ∧ S2`
74
+
75
+ Run the model analysis only when both hold:
76
+
77
+ - **`S1` (claim text):** the proposal names an identifiable operation (function, command, MCP tool, or lifecycle step) and carries a universal or negative quantifier ("does not require", "never", "only", "always", "no change").
78
+ - **`S2` (technical context):** that operation writes or acts by an identity **not bounded** to the shape of what is written (`memory_key`, path, row) on a store whose read-model is a filtered or ordered projection.
79
+
80
+ `S1` alone over-fires and is **not** sufficient. Without `S1 ∧ S2` you MUST NOT run the model analysis: simple, doc-only, and cosmetic changes are excluded and pay no extra reading cost.
81
+
82
+ ### Reading beyond the diff
83
+
84
+ When `S1 ∧ S2` holds, read the source of the operations that can mutate the protected state. This reading is **not limited** to the diff or to the `task-owned files`.
85
+
86
+ ### Output
87
+
88
+ When the guarantee does not hold under the real model, emit the explicit flag `GUARANTEE NOT SUSTAINED` at severity `CRITICAL`. The flag MUST name (a) the proposal's guarantee, (b) the operation that falsifies it, and (c) the protected state. Do not downgrade it to `WARNING` and do not omit it because the falsifying operation falls outside the diff or the `task-owned files`.
89
+
90
+ ### Unresolvable context
91
+
92
+ If the received context is not enough to resolve the write surface or the read-model, report the missing context to `@orchestrator` and do **not** conclude that the guarantee holds.
93
+
65
94
  ## Required Workflow
66
95
 
67
96
  1. Identify whether the task is `aspec Change`, `direct-implementation`, or `direct-test-only`
68
- 2. Read only the minimum relevant approved inputs and changed implementation files
97
+ 2. Read only the minimum relevant approved inputs and changed implementation files. When the change signals a guarantee (`S1 ∧ S2`, `aspec Change` mode only), that minimum additionally includes the source of the operations that can mutate the protected state, even beyond the diff
69
98
  3. Review the task-owned files, related tests, and Validation Ledger produced for the approved scope
70
99
  4. Check for correctness issues, missing coverage, convention violations, and scope creep
71
100
  5. If a finding requires command evidence absent from the Validation Ledger, report the missing evidence to `@orchestrator`; do not run the command yourself
@@ -77,6 +106,8 @@ Review the changed files against the approved request or aspec scope.
77
106
 
78
107
  Review scope is limited to the `task-owned files` supplied by `@orchestrator` and their directly related files. Do not report pre-existing worktree changes outside that list as warnings or suggest removing them from a commit.
79
108
 
109
+ This scope limit does not apply to a `GUARANTEE NOT SUSTAINED` finding: a guarantee can be falsified by an operation outside the `task-owned files` and outside the diff, and the finding is not discarded or downgraded for that reason. See "Guarantee Verification Under the Real Model".
110
+
80
111
  Focus on these questions:
81
112
 
82
113
  - are the modified files consistent with the approved behavior or implementation scope?
@@ -161,6 +192,7 @@ Return a short structured report with:
161
192
  - `WARNING` issues
162
193
  - `SUGGESTION` items
163
194
  - `SPEC UPDATE RECOMMENDED` flag when a `direct-implementation` change modified documented behavior
195
+ - `GUARANTEE NOT SUSTAINED` flag when the proposal's guarantee does not hold under the real model (`aspec Change` mode only), naming the guarantee, the falsifying operation, and the protected state
164
196
  - `MEMORY CANDIDATE`: one lesson worth keeping in the team memory, or "none" with the reason automatic storage is not recommended
165
197
  - missing or inconclusive command evidence that requires focused verification by `@tester`, or "none"
166
198
  - overall review verdict
@@ -188,6 +220,10 @@ Use this format:
188
220
 
189
221
  - {affected spec file and what documented behavior changed, or "none"}
190
222
 
223
+ ### GUARANTEE NOT SUSTAINED
224
+
225
+ - {the proposal's guarantee, the operation that falsifies it, and the protected state, or "none"}
226
+
191
227
  ### MEMORY CANDIDATE
192
228
 
193
229
  - {one concrete, self-contained lesson in Spanish that could save future investigation: a command or flag that fails and its alternative, a runtime/test/CI/browser/platform constraint, a verified workaround, a confirmed or refuted assumption, or a decision whose reason or consequence is not obvious. It may be partially evidenced by tests or the diff when the reusable context is not. Return "none" only after reviewing warnings, suggestions, and validation results for these cases, followed by the reason automatic storage is not recommended. `@memory-keeper` decides whether it is stored; you only propose it.}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.37",
3
+ "version": "0.7.0",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs'
3
- import { cp, mkdir, access, writeFile, readFile, readdir, rename } from 'node:fs/promises'
3
+ import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { createInterface } from 'node:readline'
@@ -9,6 +9,7 @@ import { join, dirname, resolve, basename, relative } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { homedir, tmpdir } from 'node:os'
11
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
12
+ import { writeWorkingContext } from '../core/memory/working-context.js'
12
13
  import { memoryDoctor } from '../core/memory/doctor.js'
13
14
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
14
15
  import { writeDiscoveryMap } from '../core/discovery.js'
@@ -775,6 +776,39 @@ async function scaffoldAspec(projectDir) {
775
776
  }
776
777
  }
777
778
 
779
+ const LEGACY_OPENSPEC_DIR = 'openspec'
780
+ const MIGRATION_MARKER = '.migrated-from-openspec'
781
+
782
+ // "Contenido real" = al menos una entrada bajo changes/ o specs/. Un config.yaml
783
+ // solo (o el scaffold vacio) no cuenta como real: no bloquea la migracion.
784
+ async function hasRealContent(aspecDir) {
785
+ for (const sub of ['changes', 'specs']) {
786
+ try {
787
+ if ((await readdir(join(aspecDir, sub))).length > 0) return true
788
+ } catch {
789
+ // subcarpeta inexistente o ilegible: no aporta contenido real
790
+ }
791
+ }
792
+ return false
793
+ }
794
+
795
+ // Migra la carpeta legacy openspec/ a aspec/ por copia recursiva (no destructiva).
796
+ // Unico dueno del invariante: la invocan init y upgrade antes del scaffold.
797
+ async function migrateLegacyOpenspec(projectDir) {
798
+ const legacyDir = join(projectDir, LEGACY_OPENSPEC_DIR)
799
+ if (!(await exists(legacyDir))) return // sin legacy: no-op silencioso
800
+ const aspecDir = join(projectDir, CHANGES_ROOT)
801
+ const marker = join(aspecDir, MIGRATION_MARKER)
802
+ if (await exists(marker)) return // ya migrada: no-op silencioso
803
+ if (await hasRealContent(aspecDir)) {
804
+ console.warn('ancleto: aspec/ ya tiene contenido real — no se migro openspec/ (revisar manualmente; openspec/ se conserva)')
805
+ return
806
+ }
807
+ await cp(legacyDir, aspecDir, { recursive: true, force: false })
808
+ await writeFile(marker, '# Generado por ancleto: esta aspec/ ya fue migrada desde openspec/\n')
809
+ console.log('ancleto: contenido migrado de openspec/ a aspec/ (openspec/ se conserva como backup)')
810
+ }
811
+
778
812
  function extractLockedBlocks(content) {
779
813
  const blocks = new Map()
780
814
  const re = /<!--\s*LOCKED:\s*([\w-]+)\s*-->([\s\S]*?)<!--\s*\/LOCKED:\s*\1\s*-->/g
@@ -982,14 +1016,7 @@ async function upgradeCmd(args) {
982
1016
  console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
983
1017
  process.exit(1)
984
1018
  }
985
- const legacyChanges = join(projectDir, 'openspec')
986
- const changesRoot = join(projectDir, CHANGES_ROOT)
987
- if ((await exists(legacyChanges)) && !(await exists(changesRoot))) {
988
- await rename(legacyChanges, changesRoot)
989
- console.log('ancleto: changes migrados de openspec/ a aspec/')
990
- } else if ((await exists(legacyChanges)) && (await exists(changesRoot))) {
991
- console.warn('ancleto: existen openspec/ y aspec/ — no se migro nada (revisar manualmente)')
992
- }
1019
+ await migrateLegacyOpenspec(projectDir)
993
1020
  const agent = await resolveAgent(args, rc.agent)
994
1021
  await copyTemplates(projectDir)
995
1022
  const agentSkillsDir = await installAgentSkills(projectDir, agent)
@@ -1083,6 +1110,7 @@ async function initProject(args) {
1083
1110
  if (tier) {
1084
1111
  await applyTier(join(projectDir, '.opencode', 'agents'), tier, tierModels(tier, TIERS, gratisModelChoice))
1085
1112
  }
1113
+ await migrateLegacyOpenspec(projectDir)
1086
1114
  await scaffoldAspec(projectDir)
1087
1115
  await refreshWorkingContext(projectDir)
1088
1116
  await registerProject(projectDir, { agent, tier: tier || null })
@@ -1419,16 +1447,11 @@ async function refreshWorkingContext(projectDir, scope = 'project') {
1419
1447
  const dbPath = defaultMemoryDbPath(projectDir)
1420
1448
  if (!(await exists(dbPath))) return null
1421
1449
  const engine = createMemoryEngine(dbPath)
1422
- let block
1423
1450
  try {
1424
- block = engine.buildWorkingContext(scope)
1451
+ return writeWorkingContext(engine, projectDir, scope)
1425
1452
  } finally {
1426
1453
  engine.close()
1427
1454
  }
1428
- const out = join(projectDir, '.ancleto', 'working-context.md')
1429
- await mkdir(dirname(out), { recursive: true })
1430
- await writeFile(out, block === null ? '' : block + '\n')
1431
- return block === null ? null : out
1432
1455
  }
1433
1456
 
1434
1457
  async function memoryContext(flags) {
@@ -130,8 +130,8 @@ function buildEngine(db) {
130
130
  WHERE memory_fts MATCH ? AND n.status = 'active'`
131
131
  const run = (match, loose) => {
132
132
  const sql = type
133
- ? db.prepare(`${base} AND n.type = ? ORDER BY rank LIMIT ?`)
134
- : db.prepare(`${base} ORDER BY rank LIMIT ?`)
133
+ ? db.prepare(`${base} AND n.type = ? ORDER BY rank, n.rowid LIMIT ?`)
134
+ : db.prepare(`${base} ORDER BY rank, n.rowid LIMIT ?`)
135
135
  // FTS5 no acepta parametros en ORDER BY rank; el prefijo '*' va dentro del MATCH.
136
136
  try {
137
137
  return type ? sql.all(match, type, lim) : sql.all(match, lim)
@@ -1,5 +1,6 @@
1
1
  import { createMemoryEngine, defaultMemoryDbPath } from './engine.js'
2
2
  import { memoryTools, createMemoryToolHandlers } from './tools.js'
3
+ import { resolveMemoryProjectRoot } from './working-context.js'
3
4
 
4
5
  const PROTOCOL_VERSION = '2024-11-05'
5
6
  const SERVER_INFO = { name: 'ancleto-memory', version: '1.0.0' }
@@ -7,11 +8,12 @@ const SERVER_INFO = { name: 'ancleto-memory', version: '1.0.0' }
7
8
  export function createMemoryServer({ dbPath = defaultMemoryDbPath() } = {}) {
8
9
  let engine = null
9
10
  let handlers = null
11
+ const projectRoot = resolveMemoryProjectRoot(dbPath)
10
12
 
11
13
  function toolkit() {
12
14
  if (!engine) {
13
15
  engine = createMemoryEngine(dbPath)
14
- handlers = createMemoryToolHandlers(engine, { source: 'mcp:ancleto-memory' })
16
+ handlers = createMemoryToolHandlers(engine, { source: 'mcp:ancleto-memory', projectRoot })
15
17
  }
16
18
  return handlers
17
19
  }
@@ -1,11 +1,12 @@
1
1
  import { createMemoryEngine } from './engine.js'
2
+ import { shouldRefreshWorkingContext, writeWorkingContext } from './working-context.js'
2
3
 
3
4
  const KEY_DESCRIPTION = 'Clave conceptual estable (ej. "api-error-format"). Reusala para actualizar: la nueva version supersede automaticamente la anterior.'
4
5
 
5
6
  const SCOPE_SCHEMA = {
6
7
  type: 'string',
7
8
  enum: ['project', 'feature', 'task'],
8
- description: 'Ambito: project (default, entra en <ProjectMemoryRules>), feature o task para algo mas acotado.'
9
+ description: 'Ambito: project (default; las reglas con scope project entran en <ProjectMemoryRules>, las decisiones se recuperan reactivamente con searchMemory), feature o task para algo mas acotado.'
9
10
  }
10
11
 
11
12
  const SEARCH_SCHEMA = {
@@ -62,10 +63,23 @@ export const memoryTools = [
62
63
  ]
63
64
 
64
65
  export function createMemoryToolHandlers(engine, runtimeContext = {}) {
66
+ function record(args, type, defaultSource) {
67
+ const result = engine.recordNode({ ...args, type }, { ...runtimeContext, source: runtimeContext.source || defaultSource })
68
+ if (runtimeContext.projectRoot && shouldRefreshWorkingContext(result)) {
69
+ try {
70
+ writeWorkingContext(engine, runtimeContext.projectRoot)
71
+ } catch (err) {
72
+ // stderr: stdout es el canal JSON-RPC del server MCP.
73
+ console.warn(`ancleto: no se pudo refrescar el working-context: ${err.message}`)
74
+ }
75
+ }
76
+ return result
77
+ }
78
+
65
79
  return {
66
80
  searchMemory: (args) => engine.searchMemory(args),
67
- recordRule: (args) => engine.recordNode({ ...args, type: 'rule' }, { ...runtimeContext, source: runtimeContext.source || 'tool:recordRule' }),
68
- recordDecision: (args) => engine.recordNode({ ...args, type: 'decision' }, { ...runtimeContext, source: runtimeContext.source || 'tool:recordDecision' })
81
+ recordRule: (args) => record(args, 'rule', 'tool:recordRule'),
82
+ recordDecision: (args) => record(args, 'decision', 'tool:recordDecision')
69
83
  }
70
84
  }
71
85
 
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ // Invariante "memoria activa -> working-context.md": render desde el engine vivo
5
+ // (una sola conexion, sin reabrir la DB) y persistencia del artefacto derivado.
6
+
7
+ export function workingContextPath(projectRoot) {
8
+ return join(projectRoot, '.ancleto', 'working-context.md')
9
+ }
10
+
11
+ // Trigger del refresh sobre el resultado completo de `recordNode` `{ node, superseded }`:
12
+ // (a) contribucion directa: el nodo entrante es rule + project (alimenta el bloque);
13
+ // (b) efecto por supersesion: `memory_key` es identidad transversal (supersede sin filtrar
14
+ // type/scope), asi que cualquier supersesion pudo retirar del bloque una rule de scope project.
15
+ // El over-refresh (supersesion que no altera el bloque) es intencional: el engine solo expone
16
+ // `{ node, superseded }` y el unico consumidor lee el archivo una vez por sesion.
17
+ export function shouldRefreshWorkingContext(result) {
18
+ return !!result && ((result.node.type === 'rule' && result.node.scope === 'project') || result.superseded > 0)
19
+ }
20
+
21
+ // Deriva la raiz del proyecto desde la ubicacion de la DB (no desde el cwd del proceso).
22
+ // Prioriza el primer ancestro con `.ancletorc`; si no hay, usa el candidato derivado de
23
+ // `dbPath` mientras exista el layout `.ancleto/`; si no, no hay raiz (refresh best-effort).
24
+ export function resolveMemoryProjectRoot(dbPath) {
25
+ if (!dbPath) return null
26
+ const candidate = dirname(dirname(dbPath))
27
+ for (let dir = candidate; ; dir = dirname(dir)) {
28
+ if (existsSync(join(dir, '.ancletorc'))) return dir
29
+ const parent = dirname(dir)
30
+ if (parent === dir) break
31
+ }
32
+ if (existsSync(join(candidate, '.ancleto'))) return candidate
33
+ return null
34
+ }
35
+
36
+ // Render desde el engine vivo con la raiz explicita (topologia + salida) y escritura
37
+ // sincrona del archivo: paridad con `block + '\n'` y `''` cuando el bloque es null.
38
+ // Retorna el path escrito o null si no hay bloque/raiz.
39
+ export function writeWorkingContext(engine, projectRoot, scope = 'project') {
40
+ if (!projectRoot) return null
41
+ const block = engine.buildWorkingContext(scope, undefined, projectRoot)
42
+ const out = workingContextPath(projectRoot)
43
+ mkdirSync(dirname(out), { recursive: true })
44
+ writeFileSync(out, block === null ? '' : block + '\n')
45
+ return block === null ? null : out
46
+ }