@tacuchi/agent-workflow-cli 21.16.0 → 21.17.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.
@@ -20,11 +20,13 @@ import { DESIGN_DESCRIPTOR, DESIGN_OPERATIONS } from "../../domain/design/capabi
20
20
  import { attainedMaturity, isIndexable, resolveOutputRoot, } from "../../domain/design/direct.js";
21
21
  import { deriveStructuralSignals, judgeExpansion, } from "../../domain/design/expansion.js";
22
22
  import { DESIGN_MANIFEST_FILE, DESIGN_MANIFEST_SCHEMA_ID, } from "../../domain/design/manifest.js";
23
+ import { PROJECTIONS } from "../../domain/design/naming.js";
23
24
  import { DESIGN_ADAPTERS } from "../../domain/design/profiles.js";
24
- import { SIMPLE_CORE_SECTIONS, SIMPLE_DESIGN_FILE, SIMPLE_SECTIONS, designFolder, designSlug, nextPackageId, } from "../../domain/design/simple.js";
25
+ import { SIMPLE_CORE_SECTIONS, SIMPLE_DESIGN_FILE, SIMPLE_SECTIONS, designFolder, designSlug, nextPackageId, simpleMaturity, validateSimpleDesign, } from "../../domain/design/simple.js";
25
26
  import { classifySource, reportSources, } from "../../domain/design/sources.js";
26
27
  import { baseDigest } from "../../domain/proposal.js";
27
28
  import { localDateIso } from "../dates.js";
29
+ import { currentEntries, gatePackageContent } from "../design/design-content-gate-service.js";
28
30
  import { readDesignIndex, resolveDesignPackage, } from "../design/design-index-service.js";
29
31
  import { buildPackageCandidate, } from "../design/design-publish-service.js";
30
32
  import { checkRecordPrecondition } from "../design/design-record-service.js";
@@ -78,25 +80,42 @@ async function validatePackage(ctx) {
78
80
  },
79
81
  };
80
82
  }
83
+ // The SAME content gate `aw designs` runs. Judging a package with the
84
+ // structural check alone answered `handoff` for a tree the listing then
85
+ // rejected — the verdict and the listing have to be the same verdict.
86
+ const content = await gatePackageContent(ctx.fs, ctx.workspace, found);
87
+ const failures = [...found.failures, ...content];
88
+ const ok = found.ok && content.length === 0;
81
89
  const validations = [
82
90
  {
83
91
  id: "design-manifest",
84
92
  passed: found.ok,
85
93
  detail: found.ok ? null : found.failures.map((f) => `${f.code}: ${f.message}`).join("; "),
86
94
  },
95
+ {
96
+ id: "design-content",
97
+ passed: content.length === 0,
98
+ detail: content.length === 0 ? null : content.map((f) => `${f.code}: ${f.message}`).join("; "),
99
+ },
87
100
  ];
88
101
  const report = reportSources([], `${id}`);
89
102
  const simple = found.mode === "simple";
90
- const maturity = attainedMaturity(requestedMaturity(ctx), found.ok ? "handoff" : "outline", report);
103
+ const gate = ok
104
+ ? await publishedMaturity(ctx.fs, ctx.workspace, found)
105
+ : { attained: "outline", reasons: failures.map((f) => f.message) };
106
+ const maturity = attainedMaturity(requestedMaturity(ctx), gate.attained, report);
91
107
  const fields = {
92
108
  package: found.id,
93
109
  baseline: found.current_baseline === null
94
110
  ? null
95
111
  : { revision: found.current_baseline.revision, digest: found.current_baseline.digest },
112
+ // A verdict publishes nothing, so there is nothing it could have failed to
113
+ // seal: the package's own baseline above is the whole answer.
114
+ unsealed: null,
96
115
  path: found.path,
97
116
  root: "workspace",
98
117
  indexable: true,
99
- maturity: { requested: requestedMaturity(ctx), attained: simple ? null : maturity.attained },
118
+ maturity: { requested: requestedMaturity(ctx), attained: maturity.attained },
100
119
  sources: [],
101
120
  renditions: [],
102
121
  // Judging an existing design reports the route it IS, not one this attempt
@@ -108,7 +127,7 @@ async function validatePackage(ctx) {
108
127
  kind: "completed",
109
128
  validations,
110
129
  output: {
111
- value: { design: fields, ok: found.ok, failures: found.failures },
130
+ value: { design: fields, ok, failures, gaps: [...gate.reasons, ...maturity.gaps] },
112
131
  reference: found.id === null || found.current_baseline === null
113
132
  ? null
114
133
  : {
@@ -146,6 +165,25 @@ async function authoring(ctx) {
146
165
  },
147
166
  };
148
167
  }
168
+ // Outside a workspace every destination this route could declare is ABSOLUTE,
169
+ // and the write boundary only admits workspace-relative paths inside the
170
+ // declared ones: an absolute answer is refused for being absolute and a
171
+ // relative one for falling outside them. No answer exists, so publishing the
172
+ // contract burns an authoring round over a question with no valid reply — and
173
+ // `apply` demands a workspace anyway, so it could never land either. The
174
+ // refusal belongs here, where the root is decided, and not one stage later
175
+ // where it reads as the author's mistake.
176
+ if (ctx.workspace === null) {
177
+ return {
178
+ kind: "blocked",
179
+ failure: {
180
+ code: "DESIGN_WORKSPACE_ABSENT",
181
+ message: `fuera de un workspace los destinos de '${ctx.operation.name}' son absolutos y ninguna respuesta puede caer dentro de ellos`,
182
+ action: "corré la operación dentro del workspace donde debe quedar el diseño: 'target' acota la carpeta DENTRO del workspace, no publica fuera de él",
183
+ },
184
+ };
185
+ }
186
+ const workspace = ctx.workspace;
149
187
  const precondition = await operationPrecondition(ctx);
150
188
  if (precondition !== null)
151
189
  return { kind: "blocked", failure: precondition };
@@ -166,7 +204,7 @@ async function authoring(ctx) {
166
204
  // what a valid answer looks like, and where it may land, are different on the
167
205
  // two routes, so asking first and classifying afterwards would publish a
168
206
  // contract for a route the run is not on.
169
- const route = await decideRoute(ctx, sources, root.value);
207
+ const route = await decideRoute(ctx, workspace, sources, root.value);
170
208
  if (!route.ok)
171
209
  return { kind: "blocked", failure: route.failure };
172
210
  const request = buildSemanticRequest({
@@ -204,9 +242,14 @@ async function authoring(ctx) {
204
242
  if (!parsed.ok)
205
243
  return { kind: "blocked", failure: parsed.failure };
206
244
  const answered = parsed.value.artifacts ?? [];
207
- return route.value.target === null
208
- ? packageProposal(ctx, report, route.value, answered)
209
- : simpleProposal(ctx, report, route.value, answered);
245
+ const { target } = route.value;
246
+ if (target.mode === "simple") {
247
+ return simpleProposal(ctx, workspace, report, route.value, target.simple, answered);
248
+ }
249
+ if (target.mode === "package") {
250
+ return packageProposal(ctx, workspace, report, route.value, target.package, answered);
251
+ }
252
+ return projectionProposal(ctx, workspace, report, route.value, target.projection, answered);
210
253
  }
211
254
  /**
212
255
  * Which route this attempt runs on, and everything that follows from it.
@@ -217,13 +260,17 @@ async function authoring(ctx) {
217
260
  * or a structural one somebody typed comes back as a rejection in the verdict
218
261
  * rather than as an expansion nobody can explain.
219
262
  */
220
- async function decideRoute(ctx, sources, root) {
221
- const index = ctx.workspace === null ? null : await readDesignIndex(ctx.fs, ctx.workspace);
263
+ async function decideRoute(ctx, workspace, sources, root) {
264
+ // Always readable, never null: a workspace with no `docs/designs/` yet answers
265
+ // an EMPTY index, and outside a workspace `authoring` already refused. A
266
+ // nullable index here used to carry a third state that every branch below had
267
+ // to restate and that no invocation could reach.
268
+ const index = await readDesignIndex(ctx.fs, workspace);
222
269
  // By IDENTITY and only when one was named. `find(p => p.id === null)` would
223
270
  // match the first package whose manifest does not validate — an entry that has
224
271
  // no identity is not the one this invocation continues.
225
272
  const named = packageInput(ctx);
226
- const targeted = index === null || named === null ? null : (index.packages.find((p) => p.id === named) ?? null);
273
+ const targeted = named === null ? null : (index.packages.find((p) => p.id === named) ?? null);
227
274
  const verdict = judgeExpansion(declaredExpansionSignals(ctx), deriveStructuralSignals({
228
275
  sensitiveSources: ctx.request.policy.sensitive_sources === true,
229
276
  externalTransmission: ctx.request.policy.external_transmission === true,
@@ -232,9 +279,9 @@ async function decideRoute(ctx, sources, root) {
232
279
  (targeted?.manifest?.governance.revocations.length ?? 0),
233
280
  publishedRevisions: targeted?.manifest?.baselines.length ?? 0,
234
281
  }));
235
- // The package route is also the only one available outside a workspace or
236
- // outside `docs/designs/`: a simple design derives its identity from the index,
237
- // and there is no index to derive it from.
282
+ // The package route is also the only one available outside `docs/designs/`: a
283
+ // simple design derives its identity from the index, and a root the index does
284
+ // not cover has none to derive from.
238
285
  //
239
286
  // `render` and `record` are package operations whatever the signals say —
240
287
  // projecting revisions and sealing governance decisions are things a catalog
@@ -242,13 +289,13 @@ async function decideRoute(ctx, sources, root) {
242
289
  // simple design for a maturity and a rendition it never had.
243
290
  if (verdict.mode === "package" ||
244
291
  !AUTHORING_OPERATIONS.includes(ctx.operation.name) ||
245
- index === null ||
246
292
  !isIndexable(root)) {
247
293
  return packageRoute(ctx, verdict, index, root);
248
294
  }
249
295
  const resolved = resolveSimpleTarget(index, ctx.operation.name, {
250
296
  title: textInput(ctx, "title"),
251
297
  packageId: packageInput(ctx),
298
+ root: root.root,
252
299
  });
253
300
  if (!resolved.ok) {
254
301
  const { code, message, action } = resolved.failure;
@@ -269,49 +316,32 @@ async function decideRoute(ctx, sources, root) {
269
316
  // The exact file, not its folder: on the simple route the CLI already knows
270
317
  // the one destination, so anything else is not a design it can publish.
271
318
  destinations: [`${resolved.value.path}/${SIMPLE_DESIGN_FILE}`],
272
- target: resolved.value,
273
- packageTarget: null,
319
+ target: { mode: "simple", simple: resolved.value },
274
320
  root,
275
321
  },
276
322
  };
277
323
  }
278
324
  /**
279
- * The package route: sealed when the target can be derived, verbatim when not.
325
+ * The package route. It seals, or it does not run.
280
326
  *
281
- * The compare-and-swap base is the caller's claim on this route, and a safety
282
- * check that can be omitted is one nobody performs. It is demanded HERE rather
283
- * than in the descriptor because the simple route derives it instead.
327
+ * There used to be a third outcome, declared in the code as a known limitation:
328
+ * when the target could not be derived the answer was written EXACTLY as
329
+ * authored no baseline, no manifest, no gate and the receipt said
330
+ * `completed` anyway. The measured effect was a tree `aw designs` refuses right
331
+ * afterwards, which only a hand check ever discovers. An operation that did not
332
+ * happen is a better outcome than a dossier nobody can read, so what used to be
333
+ * a silent half-write is now either a sealed publication or a refusal that names
334
+ * what is missing.
284
335
  */
285
336
  function packageRoute(ctx, verdict, index, root) {
286
- if (ctx.operation.name === "update" && textInput(ctx, "base") === null) {
287
- return {
288
- ok: false,
289
- failure: {
290
- code: "DESIGN_FIELD_INVALID",
291
- message: "actualizar un package declara sobre qué revisión se preparó",
292
- action: "pasá 'base' con la revisión vigente (por ejemplo DES-001@r3), o null si el package no publicó ninguna",
293
- },
294
- };
337
+ // `render` and `record` author no normative content: a projection is derived
338
+ // from the manifest and a governance decision decides ON a baseline. Minting
339
+ // no revision is their NATURE, not a defect, so they get their own route
340
+ // instead of the refusal that briefly made both unreachable.
341
+ if (!AUTHORING_OPERATIONS.includes(ctx.operation.name)) {
342
+ return projectionRoute(ctx, verdict, index, root);
295
343
  }
296
- // KNOWN LIMITATION: `render`/`record` — and a create/update with no index to
297
- // derive from (outside a workspace or outside `docs/designs/`) — still run
298
- // the verbatim path: the answer is published as authored, with no derived
299
- // manifest, baseline or gate. Only create/update over the index seal.
300
- if (index === null || !AUTHORING_OPERATIONS.includes(ctx.operation.name) || !isIndexable(root)) {
301
- return {
302
- ok: true,
303
- value: {
304
- verdict,
305
- contract: contractFor(ctx.operation.name),
306
- inventory: { root: root.root, mode: root.kind },
307
- destinations: [root.root],
308
- target: null,
309
- packageTarget: null,
310
- root,
311
- },
312
- };
313
- }
314
- const resolved = resolvePackageTarget(ctx, index);
344
+ const resolved = resolvePackageTarget(ctx, index, root);
315
345
  if (!resolved.ok)
316
346
  return { ok: false, failure: resolved.failure };
317
347
  const target = resolved.value;
@@ -330,67 +360,160 @@ function packageRoute(ctx, verdict, index, root) {
330
360
  // The package folder, not the taxonomy root: the destination check is
331
361
  // segment-based, so every artifact lands INSIDE this package or nowhere.
332
362
  destinations: [target.path],
333
- target: null,
334
- packageTarget: target,
363
+ target: { mode: "package", package: target },
335
364
  root,
336
365
  },
337
366
  };
338
367
  }
368
+ /** Why each non-authoring operation mints no revision, said in its own receipt. */
369
+ const UNSEALED_CAUSE = {
370
+ render: "'render' regenera proyecciones: las deriva el CLI del manifest y ningún baseline las selecciona, así que no hay revisión que acuñar",
371
+ record: "'record' decide SOBRE una revisión que ya existe: sella una decisión de gobierno y no acuña una línea base nueva",
372
+ };
339
373
  /**
340
- * The package route's target, derived BEFORE the contract is published.
374
+ * The route of an operation that publishes WITHOUT minting a revision.
341
375
  *
342
- * `create` mints the identity and the folder from the title, over an initial
343
- * manifest that exists only to give the candidate builder a line to start from.
344
- * `update` locates the package BY IDENTITY and checks the declared base against
345
- * the line in force — the same compare-and-swap `publishDesignRevision` runs,
346
- * moved to the moment the contract is fixed instead of discovered mid-publish.
376
+ * It writes inside a package the index already carries, and refuses when there
377
+ * is none. The two halves are one rule: with a manifest already there the tree
378
+ * stays readable `aw designs` accepts afterwards exactly what it accepted
379
+ * before, because nothing sealed moves and without one the files would land in
380
+ * a folder the listing then rejects for having no manifest, which is the
381
+ * illegible tree this plan exists to stop.
347
382
  */
348
- function resolvePackageTarget(ctx, index) {
349
- if (ctx.operation.name === "create") {
350
- const title = textInput(ctx, "title");
351
- if (title === null) {
352
- return {
353
- ok: false,
354
- failure: {
355
- code: "DESIGN_FIELD_INVALID",
356
- message: "un package nuevo necesita un título",
357
- action: "pasá 'title' con el nombre humano del diseño: de ahí salen la carpeta y el id",
358
- },
359
- };
360
- }
361
- const packageId = nextPackageId(index.packages.map((p) => p.id ?? p.declared_id));
383
+ function projectionRoute(ctx, verdict, index, root) {
384
+ const operation = ctx.operation.name;
385
+ const named = packageInput(ctx);
386
+ if (named === null) {
362
387
  return {
363
- ok: true,
364
- value: {
365
- packageId,
366
- path: designFolder(index.root, packageId, designSlug(title)),
367
- revision: 1,
368
- manifest: initialPackageManifest(packageId, title, localDateIso(new Date())),
388
+ ok: false,
389
+ failure: {
390
+ code: "DESIGN_FIELD_INVALID",
391
+ message: `'${operation}' escribe dentro de un package que ya existe y no se declaró cuál`,
392
+ action: "pasá 'package' con su id, por ejemplo DES-007",
369
393
  },
370
394
  };
371
395
  }
372
- const named = packageInput(ctx);
373
- if (named === null) {
396
+ const located = locatePackage(index, named);
397
+ if (!located.ok)
398
+ return { ok: false, failure: located.failure };
399
+ const entry = located.value;
400
+ const target = {
401
+ entry,
402
+ unsealed: UNSEALED_CAUSE[operation] ?? `'${operation}' no acuña una revisión`,
403
+ };
404
+ return {
405
+ ok: true,
406
+ value: {
407
+ verdict,
408
+ contract: projectionContract(operation, target),
409
+ inventory: {
410
+ root: root.root,
411
+ mode: entry.manifest.mode,
412
+ package: entry.manifest.id,
413
+ path: entry.path,
414
+ // Consultative and load-bearing: an author who does not know the answer
415
+ // will not be sealed writes a revision nobody asked for.
416
+ seals: false,
417
+ },
418
+ // The package folder the INDEX reports, not the root the invocation named:
419
+ // this operation writes into a package that already exists, wherever it is.
420
+ destinations: [entry.path],
421
+ target: { mode: "projection", projection: target },
422
+ root,
423
+ },
424
+ };
425
+ }
426
+ function resolvePackageTarget(ctx, index, root) {
427
+ return ctx.operation.name === "create"
428
+ ? mintPackageTarget(ctx, index, root)
429
+ : continuePackageTarget(ctx, index);
430
+ }
431
+ /** A brand-new package: the identity and the folder, from the title and the root. */
432
+ function mintPackageTarget(ctx, index, root) {
433
+ const title = textInput(ctx, "title");
434
+ if (title === null) {
374
435
  return {
375
436
  ok: false,
376
437
  failure: {
377
438
  code: "DESIGN_FIELD_INVALID",
378
- message: "actualizar un package necesita la identidad del que se continúa",
379
- action: "pasá 'package' con su id, por ejemplo DES-007",
439
+ message: "un package nuevo necesita un título",
440
+ action: "pasá 'title' con el nombre humano del diseño: de ahí salen la carpeta y el id",
380
441
  },
381
442
  };
382
443
  }
383
- const found = index.packages.find((p) => p.id === named) ?? null;
384
- if (found === null || found.manifest === null) {
444
+ const packageId = nextPackageId(index.packages.map((p) => p.id ?? p.declared_id));
445
+ return {
446
+ ok: true,
447
+ value: {
448
+ packageId,
449
+ // The DECLARED root, not the index's: a `target` that narrows where the
450
+ // package lands has to be where it lands, or the folder and the
451
+ // destination allowlist the request publishes disagree.
452
+ path: designFolder(root.root, packageId, designSlug(title)),
453
+ revision: 1,
454
+ manifest: initialPackageManifest(packageId, title, localDateIso(new Date())),
455
+ },
456
+ };
457
+ }
458
+ function locatePackage(index, named) {
459
+ // By identity, and never by the FIRST match. Two packages claiming one id
460
+ // break every reference to it, and picking whichever the walk reached first
461
+ // would write into one of them at random.
462
+ const claiming = index.packages.filter((p) => p.id === named || p.declared_id === named);
463
+ if (claiming.length > 1) {
464
+ return {
465
+ ok: false,
466
+ failure: {
467
+ code: "DESIGN_REFERENCE_AMBIGUOUS",
468
+ message: `${named} está declarado por ${claiming.length} packages: ${claiming.map((p) => p.path).join(", ")}`,
469
+ action: "dos packages no pueden reclamar la misma identidad: renombrá uno y volvé a intentar",
470
+ },
471
+ };
472
+ }
473
+ const found = claiming[0];
474
+ if (found === undefined) {
385
475
  return {
386
476
  ok: false,
387
477
  failure: {
388
478
  code: "DESIGN_PACKAGE_NOT_FOUND",
389
- message: `no hay ningún package ${named} legible bajo ${index.root}/`,
479
+ message: `no hay ningún package ${named} bajo ${index.root}/`,
390
480
  action: `revisá 'aw designs' para ver las identidades publicadas bajo ${index.root}/`,
391
481
  },
392
482
  };
393
483
  }
484
+ // A BROKEN package is not a missing one: they are very different problems for
485
+ // whoever has to fix one, so what comes back is the manifest's own diagnosis
486
+ // and not «no existe».
487
+ if (found.manifest === null) {
488
+ const first = found.failures[0];
489
+ return {
490
+ ok: false,
491
+ failure: {
492
+ code: first?.code ?? "DESIGN_MANIFEST_MISSING",
493
+ message: `${found.manifest_path}: ${first?.message ?? "el package no tiene un manifest legible"}`,
494
+ action: first?.action ?? "reparalo antes de publicar sobre él",
495
+ },
496
+ };
497
+ }
498
+ return { ok: true, value: { ...found, manifest: found.manifest } };
499
+ }
500
+ /** The next revision of a package that exists: located by identity, base checked. */
501
+ function continuePackageTarget(ctx, index) {
502
+ const named = packageInput(ctx);
503
+ if (named === null) {
504
+ return {
505
+ ok: false,
506
+ failure: {
507
+ code: "DESIGN_FIELD_INVALID",
508
+ message: "actualizar un package necesita la identidad del que se continúa",
509
+ action: "pasá 'package' con su id, por ejemplo DES-007",
510
+ },
511
+ };
512
+ }
513
+ const located = locatePackage(index, named);
514
+ if (!located.ok)
515
+ return { ok: false, failure: located.failure };
516
+ const found = located.value;
394
517
  const manifest = found.manifest;
395
518
  const current = manifest.current_baseline;
396
519
  const actual = current === null ? null : `${manifest.id}@r${current.revision}`;
@@ -452,8 +575,7 @@ function initialPackageManifest(packageId, title, created) {
452
575
  };
453
576
  }
454
577
  /** The simple route's durable step: one authored document, everything else derived. */
455
- async function simpleProposal(ctx, report, route, answered) {
456
- const target = route.target;
578
+ async function simpleProposal(ctx, workspace, report, route, target, answered) {
457
579
  const documentPath = `${target.path}/${SIMPLE_DESIGN_FILE}`;
458
580
  const document = answered.find((a) => a.path === documentPath);
459
581
  if (answered.length !== 1 || document === undefined) {
@@ -466,9 +588,7 @@ async function simpleProposal(ctx, report, route, answered) {
466
588
  },
467
589
  };
468
590
  }
469
- // A target only exists when `decideRoute` read the index, which it only does
470
- // inside a workspace: the simple route and a null workspace cannot coexist.
471
- const built = await buildSimpleProposal(ctx.fs, ctx.workspace, {
591
+ const built = await buildSimpleProposal(ctx.fs, workspace, {
472
592
  target,
473
593
  document: document.content,
474
594
  published: localDateIso(new Date()),
@@ -484,14 +604,19 @@ async function simpleProposal(ctx, report, route, answered) {
484
604
  },
485
605
  };
486
606
  }
607
+ // The gate's verdict over the document, exactly as the package route takes it
608
+ // from the gate over its own: what the design attains cannot depend on which
609
+ // route wrote it.
610
+ const gate = built.value.maturity;
611
+ const maturity = attainedMaturity(requestedMaturity(ctx), gate.attained, report);
487
612
  const fields = {
488
613
  package: built.value.packageId,
489
614
  baseline: { revision: built.value.revision, digest: built.value.digest },
615
+ unsealed: null,
490
616
  path: target.path,
491
617
  root: route.root.kind,
492
618
  indexable: true,
493
- // No ladder to climb: see `DesignReceiptFields.maturity`.
494
- maturity: { requested: requestedMaturity(ctx), attained: null },
619
+ maturity: { requested: requestedMaturity(ctx), attained: maturity.attained },
495
620
  sources: report.sources,
496
621
  renditions: [],
497
622
  route: routeOf(route.verdict),
@@ -507,7 +632,7 @@ async function simpleProposal(ctx, report, route, answered) {
507
632
  value: {
508
633
  design: fields,
509
634
  artifacts: built.value.artifacts.map((a) => a.path),
510
- gaps: [],
635
+ gaps: [...gate.reasons, ...maturity.gaps],
511
636
  },
512
637
  reference: null,
513
638
  completeness: "partial",
@@ -516,47 +641,14 @@ async function simpleProposal(ctx, report, route, answered) {
516
641
  };
517
642
  }
518
643
  /**
519
- * The expanded route's durable step.
520
- *
521
- * With a derived target (create/update over the index) the CLI owns the seal:
522
- * the authored artifacts are candidate files, and the manifest, the baseline
523
- * and `PACKAGE.md` are DERIVED here — the same candidate `publishDesignRevision`
524
- * publishes, so the gate verdict the `012` computes runs NOW, inside `validate`,
525
- * and an invalid tree is blocked before the first byte moves.
644
+ * The expanded route's durable step. The CLI owns the seal, always.
526
645
  *
527
- * Without one (`render`/`record`, or no index to derive from) the package is
528
- * published exactly as authored. That verbatim path is the known limitation
529
- * `decideRoute` documents, not a second contract.
646
+ * The authored artifacts are candidate files, and the manifest, the baseline
647
+ * and `PACKAGE.md` are DERIVED here the one candidate the whole system
648
+ * publishes, so the gate verdict runs NOW, inside `validate`, and an invalid
649
+ * tree is blocked before the first byte moves.
530
650
  */
531
- async function packageProposal(ctx, report, route, answered) {
532
- const target = route.packageTarget;
533
- if (target === null) {
534
- const artifacts = answered.map((a) => ({ path: a.path, content: a.content }));
535
- const maturity = attainedMaturity(requestedMaturity(ctx), "outline", report);
536
- const fields = {
537
- package: packageInput(ctx),
538
- baseline: null,
539
- path: route.root.root,
540
- root: route.root.kind,
541
- indexable: isIndexable(route.root),
542
- maturity: { requested: requestedMaturity(ctx), attained: maturity.attained },
543
- sources: report.sources,
544
- renditions: [],
545
- route: routeOf(route.verdict),
546
- };
547
- return {
548
- kind: "durable",
549
- artifacts,
550
- output: {
551
- value: { design: fields, artifacts: artifacts.map((a) => a.path), gaps: maturity.gaps },
552
- reference: null,
553
- // The durable step has not run yet, so nothing is published. Claiming
554
- // `complete` here would let a gate accept a proposal as a package.
555
- completeness: "partial",
556
- },
557
- base: null,
558
- };
559
- }
651
+ async function packageProposal(ctx, workspace, report, route, target, answered) {
560
652
  // From workspace-relative to package-relative, which is the vocabulary the
561
653
  // candidate builder speaks. The destination check already confined every
562
654
  // answer to the package folder, so the prefix always strips.
@@ -564,7 +656,7 @@ async function packageProposal(ctx, report, route, answered) {
564
656
  const files = [];
565
657
  for (const artifact of answered) {
566
658
  const relative = artifact.path.slice(prefix.length);
567
- if (CLI_DERIVED_FILES.includes(relative) || relative.startsWith("baselines/")) {
659
+ if (owns(DERIVED_PATHS, relative)) {
568
660
  return {
569
661
  kind: "blocked",
570
662
  failure: {
@@ -576,9 +668,7 @@ async function packageProposal(ctx, report, route, answered) {
576
668
  }
577
669
  files.push({ path: relative, content: artifact.content });
578
670
  }
579
- // The target only exists when `decideRoute` read the index, which it only
580
- // does inside a workspace.
581
- const candidate = await buildPackageCandidate(ctx.fs, ctx.workspace, {
671
+ const candidate = await buildPackageCandidate(ctx.fs, workspace, {
582
672
  manifest: target.manifest,
583
673
  packagePath: target.path,
584
674
  files,
@@ -597,13 +687,17 @@ async function packageProposal(ctx, report, route, answered) {
597
687
  },
598
688
  };
599
689
  }
600
- // The gate passed over the merged catalog, so the verdict a proposal may
601
- // claim is what THIS revision's flows and screens claim for themselves — the
602
- // ones already sealed were judged when they were published.
603
- const maturity = attainedMaturity(requestedMaturity(ctx), claimedMaturity(candidate.value.manifest, files), report);
690
+ // The verdict over the catalog this revision LEAVES, not over the files it
691
+ // brings. A revision of a single token introduces no document that could
692
+ // object, and judging only what it introduces answered `handoff` for a package
693
+ // whose current flow was still `outline` — a receipt the `validate` right
694
+ // afterwards contradicted about the same tree.
695
+ const gate = catalogMaturity(candidate.value.manifest);
696
+ const maturity = attainedMaturity(requestedMaturity(ctx), gate.attained, report);
604
697
  const fields = {
605
698
  package: target.packageId,
606
699
  baseline: { revision: candidate.value.revision, digest: candidate.value.baseline.digest },
700
+ unsealed: null,
607
701
  path: target.path,
608
702
  root: route.root.kind,
609
703
  indexable: isIndexable(route.root),
@@ -619,39 +713,184 @@ async function packageProposal(ctx, report, route, answered) {
619
713
  value: {
620
714
  design: fields,
621
715
  artifacts: candidate.value.artifacts.map((a) => a.path),
622
- gaps: maturity.gaps,
716
+ gaps: [...gate.reasons, ...maturity.gaps],
623
717
  },
624
718
  reference: null,
625
719
  // The durable step has not run yet, so nothing is published. Claiming
626
720
  // `complete` here would let a gate accept a proposal as a package.
627
721
  completeness: "partial",
628
722
  },
629
- base: await packageManifestBase(ctx, target),
723
+ base: await packageManifestBase(ctx, workspace, target),
724
+ };
725
+ }
726
+ /**
727
+ * The durable step of a publication that mints NO revision.
728
+ *
729
+ * What it refuses is the only way one of these operations can leave the package
730
+ * unreadable: a hand-authored manifest or baseline seals the tree with something
731
+ * nobody derived, and `aw designs` rejects it right afterwards. Everything else
732
+ * lands as authored — a projection replaces the one it regenerates, anything
733
+ * else is additive — and the receipt says, in words, that nothing was sealed.
734
+ */
735
+ async function projectionProposal(ctx, workspace, report, route, target, answered) {
736
+ const entry = target.entry;
737
+ const prefix = `${entry.path}/`;
738
+ const artifacts = [];
739
+ for (const artifact of answered) {
740
+ const relative = artifact.path.slice(prefix.length);
741
+ if (owns(SEALED_PATHS, relative)) {
742
+ return {
743
+ kind: "blocked",
744
+ failure: {
745
+ code: "DESIGN_FIELD_INVALID",
746
+ message: `'${artifact.path}' es lo que sella el package, y '${ctx.operation.name}' no acuña revisión`,
747
+ action: `quitalo de 'artifacts': ${DESIGN_MANIFEST_FILE} y 'baselines/' los deriva y sella una publicación de contenido normativo, con 'create' o 'update'`,
748
+ },
749
+ };
750
+ }
751
+ artifacts.push({
752
+ path: artifact.path,
753
+ content: artifact.content,
754
+ // Regenerating a projection REPLACES it — that is what regenerating means,
755
+ // and `render` declares `mutate_overwrite` for exactly this. Nothing else
756
+ // is regenerable: a governance record decides on bytes that already exist,
757
+ // so publishing over one would rewrite a decision somebody made.
758
+ overwrite: PROJECTIONS.includes(relative),
759
+ });
760
+ }
761
+ // The design's own maturity, unchanged: this publication catalogues nothing,
762
+ // so reporting anything else would credit or blame it for a verdict it did
763
+ // not move. Through the same function `validate` uses, which is what keeps a
764
+ // simple design judged by its document instead of by an empty catalog.
765
+ const gate = await publishedMaturity(ctx.fs, workspace, entry);
766
+ const maturity = attainedMaturity(requestedMaturity(ctx), gate.attained, report);
767
+ const fields = {
768
+ package: entry.manifest.id,
769
+ // Null, and SAID: `unsealed` is what turns "no hay línea base" from an
770
+ // omission into a declaration.
771
+ baseline: null,
772
+ unsealed: target.unsealed,
773
+ path: entry.path,
774
+ root: route.root.kind,
775
+ // Resolved FROM the index, so it is indexed whatever root the invocation
776
+ // happened to name.
777
+ indexable: true,
778
+ maturity: { requested: requestedMaturity(ctx), attained: maturity.attained },
779
+ sources: report.sources,
780
+ renditions: [],
781
+ route: routeOf(route.verdict),
782
+ };
783
+ return {
784
+ kind: "durable",
785
+ artifacts,
786
+ output: {
787
+ value: {
788
+ design: fields,
789
+ artifacts: artifacts.map((a) => a.path),
790
+ gaps: [...gate.reasons, ...maturity.gaps],
791
+ },
792
+ reference: null,
793
+ // Nothing is on disk until the approval lands, here as everywhere else.
794
+ completeness: "partial",
795
+ },
796
+ // Nothing to compare and swap: this publication reads no manifest to derive
797
+ // its output, so there is no state it could have been computed against.
798
+ base: null,
630
799
  };
631
800
  }
632
- /** Package-relative file paths the CLI derives and seals — authoring one is rejected. */
633
- const CLI_DERIVED_FILES = [DESIGN_MANIFEST_FILE, "PACKAGE.md"];
634
801
  /**
635
- * The maturity this revision's own flows and screens claim, as the merged
636
- * catalog recorded it: `outline` as soon as one of them claims no more, and
637
- * `handoff` when none says otherwise — the kinds without a maturity ladder
638
- * have no vote.
802
+ * Package-relative paths the CLI SEALS. Authoring one is refused on every route:
803
+ * a hand-written manifest or baseline is precisely the tree the listing rejects.
639
804
  */
640
- function claimedMaturity(manifest, files) {
641
- const introduced = new Set(files.map((f) => f.path));
642
- const claims = [...manifest.catalog.flows, ...manifest.catalog.screens]
643
- .filter((entry) => introduced.has(entry.path))
644
- .map((entry) => entry.maturity);
645
- return claims.includes("outline") ? "outline" : "handoff";
805
+ const SEALED_PATHS = [DESIGN_MANIFEST_FILE, "baselines"];
806
+ /** What a SEALING publication also derives for itself: the projections it renders. */
807
+ const DERIVED_PATHS = [...SEALED_PATHS, ...PROJECTIONS];
808
+ /** Is this package-relative path one of `owned` — the entry itself, or under it? */
809
+ function owns(owned, relative) {
810
+ return owned.some((path) => relative === path || relative.startsWith(`${path}/`));
811
+ }
812
+ /**
813
+ * The maturity a catalog attains as a whole.
814
+ *
815
+ * `handoff` is a property of the WHOLE thing being published: a package is
816
+ * consumed as one dossier, so the weakest CURRENT document is what an
817
+ * implementer hits. The empty case is vacuously `handoff`, and that is only
818
+ * sound because every caller derives its claims from {@link currentEntries},
819
+ * which yields exactly one entry per catalogued id: no claims means the catalog
820
+ * has no flow and no screen — no ladder to climb — rather than a filter having
821
+ * eaten the ones it has.
822
+ */
823
+ function ceilingOf(claims) {
824
+ const holding = claims.filter((c) => c.maturity !== "handoff");
825
+ if (holding.length === 0)
826
+ return { attained: "handoff", reasons: [] };
827
+ return {
828
+ attained: "outline",
829
+ reasons: holding.map((c) => `${c.subject} alcanza '${c.maturity}': una publicación vale lo que vale su artefacto más flojo`),
830
+ };
831
+ }
832
+ /**
833
+ * The maturity a package attains — ONE function, over the manifest that IS its
834
+ * catalog.
835
+ *
836
+ * The same question for the tree a publication will LEAVE (the candidate's
837
+ * manifest) and for the one already published (the entry's), so the receipt and
838
+ * the `validate` right after it cannot answer differently about the same tree.
839
+ * Which revision of each artifact answers is the content gate's own
840
+ * `currentEntries`, and reusing it is the point: reading `currentness` again
841
+ * here dropped every artifact it did not enumerate, and a manifest is allowed
842
+ * not to enumerate one.
843
+ *
844
+ * Reading the catalog rather than the files is not a shortcut: the manifest
845
+ * records the maturity each revision was sealed with — the publication gate
846
+ * refused it otherwise — and that IS the verdict in force.
847
+ */
848
+ function catalogMaturity(manifest) {
849
+ const claims = [...currentEntries(manifest, "flows"), ...currentEntries(manifest, "screens")].map((entry) => ({
850
+ subject: `${entry.id}@r${entry.revision}`,
851
+ maturity: entry.maturity ?? "outline",
852
+ }));
853
+ return ceilingOf(claims);
854
+ }
855
+ /**
856
+ * The gate's verdict over a design that is ALREADY published.
857
+ *
858
+ * Two shapes, one question: a simple design is judged by its own document, a
859
+ * package by its catalog. This only runs once the content gate came back clean,
860
+ * which is what makes the catalog's recorded maturities trustworthy here.
861
+ */
862
+ async function publishedMaturity(fs, workspace, entry) {
863
+ const manifest = entry.manifest;
864
+ if (manifest === null) {
865
+ return {
866
+ attained: "outline",
867
+ reasons: [`'${entry.manifest_path}' no valida: sin manifest no hay catálogo que juzgar`],
868
+ };
869
+ }
870
+ if (manifest.mode !== "simple")
871
+ return catalogMaturity(manifest);
872
+ const absolute = join(workspace, entry.path, SIMPLE_DESIGN_FILE);
873
+ if (!(await fs.exists(absolute))) {
874
+ return {
875
+ attained: "outline",
876
+ reasons: [`'${entry.path}/${SIMPLE_DESIGN_FILE}' no está: no hay documento que juzgar`],
877
+ };
878
+ }
879
+ const parsed = validateSimpleDesign(await fs.readText(absolute), SIMPLE_DESIGN_FILE);
880
+ if (!parsed.ok || parsed.value === null) {
881
+ return { attained: "outline", reasons: parsed.failures.map((f) => f.message) };
882
+ }
883
+ const verdict = simpleMaturity(parsed.value);
884
+ return { attained: verdict.attained, reasons: verdict.reasons };
646
885
  }
647
886
  /**
648
887
  * The compare-and-swap base of a package proposal: the manifest as it stood
649
888
  * when the candidate was computed. Null when there is nothing on disk to have
650
889
  * moved — a create is protected by its destinations not existing.
651
890
  */
652
- async function packageManifestBase(ctx, target) {
891
+ async function packageManifestBase(ctx, workspace, target) {
653
892
  const path = `${target.path}/${DESIGN_MANIFEST_FILE}`;
654
- const absolute = join(ctx.workspace, path);
893
+ const absolute = join(workspace, path);
655
894
  if (!(await ctx.fs.exists(absolute)))
656
895
  return null;
657
896
  return { path, digest: baseDigest(await ctx.fs.readText(absolute)) };
@@ -765,18 +1004,6 @@ function simpleContract(target) {
765
1004
  "Respondé un único objeto JSON con 'version', 'operation', 'input_digest', 'state': 'proposed' y 'artifacts': [{path, content}] con ese único archivo.",
766
1005
  ].join(" ");
767
1006
  }
768
- function contractFor(operation) {
769
- const shared = "Respondé un único objeto JSON con 'version', 'operation', 'input_digest', 'state': 'proposed' " +
770
- "y 'artifacts': [{path, content}]. Cada 'path' es relativo al workspace y cae dentro de los " +
771
- "destinos permitidos. Ningún artefacto inventa un formato: los del UI Design Package v1 son los únicos aceptados.";
772
- const perOperation = {
773
- create: "Autorá la PRIMERA revisión del package a partir de las fuentes declaradas.",
774
- update: "Autorá la revisión SIGUIENTE sobre la base declarada. No reescribas revisiones ya selladas.",
775
- render: "Regenerá las proyecciones de la revisión indicada. Una proyección no es normativa y nunca se sella.",
776
- record: "Sellá la decisión de gobierno sobre la revisión indicada, sin tocar el contenido del package.",
777
- };
778
- return `${perOperation[operation] ?? ""} ${shared}`.trim();
779
- }
780
1007
  /**
781
1008
  * What a valid answer is on the SEALED package route: the normative artifacts,
782
1009
  * and nothing the CLI derives.
@@ -801,6 +1028,28 @@ function packageContract(operation, target) {
801
1028
  .join(" ")
802
1029
  .trim();
803
1030
  }
1031
+ /**
1032
+ * What a valid answer is on the route that mints NO revision.
1033
+ *
1034
+ * It states that first, and states it before anything else: an author who thinks
1035
+ * the answer will be sealed writes a revision, and a revision is exactly what
1036
+ * this route does not publish.
1037
+ */
1038
+ function projectionContract(operation, target) {
1039
+ const perOperation = {
1040
+ render: "Regenerá las proyecciones de la revisión vigente. Una proyección no es normativa: sale del manifest y ningún baseline la sella.",
1041
+ record: "Escribí la decisión de gobierno sobre la revisión indicada, sin tocar el contenido normativo del package.",
1042
+ };
1043
+ return [
1044
+ perOperation[operation] ?? "",
1045
+ `Esta operación NO acuña una revisión: ${target.unsealed}.`,
1046
+ `Se escribe DENTRO de '${target.entry.path}', el package ${target.entry.manifest.id} que ya está indexado.`,
1047
+ `NO autores '${DESIGN_MANIFEST_FILE}' ni nada bajo 'baselines/': son lo que sella el package y solo los deriva una publicación de contenido normativo.`,
1048
+ "Respondé un único objeto JSON con 'version', 'operation', 'input_digest', 'state': 'proposed' y 'artifacts': [{path, content}]. Cada 'path' es relativo al workspace y cae dentro de ese package.",
1049
+ ]
1050
+ .join(" ")
1051
+ .trim();
1052
+ }
804
1053
  function inputValue(ctx, name) {
805
1054
  return ctx.request.inputs.find((i) => i.name === name)?.value;
806
1055
  }