triflux 10.9.11 → 10.9.13
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/bin/triflux.mjs +55 -0
- package/hooks/session-start-fast.mjs +17 -0
- package/hub/server.mjs +19 -3
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +65 -9
- package/hub/team/swarm-reconciler.mjs +80 -0
- package/hub/team/synapse-cli.mjs +207 -0
- package/hub/team/synapse-registry.mjs +275 -0
- package/hub/team/worktree-lifecycle.mjs +48 -1
- package/hub/workers/gemini-worker.mjs +5 -1
- package/package.json +1 -1
- package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +9 -0
- package/scripts/claude-login-detect.mjs +76 -0
- package/scripts/doctor-diagnose.mjs +65 -1
- package/scripts/headless-guard.mjs +2 -11
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/session-stale-cleanup.mjs +1 -9
- package/scripts/tfx-route-worker.mjs +21 -1
- package/scripts/tfx-route.sh +37 -8
package/hub/team/swarm-locks.mjs
CHANGED
|
@@ -33,6 +33,34 @@ export function createSwarmLocks(opts = {}) {
|
|
|
33
33
|
return Date.now();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function normalizeLeaseType(leaseType) {
|
|
37
|
+
return leaseType === "shared-read" ? "shared-read" : "exclusive";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeSessionMeta(sessionMeta) {
|
|
41
|
+
return sessionMeta ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeEntry(entry) {
|
|
45
|
+
return {
|
|
46
|
+
workerId: entry.workerId,
|
|
47
|
+
acquiredAt: entry.acquiredAt,
|
|
48
|
+
leaseType: normalizeLeaseType(entry.leaseType),
|
|
49
|
+
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasConflict(existing, requesterId, requestedLeaseType) {
|
|
54
|
+
if (!existing || existing.workerId === requesterId) return false;
|
|
55
|
+
if (
|
|
56
|
+
existing.leaseType === "shared-read" &&
|
|
57
|
+
requestedLeaseType === "shared-read"
|
|
58
|
+
) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
function isExpired(entry) {
|
|
37
65
|
return now() - entry.acquiredAt > ttlMs;
|
|
38
66
|
}
|
|
@@ -65,7 +93,7 @@ export function createSwarmLocks(opts = {}) {
|
|
|
65
93
|
const ts = now();
|
|
66
94
|
for (const [path, entry] of Object.entries(data)) {
|
|
67
95
|
if (ts - entry.acquiredAt <= ttlMs) {
|
|
68
|
-
locks.set(path, entry);
|
|
96
|
+
locks.set(path, normalizeEntry(entry));
|
|
69
97
|
}
|
|
70
98
|
}
|
|
71
99
|
} catch {
|
|
@@ -82,23 +110,42 @@ export function createSwarmLocks(opts = {}) {
|
|
|
82
110
|
* Acquire file leases for a worker.
|
|
83
111
|
* @param {string} workerId — worker/shard identifier
|
|
84
112
|
* @param {string[]} files — file paths to lock
|
|
113
|
+
* @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
|
|
85
114
|
* @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
|
|
86
115
|
*/
|
|
87
|
-
function acquire(workerId, files) {
|
|
116
|
+
function acquire(workerId, files, opts = {}) {
|
|
88
117
|
pruneExpired();
|
|
89
118
|
|
|
119
|
+
const leaseType = normalizeLeaseType(opts.leaseType);
|
|
120
|
+
const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
|
|
90
121
|
const normalized = files.map((f) => normalizePath(f));
|
|
91
122
|
const conflicts = [];
|
|
92
123
|
const toAcquire = [];
|
|
124
|
+
const allowedSharedRead = [];
|
|
93
125
|
|
|
94
126
|
for (let i = 0; i < normalized.length; i++) {
|
|
95
127
|
const path = normalized[i];
|
|
96
128
|
const existing = locks.get(path);
|
|
97
129
|
|
|
98
|
-
if (existing
|
|
130
|
+
if (!existing || isExpired(existing)) {
|
|
131
|
+
toAcquire.push(path);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (hasConflict(existing, workerId, leaseType)) {
|
|
99
136
|
conflicts.push({ file: files[i], holder: existing.workerId });
|
|
100
137
|
} else {
|
|
101
|
-
|
|
138
|
+
if (
|
|
139
|
+
!(
|
|
140
|
+
existing.workerId !== workerId &&
|
|
141
|
+
existing.leaseType === "shared-read" &&
|
|
142
|
+
leaseType === "shared-read"
|
|
143
|
+
)
|
|
144
|
+
) {
|
|
145
|
+
toAcquire.push(path);
|
|
146
|
+
} else {
|
|
147
|
+
allowedSharedRead.push(path);
|
|
148
|
+
}
|
|
102
149
|
}
|
|
103
150
|
}
|
|
104
151
|
|
|
@@ -108,11 +155,15 @@ export function createSwarmLocks(opts = {}) {
|
|
|
108
155
|
|
|
109
156
|
const ts = now();
|
|
110
157
|
for (const path of toAcquire) {
|
|
111
|
-
locks.set(path, { workerId, acquiredAt: ts });
|
|
158
|
+
locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
persist();
|
|
115
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
acquired: [...toAcquire, ...allowedSharedRead],
|
|
165
|
+
conflicts: [],
|
|
166
|
+
};
|
|
116
167
|
}
|
|
117
168
|
|
|
118
169
|
/**
|
|
@@ -136,15 +187,18 @@ export function createSwarmLocks(opts = {}) {
|
|
|
136
187
|
* Check if a file write would violate any lease.
|
|
137
188
|
* @param {string} workerId — the worker attempting the write
|
|
138
189
|
* @param {string} filePath — the file being written
|
|
190
|
+
* @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
|
|
139
191
|
* @returns {{ allowed: boolean, holder?: string }}
|
|
140
192
|
*/
|
|
141
|
-
function check(workerId, filePath) {
|
|
193
|
+
function check(workerId, filePath, leaseType = "exclusive") {
|
|
142
194
|
pruneExpired();
|
|
143
195
|
const path = normalizePath(filePath);
|
|
144
196
|
const entry = locks.get(path);
|
|
197
|
+
const normalizedLeaseType = normalizeLeaseType(leaseType);
|
|
145
198
|
|
|
146
199
|
if (!entry || isExpired(entry)) return { allowed: true };
|
|
147
|
-
if (entry
|
|
200
|
+
if (!hasConflict(entry, workerId, normalizedLeaseType))
|
|
201
|
+
return { allowed: true };
|
|
148
202
|
return { allowed: false, holder: entry.workerId };
|
|
149
203
|
}
|
|
150
204
|
|
|
@@ -173,7 +227,7 @@ export function createSwarmLocks(opts = {}) {
|
|
|
173
227
|
|
|
174
228
|
/**
|
|
175
229
|
* Get snapshot of all active locks.
|
|
176
|
-
* @returns {Array<{ file: string, workerId: string, acquiredAt: number }>}
|
|
230
|
+
* @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
|
|
177
231
|
*/
|
|
178
232
|
function snapshot() {
|
|
179
233
|
pruneExpired();
|
|
@@ -181,6 +235,8 @@ export function createSwarmLocks(opts = {}) {
|
|
|
181
235
|
file,
|
|
182
236
|
workerId: entry.workerId,
|
|
183
237
|
acquiredAt: entry.acquiredAt,
|
|
238
|
+
leaseType: normalizeLeaseType(entry.leaseType),
|
|
239
|
+
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
184
240
|
}));
|
|
185
241
|
}
|
|
186
242
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// hub/team/swarm-reconciler.mjs — Redundant execution + result reconciliation
|
|
2
2
|
// For critical shards: launches primary + verifier sessions, compares results,
|
|
3
3
|
// applies conservative adoption (fewer changes wins) or HITL fallback.
|
|
4
|
+
// v2 (Synapse): parses X-Intent trailers to route complementary/contradictory
|
|
5
|
+
// commits through intent-aware paths before falling back to conservative logic.
|
|
4
6
|
|
|
5
7
|
import { execFile } from "node:child_process";
|
|
6
8
|
|
|
9
|
+
import { classifyIntentPair, parseIntentTrailer } from "./swarm-intent.mjs";
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* Compare two shard results and decide which to accept.
|
|
9
13
|
* Strategy: conservative adoption — the result with fewer changed files wins.
|
|
@@ -49,6 +53,66 @@ export async function reconcile(primaryResult, verifierResult, opts = {}) {
|
|
|
49
53
|
return decision("primary", "identical", primaryResult);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
// Intent-aware classification: parse X-Intent trailers from both commits.
|
|
57
|
+
// When both commits carry an intent, route contradictory pairs to HITL and
|
|
58
|
+
// complementary (non-overlapping) pairs to a merge-friendly path. Missing or
|
|
59
|
+
// malformed trailers fall through to the existing divergence/conservative
|
|
60
|
+
// adoption logic below.
|
|
61
|
+
const [primaryMsg, verifierMsg] = await Promise.all([
|
|
62
|
+
getCommitMessage(primaryResult.branchName, rootDir),
|
|
63
|
+
getCommitMessage(verifierResult.branchName, rootDir),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const primaryIntent = parseIntentTrailer(primaryMsg);
|
|
67
|
+
const verifierIntent = parseIntentTrailer(verifierMsg);
|
|
68
|
+
|
|
69
|
+
if (primaryIntent && verifierIntent) {
|
|
70
|
+
const classification = classifyIntentPair(primaryIntent, verifierIntent);
|
|
71
|
+
|
|
72
|
+
if (classification.relation === "contradictory") {
|
|
73
|
+
return {
|
|
74
|
+
selected: "hitl",
|
|
75
|
+
reason: `intent_contradictory: ${classification.reason}`,
|
|
76
|
+
result: null,
|
|
77
|
+
requiresManualReview: true,
|
|
78
|
+
intentClassification: classification,
|
|
79
|
+
primaryIntent,
|
|
80
|
+
verifierIntent,
|
|
81
|
+
primary: {
|
|
82
|
+
filesChanged: primaryDiff.filesChanged,
|
|
83
|
+
linesChanged: primaryDiff.linesChanged,
|
|
84
|
+
},
|
|
85
|
+
verifier: {
|
|
86
|
+
filesChanged: verifierDiff.filesChanged,
|
|
87
|
+
linesChanged: verifierDiff.linesChanged,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (classification.relation === "complementary") {
|
|
93
|
+
return {
|
|
94
|
+
selected: "complementary",
|
|
95
|
+
reason: `intent_complementary: ${classification.reason}`,
|
|
96
|
+
result: { primary: primaryResult, verifier: verifierResult },
|
|
97
|
+
requiresManualReview: false,
|
|
98
|
+
intentClassification: classification,
|
|
99
|
+
primaryIntent,
|
|
100
|
+
verifierIntent,
|
|
101
|
+
shouldAttemptMerge: true,
|
|
102
|
+
primary: {
|
|
103
|
+
filesChanged: primaryDiff.filesChanged,
|
|
104
|
+
linesChanged: primaryDiff.linesChanged,
|
|
105
|
+
},
|
|
106
|
+
verifier: {
|
|
107
|
+
filesChanged: verifierDiff.filesChanged,
|
|
108
|
+
linesChanged: verifierDiff.linesChanged,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// complementary-risky / independent → fall through to existing logic.
|
|
114
|
+
}
|
|
115
|
+
|
|
52
116
|
// Compute divergence
|
|
53
117
|
const divergence = Math.abs(
|
|
54
118
|
primaryDiff.filesChanged - verifierDiff.filesChanged,
|
|
@@ -90,6 +154,22 @@ function decision(selected, reason, result) {
|
|
|
90
154
|
};
|
|
91
155
|
}
|
|
92
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Read the HEAD commit message of a branch.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} branch
|
|
161
|
+
* @param {string} cwd
|
|
162
|
+
* @returns {Promise<string>}
|
|
163
|
+
*/
|
|
164
|
+
async function getCommitMessage(branch, cwd) {
|
|
165
|
+
try {
|
|
166
|
+
const msg = await gitExec(["log", "-1", "--format=%B", branch], cwd);
|
|
167
|
+
return msg;
|
|
168
|
+
} catch {
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
93
173
|
/**
|
|
94
174
|
* Get diff statistics for a branch relative to its merge-base.
|
|
95
175
|
*
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// hub/team/synapse-cli.mjs — CLI surface for Synapse v1.
|
|
2
|
+
// Reads persisted registry state + git log for the "status" and "why" commands.
|
|
3
|
+
// Hub integration comes later; for v1 we go straight to the JSON persist files
|
|
4
|
+
// so the CLI works even when Hub is offline.
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { parseIntentTrailer } from "./swarm-intent.mjs";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_REGISTRY_CANDIDATES = [
|
|
14
|
+
".triflux/synapse-registry.json",
|
|
15
|
+
".triflux/synapse/registry.json",
|
|
16
|
+
join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function gitExec(args, cwd) {
|
|
20
|
+
return new Promise((res, rej) => {
|
|
21
|
+
execFile(
|
|
22
|
+
"git",
|
|
23
|
+
args,
|
|
24
|
+
{ cwd, windowsHide: true, timeout: 15_000 },
|
|
25
|
+
(err, stdout) => {
|
|
26
|
+
if (err) rej(err);
|
|
27
|
+
else res(stdout);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function locateRegistryPath(explicit) {
|
|
34
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
35
|
+
for (const candidate of DEFAULT_REGISTRY_CANDIDATES) {
|
|
36
|
+
if (existsSync(candidate)) return candidate;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadRegistrySnapshot(path) {
|
|
42
|
+
if (!path) return [];
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(path, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (Array.isArray(parsed)) return parsed;
|
|
47
|
+
if (parsed && typeof parsed === "object") {
|
|
48
|
+
// Accept both array-form and object-form persist layouts.
|
|
49
|
+
if (Array.isArray(parsed.sessions)) return parsed.sessions;
|
|
50
|
+
return Object.values(parsed);
|
|
51
|
+
}
|
|
52
|
+
return [];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatStatus(sessions) {
|
|
59
|
+
if (!sessions.length) {
|
|
60
|
+
return "no active sessions (synapse-registry empty)";
|
|
61
|
+
}
|
|
62
|
+
const rows = [];
|
|
63
|
+
rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
|
|
64
|
+
rows.push("───────────────────── ────── ──────── ────── ─────── ─────────────");
|
|
65
|
+
for (const s of sessions) {
|
|
66
|
+
const id = (s.sessionId || "?").padEnd(21).slice(0, 21);
|
|
67
|
+
const host = (s.host || "?").padEnd(6).slice(0, 6);
|
|
68
|
+
const branch = (s.branch || "?").padEnd(8).slice(0, 8);
|
|
69
|
+
const dirty = String((s.dirtyFiles || []).length).padEnd(6).slice(0, 6);
|
|
70
|
+
const state = (s.status || "active").padEnd(7).slice(0, 7);
|
|
71
|
+
const task = (s.taskSummary || "").slice(0, 40);
|
|
72
|
+
rows.push(`${id} ${host} ${branch} ${dirty} ${state} ${task}`);
|
|
73
|
+
}
|
|
74
|
+
return rows.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `tfx synapse status` — list active swarm sessions from persisted registry.
|
|
79
|
+
*/
|
|
80
|
+
export async function cmdSynapseStatus(args = [], opts = {}) {
|
|
81
|
+
const jsonOut = args.includes("--json") || opts.json;
|
|
82
|
+
const explicit = extractFlag(args, "--registry");
|
|
83
|
+
const path = locateRegistryPath(explicit);
|
|
84
|
+
const sessions = loadRegistrySnapshot(path);
|
|
85
|
+
|
|
86
|
+
if (jsonOut) {
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
JSON.stringify({ registry: path, count: sessions.length, sessions }, null, 2) + "\n",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!path) {
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
"no registry file found (looked for .triflux/synapse-registry.json)\n",
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(`registry: ${path}\n`);
|
|
100
|
+
process.stdout.write(`${formatStatus(sessions)}\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractFlag(args, name) {
|
|
104
|
+
const idx = args.indexOf(name);
|
|
105
|
+
if (idx < 0) return null;
|
|
106
|
+
return args[idx + 1] || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveCommitForPath(path, cwd) {
|
|
110
|
+
try {
|
|
111
|
+
const out = await gitExec(["log", "-1", "--format=%H", "--", path], cwd);
|
|
112
|
+
return out.trim();
|
|
113
|
+
} catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readCommitMessage(sha, cwd) {
|
|
119
|
+
try {
|
|
120
|
+
return await gitExec(["log", "-1", "--format=%B", sha], cwd);
|
|
121
|
+
} catch {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readCommitMeta(sha, cwd) {
|
|
127
|
+
try {
|
|
128
|
+
const out = await gitExec(
|
|
129
|
+
["log", "-1", "--format=%h%x1f%an%x1f%ad%x1f%s", "--date=iso", sha],
|
|
130
|
+
cwd,
|
|
131
|
+
);
|
|
132
|
+
const [short, author, date, subject] = out.trim().split("\x1f");
|
|
133
|
+
return { short, author, date, subject };
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `tfx why <path>` — show the X-Intent of the last commit touching <path>.
|
|
141
|
+
*/
|
|
142
|
+
export async function cmdSynapseWhy(args = [], opts = {}) {
|
|
143
|
+
const jsonOut = args.includes("--json") || opts.json;
|
|
144
|
+
const cwd = process.cwd();
|
|
145
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
146
|
+
const target = positional[0];
|
|
147
|
+
|
|
148
|
+
if (!target) {
|
|
149
|
+
const err = { error: "path required", usage: "tfx why <path>" };
|
|
150
|
+
if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
|
|
151
|
+
else process.stderr.write("tfx why <path> — path 인자 필요\n");
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const resolved = resolve(cwd, target);
|
|
157
|
+
if (!existsSync(resolved)) {
|
|
158
|
+
const err = { error: "path not found", path: target };
|
|
159
|
+
if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
|
|
160
|
+
else process.stderr.write(`tfx why: 파일 없음: ${target}\n`);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sha = await resolveCommitForPath(target, cwd);
|
|
166
|
+
if (!sha) {
|
|
167
|
+
const result = { path: target, commit: null, intent: null };
|
|
168
|
+
if (jsonOut) process.stdout.write(JSON.stringify(result) + "\n");
|
|
169
|
+
else process.stdout.write(`no commit touches ${target}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const [message, meta] = await Promise.all([
|
|
174
|
+
readCommitMessage(sha, cwd),
|
|
175
|
+
readCommitMeta(sha, cwd),
|
|
176
|
+
]);
|
|
177
|
+
const intent = parseIntentTrailer(message);
|
|
178
|
+
|
|
179
|
+
if (jsonOut) {
|
|
180
|
+
process.stdout.write(
|
|
181
|
+
JSON.stringify({ path: target, commit: { sha, ...meta }, intent }, null, 2) + "\n",
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const header = meta
|
|
187
|
+
? `${meta.short} ${meta.author} ${meta.date} ${meta.subject}`
|
|
188
|
+
: sha;
|
|
189
|
+
process.stdout.write(`${target}\n`);
|
|
190
|
+
process.stdout.write(` last commit: ${header}\n`);
|
|
191
|
+
if (intent) {
|
|
192
|
+
process.stdout.write(` intent.scope: ${intent.scope || "-"}\n`);
|
|
193
|
+
process.stdout.write(` intent.action: ${intent.action || "-"}\n`);
|
|
194
|
+
process.stdout.write(` intent.reason: ${intent.reason || "-"}\n`);
|
|
195
|
+
if (Array.isArray(intent.touches) && intent.touches.length) {
|
|
196
|
+
process.stdout.write(` intent.touches: ${intent.touches.join(", ")}\n`);
|
|
197
|
+
}
|
|
198
|
+
if (intent.invariant) {
|
|
199
|
+
process.stdout.write(` intent.invariant: ${intent.invariant}\n`);
|
|
200
|
+
}
|
|
201
|
+
if (intent.conflictsWith) {
|
|
202
|
+
process.stdout.write(` intent.conflicts: ${intent.conflictsWith}\n`);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
process.stdout.write(" intent: (no X-Intent trailer)\n");
|
|
206
|
+
}
|
|
207
|
+
}
|