yadflow 4.0.0-next.3 → 4.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +699 -0
  2. package/README.md +85 -22
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +183 -29
  5. package/cli/capture.mjs +4 -2
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +4 -4
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +557 -141
  11. package/cli/epic-state.mjs +41 -18
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +4 -4
  15. package/cli/gate-local.mjs +13 -13
  16. package/cli/gate.mjs +111 -103
  17. package/cli/history.mjs +13 -13
  18. package/cli/hook.mjs +201 -30
  19. package/cli/index-command.mjs +6 -6
  20. package/cli/lib.mjs +91 -29
  21. package/cli/manifest.mjs +171 -104
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +6 -6
  25. package/cli/openpr.mjs +32 -24
  26. package/cli/people.mjs +22 -10
  27. package/cli/plan.mjs +718 -45
  28. package/cli/platform.mjs +13 -7
  29. package/cli/product-index.mjs +4 -4
  30. package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
  31. package/cli/protection.mjs +19 -9
  32. package/cli/reconcile.mjs +98 -23
  33. package/cli/repo-publish.mjs +22 -16
  34. package/cli/repo.mjs +128 -33
  35. package/cli/report.mjs +4 -2
  36. package/cli/review.mjs +26 -19
  37. package/cli/setup.mjs +105 -36
  38. package/cli/ship.mjs +1 -1
  39. package/cli/skip.mjs +4 -4
  40. package/cli/tidy.mjs +7 -7
  41. package/cli/update-commit.mjs +22 -19
  42. package/cli/workspace.mjs +495 -0
  43. package/package.json +7 -4
  44. package/skills/sdlc/config.yaml +14 -13
  45. package/skills/sdlc/module-help.csv +8 -8
  46. package/skills/yad-analysis/SKILL.md +6 -4
  47. package/skills/yad-architecture/SKILL.md +4 -3
  48. package/skills/yad-backfill/SKILL.md +11 -5
  49. package/skills/yad-backfill/references/backfill.md +33 -3
  50. package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
  51. package/skills/yad-change/SKILL.md +1 -1
  52. package/skills/yad-checks/SKILL.md +48 -27
  53. package/skills/yad-checks/references/check-gates.md +213 -53
  54. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  55. package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
  56. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  57. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  58. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  59. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  60. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  61. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  62. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  63. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  64. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  65. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
  66. package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
  67. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  68. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  69. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
  70. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  71. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  72. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  73. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  74. package/skills/yad-commit/SKILL.md +18 -7
  75. package/skills/yad-connect-design/SKILL.md +1 -1
  76. package/skills/yad-connect-design/references/design-registry.md +1 -1
  77. package/skills/yad-connect-docs/SKILL.md +16 -11
  78. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  79. package/skills/yad-connect-learning/SKILL.md +1 -1
  80. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  81. package/skills/yad-connect-repos/SKILL.md +31 -8
  82. package/skills/yad-connect-repos/references/code-context.md +1 -1
  83. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  84. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  85. package/skills/yad-connect-testing/SKILL.md +1 -1
  86. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  87. package/skills/yad-discovery/SKILL.md +2 -2
  88. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  89. package/skills/yad-docs/SKILL.md +1 -1
  90. package/skills/yad-docs/references/data-mapping.md +1 -1
  91. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  92. package/skills/yad-docs/templates/app/package.json +1 -1
  93. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  94. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  95. package/skills/yad-engineer-review/SKILL.md +2 -2
  96. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  97. package/skills/yad-epic/SKILL.md +6 -4
  98. package/skills/yad-epic/references/state-schema.md +13 -12
  99. package/skills/yad-open-pr/SKILL.md +1 -1
  100. package/skills/yad-pair-review/SKILL.md +5 -5
  101. package/skills/yad-pair-review/references/session-state.md +2 -2
  102. package/skills/yad-pr-template/SKILL.md +14 -12
  103. package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
  104. package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
  105. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  106. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  107. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  110. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  111. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  114. package/skills/yad-report/SKILL.md +1 -1
  115. package/skills/yad-review-companion/SKILL.md +3 -3
  116. package/skills/yad-review-gate/SKILL.md +22 -23
  117. package/skills/yad-review-gate/references/gating.md +13 -13
  118. package/skills/yad-run/SKILL.md +2 -2
  119. package/skills/yad-run/references/run-loop.md +2 -2
  120. package/skills/yad-spec/SKILL.md +2 -1
  121. package/skills/yad-spec/references/spec-handoff.md +19 -10
  122. package/skills/yad-stories/SKILL.md +2 -2
  123. package/skills/yad-sync-repos/SKILL.md +19 -3
  124. package/skills/yad-test-cases/SKILL.md +2 -2
  125. package/skills/yad-ui/SKILL.md +2 -2
  126. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  127. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
  128. package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
@@ -7,8 +7,8 @@ import fs from 'node:fs';
7
7
  import { isPlainObject, readJSON, readJSONStrict, writeJSON, fileSha } from './lib.mjs';
8
8
  import { err } from './errors.mjs';
9
9
  import {
10
- ADVANCE_FROM_AUTOMATION, AUTOMATION_FROM_ADVANCE, DRIVER_FROM_ASSISTANCE, epicFiles, preferring,
11
- PROJECT_FILES, SCHEMA_VERSION, stepAdvance,
10
+ ADVANCE_FROM_AUTOMATION, AUTOMATION_FROM_ADVANCE, DRIVER_FROM_ASSISTANCE, epicFiles, mirrorDrift, preferring,
11
+ productDrift, PROJECT_FILES, SCHEMA_VERSION, stepAdvance,
12
12
  } from './manifest.mjs';
13
13
 
14
14
  const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
@@ -37,7 +37,7 @@ const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
37
37
  // `short` is measured against the capped ask and printed, and never enforced.
38
38
  //
39
39
  // WHEN THE COUNT IS UNKNOWN (`active: null`) no cap is computed at all: an unknown is never a small
40
- // number (E71). Product CI is usually that case — it checks out only the hub, so connected repos are
40
+ // number (E71). Product CI is usually that case — it checks out only the Product, so connected repos are
41
41
  // not on disk — and the surfaces say so.
42
42
  //
43
43
  // Until E62 the base was not what held a gate either: a ROLE rule read from the roster did (1 owner,
@@ -263,10 +263,10 @@ export const gateRuleEnforced = (rule, cap = null) => {
263
263
  //
264
264
  // It sat in cli/gate.mjs until E71, which needs it in the active-people reader that gate.mjs prints —
265
265
  // a cycle. It is pure and data-only, so it belongs here beside `gateRuleFor`; gate.mjs re-exports it.
266
- export function legacyLogins(hub) {
266
+ export function legacyLogins(productConfig) {
267
267
  const out = new Map();
268
268
  const clash = new Set();
269
- for (const e of Array.isArray(hub?.roster) ? hub.roster : []) {
269
+ for (const e of Array.isArray(productConfig?.roster) ? productConfig.roster : []) {
270
270
  if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
271
271
  if (out.has(e.name) && out.get(e.name) !== e.login) clash.add(e.name);
272
272
  out.set(e.name, e.login);
@@ -347,6 +347,29 @@ export function epicIds(root) {
347
347
  return ids.sort();
348
348
  }
349
349
 
350
+ // E122: a renamed file whose two names say different things (MIRRORED_FILES, cli/manifest.mjs). No
351
+ // command picks one of them, so each refuses with this — `yad doctor` and `yad migrate` excepted: one
352
+ // reports the drift, the other ends it. `pairs` is what `productDrift` found, project-relative.
353
+ export const driftError = (pairs) => err(
354
+ 'YAD-STATE-008',
355
+ `${pairs.map((p) => `${p.canonical} and ${p.legacy}`).join('; ')} say different things — they are one file under two names until v5, so yad reads neither`,
356
+ 'run `yad migrate` to see the difference, then `yad migrate --apply` to choose the copy to keep (or `--apply --keep product` / `--keep hub`)',
357
+ );
358
+
359
+ // The drift under a Product root, as the error to throw, or null. Listing `epics/` can fail on a broken
360
+ // tree; that is left to the command, which reports it in its own words.
361
+ export function productDriftError(root) {
362
+ const pairs = productDriftPairs(root);
363
+ return pairs.length ? driftError(pairs) : null;
364
+ }
365
+ // The drifted pairs themselves: the one lookup the refusal, `yad doctor` and `yad migrate` all use, so what
366
+ // doctor names and what the commands refuse on cannot differ (E124 follow-up review).
367
+ export function productDriftPairs(root) {
368
+ let dirs = [];
369
+ try { dirs = epicIds(root).map(epicRel); } catch { /* the settings file is still checked */ }
370
+ return productDrift(root, dirs);
371
+ }
372
+
350
373
  // The folders under `epics/` that hold a `.sdlc/` but that `epicIds` did not name: a name that is not a
351
374
  // valid id, a symlinked epic (`Dirent.isDirectory()` is false for a symlink), and `epics/EP-foundation`.
352
375
  // That guard is right for an enumerator that turns a name into a path segment, and wrong for anything
@@ -455,12 +478,12 @@ export const canonicalApprovals = (approvals = []) => canonical(approvals, (a) =
455
478
  export const canonicalComments = (comments = []) => canonical(comments, (cm) =>
456
479
  [cm.step, String(cm.round ?? '').padStart(6, '0'), cm.commenter, cm.role, cm.date].map(field).join('|'));
457
480
 
458
- export const canonicalHubPrs = (hubPrs = []) => canonical(hubPrs, (p) =>
481
+ export const canonicalProductPrs = (productPrs = []) => canonical(productPrs, (p) =>
459
482
  [p.artifact, p.step].map(field).join('|'));
460
483
 
461
484
  // Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
462
- export function upsertHubPr(hubPrs = [], rec) {
463
- return canonicalHubPrs([...hubPrs.filter((p) => p.artifact !== rec.artifact), rec]);
485
+ export function upsertProductPr(productPrs = [], rec) {
486
+ return canonicalProductPrs([...productPrs.filter((p) => p.artifact !== rec.artifact), rec]);
464
487
  }
465
488
 
466
489
  // SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
@@ -1444,12 +1467,12 @@ export function loadLedger(epicDir) {
1444
1467
  state: validateState(readJSONStrict(f.state, null), f.state),
1445
1468
  approvals: requireArray(readJSONStrict(f.approvals, []), f.approvals),
1446
1469
  comments: requireArray(readJSONStrict(f.comments, []), f.comments),
1447
- // Read the OLD name while it exists, the new one otherwise — the same tie-break as the settings
1448
- // file (`preferring`, cli/manifest.mjs). Both are written on every
1449
- // save (see gateSync / gateOpen), so they agree unless someone edited one by hand — which
1450
- // `yad doctor` reports rather than leaving to be discovered.
1451
- hubPrs: (() => {
1452
- const f2 = preferring(f.productPrs, f.hubPrs);
1470
+ // Read the new name when it exists, the old one otherwise — the same rule as the settings file
1471
+ // (`preferring`, cli/manifest.mjs). Both are written on every save (see gateSync / gateOpen), so
1472
+ // they agree unless something outside yad edited one; then neither is read (E122).
1473
+ productPrs: (() => {
1474
+ if (mirrorDrift(f.productPrs, f.productPrsLegacy)) throw driftError([{ canonical: f.productPrs, legacy: f.productPrsLegacy }]);
1475
+ const f2 = preferring(f.productPrs, f.productPrsLegacy);
1453
1476
  return requireArray(readJSONStrict(f2, []), f2);
1454
1477
  })(),
1455
1478
  contractLock: readJSONStrict(f.contractLock, null),
@@ -1691,7 +1714,7 @@ export function gatePredicate({
1691
1714
  const stale = forStep.filter((a) => isStaleHash(a.artifactHash, accepted));
1692
1715
  const live = forStep.filter((a) => !stale.includes(a));
1693
1716
 
1694
- // requireEngagement (config `hub.review.requireEngagement`, soft-off by default): only an approval
1717
+ // requireEngagement (config `review.requireEngagement` in the Product settings, soft-off by default): only an approval
1695
1718
  // carrying a verified engagement signal counts. The signal is gameable by design — this raises the
1696
1719
  // cost of a bare rubber-stamp, it does not claim to prove a human read the artifact.
1697
1720
  const counted = requireEngagement ? live.filter((a) => a.engagement === 'verified') : live;
@@ -3328,7 +3351,7 @@ function reopenedLanes(ledger, { epicId, currentStep, bindings }) {
3328
3351
  .map((s) => {
3329
3352
  if (stepStatus(s) === 'blocked') return { step: s.id, kind: 'blocked', status: 'blocked', record: s.record || null };
3330
3353
  if (s.type === 'author') return { step: s.id, kind: 'author', status: s.status, ...skillFields(stepSkills(s.id, bindings)), artifact: s.artifact };
3331
- const pr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(s.artifact));
3354
+ const pr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(s.artifact));
3332
3355
  return { step: s.id, kind: pr ? 'review-sync' : 'review-open', status: s.status, artifact: s.artifact, pr: pr ? pr.number : null,
3333
3356
  command: `yad gate ${pr ? 'sync' : 'open'} ${epicId} ${s.artifact}` };
3334
3357
  });
@@ -3366,7 +3389,7 @@ function shapeNextAction(ledger, { epic, bindings = null } = {}) {
3366
3389
  ...skillFields(stepSkills(dstep.id, bindings)), artifact: dstep.artifact,
3367
3390
  why: `${dstep.id} is ${dstep.status} — author ${dstep.artifact}` };
3368
3391
  }
3369
- const dpr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(dstep.artifact));
3392
+ const dpr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(dstep.artifact));
3370
3393
  const dverb = dpr ? 'sync' : 'open';
3371
3394
  return { epicId, kind: dpr ? 'review-sync' : 'review-open', step: dstep.id, status: dstep.status,
3372
3395
  artifact: dstep.artifact, pr: dpr ? dpr.number : null,
@@ -3450,7 +3473,7 @@ function shapeNextAction(ledger, { epic, bindings = null } = {}) {
3450
3473
  }
3451
3474
 
3452
3475
  // review+approve: open the review PR if none is recorded yet, else sync the open one.
3453
- const pr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(step.artifact));
3476
+ const pr = (ledger.productPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(step.artifact));
3454
3477
  const verb = pr ? 'sync' : 'open';
3455
3478
  return { epicId, kind: pr ? 'review-sync' : 'review-open', step: step.id, status: step.status,
3456
3479
  artifact: step.artifact, pr: pr ? pr.number : null, parallel,
package/cli/errors.mjs CHANGED
@@ -23,11 +23,12 @@ export const CODES = {
23
23
  'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
24
24
  'YAD-STATE-006': 'a Build ledger is locked by another yad process that is writing it',
25
25
  'YAD-STATE-007': 'an epic cannot be seeded — the profile, the type or the existing ledger refuses it',
26
- 'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
26
+ 'YAD-STATE-008': 'a file kept under two names (product.json + hub.json, or product-prs.json + hub-prs.json) says different things under each',
27
+ 'YAD-CFG-001': 'the Product settings (product.json, or hub.json on a Product that has only that name) name an unknown platform (expected github, gitlab, or null)',
27
28
  'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
28
29
  'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
29
30
  'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
30
- 'YAD-CFG-005': 'hub.json sets a platform but is missing git_url (required to scope auth + open PRs)',
31
+ 'YAD-CFG-005': 'the Product settings (product.json, or hub.json on a Product that has only that name) set a platform but are missing git_url (required to scope auth + open PRs)',
31
32
  'YAD-CFG-006': 'skills.json binds a step to something that is not a skill name (expected a string, or a non-empty list of strings)',
32
33
  'YAD-CLI-001': 'a --json run needed an answer that only a prompt could give',
33
34
  };
@@ -0,0 +1,197 @@
1
+ // Find the Product from wherever yad runs (E80).
2
+ //
3
+ // A WORKSPACE is the folder that holds the Product and the code repos side by side (E79):
4
+ //
5
+ // acme/
6
+ // .yad-workspace.json { "version": 1, "product": "product" }
7
+ // product/ <- the Product
8
+ // backend/ web/ <- code repos
9
+ //
10
+ // `findProduct(start)` walks up from `start` and stops at the FIRST of:
11
+ // - a folder that is a Product (it has `.sdlc/hub.json` or `.sdlc/product.json`) — so any subfolder of
12
+ // the Product finds it;
13
+ // - a folder holding `.yad-workspace.json` — its `product` names the Product folder beside the repos.
14
+ // It is used ONLY when `start` is inside a repo that Product registers in its `repos.json`. A
15
+ // workspace can be a shared folder (`~/Projects`, holding many teams' repos — setup always allowed
16
+ // that), and a file there must not send an unrelated repo's `yad epic new` or `yad kill` to this
17
+ // Product (E80 review 1). Anywhere else in the workspace there is no Product.
18
+ //
19
+ // The workspace file is per machine: the workspace folder is not a git repo (`yad new`, `init` and `join`
20
+ // refuse to make one inside a repo). So a `.yad-workspace.json` found INSIDE a git work tree is not used
21
+ // — a code repo could commit one, pointing yad at a Product folder of its own making — nor one in the
22
+ // home or temp folder. The walk STOPS there with the reason, rather than going on up to whatever
23
+ // Product lies above.
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { exists, readJSON, writeJSON } from './lib.mjs';
28
+ import { productConfigPath, PROJECT_FILES } from './manifest.mjs';
29
+
30
+ export const WORKSPACE_FILE = '.yad-workspace.json';
31
+ export const WORKSPACE_VERSION = 1;
32
+
33
+ // The Product folder a workspace file may name: one folder, beside the file — never a path.
34
+ const PRODUCT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
35
+
36
+ // Is `dir`, or any folder above it, a git work tree? A `.git` folder or file (a worktree, a submodule)
37
+ // is enough — no git run needed.
38
+ function insideGitTree(dir) {
39
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
40
+ if (fs.existsSync(path.join(d, '.git'))) return true;
41
+ if (path.dirname(d) === d) return false;
42
+ }
43
+ }
44
+
45
+ // On disk, as the disk spells it: `.native` returns the stored case (APFS and NTFS ignore case, and the JS
46
+ // realpath keeps whatever case it was given) and Windows' long names for 8.3 short ones — the same rule
47
+ // as `samePath` in lib.mjs. Windows compares without case, too.
48
+ const real = (p) => { try { return fs.realpathSync.native(p); } catch { return null; } };
49
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
50
+ const within = (child, parent) => fold(child) === fold(parent) || fold(child).startsWith(fold(parent) + path.sep);
51
+
52
+ // Read one workspace file. `{ product }` (an absolute Product root) or `{ problem }`.
53
+ export function readWorkspace(workspaceDir) {
54
+ const file = path.join(workspaceDir, WORKSPACE_FILE);
55
+ const rec = readJSON(file, null);
56
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return { problem: `${file} cannot be read` };
57
+ if (rec.version !== WORKSPACE_VERSION) return { problem: `${file} is version ${JSON.stringify(rec.version ?? null)}, and this yadflow reads version ${WORKSPACE_VERSION}` };
58
+ if (typeof rec.product !== 'string' || !PRODUCT_NAME_RE.test(rec.product)) return { problem: `${file}: "product" must be one folder name beside it` };
59
+ const product = path.join(workspaceDir, rec.product);
60
+ if (!exists(productConfigPath(product))) return { problem: `${file} names ${rec.product}/, which is not a Product` };
61
+ return { product };
62
+ }
63
+
64
+ export const hasProduct = (dir) => exists(productConfigPath(dir));
65
+
66
+ // The Product a folder's own workspace file names, when the file is there and usable; else null.
67
+ export function workspaceProduct(dir) {
68
+ if (!fs.existsSync(path.join(dir, WORKSPACE_FILE)) || insideGitTree(dir)) return null;
69
+ return readWorkspace(dir).product || null;
70
+ }
71
+
72
+ // → { root, via: 'here' | 'above' | 'workspace', workspace?, repo? } | { problem } | { elsewhere } | null
73
+ // `repo` the registry entry of the code repo `start` is in, when there is one;
74
+ // `elsewhere` a workspace's Product, found from a folder that is not in one of its repos (the workspace
75
+ // folder itself, say) — not used, but worth naming to the person.
76
+ export function findProduct(start) {
77
+ const from = path.resolve(start);
78
+ // Crossing a git repo's top on the way up: the Product above is some OTHER repo's (E80 review 2). It is
79
+ // used only when it registers the repo `start` is in — the same rule as the workspace file's, so an
80
+ // unrelated repo nested in the Product's folder (`vendor/lib`) never writes to it.
81
+ let crossedRepo = false;
82
+ for (let d = from; ; d = path.dirname(d)) {
83
+ if (exists(productConfigPath(d))) {
84
+ if (d === from) return { root: d, via: 'here' };
85
+ const repo = registeredRepoHolding(d, from);
86
+ if (crossedRepo && !repo) return null;
87
+ return { root: d, via: 'above', ...(repo ? { repo } : {}) };
88
+ }
89
+ if (fs.existsSync(path.join(d, WORKSPACE_FILE))) {
90
+ const file = path.join(d, WORKSPACE_FILE);
91
+ if (insideGitTree(d)) return { problem: `${file} is inside a git repo, so it is not used (a repo could commit one)` };
92
+ if (sharedFolder(d)) return { problem: `${file} is in your home or temp folder, so it is not used` };
93
+ const ws = readWorkspace(d);
94
+ if (ws.problem) return { problem: ws.problem };
95
+ // Only for a repo this Product registers — never for any folder that happens to sit beside it.
96
+ const repo = registeredRepoHolding(ws.product, from);
97
+ return repo ? { root: ws.product, via: 'workspace', workspace: d, repo } : { elsewhere: ws.product };
98
+ }
99
+ if (fs.existsSync(path.join(d, '.git'))) crossedRepo = true;
100
+ if (path.dirname(d) === d) return null;
101
+ }
102
+ }
103
+
104
+ // The registry entry whose folder holds `dir` — the repo itself or any folder inside it — compared on
105
+ // disk (a link or a different spelling of the same folder still matches). The deepest one wins; the
106
+ // Product's own entry (a monorepo, `.`) never does.
107
+ export function registeredRepoHolding(productRoot, dir) {
108
+ const at = real(dir);
109
+ const productAt = real(productRoot);
110
+ if (!at || !productAt) return null;
111
+ const reg = readJSON(path.join(productRoot, PROJECT_FILES.reposRegistry), null);
112
+ let best = null;
113
+ let bestAt = '';
114
+ for (const r of Array.isArray(reg?.repos) ? reg.repos : []) {
115
+ if (typeof r?.path !== 'string') continue;
116
+ const repoAt = real(path.resolve(productRoot, r.path));
117
+ if (!repoAt || fold(repoAt) === fold(productAt) || !within(at, repoAt) || repoAt.length <= bestAt.length) continue;
118
+ best = r;
119
+ bestAt = repoAt;
120
+ }
121
+ return best;
122
+ }
123
+
124
+ // Folders a workspace file is never written into: the home folder, the temp folder and the top of a disk.
125
+ // A file there would be found from every folder below it — every project, every test's temp folder.
126
+ function sharedFolder(dir) {
127
+ const onDisk = (p) => fold(real(p) ?? path.resolve(p));
128
+ const d = onDisk(dir);
129
+ return path.dirname(d) === d || d === onDisk(os.homedir()) || d === onDisk(os.tmpdir());
130
+ }
131
+
132
+ // Does a registered repo live BESIDE the Product (not inside it)? Only then does a workspace file help
133
+ // from a code repo — `yad check --fix` writes one only for such a Product (E80).
134
+ export function hasSiblingRepo(productRoot) {
135
+ const root = path.resolve(productRoot);
136
+ const workspace = path.dirname(root);
137
+ const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), null);
138
+ return (Array.isArray(reg?.repos) ? reg.repos : []).some((r) => {
139
+ if (typeof r?.path !== 'string') return false;
140
+ const at = path.resolve(root, r.path);
141
+ // …and is on this machine: a teammate who cloned the Product alone gains nothing from the file.
142
+ return !(at === root || at.startsWith(root + path.sep)) && at.startsWith(workspace + path.sep) && fs.existsSync(at);
143
+ });
144
+ }
145
+
146
+ // The workspace file for this Product: 'ok' | 'missing' | 'other: <why>' (another file there, or a
147
+ // folder a file is never written into). Read by `yad doctor` and `yad check`.
148
+ export function workspaceFileState(productRoot) {
149
+ const root = path.resolve(productRoot);
150
+ const workspace = path.dirname(root);
151
+ const name = path.basename(root);
152
+ if (path.dirname(root) === root || sharedFolder(workspace)) return 'other: the folder above the Product is your home folder, the temp folder or the top of the disk — move the Product into a folder of its own';
153
+ if (!PRODUCT_NAME_RE.test(name)) return `other: the Product folder's name (${name}) is not one a workspace file can hold`;
154
+ if (insideGitTree(workspace)) return 'other: the folder above the Product is inside a git repo';
155
+ const cur = readJSON(path.join(workspace, WORKSPACE_FILE), null);
156
+ if (cur && cur.product === name && cur.version === WORKSPACE_VERSION) return 'ok';
157
+ if (cur && cur.product === name) return `other: ${WORKSPACE_FILE} there is version ${JSON.stringify(cur.version ?? null)}, not ${WORKSPACE_VERSION}`;
158
+ if (cur) return `other: ${WORKSPACE_FILE} there names ${typeof cur.product === 'string' ? `${cur.product}/` : 'no Product'}`;
159
+ return 'missing';
160
+ }
161
+
162
+ // Write the workspace file beside the Product: `{ version, product }`, the Product folder's own name.
163
+ // Only where `workspaceFileState` says it is missing — never over another file, never inside a git work
164
+ // tree (it would be committed to someone's repo, and is ignored there — see above), never into a shared
165
+ // folder. Returns 'written' | 'ok' | 'skipped: <why>'.
166
+ export function writeWorkspaceFile(productRoot) {
167
+ const state = workspaceFileState(productRoot);
168
+ if (state === 'ok') return 'ok';
169
+ if (state !== 'missing') return `skipped: ${state.replace(/^other: /, '')}`;
170
+ const root = path.resolve(productRoot);
171
+ writeJSON(path.join(path.dirname(root), WORKSPACE_FILE), { version: WORKSPACE_VERSION, product: path.basename(root) });
172
+ return 'written';
173
+ }
174
+
175
+ // The top of the git checkout `dir` is in: the nearest folder holding `.git` (a folder, or the file a
176
+ // worktree or submodule has). Null outside any checkout.
177
+ export function gitTopOf(dir) {
178
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
179
+ if (fs.existsSync(path.join(d, '.git'))) return d;
180
+ if (path.dirname(d) === d) return null;
181
+ }
182
+ }
183
+
184
+ // The registry entry for the code repo a code-repo command runs in (E80), with the folder to work in.
185
+ // It is the CHECKOUT that must be the registered repo — not merely a folder inside it: a worktree or a
186
+ // repo nested in `backend/` is another checkout, on another branch, and must never be swapped for
187
+ // backend's own (E80 review 3). → { meta, top } or null. Never the Product's own entry.
188
+ export function registryEntryFor(productRoot, dir) {
189
+ if (!productRoot) return null;
190
+ const top = gitTopOf(dir);
191
+ if (!top) return null;
192
+ const meta = registeredRepoHolding(productRoot, top);
193
+ if (!meta) return null;
194
+ const at = real(path.resolve(productRoot, meta.path));
195
+ const topAt = real(top);
196
+ return at && topAt && fold(at) === fold(topAt) ? { meta, top } : null;
197
+ }
package/cli/fold.mjs CHANGED
@@ -32,7 +32,7 @@ import { ok, info, warn, fail, hand, readJSON } from './lib.mjs';
32
32
  import { productConfigPath, isVerifiedLedger } from './manifest.mjs';
33
33
  import { STEPS, FOUNDATION_EPIC, FOUNDATION_DIR, DISCOVERY_EPIC, artifactBase, artifactPaths, epicRoot } from './epic-state.mjs';
34
34
  import { capturedEpic, gitIn, isOwnerPath, statusEntries, wipName, wipBranch, runCapture } from './capture.mjs';
35
- import { resolveDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
35
+ import { resolveDefaultBranch, preflightGuardReadiness } from './productcommit.mjs';
36
36
 
37
37
  // The authoring steps a fold can close: every catalogue step of kind `author` that writes a Shape artifact.
38
38
  // The Build steps (spec, tasks, implement, checks) write code in another repo and have no artifact here.
@@ -125,8 +125,8 @@ export async function runFold(root, { epic, step, env = process.env, capture = r
125
125
  if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/hub.json or product.json here)', 'run it from the Product root, or pass --dir');
126
126
  if (!fs.existsSync(epicRoot(root, epic))) return refuse(`no ${epicRel(epic)}/ in this Product`);
127
127
 
128
- const hub = readJSON(productConfigPath(root), {}) || {};
129
- const verified = isVerifiedLedger(hub);
128
+ const productConfig = readJSON(productConfigPath(root), {}) || {};
129
+ const verified = isVerifiedLedger(productConfig);
130
130
  const branchR = git(['symbolic-ref', '--short', '-q', 'HEAD']);
131
131
  const branch = branchR.ok ? branchR.out.trim() : null;
132
132
  if (verified && !branch) {
@@ -140,7 +140,7 @@ export async function runFold(root, { epic, step, env = process.env, capture = r
140
140
  .find((f) => { const r = git(['rev-parse', '--git-path', f]); return r.ok && fs.existsSync(path.resolve(root, r.out.trim())); });
141
141
  if (busy) return refuse(`a ${IN_PROGRESS[busy]} is in progress — finish or abort it first, then run yad fold again`);
142
142
  if (verified) {
143
- const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, hub);
143
+ const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, productConfig);
144
144
  if (branch === def) {
145
145
  return refuse(`on the default branch '${def}' — with a verified ledger, artifacts reach it only through the review PR`,
146
146
  `switch to the step's authoring branch first (git switch -c ${step}/${epic}), then run yad fold again`);
@@ -43,12 +43,12 @@ export function reviewerName(v) {
43
43
  function localGate(root, { epic, artifact, verb }) {
44
44
  const usage = `yad gate ${verb} <epic> <artifact>${verb === 'advance' ? '' : ' --by <name>'}`;
45
45
  if (!artifact) { fail(`usage: ${usage}`); process.exitCode = 1; return null; }
46
- const { hub } = loadProduct(root);
46
+ const { productConfig } = loadProduct(root);
47
47
  // One check covers both refusals: a verified ledger needs a platform (`isVerifiedLedger`), so any
48
48
  // platform at all means the approvals live on the PR/MR, not here.
49
- if (hub?.platform) {
50
- fail(`\`yad gate ${verb}\` is for a Product with no platform — this one reviews on ${printable(String(hub.platform)) ?? 'a platform'}, so nothing is written`);
51
- if (isVerifiedLedger(hub)) hand('CI owns this ledger: approvals, comments and the advance land from the review PR/MR when it merges (`yad gate ci`)');
49
+ if (productConfig?.platform) {
50
+ fail(`\`yad gate ${verb}\` is for a Product with no platform — this one reviews on ${printable(String(productConfig.platform)) ?? 'a platform'}, so nothing is written`);
51
+ if (isVerifiedLedger(productConfig)) hand('CI owns this ledger: approvals, comments and the advance land from the review PR/MR when it merges (`yad gate ci`)');
52
52
  else hand(`review on the PR/MR, then run \`yad gate sync ${epic} ${artifact}\` — it reads the approvals and advances the step when the PR merges`);
53
53
  process.exitCode = 1;
54
54
  return null;
@@ -58,7 +58,7 @@ function localGate(root, { epic, artifact, verb }) {
58
58
  if (!ledger.state) { fail(`no epic state at ${epicRel(epic)}/.sdlc/state.json`); process.exitCode = 1; return null; }
59
59
  const step = findReviewStep(ledger.state, artifact);
60
60
  if (!step) { fail(`${epic} has no review step for ${artifact}`); process.exitCode = 1; return null; }
61
- return { hub, epicDir, ledger, step, solo: isSolo(hub), reqEng: requireEngagement(hub) };
61
+ return { productConfig, epicDir, ledger, step, solo: isSolo(productConfig), reqEng: requireEngagement(productConfig) };
62
62
  }
63
63
 
64
64
  // A gate is recorded against only while it is OPEN. A step not reached yet has no review to approve:
@@ -98,8 +98,8 @@ const peopleOnStep = (g) => [
98
98
 
99
99
  // The one count every verb reports, read once per command (E71) — or handed in by a test, which must not
100
100
  // walk git from inside a fixture.
101
- function countPeople(root, hub, today, headCount) {
102
- return headCount || activePeople(root, { today: today || undefined, aliases: legacyLogins(hub) });
101
+ function countPeople(root, productConfig, today, headCount) {
102
+ return headCount || activePeople(root, { today: today || undefined, aliases: legacyLogins(productConfig) });
103
103
  }
104
104
 
105
105
  function judge(g, epicDir, people) {
@@ -162,7 +162,7 @@ export async function gateApprove(root, { epic, artifact, by, engagement = null,
162
162
  const { epicDir, ledger, step } = g;
163
163
  const hash = artifactHash(epicDir, step.artifact);
164
164
  const eng = engagement || 'none';
165
- // Filter-then-push, keyed on (step, approver), as `upsertHubPr` does: one record per person. A bridge
165
+ // Filter-then-push, keyed on (step, approver), as `upsertProductPr` does: one record per person. A bridge
166
166
  // record (from a platform the Product once had) is left alone — it is another source's history.
167
167
  // The same person approving the same content again keeps the record as it was, date included, so a
168
168
  // repeat is a byte-identical no-op rather than a new line in the ledger's history.
@@ -180,12 +180,12 @@ export async function gateApprove(root, { epic, artifact, by, engagement = null,
180
180
  // approvers, commenters, what is still required): writing the short `gate sync` list here would erase
181
181
  // it on every approval of the day (E112 review).
182
182
  writeJSON(ledger.files.approvals, g.ledger.approvals);
183
- refreshIndexAfterWrite(root, g.hub);
183
+ refreshIndexAfterWrite(root, g.productConfig);
184
184
  }
185
185
  if (same) info(`${name} had already approved this content of ${step.artifact} — nothing changed`);
186
186
  else ok(`${name} approved ${step.artifact} (${step.id})${was ? ' — their earlier approval is replaced' : ''}`);
187
187
 
188
- const people = countPeople(root, g.hub, today, headCount);
188
+ const people = countPeople(root, g.productConfig, today, headCount);
189
189
  log(` ${c.dim(activeSum(people))}`);
190
190
  note(c.dim(activeBasis(people)));
191
191
  const pred = judge(g, epicDir, people);
@@ -247,7 +247,7 @@ export async function gateComment(root, { epic, artifact, by, count = null, newR
247
247
  ledger.comments = canonicalComments([...ledger.comments.filter((cm) => !mine(cm)), record]);
248
248
  if (!same) {
249
249
  writeJSON(ledger.files.comments, ledger.comments);
250
- refreshIndexAfterWrite(root, g.hub);
250
+ refreshIndexAfterWrite(root, g.productConfig);
251
251
  }
252
252
  if (same) info(`${name}'s round ${round} on ${step.artifact} already says ${n} comment(s) — nothing changed`);
253
253
  else ok(`${name}: ${n} comment(s) on ${step.artifact}, round ${round}${was ? ` (was ${was.count})` : ''}`);
@@ -270,7 +270,7 @@ export async function gateAdvance(root, { epic, artifact, today, headCount = nul
270
270
  return;
271
271
  }
272
272
  if (refuseUnopened(g, { epic, artifact, verb: 'advance' })) return;
273
- const people = countPeople(root, g.hub, today, headCount);
273
+ const people = countPeople(root, g.productConfig, today, headCount);
274
274
  log(` ${c.dim(activeSum(people))}`);
275
275
  note(c.dim(activeBasis(people)));
276
276
  const pred = judge(g, epicDir, people);
@@ -290,7 +290,7 @@ export async function gateAdvance(root, { epic, artifact, today, headCount = nul
290
290
  capped: pred.rule === 'count' && pred.cap?.capped ? { needed: pred.gateRule.needed, to: pred.cap.to, active: pred.cap.active } : null,
291
291
  });
292
292
  writeState(ledger.files.state, state);
293
- refreshIndexAfterWrite(root, g.hub);
293
+ refreshIndexAfterWrite(root, g.productConfig);
294
294
  ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
295
295
  return { epic, advanced: true, currentStep: state.currentStep ?? null, gate };
296
296
  }