yadflow 3.19.0-next.2 → 4.0.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -0
- package/README.md +160 -22
- package/bin/commands.mjs +46 -0
- package/bin/yad.mjs +506 -117
- package/cli/artifact-status.mjs +30 -11
- package/cli/capture.mjs +417 -0
- package/cli/checkpoint.mjs +47 -27
- package/cli/claims.mjs +165 -0
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1682 -90
- package/cli/epic-state.mjs +3460 -211
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +3 -0
- package/cli/fold.mjs +247 -0
- package/cli/gate-local.mjs +296 -0
- package/cli/gate.mjs +977 -183
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +264 -53
- package/cli/hubcommit.mjs +9 -19
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +54 -4
- package/cli/lib.mjs +161 -9
- package/cli/manifest.mjs +376 -38
- package/cli/migrate.mjs +683 -52
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +365 -86
- package/cli/openpr.mjs +193 -34
- package/cli/owners.mjs +266 -0
- package/cli/people.mjs +657 -0
- package/cli/plan.mjs +466 -125
- package/cli/platform.mjs +109 -128
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +40 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +24 -14
- package/cli/review.mjs +19 -22
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +127 -279
- package/cli/ship.mjs +6 -5
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +41 -16
- package/cli/tidy.mjs +18 -18
- package/cli/update-commit.mjs +21 -21
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +149 -70
- package/skills/sdlc/module-help.csv +24 -24
- package/skills/yad-analysis/SKILL.md +121 -59
- package/skills/yad-architecture/SKILL.md +30 -18
- package/skills/yad-architecture/references/contract-format.md +8 -5
- package/skills/yad-backfill/SKILL.md +10 -4
- package/skills/yad-backfill/references/backfill.md +8 -1
- package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
- package/skills/yad-change/SKILL.md +119 -42
- package/skills/yad-change/references/triage.md +42 -27
- package/skills/yad-checks/SKILL.md +68 -35
- package/skills/yad-checks/references/check-gates.md +310 -85
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
- package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
- package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
- package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
- package/skills/yad-commit/SKILL.md +4 -4
- package/skills/yad-connect-design/SKILL.md +5 -5
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +11 -11
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +66 -45
- package/skills/yad-connect-repos/references/code-context.md +4 -4
- package/skills/yad-connect-repos/references/hub-config.md +47 -51
- package/skills/yad-connect-repos/references/repos-registry.md +8 -7
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +5 -5
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +7 -7
- package/skills/yad-discovery/SKILL.md +131 -94
- package/skills/yad-discovery/references/discovery-schema.md +22 -6
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +13 -8
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +30 -15
- package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +81 -17
- package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
- package/skills/yad-epic/SKILL.md +173 -92
- package/skills/yad-epic/references/state-schema.md +610 -94
- package/skills/yad-hub-bridge/SKILL.md +57 -38
- package/skills/yad-hub-bridge/references/bridge.md +94 -67
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
- package/skills/yad-implement/SKILL.md +27 -13
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +61 -26
- package/skills/yad-pair-review/SKILL.md +7 -5
- package/skills/yad-pair-review/references/session-state.md +1 -1
- package/skills/yad-pr-template/SKILL.md +47 -26
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
- package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +6 -3
- package/skills/yad-review-gate/SKILL.md +203 -93
- package/skills/yad-review-gate/references/gating.md +232 -54
- package/skills/yad-run/SKILL.md +81 -51
- package/skills/yad-run/references/run-loop.md +61 -39
- package/skills/yad-ship/SKILL.md +15 -11
- package/skills/yad-spec/SKILL.md +21 -6
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +115 -50
- package/skills/yad-stories/SKILL.md +37 -21
- package/skills/yad-stories/references/story-schema.md +7 -6
- package/skills/yad-stub/SKILL.md +58 -47
- package/skills/yad-sync-repos/SKILL.md +2 -2
- package/skills/yad-test-cases/SKILL.md +32 -23
- package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +41 -18
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/artifact-status.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { c, log, ok, info, readJSONStrict } from './lib.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
epicIds, epicRoot, artifactBase, artifactFromBase, findReviewStep, DISCOVERY_FILES, FOUNDATION_FILES, isPassed, stepStatus, FRONTMATTER_BLOCK,
|
|
11
|
+
} from './epic-state.mjs';
|
|
10
12
|
import { epicFiles } from './manifest.mjs';
|
|
11
13
|
|
|
12
14
|
// The Shape gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
|
|
@@ -29,8 +31,16 @@ export function desiredStatus(state, base) {
|
|
|
29
31
|
const review = findReviewStep(state, artifactFromBase(base));
|
|
30
32
|
const author = state.steps.find((s) => s.type === 'author' && artifactBase(s.artifact) === base);
|
|
31
33
|
if (!review && !author) return null;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
// `isPassed`, not `status === 'done'`: a gate that PASSED is what makes an artifact approved,
|
|
35
|
+
// however it passed. An inherited gate was approved upstream in the thread, and a skipped one has
|
|
36
|
+
// no artifact for this command to find — both used to reach here spelled `done`.
|
|
37
|
+
// A gate SET ASIDE — skipped or deferred — lets the chain continue without anybody reviewing the
|
|
38
|
+
// artifact, so it says nothing about the file. `isPassed` alone read it as approved: a skip taken over a half-written draft,
|
|
39
|
+
// or a deferral of one, would have had CI stamp `status: approved` on a file nobody reviewed (E37).
|
|
40
|
+
const reviewState = stepStatus(review);
|
|
41
|
+
if (reviewState === 'skipped' || reviewState === 'deferred') return null;
|
|
42
|
+
if (isPassed(review)) return 'approved';
|
|
43
|
+
if (stepStatus(review) === 'in_review' || isPassed(author)) return 'in-review';
|
|
34
44
|
return 'draft';
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -41,24 +51,27 @@ export function desiredStatus(state, base) {
|
|
|
41
51
|
export function setFrontmatterStatus(file, status) {
|
|
42
52
|
if (!fs.existsSync(file)) return null;
|
|
43
53
|
const text = fs.readFileSync(file, 'utf8');
|
|
44
|
-
const fm = text.match(
|
|
54
|
+
const fm = text.match(FRONTMATTER_BLOCK);
|
|
45
55
|
if (!fm) return null;
|
|
46
56
|
const cur = (fm[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
47
57
|
if (cur === undefined) return null;
|
|
48
58
|
// Advance-only within the managed ladder; anything else (build-owned, roll-ups) is left as-is.
|
|
49
59
|
if (PRESERVE.has(cur)) return null;
|
|
50
60
|
if (!(cur in RANK) || !(status in RANK) || RANK[status] <= RANK[cur]) return null;
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// Replacement FUNCTIONS, not strings: in a replacement string `$'`, `$&` and `$$` are codes, so a
|
|
62
|
+
// frontmatter value holding one (`title: costs $' less`) rewrote the file wrongly — on the default
|
|
63
|
+
// branch, in the gate's merge commit.
|
|
64
|
+
const block = fm[1].replace(/^status:\s*.*$/m, () => `status: ${status}`);
|
|
65
|
+
fs.writeFileSync(file, text.replace(fm[1], () => block));
|
|
53
66
|
return cur;
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
// Sweep one epic (or every epic under epics/) and reconcile artifact frontmatter with state.json.
|
|
57
70
|
export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
// `epicIds` — the Foundation included (E75), and only VALID ids: a backup directory or any other
|
|
72
|
+
// folder left under `epics/` is not an epic, and rewriting frontmatter inside it would be a write to
|
|
73
|
+
// files nobody asked this command to touch.
|
|
74
|
+
const epics = epic ? [epic] : epicIds(root);
|
|
62
75
|
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0, files: [] }; }
|
|
63
76
|
|
|
64
77
|
let changed = 0;
|
|
@@ -86,6 +99,12 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
86
99
|
for (const f of DISCOVERY_FILES) {
|
|
87
100
|
if (fs.existsSync(path.join(dir, f))) files.push({ base: 'discovery', file: path.join(dir, f) });
|
|
88
101
|
}
|
|
102
|
+
// The Foundation's sections key to its one foundation / foundation-review pair the same way. Both
|
|
103
|
+
// spellings are offered; `desiredStatus` answers null for the base the chain does not carry, so
|
|
104
|
+
// a converted Foundation holding the six old files still reconciles them under `discovery`.
|
|
105
|
+
for (const f of FOUNDATION_FILES) {
|
|
106
|
+
if (fs.existsSync(path.join(dir, f))) files.push({ base: 'foundation', file: path.join(dir, f) });
|
|
107
|
+
}
|
|
89
108
|
|
|
90
109
|
for (const { base, file } of files) {
|
|
91
110
|
if (!fs.existsSync(file)) continue;
|
|
@@ -95,7 +114,7 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
95
114
|
// Peek without writing so --dry-run reports exactly what would change. Scope the match to the
|
|
96
115
|
// frontmatter block so a `status:` line in the Markdown body can't be mistaken for the value.
|
|
97
116
|
const text = fs.readFileSync(file, 'utf8');
|
|
98
|
-
const fm = text.match(
|
|
117
|
+
const fm = text.match(FRONTMATTER_BLOCK);
|
|
99
118
|
const cur = (fm?.[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
100
119
|
if (cur && !PRESERVE.has(cur) && cur in RANK && RANK[want] > RANK[cur]) {
|
|
101
120
|
log(` ${c.dim('• would update')} ${path.relative(root, file)}: ${cur} → ${want}`);
|
package/cli/capture.mjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// `yad capture` (E43) — background capture of Shape artifacts to private `yad/wip/<name>/<epic>` branches.
|
|
2
|
+
//
|
|
3
|
+
// "Humans write content. Only the engine writes git." Every change to an artifact is snapshotted onto a
|
|
4
|
+
// private branch, so nothing is ever lost and nobody types a git command. E44 later folds a branch into one
|
|
5
|
+
// clean commit at a step boundary; this row only captures. The decisions were the user's (2026-09-25):
|
|
6
|
+
// 1. TRIGGER: this command, run after every agent edit by a harness hook (Claude Code `PostToolUse`,
|
|
7
|
+
// Cursor `afterFileEdit` — see CAPTURE_ADAPTERS in manifest.mjs) and by hand from any other tool. Every
|
|
8
|
+
// capture snapshots EVERY changed artifact, so a person's own editor edits ride along on the next one.
|
|
9
|
+
// 2. PUSH: the commit is local and immediate; the push runs in a detached background process, at most
|
|
10
|
+
// once every PUSH_EVERY_MS, with no prompts and a short timeout — an edit never waits on the network.
|
|
11
|
+
// No remote, or offline: the capture stays local, and nothing fails.
|
|
12
|
+
// 3. BRANCH: `yad/wip/<git user.name, made ref-safe>/<epic>` — one branch per person per epic. The git
|
|
13
|
+
// name, not the platform login: it is there offline and never flips, so one person's work never
|
|
14
|
+
// splits across two branches when the network drops. Two people with one git name share branches.
|
|
15
|
+
// 4. FILES: everything under `epics/<epic>/` and `foundation/` EXCEPT the ledger — any `.sdlc/` folder
|
|
16
|
+
// and `reviews/`. `contract-lock.json` and `change.json` are the two `.sdlc/` files a person writes,
|
|
17
|
+
// so they are captured, and so is a step's owner file (`.sdlc/owners/<step>.json`, E47). A new kind of
|
|
18
|
+
// artifact is captured with no list to update.
|
|
19
|
+
//
|
|
20
|
+
// THE MECHANISM NEVER TOUCHES THE CHECKOUT. It builds each commit with git plumbing in a throwaway index
|
|
21
|
+
// (`GIT_INDEX_FILE`): the tree is HEAD's, with this epic's changed artifacts as they are on disk. So the
|
|
22
|
+
// person's branch, index and working tree are exactly as they were, a hook firing mid-edit can never
|
|
23
|
+
// disturb anything, and no `git commit` hook runs. The ref moves by compare-and-swap (`update-ref <new>
|
|
24
|
+
// <old>`), so two agents capturing at once cannot lose each other's commit.
|
|
25
|
+
//
|
|
26
|
+
// THE COMMIT SHAPE IS A CONTRACT E44 READS: subject `wip(<epic>): capture`, and three trailers —
|
|
27
|
+
// `Yad-Epic`, `Yad-Base` (the HEAD the tree was built on, or `none`) and `Yad-Branch` (the branch checked
|
|
28
|
+
// out, or `(detached)`). `wip` is not a commit-message type the gates accept, and it need not be: only a
|
|
29
|
+
// PR to the default branch is message-gated, and a capture branch is never one. The commits are unsigned
|
|
30
|
+
// on purpose (`--no-gpg-sign`): a signing prompt inside a hook would hang the agent, and E44's fold is the
|
|
31
|
+
// commit that gets signed.
|
|
32
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { ok, info, warn, fail, hand, readJSON, writeJSON } from './lib.mjs';
|
|
38
|
+
import { productConfigPath } from './manifest.mjs';
|
|
39
|
+
import { FOUNDATION_DIR, FOUNDATION_EPIC } from './epic-state.mjs';
|
|
40
|
+
|
|
41
|
+
export const WIP_PREFIX = 'yad/wip';
|
|
42
|
+
// How often the background push may run. A capture between two pushes is committed locally and rides the
|
|
43
|
+
// next one — the push sends every capture branch of this person at once.
|
|
44
|
+
export const PUSH_EVERY_MS = 5 * 60 * 1000;
|
|
45
|
+
// The `.sdlc/` files a person, or their skill, writes. hook.mjs calls the first two artifact-side; the yad-ui
|
|
46
|
+
// and yad-test-cases skills commit the two links files with their artifacts, "artifact-side, not ledger".
|
|
47
|
+
// Everything else in a `.sdlc/` folder is the ledger, or a Build log only the engine writes.
|
|
48
|
+
const PERSON_WRITTEN_SDLC = new Set(['.sdlc/contract-lock.json', '.sdlc/change.json', '.sdlc/design-links.json', '.sdlc/test-links.json']);
|
|
49
|
+
|
|
50
|
+
// A step owner file (E47): `<epic>/.sdlc/owners/<step>.json`, directly in that folder. The one rule capture,
|
|
51
|
+
// fold, claims and the owners module all use, so the four can never disagree.
|
|
52
|
+
export const isOwnerPath = (rel) => typeof rel === 'string'
|
|
53
|
+
&& /^(epics\/EP-[^/]+|foundation)\/\.sdlc\/owners\/[^/]+\.json$/.test(rel.split(path.sep).join('/'));
|
|
54
|
+
|
|
55
|
+
// Which epic a Product-relative path belongs to, if capture takes it — else null. The complement of the
|
|
56
|
+
// ledger, not a list of artifacts (decision 4).
|
|
57
|
+
export function capturedEpic(rel) {
|
|
58
|
+
if (typeof rel !== 'string') return null;
|
|
59
|
+
const p = rel.split(path.sep).join('/');
|
|
60
|
+
let epic;
|
|
61
|
+
let rest;
|
|
62
|
+
if (p.startsWith(`${FOUNDATION_DIR}/`)) { epic = FOUNDATION_EPIC; rest = p.slice(FOUNDATION_DIR.length + 1); }
|
|
63
|
+
else {
|
|
64
|
+
const m = p.match(/^epics\/(EP-[^/]+)\/(.+)$/);
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
[, epic, rest] = m;
|
|
67
|
+
}
|
|
68
|
+
if (!rest || PERSON_WRITTEN_SDLC.has(rest)) return rest ? epic : null;
|
|
69
|
+
// A step's owner file (E47), written by `yad assign` — never the ledger.
|
|
70
|
+
if (isOwnerPath(p)) return epic;
|
|
71
|
+
const parts = rest.split('/');
|
|
72
|
+
// The ledger at any depth: a `.sdlc/` folder (state, approvals, Build logs, shards) or `reviews/`.
|
|
73
|
+
if (parts.slice(0, -1).some((d) => d === '.sdlc' || d === 'reviews')) return null;
|
|
74
|
+
return epic;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The git user.name as one branch-name segment. Lower case, because refs are files and a case-insensitive
|
|
78
|
+
// disk (macOS, Windows) would make `Ann` and `ann` one file. A name written in a script with no Latin
|
|
79
|
+
// letters (Arabic, Chinese…) leaves nothing after the cleaning, so it falls back to the local part of the
|
|
80
|
+
// git email, and then to a short fingerprint of the name itself — the same name always gives the same
|
|
81
|
+
// branch. Null only when git has no name at all.
|
|
82
|
+
const safeSegment = (v) => (typeof v === 'string' ? v : '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9._-]+/g, '-').replace(/\.{2,}/g, '.').replace(/-{2,}/g, '-')
|
|
84
|
+
.replace(/^[-.]+|[-.]+$/g, '').replace(/\.lock$/, '');
|
|
85
|
+
export function wipName(gitName, email = null) {
|
|
86
|
+
if (typeof gitName !== 'string' || !gitName.trim()) return null;
|
|
87
|
+
return safeSegment(gitName)
|
|
88
|
+
|| safeSegment(typeof email === 'string' ? email.split('@')[0] : '')
|
|
89
|
+
|| `u-${createHash('sha256').update(gitName.trim()).digest('hex').slice(0, 8)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const wipBranch = (name, epic) => `${WIP_PREFIX}/${name}/${epic}`;
|
|
93
|
+
|
|
94
|
+
// Is capture turned off? `capture: false` in the Product config, or `YAD_CAPTURE=0` for one shell.
|
|
95
|
+
export function captureOff(root, env = process.env) {
|
|
96
|
+
if (env.YAD_CAPTURE === '0') return 'YAD_CAPTURE=0 is set';
|
|
97
|
+
const cfg = readJSON(productConfigPath(root), null);
|
|
98
|
+
return cfg && cfg.capture === false ? 'capture is off in the Product config (`"capture": false`)' : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// git, spawned directly: `run()` trims its output, and a NUL-separated list must reach the parser whole.
|
|
102
|
+
// `env` is the whole environment the command was given (`runCapture`'s, so a caller's settings reach every git
|
|
103
|
+
// call); `extra` adds this call's own keys on top.
|
|
104
|
+
export function gitIn(root, env = process.env, extra = null) {
|
|
105
|
+
return (args, input = null) => {
|
|
106
|
+
const r = spawnSync('git', args, {
|
|
107
|
+
cwd: root, encoding: 'utf8', maxBuffer: 1 << 30,
|
|
108
|
+
env: extra ? { ...env, ...extra } : env,
|
|
109
|
+
...(input !== null ? { input } : {}),
|
|
110
|
+
});
|
|
111
|
+
return { ok: r.status === 0, out: r.stdout || '', err: (r.stderr || '').trim() };
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every changed path under epics/ and foundation/ — tracked changes, deletions and new files — from
|
|
116
|
+
// `git status -z`. A rename yields both paths, so the old one is captured as deleted. git prints paths from
|
|
117
|
+
// the TOP of the repository, and a Product may live in a subfolder of it (a monorepo), so `prefix` — the
|
|
118
|
+
// Product's place in the repo — is cut off, and every path below is relative to the Product root.
|
|
119
|
+
// `GIT_OPTIONAL_LOCKS=0`: a plain `git status` refreshes and rewrites the person's real index, taking
|
|
120
|
+
// `index.lock` for a moment, and a `git commit` of theirs at that instant would fail. This must not.
|
|
121
|
+
// `statusEntries` keeps git's two-letter status (`??` new, `A ` added, ` M` changed…) beside each path;
|
|
122
|
+
// the fold (E44) reads it to tell a file being CREATED from one being changed.
|
|
123
|
+
export function statusEntries(root, prefix, env = process.env) {
|
|
124
|
+
const r = gitIn(root, env, { GIT_OPTIONAL_LOCKS: '0' })(['status', '-z', '--porcelain=v1', '--untracked-files=all', '--no-renames', '--', 'epics', FOUNDATION_DIR]);
|
|
125
|
+
if (!r.ok) return null;
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const entry of r.out.split('\0')) {
|
|
128
|
+
if (entry.length <= 3) continue;
|
|
129
|
+
const p = entry.slice(3);
|
|
130
|
+
if (p.startsWith(prefix)) out.push({ xy: entry.slice(0, 2), path: p.slice(prefix.length) });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function changedPaths(root, prefix, env) {
|
|
135
|
+
const entries = statusEntries(root, prefix, env);
|
|
136
|
+
return entries && entries.map((e) => e.path);
|
|
137
|
+
}
|
|
138
|
+
// A tree path from the top of the repo, as a Product-relative one — or null outside the Product.
|
|
139
|
+
const underPrefix = (p, prefix) => (p.startsWith(prefix) ? p.slice(prefix.length) : null);
|
|
140
|
+
|
|
141
|
+
const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
142
|
+
|
|
143
|
+
const trailers = ({ epic, base, branch }) => `Yad-Epic: ${epic}\nYad-Base: ${base || 'none'}\nYad-Branch: ${branch || '(detached)'}`;
|
|
144
|
+
export const captureMessage = (meta) => `wip(${meta.epic}): capture\n\n${trailers(meta)}\n`;
|
|
145
|
+
|
|
146
|
+
// One epic's capture: build the tree, and commit it on the epic's branch when its artifacts differ from the
|
|
147
|
+
// branch tip. Returns { epic, branch, commit, files } for a commit, { epic, branch, unchanged: true } when
|
|
148
|
+
// there was nothing new, or { epic, branch, error } on a failure (reported, never thrown).
|
|
149
|
+
function captureEpic(root, git, { name, epic, paths, head, headTree, current, prefix, env }) {
|
|
150
|
+
const branch = wipBranch(name, epic);
|
|
151
|
+
const ref = `refs/heads/${branch}`;
|
|
152
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yad-capture-'));
|
|
153
|
+
const idx = gitIn(root, env, { GIT_INDEX_FILE: path.join(tmp, 'index'), GIT_LITERAL_PATHSPECS: '1' });
|
|
154
|
+
try {
|
|
155
|
+
const seeded = head ? idx(['read-tree', head]) : idx(['read-tree', '--empty']);
|
|
156
|
+
if (!seeded.ok) return { epic, branch, error: `could not read HEAD's tree: ${seeded.err}` };
|
|
157
|
+
// The paths come on stdin, NUL-separated, so a name holding a space, a quote or a newline is read as
|
|
158
|
+
// written and no command line grows past the system's limit. `-A` adds, updates AND removes.
|
|
159
|
+
// A path that is neither on disk nor in HEAD's tree cannot be added — a file staged and then deleted
|
|
160
|
+
// (`AD`), or deleted on both sides of a merge (`DD`) — and one such path would make `git add` refuse
|
|
161
|
+
// the whole epic, on every capture, until someone fixed the index. It has nothing to capture: drop it.
|
|
162
|
+
const known = new Set(idx(['ls-files', '-z', '--', 'epics', FOUNDATION_DIR]).out.split('\0').filter(Boolean));
|
|
163
|
+
paths = paths.filter((p) => known.has(p) || fs.existsSync(path.join(root, p)) || isLink(path.join(root, p)));
|
|
164
|
+
if (paths.length) {
|
|
165
|
+
const added = idx(['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], paths.join('\0'));
|
|
166
|
+
if (!added.ok) return { epic, branch, error: `could not stage the artifacts: ${added.err}` };
|
|
167
|
+
}
|
|
168
|
+
const tree = idx(['write-tree']);
|
|
169
|
+
if (!tree.ok) return { epic, branch, error: `could not write the tree: ${tree.err}` };
|
|
170
|
+
const treeSha = tree.out.trim();
|
|
171
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
172
|
+
const tipR = git(['rev-parse', '--verify', '-q', `${ref}^{commit}`]);
|
|
173
|
+
const local = tipR.ok ? tipR.out.trim() : null;
|
|
174
|
+
// No local branch, but origin has one (a fresh clone, or the local branch deleted after a push): the
|
|
175
|
+
// capture CONTINUES origin's, or the plain push below could never fast-forward it and the epic's
|
|
176
|
+
// captures would never leave this machine (E43 review 2). A capture by hand fetches these first.
|
|
177
|
+
const remoteR = local ? null : git(['rev-parse', '--verify', '-q', `refs/remotes/origin/${branch}^{commit}`]);
|
|
178
|
+
const tip = local || (remoteR?.ok ? remoteR.out.trim() : null);
|
|
179
|
+
// Nothing new when this epic's artifacts are the same as on the branch tip (or, for a first capture,
|
|
180
|
+
// as in HEAD). Compared on the artifacts only: HEAD moving under the branch changes the ledger and
|
|
181
|
+
// the rest of the tree, and that alone is not a capture.
|
|
182
|
+
const against = tip ? `${tip}^{tree}` : headTree;
|
|
183
|
+
let changed = paths.filter((p) => capturedEpic(p) === epic);
|
|
184
|
+
if (against) {
|
|
185
|
+
const diff = git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', against, treeSha]);
|
|
186
|
+
if (!diff.ok) return { epic, branch, error: `could not compare with ${tip ? branch : 'HEAD'}: ${diff.err}` };
|
|
187
|
+
const moved = diff.out.split('\0').filter((p) => p && capturedEpic(underPrefix(p, prefix)) === epic);
|
|
188
|
+
if (!moved.length) return { epic, branch, unchanged: true };
|
|
189
|
+
// What THIS capture changed that is the person's OWN edit — the files E46's claim check asks about. A
|
|
190
|
+
// file a pull brought in also moved since the last capture, but it equals HEAD's: not theirs.
|
|
191
|
+
const own = headTree && tip ? git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', headTree, treeSha]) : null;
|
|
192
|
+
const ownSet = own?.ok ? new Set(own.out.split('\0').filter(Boolean)) : null;
|
|
193
|
+
changed = moved.filter((p) => !ownSet || ownSet.has(p)).map((p) => underPrefix(p, prefix));
|
|
194
|
+
}
|
|
195
|
+
const parent = tip || head;
|
|
196
|
+
const commit = git(['commit-tree', '--no-gpg-sign', treeSha, ...(parent ? ['-p', parent] : []), '-F', '-'],
|
|
197
|
+
captureMessage({ epic, base: head, branch: current }));
|
|
198
|
+
if (!commit.ok) return { epic, branch, error: `could not commit: ${commit.err}` };
|
|
199
|
+
const sha = commit.out.trim();
|
|
200
|
+
// Compare-and-swap: move the ref only if it is still where it was read. An all-zero old value means
|
|
201
|
+
// "must not exist yet". A lost race re-reads the tip and commits on top of it, once.
|
|
202
|
+
const moved = git(['update-ref', '-m', `yad capture ${epic}`, ref, sha, local || '0'.repeat(sha.length)]);
|
|
203
|
+
if (moved.ok) return { epic, branch, commit: sha, files: paths.filter((p) => capturedEpic(p) === epic).length, changed };
|
|
204
|
+
if (attempt === 1) return { epic, branch, error: `${branch} moved while it was being written — run \`yad capture\` again` };
|
|
205
|
+
}
|
|
206
|
+
return { epic, branch, error: 'unreachable' };
|
|
207
|
+
} finally {
|
|
208
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The push, as git arguments: every capture branch of this person in one go. A PLAIN push, never a forced
|
|
213
|
+
// one: each capture is built on the last — or, with no local branch, on origin's (see `captureEpic`) — so
|
|
214
|
+
// the push is a fast-forward. When it is not — the same person captured the same epic on two machines —
|
|
215
|
+
// git refuses that one branch and nothing is overwritten (E43 review: a lease read remote-tracking refs, which a
|
|
216
|
+
// single-branch clone never has, so it refused every push after the first; and which an IDE's background
|
|
217
|
+
// fetch updates, so it would have let the push overwrite the other machine). `--no-verify`: a team's
|
|
218
|
+
// pre-push hook (tests, linters) has no business running on a draft snapshot, and could not run unattended.
|
|
219
|
+
export const pushArgs = (name) => [
|
|
220
|
+
'-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20',
|
|
221
|
+
'push', '--no-verify', '--quiet', 'origin',
|
|
222
|
+
`refs/heads/${WIP_PREFIX}/${name}/*:refs/heads/${WIP_PREFIX}/${name}/*`,
|
|
223
|
+
];
|
|
224
|
+
// E46: everyone's capture branches, as remote-tracking refs — what `yad claims` reads. No `--prune` from the
|
|
225
|
+
// hook: a deleted branch lingers until the next `yad claims`, and the 4-hour claim expiry bounds what it says.
|
|
226
|
+
export const FETCH_ALL_REFSPEC = `+refs/heads/${WIP_PREFIX}/*:refs/remotes/origin/${WIP_PREFIX}/*`;
|
|
227
|
+
export const fetchAllArgs = ({ prune = false, extra = [] } = {}) => [
|
|
228
|
+
'-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20',
|
|
229
|
+
'fetch', '--quiet', '--no-tags', ...(prune ? ['--prune'] : []), 'origin', FETCH_ALL_REFSPEC, ...extra,
|
|
230
|
+
];
|
|
231
|
+
// No prompt may ever appear: a hook has no terminal, and a prompt nobody sees hangs the push for ever.
|
|
232
|
+
export const pushEnv = (env = process.env) => ({
|
|
233
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
234
|
+
...(env.GIT_SSH_COMMAND ? {} : { GIT_SSH_COMMAND: 'ssh -oBatchMode=yes -oConnectTimeout=10' }),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// When the last push started, per clone — kept in git's own COMMON folder (`--git-common-dir`), so it is
|
|
238
|
+
// never committed and every worktree of one clone shares it: they push the same branches.
|
|
239
|
+
function pushStatePath(git) {
|
|
240
|
+
const r = git(['rev-parse', '--git-common-dir']);
|
|
241
|
+
return r.ok && r.out.trim() ? path.join(r.out.trim(), 'yad-capture.json') : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The state file's other keys (E46's `claimsWarned`) survive a push's write. A cache, so read leniently: an
|
|
245
|
+
// unreadable file reads as empty and is replaced (claimWarnings, which only adds to it, refuses to overwrite one).
|
|
246
|
+
const stateOf = (file) => { const s = readJSON(file, {}); return s && typeof s === 'object' && !Array.isArray(s) ? s : {}; };
|
|
247
|
+
|
|
248
|
+
// Push now (a person ran `yad capture`), or start one in the background when due (the hook).
|
|
249
|
+
function push(root, git, name, { hook, now, spawner, env }) {
|
|
250
|
+
if (!git(['remote', 'get-url', 'origin']).ok) return { pushed: 'local', why: 'no remote named origin' };
|
|
251
|
+
const statePath = pushStatePath(git);
|
|
252
|
+
const stateFile = statePath && path.resolve(root, statePath);
|
|
253
|
+
if (hook) {
|
|
254
|
+
const state = stateFile ? stateOf(stateFile) : {};
|
|
255
|
+
const last = Number(state.lastPushAt) || 0;
|
|
256
|
+
if (now - last < PUSH_EVERY_MS) return { pushed: 'later', why: `the last push started under ${PUSH_EVERY_MS / 60000} minutes ago` };
|
|
257
|
+
// Recorded BEFORE the push starts, so two hooks a moment apart cannot both start one.
|
|
258
|
+
if (stateFile) writeJSON(stateFile, { ...state, lastPushAt: now });
|
|
259
|
+
try {
|
|
260
|
+
spawner('git', pushArgs(name), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore' }).unref();
|
|
261
|
+
// E46: fetch everyone's capture branches in the same window, so the claim check at the next edit reads
|
|
262
|
+
// minutes-old claims, not hours-old ones. Detached too: an edit never waits on it, and a failure is silent.
|
|
263
|
+
try { spawner('git', fetchAllArgs(), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore' }).unref(); } catch { /* claims read what was last fetched */ }
|
|
264
|
+
return { pushed: 'started' };
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return { pushed: 'failed', why: e.message };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const r = spawnSync('git', pushArgs(name), { cwd: root, encoding: 'utf8', timeout: 60_000, env: { ...env, ...pushEnv(env) } });
|
|
270
|
+
if (stateFile) writeJSON(stateFile, { ...stateOf(stateFile), lastPushAt: now });
|
|
271
|
+
if (r.status === 0) return { pushed: 'done' };
|
|
272
|
+
// Refused, not failed: origin has a capture branch this one does not continue. The next push is refused
|
|
273
|
+
// the same way, so it is named as stuck — never "rides the next push".
|
|
274
|
+
const refused = [...(r.stderr || '').matchAll(/\[rejected\]\s+(\S+)/g)].map((m) => m[1]);
|
|
275
|
+
if (refused.length) return { pushed: 'refused', branches: refused, why: `origin already has ${refused.join(', ')} with captures this machine does not have (pushed from another machine) — nothing was overwritten` };
|
|
276
|
+
const why = r.error?.code === 'ETIMEDOUT' ? 'the push took over a minute' : ((r.stderr || '').trim().split('\n').pop() || `git exited ${r.status}`);
|
|
277
|
+
return { pushed: 'failed', why };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// `yad capture [--no-push] [--hook]`. With `--hook` it is the harness's post-edit command: it never fails,
|
|
281
|
+
// never prints to stdout (a harness may read it), and says nothing unless something went wrong.
|
|
282
|
+
// `format: 'claude'` (from `--format claude`, which the hook script passes under Claude Code) makes a claim
|
|
283
|
+
// warning the one thing the hook prints on stdout: PostToolUse JSON the agent reads (E46).
|
|
284
|
+
export async function runCapture(root, { hook = false, noPush = false, now = Date.now(), spawner = spawn, env = process.env, format = null } = {}) {
|
|
285
|
+
const quiet = hook;
|
|
286
|
+
const say = (fn, msg) => { if (!quiet) fn(msg); };
|
|
287
|
+
const bail = (msg, extra = {}) => {
|
|
288
|
+
if (quiet) { if (extra.loud) process.stderr.write(` • yad capture: ${msg}\n`); }
|
|
289
|
+
else { fail(msg); if (extra.hint) hand(extra.hint); process.exitCode = 1; }
|
|
290
|
+
return { captured: [], unchanged: [], errors: [], pushed: null, off: extra.off ?? null };
|
|
291
|
+
};
|
|
292
|
+
const git = gitIn(root, env);
|
|
293
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
294
|
+
if (!top.ok) return bail('not a git repository — nothing to capture');
|
|
295
|
+
if (!fs.existsSync(productConfigPath(root))) return bail('not a Product (no .sdlc/hub.json or product.json here) — nothing to capture', { hint: 'run it from the Product root, or pass --dir' });
|
|
296
|
+
const off = captureOff(root, env);
|
|
297
|
+
if (off) {
|
|
298
|
+
say(info, `capture is off — ${off}`);
|
|
299
|
+
return { captured: [], unchanged: [], errors: [], pushed: null, off };
|
|
300
|
+
}
|
|
301
|
+
const name = wipName(git(['config', 'user.name']).out.trim(), git(['config', 'user.email']).out.trim());
|
|
302
|
+
const prefix = git(['rev-parse', '--show-prefix']).out.trim();
|
|
303
|
+
if (!name) return bail('git has no user.name to name the capture branch after', { hint: 'git config user.name "<your name>"', loud: true });
|
|
304
|
+
|
|
305
|
+
const headR = git(['rev-parse', '--verify', '-q', 'HEAD^{commit}']);
|
|
306
|
+
const head = headR.ok ? headR.out.trim() : null;
|
|
307
|
+
const headTreeR = head ? git(['rev-parse', `${head}^{tree}`]) : null;
|
|
308
|
+
const headTree = headTreeR?.ok ? headTreeR.out.trim() : null;
|
|
309
|
+
const branchR = git(['symbolic-ref', '--short', '-q', 'HEAD']);
|
|
310
|
+
const current = branchR.ok ? branchR.out.trim() : null;
|
|
311
|
+
|
|
312
|
+
// A capture by hand first fetches this person's capture branches (no prompts, short), so one with no local
|
|
313
|
+
// copy — a fresh clone, a deleted branch — is continued rather than restarted from HEAD. Not from the hook:
|
|
314
|
+
// an edit must never wait on the network. Offline, it is skipped in silence.
|
|
315
|
+
if (!hook && !noPush && git(['remote', 'get-url', 'origin']).ok) {
|
|
316
|
+
// `--prune`, confined by the refspec to this person's capture copies: a branch deleted on origin (a
|
|
317
|
+
// secret captured by mistake, say) is not continued from a stale copy (E43 review 3). This prune clears
|
|
318
|
+
// every stale copy on the hand path; a LOCAL branch is still pushed back, and the hook, which never
|
|
319
|
+
// fetches, still reads a copy another fetch left. Removing one for good means `git branch -D` and
|
|
320
|
+
// `git branch -dr origin/…` on every machine too (README). The same
|
|
321
|
+
// low-speed limit as the push, so a stalled HTTPS connection gives up instead of holding the capture.
|
|
322
|
+
spawnSync('git', ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', 'fetch', '--quiet', '--prune', '--no-tags', 'origin', `+refs/heads/${WIP_PREFIX}/${name}/*:refs/remotes/origin/${WIP_PREFIX}/${name}/*`],
|
|
323
|
+
{ cwd: root, stdio: 'ignore', timeout: 30_000, env: { ...env, ...pushEnv(env) } });
|
|
324
|
+
}
|
|
325
|
+
const changed = changedPaths(root, prefix, env);
|
|
326
|
+
if (changed === null) return bail('git could not list the changed files', { loud: true });
|
|
327
|
+
const byEpic = new Map();
|
|
328
|
+
for (const p of changed) {
|
|
329
|
+
const epic = capturedEpic(p);
|
|
330
|
+
if (!epic) continue;
|
|
331
|
+
if (!byEpic.has(epic)) byEpic.set(epic, []);
|
|
332
|
+
byEpic.get(epic).push(p);
|
|
333
|
+
}
|
|
334
|
+
// An epic whose capture branch has work the checkout no longer shows — every edit reverted, or the files
|
|
335
|
+
// deleted — is captured too, so the branch ends on what is really there. Only while the checkout is still
|
|
336
|
+
// on the HEAD that branch was last captured from (`Yad-Base`): once HEAD moves (a pull, a merge, a
|
|
337
|
+
// switch), a difference is other work arriving, not an edit undone, and recording it would add a commit
|
|
338
|
+
// to every old capture branch on every pull.
|
|
339
|
+
// `trailer.separators=:` pins how the `Yad-Base: <sha>` line is read against a person's own git config.
|
|
340
|
+
const branches = git(['-c', 'trailer.separators=:', 'for-each-ref', '--format=%(refname)%00%(trailers:key=Yad-Base,valueonly)', `refs/heads/${WIP_PREFIX}/${name}/`]);
|
|
341
|
+
for (const line of branches.ok ? branches.out.split('\n').filter(Boolean) : []) {
|
|
342
|
+
const [r, base = ''] = line.split('\0');
|
|
343
|
+
const epic = r.slice(`refs/heads/${WIP_PREFIX}/${name}/`.length);
|
|
344
|
+
if (/^EP-[^/]+$/.test(epic) && !byEpic.has(epic) && base.trim() === (head || 'none')) byEpic.set(epic, []);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const captured = [];
|
|
348
|
+
const unchanged = [];
|
|
349
|
+
const errors = [];
|
|
350
|
+
for (const [epic, paths] of [...byEpic].sort(([a], [b]) => a.localeCompare(b))) {
|
|
351
|
+
const res = captureEpic(root, git, { name, epic, paths, head, headTree, current, prefix, env });
|
|
352
|
+
if (res.error) errors.push(res);
|
|
353
|
+
else if (res.unchanged) unchanged.push(res);
|
|
354
|
+
else captured.push(res);
|
|
355
|
+
}
|
|
356
|
+
for (const e of errors) {
|
|
357
|
+
if (quiet) process.stderr.write(` • yad capture: ${e.epic}: ${e.error}\n`);
|
|
358
|
+
else warn(`${e.epic}: ${e.error}`);
|
|
359
|
+
}
|
|
360
|
+
for (const c of captured) say(ok, `${c.epic} → ${c.branch} (${c.files} file(s))`);
|
|
361
|
+
if (!captured.length && !errors.length) say(info, byEpic.size ? 'nothing new since the last capture' : 'no changed artifacts to capture');
|
|
362
|
+
|
|
363
|
+
let pushed = null;
|
|
364
|
+
if (!noPush && (captured.length || !hook)) {
|
|
365
|
+
// Only this person's branches exist to push; with none at all there is nothing to send.
|
|
366
|
+
const any = git(['for-each-ref', '--count=1', '--format=%(refname)', `refs/heads/${WIP_PREFIX}/${name}/`]);
|
|
367
|
+
if (any.ok && any.out.trim()) {
|
|
368
|
+
pushed = push(root, git, name, { hook, now, spawner, env });
|
|
369
|
+
if (pushed.pushed === 'local') say(info, `local only — ${pushed.why}; the captures stay on this machine`);
|
|
370
|
+
else if (pushed.pushed === 'done') say(ok, `pushed ${WIP_PREFIX}/${name}/* to origin`);
|
|
371
|
+
else if (pushed.pushed === 'failed') {
|
|
372
|
+
if (quiet) process.stderr.write(` • yad capture: push failed — ${pushed.why}\n`);
|
|
373
|
+
else { warn(`push failed — ${pushed.why}; the captures are safe on this machine and ride the next push`); }
|
|
374
|
+
} else if (pushed.pushed === 'refused') {
|
|
375
|
+
if (quiet) process.stderr.write(` • yad capture: push refused — ${pushed.why}\n`);
|
|
376
|
+
else {
|
|
377
|
+
warn(`push refused — ${pushed.why}; it stays refused while both machines keep their own copy`);
|
|
378
|
+
hand(`keep origin's: \`git branch -D ${pushed.branches.join(' ')}\` here, then \`yad capture\` continues it (this machine's un-pushed captures of that epic are dropped)`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// E46: of the files this capture changed, does someone else have them open? Advice only, and never a
|
|
384
|
+
// reason for the capture to fail.
|
|
385
|
+
let claims = [];
|
|
386
|
+
let warningText = null;
|
|
387
|
+
try {
|
|
388
|
+
// Loaded here, not at the top: claims.mjs reads this module's exports, and the hook's fast path should
|
|
389
|
+
// not pay for it on an edit that captured nothing.
|
|
390
|
+
const mod = captured.length ? await import('./claims.mjs') : null;
|
|
391
|
+
warningText = mod?.warningText;
|
|
392
|
+
const statePath = pushStatePath(git);
|
|
393
|
+
if (mod) claims = mod.claimWarnings(root, captured.flatMap((c) => c.changed || []), { env, now, statePath: statePath && path.resolve(root, statePath) });
|
|
394
|
+
} catch { claims = []; }
|
|
395
|
+
// E47: of the files this capture changed, which belong to a step someone else owns? The same advice-only
|
|
396
|
+
// rule. One note carries both warnings: a harness reads one PostToolUse JSON object.
|
|
397
|
+
let owners = [];
|
|
398
|
+
let ownersText = null;
|
|
399
|
+
try {
|
|
400
|
+
const mod = captured.length ? await import('./owners.mjs') : null;
|
|
401
|
+
const statePath = pushStatePath(git);
|
|
402
|
+
if (mod) {
|
|
403
|
+
owners = mod.ownerWarnings(root, captured.flatMap((c) => c.changed || []), { me: name, now, statePath: statePath && path.resolve(root, statePath) });
|
|
404
|
+
if (owners.length) ownersText = mod.ownerWarningText(owners);
|
|
405
|
+
}
|
|
406
|
+
} catch { owners = []; }
|
|
407
|
+
const texts = [claims.length && warningText ? warningText(claims, now) : null, ownersText].filter(Boolean);
|
|
408
|
+
if (texts.length) {
|
|
409
|
+
const text = texts.join('\n');
|
|
410
|
+
if (hook && format === 'claude') {
|
|
411
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: text, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } })}\n`);
|
|
412
|
+
} else if (quiet) process.stderr.write(` • ${text.replace(/\n/g, '\n ')}\n`);
|
|
413
|
+
else warn(text);
|
|
414
|
+
}
|
|
415
|
+
if (!quiet && errors.length) process.exitCode = 1;
|
|
416
|
+
return { name, captured, unchanged: unchanged.map((u) => u.epic), errors, pushed, off: null, claims, owners };
|
|
417
|
+
}
|