halfcycle 0.3.22 → 0.3.24
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/.claude-plugin/plugin.json +1 -1
- package/bin/bin.bundle.mjs +885 -127
- package/dist/account-credential.d.ts +12 -12
- package/dist/account-credential.d.ts.map +1 -1
- package/dist/banner-facts.d.ts +9 -0
- package/dist/banner-facts.d.ts.map +1 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +894 -214
- package/dist/bin.js.map +3 -3
- package/dist/build-record/template.d.ts +11 -0
- package/dist/build-record/template.d.ts.map +1 -1
- package/dist/ci-bind.d.ts +116 -0
- package/dist/ci-bind.d.ts.map +1 -0
- package/dist/cli-contract.d.ts +11 -1
- package/dist/cli-contract.d.ts.map +1 -1
- package/dist/confirm-identity.d.ts +50 -0
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/create-engagement.d.ts +15 -4
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/device-signin.d.ts +13 -0
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +64 -85
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/identity.d.ts +10 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.js +641 -143
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts +112 -11
- package/dist/install.d.ts.map +1 -1
- package/dist/merge-settings.d.ts +27 -9
- package/dist/merge-settings.d.ts.map +1 -1
- package/dist/setup/manifest.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -30,6 +30,107 @@ var matcherSchema = z.discriminatedUnion("kind", [
|
|
|
30
30
|
llmMatcherSchema
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
|
+
// ../core/dist/credential-format.js
|
|
34
|
+
var HALFCYCLE_DIR_NAME = ".halfcycle";
|
|
35
|
+
var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
36
|
+
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
37
|
+
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
38
|
+
var ACCOUNT_STORE_FILENAME = "account.json";
|
|
39
|
+
var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
40
|
+
function shq(value) {
|
|
41
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
42
|
+
}
|
|
43
|
+
function unquote(value) {
|
|
44
|
+
const v = value.trim();
|
|
45
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
46
|
+
return v.slice(1, -1).split(`'\\''`).join(`'`);
|
|
47
|
+
}
|
|
48
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
49
|
+
return v.slice(1, -1);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
function parseEnvText(raw) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const line of raw.split("\n")) {
|
|
55
|
+
const eq = line.indexOf("=");
|
|
56
|
+
if (eq === -1)
|
|
57
|
+
continue;
|
|
58
|
+
const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
|
|
59
|
+
if (key === "" || key.startsWith("#"))
|
|
60
|
+
continue;
|
|
61
|
+
out[key] = unquote(line.slice(eq + 1));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function reconcileEnvText(existing, values, keys) {
|
|
66
|
+
const desired = new Map(keys.map((k) => {
|
|
67
|
+
const value = values[k];
|
|
68
|
+
return [k, value === void 0 ? "" : value];
|
|
69
|
+
}));
|
|
70
|
+
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
71
|
+
const written = keys.filter((k) => desired.get(k) !== null);
|
|
72
|
+
if (existing === null) {
|
|
73
|
+
if (written.length === 0)
|
|
74
|
+
return "";
|
|
75
|
+
return `${ENGAGEMENT_ENV_HEADER}
|
|
76
|
+
` + written.map(line).join("\n") + "\n";
|
|
77
|
+
}
|
|
78
|
+
const seen = /* @__PURE__ */ new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const existingLine of existing.split("\n")) {
|
|
81
|
+
const eq = existingLine.indexOf("=");
|
|
82
|
+
if (eq === -1) {
|
|
83
|
+
out.push(existingLine);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const lhs = existingLine.slice(0, eq);
|
|
87
|
+
const exported = /^\s*export\s+/.exec(lhs);
|
|
88
|
+
const prefix = exported === null ? "" : exported[0];
|
|
89
|
+
const key = lhs.slice(prefix.length).trim();
|
|
90
|
+
if (!desired.has(key)) {
|
|
91
|
+
out.push(existingLine);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
seen.add(key);
|
|
95
|
+
if (desired.get(key) === null)
|
|
96
|
+
continue;
|
|
97
|
+
out.push(`${prefix}${line(key)}`);
|
|
98
|
+
}
|
|
99
|
+
const missing = written.filter((k) => !seen.has(k));
|
|
100
|
+
if (missing.length > 0) {
|
|
101
|
+
const header = existing.includes(ENGAGEMENT_ENV_HEADER) ? "" : `${ENGAGEMENT_ENV_HEADER}
|
|
102
|
+
`;
|
|
103
|
+
const block = header + missing.map(line).join("\n");
|
|
104
|
+
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
105
|
+
if (trailingBlank)
|
|
106
|
+
out.splice(out.length - 1, 0, block);
|
|
107
|
+
else
|
|
108
|
+
out.push(`
|
|
109
|
+
${block}`);
|
|
110
|
+
}
|
|
111
|
+
let result = out.join("\n");
|
|
112
|
+
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
113
|
+
result += "\n";
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
function normaliseOrigin(serviceUrl) {
|
|
117
|
+
return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
function parseAccountStoreText(raw) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
|
|
123
|
+
return { version: 1, accounts: {} };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
version: 1,
|
|
127
|
+
accounts: parsed.accounts ?? {}
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return { version: 1, accounts: {} };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
33
134
|
// ../events/dist/result.js
|
|
34
135
|
var firedGuardSchema = z2.object({
|
|
35
136
|
guardId: z2.string(),
|
|
@@ -90,7 +191,7 @@ var wireErrorSchema = z2.object({
|
|
|
90
191
|
|
|
91
192
|
// ../events/dist/telemetry.js
|
|
92
193
|
import { z as z3 } from "zod";
|
|
93
|
-
var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z
|
|
194
|
+
var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
|
|
94
195
|
var guardEvalOutcomeSchema = z3.enum([
|
|
95
196
|
"evaluated",
|
|
96
197
|
"infra-error",
|
|
@@ -146,7 +247,7 @@ var guardEvalRunSchema = z3.object({
|
|
|
146
247
|
|
|
147
248
|
// ../events/dist/engagement-lifecycle.js
|
|
148
249
|
import { z as z4 } from "zod";
|
|
149
|
-
var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z
|
|
250
|
+
var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
|
|
150
251
|
var engagementTypeSchema = z4.enum(["client"]);
|
|
151
252
|
var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
|
|
152
253
|
var createEngagementRequestSchema = z4.object({
|
|
@@ -275,57 +376,371 @@ var openPhaseResponseSchema = z4.object({
|
|
|
275
376
|
decision: phaseEntryDecisionRecordSchema
|
|
276
377
|
}).strict();
|
|
277
378
|
|
|
278
|
-
// ../events/dist/
|
|
379
|
+
// ../events/dist/state.js
|
|
279
380
|
import { z as z5 } from "zod";
|
|
280
|
-
var
|
|
281
|
-
var
|
|
381
|
+
var utcIso86013 = z5.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
|
|
382
|
+
var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
|
|
383
|
+
var stepIdSchema = z5.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
|
|
384
|
+
var methodVersionSchema = z5.string().regex(/^\d+\.\d+\.\d+$/);
|
|
385
|
+
var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
|
|
386
|
+
var recordKindSchema = z5.enum(["artefact", "step-run", "gate", "intervention"]);
|
|
387
|
+
var methodLayerSchema = z5.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
|
|
388
|
+
var evidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
|
|
389
|
+
var declarableEvidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer"]);
|
|
390
|
+
var dispositionSchema = z5.enum([
|
|
391
|
+
"prevented",
|
|
392
|
+
"corrected-in-spec",
|
|
393
|
+
"corrected-in-scope",
|
|
394
|
+
"blocked-in-code",
|
|
395
|
+
"flagged",
|
|
396
|
+
"auto-fixed",
|
|
397
|
+
"surfaced",
|
|
398
|
+
"filed-forward"
|
|
399
|
+
]);
|
|
400
|
+
var mechanismSchema = z5.enum(["guard", "verdict", "served-step", "method-step"]);
|
|
401
|
+
var artefactStateSchema = z5.enum(["created", "updated"]);
|
|
402
|
+
var QUALIFIED_OUTCOMES = ["declined", "parked"];
|
|
403
|
+
var qualifiedOutcomeSchema = z5.enum(QUALIFIED_OUTCOMES);
|
|
404
|
+
function isQualifiedOutcome(outcome) {
|
|
405
|
+
return QUALIFIED_OUTCOMES.includes(outcome);
|
|
406
|
+
}
|
|
407
|
+
var runOutcomeSchema = z5.enum([
|
|
408
|
+
"completed",
|
|
409
|
+
"attempted-failed",
|
|
410
|
+
...QUALIFIED_OUTCOMES
|
|
411
|
+
]);
|
|
412
|
+
var gateKindSchema = z5.enum(["approval", "mechanical"]);
|
|
413
|
+
var gateVerdictSchema = z5.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
|
|
414
|
+
function outcomeReasonIssue(outcome, reason) {
|
|
415
|
+
if (isQualifiedOutcome(outcome) && reason === void 0) {
|
|
416
|
+
return { message: `outcomeReason is required when the outcome is '${outcome}'` };
|
|
417
|
+
}
|
|
418
|
+
if (!isQualifiedOutcome(outcome) && reason !== void 0) {
|
|
419
|
+
return {
|
|
420
|
+
message: `outcomeReason is only present on a qualified outcome (${QUALIFIED_OUTCOMES.join(" | ")})`
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
var anchorEvidenceSchema = z5.object({
|
|
426
|
+
kind: z5.literal("anchor"),
|
|
427
|
+
path: z5.string().regex(anchorPathRegex),
|
|
428
|
+
// Optional 1-based line number within the file at `path`. Present when the
|
|
429
|
+
// writer knows the exact line; omitted — never `0`, never `null` — when it
|
|
430
|
+
// does not, because a line number is not always obtainable. Absence is a
|
|
431
|
+
// valid, permanent state here, not a gap meant to be filled in later.
|
|
432
|
+
line: z5.number().optional()
|
|
433
|
+
}).strict();
|
|
434
|
+
var diffEvidenceSchema = z5.object({
|
|
435
|
+
kind: z5.literal("diff"),
|
|
436
|
+
ref: z5.string()
|
|
437
|
+
}).strict();
|
|
438
|
+
var reproEvidenceSchema = z5.object({
|
|
439
|
+
kind: z5.literal("repro"),
|
|
440
|
+
steps: z5.string().max(2e3)
|
|
441
|
+
}).strict();
|
|
442
|
+
var questionAnswerEvidenceSchema = z5.object({
|
|
443
|
+
kind: z5.literal("question-answer"),
|
|
444
|
+
question: z5.string().max(2e3),
|
|
445
|
+
before: z5.string().max(2e3),
|
|
446
|
+
after: z5.string().max(2e3)
|
|
447
|
+
}).strict();
|
|
448
|
+
var noOpEvidenceSchema = z5.object({
|
|
449
|
+
kind: z5.literal("no-op"),
|
|
450
|
+
inspected: z5.string().max(2e3),
|
|
451
|
+
unchangedBecause: z5.string().max(2e3)
|
|
452
|
+
}).strict();
|
|
453
|
+
var evidenceSchema = z5.discriminatedUnion("kind", [
|
|
454
|
+
anchorEvidenceSchema,
|
|
455
|
+
diffEvidenceSchema,
|
|
456
|
+
reproEvidenceSchema,
|
|
457
|
+
questionAnswerEvidenceSchema,
|
|
458
|
+
noOpEvidenceSchema
|
|
459
|
+
]);
|
|
460
|
+
var stateRecordEnvelopeSchema = z5.object({
|
|
461
|
+
format: z5.literal(STATE_RECORD_FORMAT),
|
|
462
|
+
recordKind: recordKindSchema,
|
|
463
|
+
recordId: z5.string().uuid(),
|
|
464
|
+
methodVersion: methodVersionSchema,
|
|
465
|
+
engagementId: z5.string(),
|
|
466
|
+
stepId: stepIdSchema,
|
|
467
|
+
// Immutable: names when the thing happened, never when a later disposition
|
|
468
|
+
// moved.
|
|
469
|
+
occurredAt: utcIso86013,
|
|
470
|
+
// Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
|
|
471
|
+
// Carried on the shared envelope so a "when did this last change" query is
|
|
472
|
+
// kind-agnostic. Kept rather than generalised to `updatedAt` because
|
|
473
|
+
// `disposition` is the only field any re-emit may change (§5.3).
|
|
474
|
+
dispositionAt: utcIso86013,
|
|
475
|
+
evidence: evidenceSchema,
|
|
476
|
+
// Which phase of the project this record belongs to, if any. Present on
|
|
477
|
+
// records written during phase-scoped work; absent on records that run once
|
|
478
|
+
// for the whole project rather than per phase. Absence is meaningful on its
|
|
479
|
+
// own — it is never replaced with a placeholder value, and it is read as
|
|
480
|
+
// "satisfies any phase", not as "unknown".
|
|
481
|
+
phase: z5.string().optional()
|
|
482
|
+
});
|
|
483
|
+
var artefactRefSchema = z5.object({
|
|
484
|
+
name: z5.string(),
|
|
485
|
+
parent: z5.string()
|
|
486
|
+
}).strict();
|
|
487
|
+
var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
|
|
488
|
+
recordKind: z5.literal("artefact"),
|
|
489
|
+
artefactRef: artefactRefSchema,
|
|
490
|
+
// Some layer wrote it, by construction — an empty array is not a legitimate
|
|
491
|
+
// state, so the schema says so.
|
|
492
|
+
writtenByLayers: z5.array(methodLayerSchema).min(1),
|
|
493
|
+
state: artefactStateSchema
|
|
494
|
+
}).strict();
|
|
495
|
+
var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
|
|
496
|
+
recordKind: z5.literal("step-run"),
|
|
497
|
+
runOutcome: runOutcomeSchema,
|
|
498
|
+
failureReason: z5.string().optional(),
|
|
499
|
+
outcomeReason: z5.string().max(2e3).optional()
|
|
500
|
+
}).strict().superRefine((rec, ctx) => {
|
|
501
|
+
if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
|
|
502
|
+
ctx.addIssue({
|
|
503
|
+
code: z5.ZodIssueCode.custom,
|
|
504
|
+
path: ["failureReason"],
|
|
505
|
+
message: "failureReason is required when runOutcome is 'attempted-failed'"
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
|
|
509
|
+
ctx.addIssue({
|
|
510
|
+
code: z5.ZodIssueCode.custom,
|
|
511
|
+
path: ["failureReason"],
|
|
512
|
+
message: "failureReason is only present when runOutcome is 'attempted-failed'"
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
|
|
516
|
+
if (issue) {
|
|
517
|
+
ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
var gateRecordSchema = stateRecordEnvelopeSchema.extend({
|
|
521
|
+
recordKind: z5.literal("gate"),
|
|
522
|
+
gateKind: gateKindSchema,
|
|
523
|
+
verdict: gateVerdictSchema,
|
|
524
|
+
actor: z5.string(),
|
|
525
|
+
outcomeReason: z5.string().max(2e3).optional()
|
|
526
|
+
}).strict().superRefine((rec, ctx) => {
|
|
527
|
+
const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
|
|
528
|
+
if (issue) {
|
|
529
|
+
ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
|
|
533
|
+
recordKind: z5.literal("intervention"),
|
|
534
|
+
layer: methodLayerSchema,
|
|
535
|
+
mechanism: mechanismSchema,
|
|
536
|
+
severity: severitySchema,
|
|
537
|
+
disposition: dispositionSchema,
|
|
538
|
+
summary: z5.string().max(2e3)
|
|
539
|
+
}).strict();
|
|
540
|
+
var stateRecordSchema = z5.discriminatedUnion("recordKind", [
|
|
541
|
+
artefactRecordSchema,
|
|
542
|
+
stepRunRecordSchema,
|
|
543
|
+
gateRecordSchema,
|
|
544
|
+
interventionRecordSchema
|
|
545
|
+
]);
|
|
546
|
+
|
|
547
|
+
// ../events/dist/crew.js
|
|
548
|
+
import { z as z6 } from "zod";
|
|
549
|
+
var crewGroupSchema = z6.enum(["builder", "reviewer", "always-on"]);
|
|
550
|
+
var crewTierSchema = z6.enum(["deep", "standard", "fast"]);
|
|
282
551
|
var CREW_DISCIPLINE_MAX = 64;
|
|
283
|
-
var
|
|
552
|
+
var crewFaceHeadSchema = z6.enum(["sq", "sqr", "rnd", "dome", "hex"]);
|
|
553
|
+
var crewFaceAntennaSchema = z6.enum(["none", "stalk", "twin", "dish"]);
|
|
554
|
+
var crewFaceEyesSchema = z6.enum(["dots", "bars", "visor", "cyclops"]);
|
|
555
|
+
var crewFaceMouthSchema = z6.enum(["line", "grid", "dots", "wave"]);
|
|
556
|
+
var crewFaceSchema = z6.object({
|
|
557
|
+
// The outline of the head. Members who do the same kind of work share one, so a
|
|
558
|
+
// team is recognisable before any name is read.
|
|
559
|
+
head: crewFaceHeadSchema,
|
|
560
|
+
// What sits on top of the head, if anything.
|
|
561
|
+
antenna: crewFaceAntennaSchema,
|
|
562
|
+
// The eyes.
|
|
563
|
+
eyes: crewFaceEyesSchema,
|
|
564
|
+
// The mouth.
|
|
565
|
+
mouth: crewFaceMouthSchema,
|
|
566
|
+
// Whether this member is drawn with ears.
|
|
567
|
+
ears: z6.boolean()
|
|
568
|
+
}).strict();
|
|
569
|
+
var crewMemberSchema = z6.object({
|
|
284
570
|
// The agent's name. The same twenty-one on every project, so a name is a
|
|
285
571
|
// vocabulary a returning client already knows.
|
|
286
|
-
callsign:
|
|
572
|
+
callsign: z6.string().min(1).max(32),
|
|
287
573
|
// Which of the three headings this member reads under.
|
|
288
574
|
group: crewGroupSchema,
|
|
289
575
|
// The kind of work this member takes. A task names a discipline, and only
|
|
290
576
|
// agents holding it can be dispatched to it.
|
|
291
|
-
discipline:
|
|
577
|
+
discipline: z6.string().min(1).max(CREW_DISCIPLINE_MAX),
|
|
292
578
|
// How much thinking this member brings. There is no field naming the supplier
|
|
293
579
|
// behind it, on this shape or on any other.
|
|
294
|
-
tier: crewTierSchema
|
|
580
|
+
tier: crewTierSchema,
|
|
581
|
+
// How this member's face is drawn. The same face on every project, so a
|
|
582
|
+
// returning client recognises a member before reading the name.
|
|
583
|
+
face: crewFaceSchema
|
|
295
584
|
}).strict();
|
|
296
585
|
var CREW_ROSTER = [
|
|
297
586
|
// ── builders: code ──────────────────────────────────────────────────────────
|
|
298
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
{
|
|
587
|
+
{
|
|
588
|
+
callsign: "ADA",
|
|
589
|
+
group: "builder",
|
|
590
|
+
discipline: "implementation",
|
|
591
|
+
tier: "deep",
|
|
592
|
+
face: { head: "sq", antenna: "stalk", eyes: "dots", mouth: "line", ears: false }
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
callsign: "OTTO",
|
|
596
|
+
group: "builder",
|
|
597
|
+
discipline: "implementation",
|
|
598
|
+
tier: "deep",
|
|
599
|
+
face: { head: "sq", antenna: "twin", eyes: "bars", mouth: "grid", ears: true }
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
callsign: "MILO",
|
|
603
|
+
group: "builder",
|
|
604
|
+
discipline: "implementation",
|
|
605
|
+
tier: "standard",
|
|
606
|
+
face: { head: "sqr", antenna: "stalk", eyes: "dots", mouth: "dots", ears: false }
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
callsign: "NOVA",
|
|
610
|
+
group: "builder",
|
|
611
|
+
discipline: "implementation",
|
|
612
|
+
tier: "standard",
|
|
613
|
+
face: { head: "sq", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
callsign: "KAI",
|
|
617
|
+
group: "builder",
|
|
618
|
+
discipline: "implementation",
|
|
619
|
+
tier: "standard",
|
|
620
|
+
face: { head: "sqr", antenna: "none", eyes: "bars", mouth: "line", ears: true }
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
callsign: "HUGO",
|
|
624
|
+
group: "builder",
|
|
625
|
+
discipline: "infrastructure",
|
|
626
|
+
tier: "standard",
|
|
627
|
+
face: { head: "sq", antenna: "twin", eyes: "dots", mouth: "grid", ears: true }
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
callsign: "WREN",
|
|
631
|
+
group: "builder",
|
|
632
|
+
discipline: "refactor",
|
|
633
|
+
tier: "fast",
|
|
634
|
+
face: { head: "sqr", antenna: "stalk", eyes: "bars", mouth: "wave", ears: false }
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
callsign: "PIP",
|
|
638
|
+
group: "builder",
|
|
639
|
+
discipline: "refactor",
|
|
640
|
+
tier: "fast",
|
|
641
|
+
face: { head: "sqr", antenna: "none", eyes: "dots", mouth: "dots", ears: false }
|
|
642
|
+
},
|
|
306
643
|
// ── builders: specs and docs ────────────────────────────────────────────────
|
|
307
|
-
{
|
|
308
|
-
|
|
309
|
-
|
|
644
|
+
{
|
|
645
|
+
callsign: "IRIS",
|
|
646
|
+
group: "builder",
|
|
647
|
+
discipline: "feature spec",
|
|
648
|
+
tier: "deep",
|
|
649
|
+
face: { head: "dome", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
callsign: "JUNO",
|
|
653
|
+
group: "builder",
|
|
654
|
+
discipline: "feature spec",
|
|
655
|
+
tier: "standard",
|
|
656
|
+
face: { head: "dome", antenna: "none", eyes: "dots", mouth: "wave", ears: false }
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
callsign: "LEX",
|
|
660
|
+
group: "builder",
|
|
661
|
+
discipline: "context & docs",
|
|
662
|
+
tier: "fast",
|
|
663
|
+
face: { head: "dome", antenna: "twin", eyes: "bars", mouth: "dots", ears: false }
|
|
664
|
+
},
|
|
310
665
|
// ── reviewers ───────────────────────────────────────────────────────────────
|
|
311
|
-
{
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{
|
|
666
|
+
{
|
|
667
|
+
callsign: "VERA",
|
|
668
|
+
group: "reviewer",
|
|
669
|
+
discipline: "spec consistency",
|
|
670
|
+
tier: "deep",
|
|
671
|
+
face: { head: "rnd", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
callsign: "ODIN",
|
|
675
|
+
group: "reviewer",
|
|
676
|
+
discipline: "cross-spec audit",
|
|
677
|
+
tier: "deep",
|
|
678
|
+
face: { head: "rnd", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
callsign: "CASS",
|
|
682
|
+
group: "reviewer",
|
|
683
|
+
discipline: "code review",
|
|
684
|
+
tier: "deep",
|
|
685
|
+
face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "grid", ears: true }
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
callsign: "THEO",
|
|
689
|
+
group: "reviewer",
|
|
690
|
+
discipline: "code review",
|
|
691
|
+
tier: "standard",
|
|
692
|
+
face: { head: "rnd", antenna: "twin", eyes: "bars", mouth: "line", ears: false }
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
callsign: "ECHO",
|
|
696
|
+
group: "reviewer",
|
|
697
|
+
discipline: "test quality",
|
|
698
|
+
tier: "standard",
|
|
699
|
+
face: { head: "rnd", antenna: "stalk", eyes: "dots", mouth: "wave", ears: false }
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
callsign: "MAVIS",
|
|
703
|
+
group: "reviewer",
|
|
704
|
+
discipline: "suites & coverage",
|
|
705
|
+
tier: "standard",
|
|
706
|
+
face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "dots", ears: false }
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
callsign: "ZARA",
|
|
710
|
+
group: "reviewer",
|
|
711
|
+
discipline: "smoke & walk aid",
|
|
712
|
+
tier: "standard",
|
|
713
|
+
face: { head: "rnd", antenna: "dish", eyes: "bars", mouth: "wave", ears: false }
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
callsign: "NORA",
|
|
717
|
+
group: "reviewer",
|
|
718
|
+
discipline: "evidence & provenance",
|
|
719
|
+
tier: "fast",
|
|
720
|
+
face: { head: "rnd", antenna: "twin", eyes: "dots", mouth: "line", ears: false }
|
|
721
|
+
},
|
|
319
722
|
// ── always on: continuous, takes no task ────────────────────────────────────
|
|
320
|
-
{
|
|
321
|
-
|
|
723
|
+
{
|
|
724
|
+
callsign: "ARGUS",
|
|
725
|
+
group: "always-on",
|
|
726
|
+
discipline: "guard \xB7 every diff",
|
|
727
|
+
tier: "standard",
|
|
728
|
+
face: { head: "hex", antenna: "dish", eyes: "visor", mouth: "grid", ears: true }
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
callsign: "ATLAS",
|
|
732
|
+
group: "always-on",
|
|
733
|
+
discipline: "coordinator",
|
|
734
|
+
tier: "deep",
|
|
735
|
+
face: { head: "hex", antenna: "twin", eyes: "cyclops", mouth: "line", ears: true }
|
|
736
|
+
}
|
|
322
737
|
];
|
|
323
738
|
var CREW_ROSTER_SIZE = CREW_ROSTER.length;
|
|
324
739
|
var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
|
|
325
740
|
|
|
326
741
|
// ../events/dist/device-auth.js
|
|
327
|
-
import { z as
|
|
328
|
-
var
|
|
742
|
+
import { z as z7 } from "zod";
|
|
743
|
+
var utcIso86014 = z7.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
|
|
329
744
|
var DEVICE_AUTH_STATUS = {
|
|
330
745
|
/** The poll: minted, nobody has acted yet. Keep polling. 200. */
|
|
331
746
|
PENDING: "pending",
|
|
@@ -351,7 +766,7 @@ var DEVICE_AUTH_STATUS = {
|
|
|
351
766
|
ALREADY_DECIDED: "already-decided",
|
|
352
767
|
/**
|
|
353
768
|
* Confirm: an approval named a signed-in identity whose account has not accepted
|
|
354
|
-
* the terms of service and privacy policy currently in force
|
|
769
|
+
* the terms of service and privacy policy currently in force. 400.
|
|
355
770
|
*
|
|
356
771
|
* NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
|
|
357
772
|
* says nothing about the code itself — the code is still `pending` after this
|
|
@@ -366,25 +781,25 @@ var DEVICE_POLL_REFUSALS = [
|
|
|
366
781
|
DEVICE_AUTH_STATUS.EXPIRED,
|
|
367
782
|
DEVICE_AUTH_STATUS.UNKNOWN
|
|
368
783
|
];
|
|
369
|
-
var deviceAuthStartResponseSchema =
|
|
370
|
-
deviceCode:
|
|
371
|
-
userCode:
|
|
372
|
-
verificationUrl:
|
|
373
|
-
expiresAt:
|
|
374
|
-
pollIntervalMs:
|
|
784
|
+
var deviceAuthStartResponseSchema = z7.object({
|
|
785
|
+
deviceCode: z7.string().min(1),
|
|
786
|
+
userCode: z7.string().min(1),
|
|
787
|
+
verificationUrl: z7.string().min(1),
|
|
788
|
+
expiresAt: utcIso86014,
|
|
789
|
+
pollIntervalMs: z7.number().int().positive()
|
|
375
790
|
}).strict();
|
|
376
|
-
var devicePollPendingSchema =
|
|
377
|
-
status:
|
|
378
|
-
pollIntervalMs:
|
|
791
|
+
var devicePollPendingSchema = z7.object({
|
|
792
|
+
status: z7.literal(DEVICE_AUTH_STATUS.PENDING),
|
|
793
|
+
pollIntervalMs: z7.number().int().positive()
|
|
379
794
|
}).strict();
|
|
380
|
-
var devicePollApprovedSchema =
|
|
381
|
-
status:
|
|
382
|
-
accountId:
|
|
383
|
-
credential:
|
|
384
|
-
expiresAt:
|
|
795
|
+
var devicePollApprovedSchema = z7.object({
|
|
796
|
+
status: z7.literal(DEVICE_AUTH_STATUS.APPROVED),
|
|
797
|
+
accountId: z7.string().min(1),
|
|
798
|
+
credential: z7.string().min(1),
|
|
799
|
+
expiresAt: utcIso86014
|
|
385
800
|
}).strict();
|
|
386
|
-
var deviceAuthRefusalSchema =
|
|
387
|
-
status:
|
|
801
|
+
var deviceAuthRefusalSchema = z7.object({
|
|
802
|
+
status: z7.enum([
|
|
388
803
|
DEVICE_AUTH_STATUS.DECLINED,
|
|
389
804
|
DEVICE_AUTH_STATUS.EXPIRED,
|
|
390
805
|
DEVICE_AUTH_STATUS.UNKNOWN,
|
|
@@ -395,25 +810,25 @@ var deviceAuthRefusalSchema = z6.object({
|
|
|
395
810
|
DEVICE_AUTH_STATUS.ALREADY_DECIDED,
|
|
396
811
|
DEVICE_AUTH_STATUS.TERMS_REQUIRED
|
|
397
812
|
]),
|
|
398
|
-
message:
|
|
813
|
+
message: z7.string().min(1)
|
|
399
814
|
}).strict();
|
|
400
|
-
var devicePollResponseSchema =
|
|
815
|
+
var devicePollResponseSchema = z7.union([
|
|
401
816
|
devicePollPendingSchema,
|
|
402
817
|
devicePollApprovedSchema,
|
|
403
818
|
deviceAuthRefusalSchema
|
|
404
819
|
]);
|
|
405
|
-
var deviceConfirmRequestSchema =
|
|
406
|
-
userCode:
|
|
407
|
-
decision:
|
|
408
|
-
externalAuthId:
|
|
409
|
-
email:
|
|
410
|
-
acceptTerms:
|
|
820
|
+
var deviceConfirmRequestSchema = z7.object({
|
|
821
|
+
userCode: z7.string().min(1),
|
|
822
|
+
decision: z7.enum(["approve", "decline"]),
|
|
823
|
+
externalAuthId: z7.string().min(1).optional(),
|
|
824
|
+
email: z7.string().optional(),
|
|
825
|
+
acceptTerms: z7.boolean().optional()
|
|
411
826
|
}).strict();
|
|
412
|
-
var deviceConfirmRecordedSchema =
|
|
413
|
-
status:
|
|
414
|
-
accountId:
|
|
827
|
+
var deviceConfirmRecordedSchema = z7.object({
|
|
828
|
+
status: z7.literal(DEVICE_AUTH_STATUS.RECORDED),
|
|
829
|
+
accountId: z7.string().min(1).optional()
|
|
415
830
|
}).strict();
|
|
416
|
-
var deviceConfirmResponseSchema =
|
|
831
|
+
var deviceConfirmResponseSchema = z7.union([
|
|
417
832
|
deviceConfirmRecordedSchema,
|
|
418
833
|
deviceAuthRefusalSchema
|
|
419
834
|
]);
|
|
@@ -442,25 +857,25 @@ function loopbackVerificationUrl(verificationUrl, target) {
|
|
|
442
857
|
}
|
|
443
858
|
|
|
444
859
|
// ../events/dist/join-refusal.js
|
|
445
|
-
import { z as
|
|
860
|
+
import { z as z8 } from "zod";
|
|
446
861
|
var NOT_THIS_ACCOUNT_REASON = "not-this-account";
|
|
447
862
|
var JOIN_REFUSAL_STATUS = 403;
|
|
448
|
-
var notThisAccountRefusalSchema =
|
|
449
|
-
statusCode:
|
|
450
|
-
error:
|
|
451
|
-
message:
|
|
452
|
-
reason:
|
|
863
|
+
var notThisAccountRefusalSchema = z8.object({
|
|
864
|
+
statusCode: z8.literal(JOIN_REFUSAL_STATUS),
|
|
865
|
+
error: z8.string(),
|
|
866
|
+
message: z8.string(),
|
|
867
|
+
reason: z8.literal(NOT_THIS_ACCOUNT_REASON)
|
|
453
868
|
}).strict();
|
|
454
|
-
var joinRefusalSchema =
|
|
869
|
+
var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
|
|
455
870
|
function isNotThisAccountRefusal(body) {
|
|
456
871
|
return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
|
|
457
872
|
}
|
|
458
873
|
|
|
459
874
|
// ../events/dist/account-identity.js
|
|
460
|
-
import { z as
|
|
461
|
-
var accountIdentitySchema =
|
|
462
|
-
accountId:
|
|
463
|
-
email:
|
|
875
|
+
import { z as z9 } from "zod";
|
|
876
|
+
var accountIdentitySchema = z9.object({
|
|
877
|
+
accountId: z9.string().min(1),
|
|
878
|
+
email: z9.string().min(1).optional()
|
|
464
879
|
}).strict();
|
|
465
880
|
function safeParseAccountIdentity(payload) {
|
|
466
881
|
return accountIdentitySchema.safeParse(payload);
|
|
@@ -527,6 +942,21 @@ function describeIdentity(identity) {
|
|
|
527
942
|
return `(a ${typeof identity})`;
|
|
528
943
|
}
|
|
529
944
|
|
|
945
|
+
// ../events/dist/credential-wire.js
|
|
946
|
+
import { z as z10 } from "zod";
|
|
947
|
+
var engagementCredentialResponseSchema = z10.object({
|
|
948
|
+
engagementId: z10.string(),
|
|
949
|
+
sessionToken: z10.string(),
|
|
950
|
+
mcpUrl: z10.string(),
|
|
951
|
+
guardUrl: z10.string(),
|
|
952
|
+
controlTelemetryUrl: z10.string()
|
|
953
|
+
}).strict();
|
|
954
|
+
var ciTokenExchangeResponseSchema = z10.object({
|
|
955
|
+
token: z10.string(),
|
|
956
|
+
expiresAt: utcIso86013,
|
|
957
|
+
engagementId: z10.string()
|
|
958
|
+
}).strict();
|
|
959
|
+
|
|
530
960
|
// dist/engagement-credential.js
|
|
531
961
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
532
962
|
import { platform as platform2 } from "node:os";
|
|
@@ -537,27 +967,14 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
537
967
|
import { homedir, platform } from "node:os";
|
|
538
968
|
import { dirname, join } from "node:path";
|
|
539
969
|
function halfcycleHome(home) {
|
|
540
|
-
return join(home ?? homedir(),
|
|
970
|
+
return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
|
|
541
971
|
}
|
|
542
972
|
function accountStorePath(home) {
|
|
543
|
-
return join(halfcycleHome(home),
|
|
544
|
-
}
|
|
545
|
-
function normaliseOrigin(serviceUrl) {
|
|
546
|
-
return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
|
|
973
|
+
return join(halfcycleHome(home), ACCOUNT_STORE_FILENAME);
|
|
547
974
|
}
|
|
548
975
|
function readStore(home) {
|
|
549
|
-
let raw;
|
|
550
976
|
try {
|
|
551
|
-
|
|
552
|
-
} catch {
|
|
553
|
-
return { version: 1, accounts: {} };
|
|
554
|
-
}
|
|
555
|
-
try {
|
|
556
|
-
const parsed = JSON.parse(raw);
|
|
557
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
|
|
558
|
-
return { version: 1, accounts: {} };
|
|
559
|
-
}
|
|
560
|
-
return { version: 1, accounts: parsed.accounts ?? {} };
|
|
977
|
+
return parseAccountStoreText(readFileSync(accountStorePath(home), "utf-8"));
|
|
561
978
|
} catch {
|
|
562
979
|
return { version: 1, accounts: {} };
|
|
563
980
|
}
|
|
@@ -603,8 +1020,9 @@ function forgetStoredCredential(serviceUrl, home) {
|
|
|
603
1020
|
}
|
|
604
1021
|
|
|
605
1022
|
// dist/engagement-credential.js
|
|
606
|
-
var ENGAGEMENTS_DIR =
|
|
607
|
-
var ENV_FILENAME =
|
|
1023
|
+
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1024
|
+
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1025
|
+
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
608
1026
|
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
609
1027
|
function engagementStateDir(engagementId, home) {
|
|
610
1028
|
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
@@ -612,7 +1030,6 @@ function engagementStateDir(engagementId, home) {
|
|
|
612
1030
|
function engagementEnvPath(engagementId, home) {
|
|
613
1031
|
return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
|
|
614
1032
|
}
|
|
615
|
-
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
616
1033
|
function notThisAccountMarkerPath(engagementId, home) {
|
|
617
1034
|
return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
|
|
618
1035
|
}
|
|
@@ -639,82 +1056,8 @@ var ENGAGEMENT_ENV_KEYS = [
|
|
|
639
1056
|
"CONTROL_TELEMETRY_URL"
|
|
640
1057
|
];
|
|
641
1058
|
var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
645
|
-
}
|
|
646
|
-
function unquote(value) {
|
|
647
|
-
const v = value.trim();
|
|
648
|
-
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
649
|
-
return v.slice(1, -1).split(`'\\''`).join(`'`);
|
|
650
|
-
}
|
|
651
|
-
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
652
|
-
return v.slice(1, -1);
|
|
653
|
-
return v;
|
|
654
|
-
}
|
|
655
|
-
function parseEnvText(raw) {
|
|
656
|
-
const out = {};
|
|
657
|
-
for (const line of raw.split("\n")) {
|
|
658
|
-
const eq = line.indexOf("=");
|
|
659
|
-
if (eq === -1)
|
|
660
|
-
continue;
|
|
661
|
-
const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
|
|
662
|
-
if (key === "" || key.startsWith("#"))
|
|
663
|
-
continue;
|
|
664
|
-
out[key] = unquote(line.slice(eq + 1));
|
|
665
|
-
}
|
|
666
|
-
return out;
|
|
667
|
-
}
|
|
668
|
-
function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
669
|
-
const desired = new Map(keys.map((k) => {
|
|
670
|
-
const value = values[k];
|
|
671
|
-
return [k, value === void 0 ? "" : value];
|
|
672
|
-
}));
|
|
673
|
-
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
674
|
-
const written = keys.filter((k) => desired.get(k) !== null);
|
|
675
|
-
if (existing === null) {
|
|
676
|
-
if (written.length === 0)
|
|
677
|
-
return "";
|
|
678
|
-
return `${ENV_HEADER}
|
|
679
|
-
` + written.map(line).join("\n") + "\n";
|
|
680
|
-
}
|
|
681
|
-
const seen = /* @__PURE__ */ new Set();
|
|
682
|
-
const out = [];
|
|
683
|
-
for (const existingLine of existing.split("\n")) {
|
|
684
|
-
const eq = existingLine.indexOf("=");
|
|
685
|
-
if (eq === -1) {
|
|
686
|
-
out.push(existingLine);
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
const lhs = existingLine.slice(0, eq);
|
|
690
|
-
const exported = /^\s*export\s+/.exec(lhs);
|
|
691
|
-
const prefix = exported === null ? "" : exported[0];
|
|
692
|
-
const key = lhs.slice(prefix.length).trim();
|
|
693
|
-
if (!desired.has(key)) {
|
|
694
|
-
out.push(existingLine);
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
seen.add(key);
|
|
698
|
-
if (desired.get(key) === null)
|
|
699
|
-
continue;
|
|
700
|
-
out.push(`${prefix}${line(key)}`);
|
|
701
|
-
}
|
|
702
|
-
const missing = written.filter((k) => !seen.has(k));
|
|
703
|
-
if (missing.length > 0) {
|
|
704
|
-
const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
|
|
705
|
-
`;
|
|
706
|
-
const block = header + missing.map(line).join("\n");
|
|
707
|
-
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
708
|
-
if (trailingBlank)
|
|
709
|
-
out.splice(out.length - 1, 0, block);
|
|
710
|
-
else
|
|
711
|
-
out.push(`
|
|
712
|
-
${block}`);
|
|
713
|
-
}
|
|
714
|
-
let result = out.join("\n");
|
|
715
|
-
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
716
|
-
result += "\n";
|
|
717
|
-
return result;
|
|
1059
|
+
function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
1060
|
+
return reconcileEnvText(existing, values, keys);
|
|
718
1061
|
}
|
|
719
1062
|
function readEngagementEnv(engagementId, home) {
|
|
720
1063
|
try {
|
|
@@ -732,7 +1075,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
|
|
|
732
1075
|
} catch {
|
|
733
1076
|
existing = null;
|
|
734
1077
|
}
|
|
735
|
-
const reconciled =
|
|
1078
|
+
const reconciled = reconcileEnvText2(existing, values, keys);
|
|
736
1079
|
if (existing === null && reconciled === "")
|
|
737
1080
|
return "skipped";
|
|
738
1081
|
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
@@ -758,7 +1101,7 @@ function applyOwnerOnly(path, dir) {
|
|
|
758
1101
|
function engagementResolutionShell() {
|
|
759
1102
|
return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
|
|
760
1103
|
# THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
|
|
761
|
-
# $HOME
|
|
1104
|
+
# $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
|
|
762
1105
|
# written owner-only by \`npx halfcycle\`. What this repository holds is the
|
|
763
1106
|
# engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
|
|
764
1107
|
# the pin, then name the file. There is no jq here and node is not dependable on a
|
|
@@ -789,10 +1132,10 @@ halfcycle_env_file() {
|
|
|
789
1132
|
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
790
1133
|
return 1
|
|
791
1134
|
fi
|
|
792
|
-
if [ -f "$HOME
|
|
1135
|
+
if [ -f "$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
|
|
793
1136
|
HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
|
|
794
1137
|
fi
|
|
795
|
-
hc_env="$HOME
|
|
1138
|
+
hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
|
|
796
1139
|
if [ ! -f "$hc_env" ]; then
|
|
797
1140
|
HALFCYCLE_ENV_PROBLEM="no-credential"
|
|
798
1141
|
return 1
|
|
@@ -983,7 +1326,7 @@ function resolveVendoredBinary() {
|
|
|
983
1326
|
if (existsSync3(candidate))
|
|
984
1327
|
return candidate;
|
|
985
1328
|
}
|
|
986
|
-
throw new Error(`[bundle install] Cannot find the self-contained guard binary bin.bundle.mjs.
|
|
1329
|
+
throw new Error(`[bundle install] Cannot find the self-contained guard binary bin.bundle.mjs. This copy of the halfcycle package is incomplete \u2014 reinstall it, or rebuild it if you are working on it. Looked in: ${candidates.join(", ")}.`);
|
|
987
1330
|
}
|
|
988
1331
|
var WRITE_ALLOWLIST = [
|
|
989
1332
|
".claude/commands",
|
|
@@ -1044,11 +1387,15 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
|
|
|
1044
1387
|
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
1045
1388
|
return "written";
|
|
1046
1389
|
}
|
|
1390
|
+
var INSTALLER_OWNED_DIR = ".halfcycle/";
|
|
1047
1391
|
function writeOwned(targetAbsPath, targetRepoRoot, content) {
|
|
1048
1392
|
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
1049
1393
|
if (!isAllowlisted(rel)) {
|
|
1050
1394
|
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
1051
1395
|
}
|
|
1396
|
+
if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
|
|
1397
|
+
throw new Error(`[bundle install] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
|
|
1398
|
+
}
|
|
1052
1399
|
if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
|
|
1053
1400
|
return "skipped";
|
|
1054
1401
|
}
|
|
@@ -1056,6 +1403,39 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
|
|
|
1056
1403
|
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
1057
1404
|
return "written";
|
|
1058
1405
|
}
|
|
1406
|
+
var GENERATED_HEADER_LINE_INDEX = 1;
|
|
1407
|
+
function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
|
|
1408
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
1409
|
+
if (!isAllowlisted(rel)) {
|
|
1410
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
1411
|
+
}
|
|
1412
|
+
let replacedExisting = false;
|
|
1413
|
+
if (existsSync3(targetAbsPath)) {
|
|
1414
|
+
const current = readFileSync5(targetAbsPath, "utf-8");
|
|
1415
|
+
if (current === content)
|
|
1416
|
+
return { outcome: "skipped", replacedExisting: false };
|
|
1417
|
+
const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
|
|
1418
|
+
if (!headerLine.startsWith(generatedMarker))
|
|
1419
|
+
return { outcome: "collided", replacedExisting: false };
|
|
1420
|
+
replacedExisting = true;
|
|
1421
|
+
}
|
|
1422
|
+
mkdirSync4(dirname2(targetAbsPath), { recursive: true });
|
|
1423
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
1424
|
+
return { outcome: "written", replacedExisting };
|
|
1425
|
+
}
|
|
1426
|
+
function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
|
|
1427
|
+
const { outcome, replacedExisting } = writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker);
|
|
1428
|
+
record(report, outcome, rel);
|
|
1429
|
+
if (replacedExisting)
|
|
1430
|
+
report.replacedPaths.push(rel);
|
|
1431
|
+
}
|
|
1432
|
+
function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
|
|
1433
|
+
const existedBefore = existsSync3(targetAbsPath);
|
|
1434
|
+
const outcome = writeOwned(targetAbsPath, targetRepoRoot, content);
|
|
1435
|
+
record(report, outcome, rel);
|
|
1436
|
+
if (outcome === "written" && existedBefore)
|
|
1437
|
+
report.replacedPaths.push(rel);
|
|
1438
|
+
}
|
|
1059
1439
|
function record(report, outcome, rel) {
|
|
1060
1440
|
const bucket = {
|
|
1061
1441
|
written: report.writtenPaths,
|
|
@@ -1095,6 +1475,15 @@ function generateSettingsJson() {
|
|
|
1095
1475
|
timeout: 5
|
|
1096
1476
|
}
|
|
1097
1477
|
]
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
hooks: [
|
|
1481
|
+
{
|
|
1482
|
+
type: "command",
|
|
1483
|
+
command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
|
|
1484
|
+
timeout: 10
|
|
1485
|
+
}
|
|
1486
|
+
]
|
|
1098
1487
|
}
|
|
1099
1488
|
],
|
|
1100
1489
|
UserPromptSubmit: [
|
|
@@ -1120,6 +1509,17 @@ function generateSettingsJson() {
|
|
|
1120
1509
|
]
|
|
1121
1510
|
}
|
|
1122
1511
|
],
|
|
1512
|
+
Stop: [
|
|
1513
|
+
{
|
|
1514
|
+
hooks: [
|
|
1515
|
+
{
|
|
1516
|
+
type: "command",
|
|
1517
|
+
command: "bash ./.claude/hooks/guard-runner.sh hook stop",
|
|
1518
|
+
timeout: 60
|
|
1519
|
+
}
|
|
1520
|
+
]
|
|
1521
|
+
}
|
|
1522
|
+
],
|
|
1123
1523
|
SubagentStop: [
|
|
1124
1524
|
{
|
|
1125
1525
|
hooks: [
|
|
@@ -1130,20 +1530,32 @@ function generateSettingsJson() {
|
|
|
1130
1530
|
}
|
|
1131
1531
|
]
|
|
1132
1532
|
}
|
|
1533
|
+
],
|
|
1534
|
+
SessionEnd: [
|
|
1535
|
+
{
|
|
1536
|
+
hooks: [
|
|
1537
|
+
{
|
|
1538
|
+
type: "command",
|
|
1539
|
+
command: "bash ./.claude/hooks/session-end-marker.sh",
|
|
1540
|
+
timeout: 5
|
|
1541
|
+
}
|
|
1542
|
+
]
|
|
1543
|
+
}
|
|
1133
1544
|
]
|
|
1134
1545
|
}
|
|
1135
1546
|
};
|
|
1136
1547
|
return JSON.stringify(settings, null, 2) + "\n";
|
|
1137
1548
|
}
|
|
1138
1549
|
var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
|
|
1550
|
+
var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
|
|
1139
1551
|
function generateGuardRunnerWrapper() {
|
|
1140
1552
|
return `#!/usr/bin/env bash
|
|
1141
|
-
|
|
1553
|
+
${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
|
|
1142
1554
|
#
|
|
1143
1555
|
# Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
|
|
1144
|
-
# values when Claude Code fires PostToolUse / SubagentStop,
|
|
1145
|
-
# SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path
|
|
1146
|
-
# project, so the hook resolves in any checkout of it.
|
|
1556
|
+
# values when Claude Code fires SessionStart / PostToolUse / Stop / SubagentStop,
|
|
1557
|
+
# then exec's the SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path
|
|
1558
|
+
# inside this project, so the hook resolves in any checkout of it.
|
|
1147
1559
|
#
|
|
1148
1560
|
# The runner itself reads only process.env, so env loading lives here in the hook
|
|
1149
1561
|
# wiring rather than inside the binary.
|
|
@@ -1158,6 +1570,16 @@ ${engagementResolutionShell()}
|
|
|
1158
1570
|
# the runner say what it found \u2014 its never-configured branch is loud and non-zero
|
|
1159
1571
|
# on purpose, and a wrapper that exited quietly here would hide it.
|
|
1160
1572
|
if halfcycle_env_file "$PROJECT_DIR"; then
|
|
1573
|
+
# EXPORTED, NOT MERELY SET \u2014 one word, and without it a whole feature is off.
|
|
1574
|
+
# \`set -a\` exports what is assigned AFTER it, and the resolution above assigned
|
|
1575
|
+
# this variable BEFORE it, so the exec'd binary would never see the name of the
|
|
1576
|
+
# file these credentials came from. It needs that name: when the service says
|
|
1577
|
+
# this engagement's credential has timed out, the binary renews it by rewriting
|
|
1578
|
+
# that file. With nothing naming the file it renews nothing \u2014 and it says
|
|
1579
|
+
# nothing and exits 0 while doing so, because there is no session in which that
|
|
1580
|
+
# silence is wrong on its own. So a wrapper missing this line looks perfectly
|
|
1581
|
+
# healthy at every session start and renews nothing, forever.
|
|
1582
|
+
export HALFCYCLE_ENV_FILE
|
|
1161
1583
|
set -a
|
|
1162
1584
|
. "$HALFCYCLE_ENV_FILE"
|
|
1163
1585
|
set +a
|
|
@@ -1173,15 +1595,28 @@ fi
|
|
|
1173
1595
|
exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
1174
1596
|
`;
|
|
1175
1597
|
}
|
|
1598
|
+
var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
|
|
1599
|
+
var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
|
|
1176
1600
|
function generateSessionStartMarker() {
|
|
1177
1601
|
return `#!/usr/bin/env bash
|
|
1178
|
-
|
|
1179
|
-
#
|
|
1180
|
-
#
|
|
1602
|
+
${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
1603
|
+
# Records the git HEAD sha this session starts from, in a file named after this
|
|
1604
|
+
# session, so the stop-time check can compare what the session did against where
|
|
1605
|
+
# it began instead of only against the latest commit. The session id comes in on
|
|
1606
|
+
# stdin with the hook input; without one, the older session-less name is written
|
|
1607
|
+
# and the stop-time check compares against the latest commit as it used to.
|
|
1608
|
+
INPUT="$(cat 2>/dev/null || true)"
|
|
1181
1609
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
1182
1610
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
1183
|
-
HASH="$(
|
|
1184
|
-
|
|
1611
|
+
HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
|
|
1612
|
+
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
1613
|
+
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
1614
|
+
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
1615
|
+
if [ -n "$SESSION_ID" ]; then
|
|
1616
|
+
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref"
|
|
1617
|
+
else
|
|
1618
|
+
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
|
|
1619
|
+
fi
|
|
1185
1620
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
1186
1621
|
|
|
1187
1622
|
# ---------------------------------------------------------------------------
|
|
@@ -1244,9 +1679,41 @@ ${engagementResolutionShell()}
|
|
|
1244
1679
|
exit 0
|
|
1245
1680
|
`;
|
|
1246
1681
|
}
|
|
1682
|
+
var GENERATED_SESSION_END_HEADER = "# Session-end marker cleanup \u2014 generated by";
|
|
1683
|
+
function generateSessionEndMarker() {
|
|
1684
|
+
return `#!/usr/bin/env bash
|
|
1685
|
+
${GENERATED_SESSION_END_HEADER} the Halfcycle installer.
|
|
1686
|
+
# Removes this session's marker file (and the evaluation-state file beside it)
|
|
1687
|
+
# written at session start. Deletion happens HERE and not at stop time: the stop
|
|
1688
|
+
# hook runs at the end of every turn, and deleting the marker there would throw
|
|
1689
|
+
# away the session's starting point for every later turn.
|
|
1690
|
+
INPUT="$(cat 2>/dev/null || true)"
|
|
1691
|
+
|
|
1692
|
+
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
1693
|
+
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
1694
|
+
HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
|
|
1695
|
+
|
|
1696
|
+
# The session id arrives on stdin as part of the hook payload. Empty when absent.
|
|
1697
|
+
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
1698
|
+
|
|
1699
|
+
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
1700
|
+
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
1701
|
+
|
|
1702
|
+
# Exact names only \u2014 never a wildcard. A pattern would match another session
|
|
1703
|
+
# running in this same repository and delete the point it started from.
|
|
1704
|
+
rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.ref" 2>/dev/null || true
|
|
1705
|
+
rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.eval" 2>/dev/null || true
|
|
1706
|
+
if [ -n "$SESSION_ID" ]; then
|
|
1707
|
+
rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref" 2>/dev/null || true
|
|
1708
|
+
rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.eval" 2>/dev/null || true
|
|
1709
|
+
fi
|
|
1710
|
+
exit 0
|
|
1711
|
+
`;
|
|
1712
|
+
}
|
|
1713
|
+
var GENERATED_USER_PROMPT_REMINDER_HEADER = "# UserPromptSubmit house-rule reminder \u2014 generated by";
|
|
1247
1714
|
function generateUserPromptReminderHook() {
|
|
1248
1715
|
return `#!/usr/bin/env bash
|
|
1249
|
-
|
|
1716
|
+
${GENERATED_USER_PROMPT_REMINDER_HEADER} the Halfcycle installer.
|
|
1250
1717
|
#
|
|
1251
1718
|
# Injects ONE plain-text sentence into context on every prompt (F-2(c),
|
|
1252
1719
|
# W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
|
|
@@ -1344,17 +1811,31 @@ function generateCiStanza() {
|
|
|
1344
1811
|
#
|
|
1345
1812
|
# The job prints the diff base it used and how many files it evaluated, on every
|
|
1346
1813
|
# run. If that line says 0 files on a commit that changed something, the base is
|
|
1347
|
-
# wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block
|
|
1348
|
-
# (on a GitHub push event, \${{ github.event.before }}
|
|
1814
|
+
# wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block OF YOUR COPY, beside the two
|
|
1815
|
+
# secrets, to name it explicitly (on a GitHub push event, \${{ github.event.before }}
|
|
1816
|
+
# is the right value).
|
|
1817
|
+
#
|
|
1818
|
+
# EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
|
|
1819
|
+
# changes to it would be replaced the next time you run \`npx halfcycle\`. It is
|
|
1820
|
+
# also inert where it sits: no CI system reads this path. Copy the job above into
|
|
1821
|
+
# your own workflow and change it there.
|
|
1349
1822
|
`;
|
|
1350
1823
|
}
|
|
1351
1824
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1352
1825
|
var MCP_SERVER_KEY = "halfcycle";
|
|
1353
1826
|
var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
1354
1827
|
var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
|
|
1828
|
+
var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
|
|
1829
|
+
var OWNED_GENERATED_HEADERS = {
|
|
1830
|
+
".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
|
|
1831
|
+
".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
|
|
1832
|
+
".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
|
|
1833
|
+
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
|
|
1834
|
+
".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
|
|
1835
|
+
};
|
|
1355
1836
|
function generateMcpHeadersHelper() {
|
|
1356
1837
|
return `#!/bin/sh
|
|
1357
|
-
|
|
1838
|
+
${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
|
|
1358
1839
|
#
|
|
1359
1840
|
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
1360
1841
|
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
@@ -1539,7 +2020,7 @@ function writeCrewRoster(targetRepoRoot, report) {
|
|
|
1539
2020
|
const rendered = `${JSON.stringify(doc, null, 2)}
|
|
1540
2021
|
`;
|
|
1541
2022
|
const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
|
|
1542
|
-
|
|
2023
|
+
recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
|
|
1543
2024
|
}
|
|
1544
2025
|
function readBundlePin(targetRepoRoot) {
|
|
1545
2026
|
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
@@ -1690,7 +2171,8 @@ async function install(options) {
|
|
|
1690
2171
|
writtenPaths: [],
|
|
1691
2172
|
skippedPaths: [],
|
|
1692
2173
|
mergedPaths: [],
|
|
1693
|
-
collidedPaths: []
|
|
2174
|
+
collidedPaths: [],
|
|
2175
|
+
replacedPaths: []
|
|
1694
2176
|
};
|
|
1695
2177
|
copyManifestCommands(manifest, targetRepo, report);
|
|
1696
2178
|
const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
|
|
@@ -1703,7 +2185,7 @@ async function install(options) {
|
|
|
1703
2185
|
}
|
|
1704
2186
|
const vendoredBinSrc = resolveVendoredBinary();
|
|
1705
2187
|
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
1706
|
-
|
|
2188
|
+
recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
|
|
1707
2189
|
const settingsPath = join5(targetRepo, ".claude", "settings.json");
|
|
1708
2190
|
const settingsPreexisted = existsSync3(settingsPath);
|
|
1709
2191
|
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
@@ -1715,17 +2197,19 @@ async function install(options) {
|
|
|
1715
2197
|
(settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
|
|
1716
2198
|
for (const [name, content] of [
|
|
1717
2199
|
["guard-runner.sh", generateGuardRunnerWrapper()],
|
|
1718
|
-
["session-
|
|
1719
|
-
["user-prompt-reminder.sh", generateUserPromptReminderHook()]
|
|
2200
|
+
["session-end-marker.sh", generateSessionEndMarker()],
|
|
2201
|
+
["user-prompt-reminder.sh", generateUserPromptReminderHook()],
|
|
2202
|
+
["session-start-marker.sh", generateSessionStartMarker()]
|
|
1720
2203
|
]) {
|
|
2204
|
+
const rel = `.claude/hooks/${name}`;
|
|
1721
2205
|
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
1722
|
-
|
|
2206
|
+
recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
1723
2207
|
}
|
|
1724
2208
|
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
1725
|
-
|
|
2209
|
+
recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
|
|
1726
2210
|
if (credential) {
|
|
1727
2211
|
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
1728
|
-
|
|
2212
|
+
recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
1729
2213
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
1730
2214
|
const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
|
|
1731
2215
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
@@ -1779,6 +2263,7 @@ async function install(options) {
|
|
|
1779
2263
|
skippedPaths: report.skippedPaths,
|
|
1780
2264
|
mergedPaths: report.mergedPaths,
|
|
1781
2265
|
collidedPaths: report.collidedPaths,
|
|
2266
|
+
replacedPaths: report.replacedPaths,
|
|
1782
2267
|
bootstrapScanRan: scanResult.ran,
|
|
1783
2268
|
legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
|
|
1784
2269
|
};
|
|
@@ -1944,6 +2429,7 @@ var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped
|
|
|
1944
2429
|
var SIGN_IN_NON_INTERACTIVE_MESSAGE = "Halfcycle needs a signed-in account and this looks like a non-interactive run (CI is set), so there is no browser to sign in with. Set HALFCYCLE_TOKEN in the environment to a Halfcycle credential and run this again. Nothing was created.";
|
|
1945
2430
|
var DEADLINE_SLACK_INTERVALS = 1;
|
|
1946
2431
|
var STILL_WAITING_MS = 15e3;
|
|
2432
|
+
var DEFAULT_SIGN_IN_REASON = "create an engagement";
|
|
1947
2433
|
function defaultWrite(text) {
|
|
1948
2434
|
process.stdout.write(text);
|
|
1949
2435
|
}
|
|
@@ -2133,7 +2619,7 @@ async function signIn(serviceUrl, deps = {}) {
|
|
|
2133
2619
|
if (isNonInteractive(env)) {
|
|
2134
2620
|
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_NON_INTERACTIVE_MESSAGE);
|
|
2135
2621
|
}
|
|
2136
|
-
write(`[halfcycle] Halfcycle needs an account before it can
|
|
2622
|
+
write(`[halfcycle] Halfcycle needs an account before it can ${deps.reason ?? DEFAULT_SIGN_IN_REASON} \u2014 signing you in through your browser.
|
|
2137
2623
|
`);
|
|
2138
2624
|
const bind = await bindLoopback(env);
|
|
2139
2625
|
let closed = false;
|
|
@@ -2290,6 +2776,12 @@ async function joinEngagement(baseUrl, engagementId, credential) {
|
|
|
2290
2776
|
route: "POST /engagements/:engagementId/join"
|
|
2291
2777
|
});
|
|
2292
2778
|
}
|
|
2779
|
+
function withDeclaredTelemetryKey(raw) {
|
|
2780
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
2781
|
+
return null;
|
|
2782
|
+
const obj = raw;
|
|
2783
|
+
return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
|
|
2784
|
+
}
|
|
2293
2785
|
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
2294
2786
|
const bearer = credential?.trim();
|
|
2295
2787
|
let res;
|
|
@@ -2314,7 +2806,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2314
2806
|
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
|
|
2315
2807
|
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2316
2808
|
}
|
|
2317
|
-
const
|
|
2809
|
+
const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
|
|
2810
|
+
const declared = engagementCredentialResponseSchema.safeParse(candidate);
|
|
2811
|
+
const body = declared.success ? declared.data : candidate;
|
|
2318
2812
|
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
2319
2813
|
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2320
2814
|
}
|
|
@@ -2333,6 +2827,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2333
2827
|
throw new Error(`[bundle install] The Halfcycle service at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that service is older than this installer, or ${url} is not a Halfcycle service.`);
|
|
2334
2828
|
}
|
|
2335
2829
|
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
2830
|
+
if (!declared.success) {
|
|
2831
|
+
const fields = declared.error.issues.map((issue) => {
|
|
2832
|
+
const unrecognised = issue.keys;
|
|
2833
|
+
if (unrecognised && unrecognised.length > 0) {
|
|
2834
|
+
return `unexpected: ${unrecognised.map(String).join(", ")}`;
|
|
2835
|
+
}
|
|
2836
|
+
return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
|
|
2837
|
+
}).join(", ");
|
|
2838
|
+
throw new Error(`[bundle install] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
|
|
2839
|
+
}
|
|
2336
2840
|
return {
|
|
2337
2841
|
engagementId: body.engagementId,
|
|
2338
2842
|
sessionToken: body.sessionToken,
|
|
@@ -2671,7 +3175,8 @@ function currentEnvironment(quiet2) {
|
|
|
2671
3175
|
// dist/confirm-identity.js
|
|
2672
3176
|
var IDENTITY_TIMEOUT_MS = 5e3;
|
|
2673
3177
|
var IDENTITY_DECLINED = "identity-declined";
|
|
2674
|
-
|
|
3178
|
+
var CREDENTIAL_REFUSED = 401;
|
|
3179
|
+
async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
2675
3180
|
const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
|
|
2676
3181
|
const controller = new AbortController();
|
|
2677
3182
|
const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
|
|
@@ -2681,15 +3186,18 @@ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
|
2681
3186
|
signal: controller.signal
|
|
2682
3187
|
});
|
|
2683
3188
|
if (!res.ok)
|
|
2684
|
-
return void 0;
|
|
3189
|
+
return { identity: void 0, refused: res.status === CREDENTIAL_REFUSED };
|
|
2685
3190
|
const parsed = safeParseAccountIdentity(await res.json());
|
|
2686
|
-
return parsed.success ? parsed.data : void 0;
|
|
3191
|
+
return { identity: parsed.success ? parsed.data : void 0, refused: false };
|
|
2687
3192
|
} catch {
|
|
2688
|
-
return void 0;
|
|
3193
|
+
return { identity: void 0, refused: false };
|
|
2689
3194
|
} finally {
|
|
2690
3195
|
clearTimeout(timer);
|
|
2691
3196
|
}
|
|
2692
3197
|
}
|
|
3198
|
+
async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
3199
|
+
return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
|
|
3200
|
+
}
|
|
2693
3201
|
function accountLabel(identity, storedAccountId, verbose2 = false) {
|
|
2694
3202
|
const accountId = identity?.accountId ?? storedAccountId;
|
|
2695
3203
|
if (identity?.email !== void 0 && identity.email.trim() !== "") {
|
|
@@ -3143,7 +3651,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3143
3651
|
"",
|
|
3144
3652
|
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
3145
3653
|
"",
|
|
3146
|
-
"> This Build Record was assembled automatically at phase close by
|
|
3654
|
+
"> This Build Record was assembled automatically at phase close by `npx halfcycle close-phase`. Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
|
|
3147
3655
|
"",
|
|
3148
3656
|
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
3149
3657
|
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
@@ -3163,7 +3671,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3163
3671
|
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
3164
3672
|
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
3165
3673
|
"",
|
|
3166
|
-
guardsList ? `Guards evaluated (
|
|
3674
|
+
guardsList ? `Guards evaluated (results only): ${guardsList}. *(What a guard reported about this phase is recorded; the guard's own definition and its history are not.)*` : "No guards fired during this phase.",
|
|
3167
3675
|
"",
|
|
3168
3676
|
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
3169
3677
|
"",
|
|
@@ -3175,7 +3683,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3175
3683
|
""
|
|
3176
3684
|
];
|
|
3177
3685
|
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
3178
|
-
lines.push(`**
|
|
3686
|
+
lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
3179
3687
|
}
|
|
3180
3688
|
lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
|
|
3181
3689
|
return lines.join("\n");
|
|
@@ -3568,15 +4076,115 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
|
|
|
3568
4076
|
return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
|
|
3569
4077
|
}
|
|
3570
4078
|
|
|
4079
|
+
// dist/ci-bind.js
|
|
4080
|
+
function parseCiRepositoryArg(arg) {
|
|
4081
|
+
const parts = arg.split("/");
|
|
4082
|
+
if (parts.length !== 2)
|
|
4083
|
+
return null;
|
|
4084
|
+
const [owner, repo] = parts;
|
|
4085
|
+
if (owner === void 0 || owner.trim() === "")
|
|
4086
|
+
return null;
|
|
4087
|
+
if (repo === void 0 || repo.trim() === "")
|
|
4088
|
+
return null;
|
|
4089
|
+
return { owner, repo };
|
|
4090
|
+
}
|
|
4091
|
+
var CiBindRefused = class extends Error {
|
|
4092
|
+
status;
|
|
4093
|
+
constructor(status, message) {
|
|
4094
|
+
super(message);
|
|
4095
|
+
this.status = status;
|
|
4096
|
+
this.name = "CiBindRefused";
|
|
4097
|
+
}
|
|
4098
|
+
/** Is this the ONE arm signing in again can fix — see this file's header. */
|
|
4099
|
+
get authRefused() {
|
|
4100
|
+
return this.status === 401;
|
|
4101
|
+
}
|
|
4102
|
+
/**
|
|
4103
|
+
* Is this "ours to fix, try again", not "yours to fix"? The question is not
|
|
4104
|
+
* mechanical (did a handler run) — it is what a developer reading the message
|
|
4105
|
+
* does next. `502`/`504` are the reverse proxy in front of the published origin
|
|
4106
|
+
* answering for a control plane that is down or slow, with an HTML body this
|
|
4107
|
+
* file's own `requestBinding` cannot parse, so the message would otherwise
|
|
4108
|
+
* degrade to a bare "the URL returned 502". `503` is `resolveAccount`'s own
|
|
4109
|
+
* fail-closed refusal when it could not reach the token store — the caller's
|
|
4110
|
+
* credential was never actually checked, and its posture is emphatic this is NOT
|
|
4111
|
+
* a "no". **`500` belongs beside them, not with the 4xx arms.** It is an
|
|
4112
|
+
* unhandled throw: nothing decided the request's merits, and the bind may even
|
|
4113
|
+
* have half-happened if the throw landed after a commit. On this route the
|
|
4114
|
+
* body is the same degraded "the URL returned 500" a 502/504 produces — an
|
|
4115
|
+
* internal path echoed back with no advice — so `refused (500)` would send a
|
|
4116
|
+
* developer to re-check ownership, the repository name and whether it is bound
|
|
4117
|
+
* elsewhere: every 4xx question, none of them this status's actual cause.
|
|
4118
|
+
*
|
|
4119
|
+
* So this is every `5xx`, not an enumerated set of three — the boundary is
|
|
4120
|
+
* "did any handler decide yes or no", and a 4xx is the only family that ever did.
|
|
4121
|
+
*/
|
|
4122
|
+
get unavailable() {
|
|
4123
|
+
return this.status >= 500 && this.status < 600;
|
|
4124
|
+
}
|
|
4125
|
+
};
|
|
4126
|
+
async function requestBinding(method, serviceUrl, engagementId, repository, credential, noun) {
|
|
4127
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/ci-bindings/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
|
|
4128
|
+
let res;
|
|
4129
|
+
try {
|
|
4130
|
+
res = await fetch(url, { method, headers: { authorization: `Bearer ${credential}` } });
|
|
4131
|
+
} catch (err) {
|
|
4132
|
+
throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. Nothing was ${noun}.`);
|
|
4133
|
+
}
|
|
4134
|
+
if (res.status === 204)
|
|
4135
|
+
return;
|
|
4136
|
+
const body = await res.json().catch(() => null);
|
|
4137
|
+
const message = typeof body?.message === "string" ? body.message : `${url} returned ${res.status}.`;
|
|
4138
|
+
throw new CiBindRefused(res.status, message);
|
|
4139
|
+
}
|
|
4140
|
+
async function setCiBinding(action, serviceUrl, engagementId, repository, deps = {}) {
|
|
4141
|
+
const noun = action === "bind" ? "bound" : "unbound";
|
|
4142
|
+
const signInDeps = {
|
|
4143
|
+
...deps,
|
|
4144
|
+
reason: deps.reason ?? (action === "bind" ? "trust this repository for this engagement's CI" : "stop trusting this repository for this engagement's CI")
|
|
4145
|
+
};
|
|
4146
|
+
let credential = await obtainCredential(serviceUrl, signInDeps);
|
|
4147
|
+
for (; ; ) {
|
|
4148
|
+
try {
|
|
4149
|
+
await requestBinding(action === "bind" ? "PUT" : "DELETE", serviceUrl, engagementId, repository, credential.credential, noun);
|
|
4150
|
+
return;
|
|
4151
|
+
} catch (err) {
|
|
4152
|
+
if (!(err instanceof CiBindRefused) || !err.authRefused)
|
|
4153
|
+
throw err;
|
|
4154
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
|
|
4155
|
+
if (replacement === null) {
|
|
4156
|
+
throw new SignInRefused(`ci-${action}-${err.status}`, `${err.message} ${refusedCredentialRemedy(credential.source)} Nothing was ${noun}.`);
|
|
4157
|
+
}
|
|
4158
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
4159
|
+
`);
|
|
4160
|
+
credential = replacement;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
function bindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4165
|
+
return setCiBinding("bind", serviceUrl, engagementId, repository, deps);
|
|
4166
|
+
}
|
|
4167
|
+
function unbindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4168
|
+
return setCiBinding("unbind", serviceUrl, engagementId, repository, deps);
|
|
4169
|
+
}
|
|
4170
|
+
|
|
3571
4171
|
// dist/cli-contract.js
|
|
3572
|
-
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase"];
|
|
4172
|
+
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "ci"];
|
|
3573
4173
|
var CONTRACTS = [
|
|
3574
|
-
// `install`, `check-drift
|
|
3575
|
-
// flags. They are declared so the verb set has ONE home — a remedy
|
|
3576
|
-
// this CLI does not have is the same defect as one missing a flag.
|
|
4174
|
+
// `install`, `check-drift`, `build-record` and `ci` take positionals and no
|
|
4175
|
+
// required flags. They are declared so the verb set has ONE home — a remedy
|
|
4176
|
+
// naming a verb this CLI does not have is the same defect as one missing a flag.
|
|
4177
|
+
//
|
|
4178
|
+
// `ci`'s two forms (`ci bind <owner>/<repo>`, `ci unbind <owner>/<repo>`) take a
|
|
4179
|
+
// sub-action and a repository as POSITIONALS, not flags — this table answers only
|
|
4180
|
+
// *which flags must an invocation carry*, and neither form has one (T-11,
|
|
4181
|
+
// `ci-oidc-token-exchange`). The route it calls declares no wire shape either
|
|
4182
|
+
// (path segments in, `204` out — T-05), so there is no schema for an argument
|
|
4183
|
+
// here to disagree with.
|
|
3577
4184
|
{ verb: "install", required: [], conditional: [] },
|
|
3578
4185
|
{ verb: "check-drift", required: [], conditional: [] },
|
|
3579
4186
|
{ verb: "build-record", required: [], conditional: [] },
|
|
4187
|
+
{ verb: "ci", required: [], conditional: [] },
|
|
3580
4188
|
{
|
|
3581
4189
|
verb: "open-phase",
|
|
3582
4190
|
required: [
|
|
@@ -3610,7 +4218,7 @@ var CONTRACTS = [
|
|
|
3610
4218
|
{
|
|
3611
4219
|
flag: "--verdict",
|
|
3612
4220
|
takesValue: true,
|
|
3613
|
-
missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict
|
|
4221
|
+
missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict and nothing derives it for you.`
|
|
3614
4222
|
},
|
|
3615
4223
|
{
|
|
3616
4224
|
flag: "--actor",
|
|
@@ -3769,10 +4377,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
|
|
|
3769
4377
|
fact("Claude Code", probes.claudeCodeVersion()),
|
|
3770
4378
|
fact("Project", project, name),
|
|
3771
4379
|
fact("Status", status, installed === void 0 ? "new" : "installed"),
|
|
3772
|
-
// `Website`, NOT `Plane`
|
|
3773
|
-
//
|
|
3774
|
-
//
|
|
3775
|
-
// answers *which Halfcycle is this*, and an address is the answer they recognise.
|
|
4380
|
+
// `Website`, NOT `Plane` — see this function's docblock. (No issue number here:
|
|
4381
|
+
// a comment beside a property survives into the published entry, unlike a
|
|
4382
|
+
// leading docblock, which esbuild drops.)
|
|
3776
4383
|
fact("Website", probes.websiteHost(env))
|
|
3777
4384
|
].filter((f) => f !== void 0);
|
|
3778
4385
|
return {
|
|
@@ -3852,6 +4459,8 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
|
|
|
3852
4459
|
halfcycle build-record <phase> [--repo <root>]
|
|
3853
4460
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
3854
4461
|
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
4462
|
+
halfcycle ci bind <owner>/<repo> [--repo <root>]
|
|
4463
|
+
halfcycle ci unbind <owner>/<repo> [--repo <root>]
|
|
3855
4464
|
|
|
3856
4465
|
--quiet / -q: print no opening banner (any command)
|
|
3857
4466
|
--verbose / -v: print full run detail (paths written, merged, skipped) on install
|
|
@@ -3866,7 +4475,7 @@ function isHalfcycleMonorepo(dir) {
|
|
|
3866
4475
|
}
|
|
3867
4476
|
async function main() {
|
|
3868
4477
|
const [cmd, ...rest] = args;
|
|
3869
|
-
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
|
|
4478
|
+
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase|ci)$/.test(cmd);
|
|
3870
4479
|
const installArm = cmd === "install" || cmd === void 0 || bareTarget;
|
|
3871
4480
|
const positionals = cmd === "install" ? rest : args;
|
|
3872
4481
|
const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
|
|
@@ -3907,7 +4516,9 @@ ${USAGE}`);
|
|
|
3907
4516
|
}
|
|
3908
4517
|
const pinned = readPinnedEngagement(targetRepo);
|
|
3909
4518
|
const requestedId = engagementIdArg ?? pinned?.engagementId;
|
|
3910
|
-
const
|
|
4519
|
+
const held = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
4520
|
+
const heldAnswer = held !== void 0 ? await askAccount(held.serviceUrl, held.token) : void 0;
|
|
4521
|
+
const reusable = heldAnswer?.refused === true ? void 0 : held;
|
|
3911
4522
|
let engagementId;
|
|
3912
4523
|
let credential;
|
|
3913
4524
|
let notThisAccountMessage;
|
|
@@ -4007,6 +4618,12 @@ ${USAGE}`);
|
|
|
4007
4618
|
}
|
|
4008
4619
|
if (result.collidedPaths.length > 0) {
|
|
4009
4620
|
process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
|
|
4621
|
+
`);
|
|
4622
|
+
}
|
|
4623
|
+
if (result.replacedPaths.length > 0) {
|
|
4624
|
+
process.stdout.write(`[halfcycle] Updated (files Halfcycle wrote earlier, now replaced): ${result.replacedPaths.join(", ")}
|
|
4625
|
+
`);
|
|
4626
|
+
process.stdout.write(`[halfcycle] If you had changed one of those: a change you had COMMITTED is still in your git history ("git log -p -- <path>"). A change you had not committed is gone \u2014 this command overwrote the file.
|
|
4010
4627
|
`);
|
|
4011
4628
|
}
|
|
4012
4629
|
if (result.legacyCredentialKept !== null) {
|
|
@@ -4047,7 +4664,7 @@ ${USAGE}`);
|
|
|
4047
4664
|
`);
|
|
4048
4665
|
}
|
|
4049
4666
|
reportClaudeCodeVersion(verbose);
|
|
4050
|
-
const identity = actingIdentity ?? (credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
|
|
4667
|
+
const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
|
|
4051
4668
|
const written = result.writtenPaths.length;
|
|
4052
4669
|
const merged = result.mergedPaths.length;
|
|
4053
4670
|
process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
|
|
@@ -4318,6 +4935,69 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
|
|
|
4318
4935
|
return;
|
|
4319
4936
|
}
|
|
4320
4937
|
process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
|
|
4938
|
+
`);
|
|
4939
|
+
process.exit(1);
|
|
4940
|
+
}
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
if (cmd === "ci") {
|
|
4944
|
+
const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
|
|
4945
|
+
const positionals2 = rest.filter((token, i) => !token.startsWith("--") && !(i > 0 && rest[i - 1] === "--repo"));
|
|
4946
|
+
const action = positionals2[0];
|
|
4947
|
+
const repositoryArg = positionals2[1];
|
|
4948
|
+
if (action !== "bind" && action !== "unbind") {
|
|
4949
|
+
process.stderr.write("halfcycle ci: name bind or unbind.\n halfcycle ci bind <owner>/<repo> [--repo <root>]\n halfcycle ci unbind <owner>/<repo> [--repo <root>]\n");
|
|
4950
|
+
process.exit(2);
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4953
|
+
if (repositoryArg === void 0) {
|
|
4954
|
+
process.stderr.write(`halfcycle ci ${action}: name the repository, as owner/repo.
|
|
4955
|
+
`);
|
|
4956
|
+
process.exit(2);
|
|
4957
|
+
return;
|
|
4958
|
+
}
|
|
4959
|
+
const repository = parseCiRepositoryArg(repositoryArg);
|
|
4960
|
+
if (repository === null) {
|
|
4961
|
+
process.stderr.write(`halfcycle ci ${action}: "${repositoryArg}" is not a repository \u2014 give it as owner/repo, exactly as GitHub spells it, for example acme/widgets.
|
|
4962
|
+
`);
|
|
4963
|
+
process.exit(2);
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4966
|
+
const pin = readBundlePin(repoRoot);
|
|
4967
|
+
if (pin === null || pin.engagementId === "") {
|
|
4968
|
+
process.stderr.write(`halfcycle ci ${action}: ${join11(repoRoot, ".halfcycle", "bundle.json")} names no Halfcycle engagement. Run \`npx halfcycle\` here first.
|
|
4969
|
+
`);
|
|
4970
|
+
process.exit(1);
|
|
4971
|
+
return;
|
|
4972
|
+
}
|
|
4973
|
+
try {
|
|
4974
|
+
const controlOrigin = resolveControlOrigin(process.env);
|
|
4975
|
+
const label = `${repository.owner}/${repository.repo}`;
|
|
4976
|
+
if (action === "bind") {
|
|
4977
|
+
await bindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4978
|
+
process.stdout.write(`[halfcycle] ${label} now trusts this engagement's CI \u2014 a workflow there granting both \`contents: read\` and \`id-token: write\` can authenticate with no stored secret. Both lines: declaring any permission replaces the defaults, so naming only the identity token stops a private repository checking out.
|
|
4979
|
+
`);
|
|
4980
|
+
} else {
|
|
4981
|
+
await unbindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4982
|
+
process.stdout.write(`[halfcycle] ${label} no longer trusts this engagement's CI.
|
|
4983
|
+
`);
|
|
4984
|
+
}
|
|
4985
|
+
process.exit(0);
|
|
4986
|
+
} catch (err) {
|
|
4987
|
+
if (err instanceof SignInRefused) {
|
|
4988
|
+
process.stderr.write(`halfcycle ci ${action}: ${err.message}
|
|
4989
|
+
`);
|
|
4990
|
+
process.exit(1);
|
|
4991
|
+
return;
|
|
4992
|
+
}
|
|
4993
|
+
if (err instanceof CiBindRefused) {
|
|
4994
|
+
const label = err.unavailable ? `temporarily unavailable (${err.status})` : `refused (${err.status})`;
|
|
4995
|
+
process.stderr.write(`halfcycle ci ${action}: ${label}. ${err.message}
|
|
4996
|
+
`);
|
|
4997
|
+
process.exit(1);
|
|
4998
|
+
return;
|
|
4999
|
+
}
|
|
5000
|
+
process.stderr.write(`halfcycle ci ${action} failed: ${err instanceof Error ? err.message : String(err)}
|
|
4321
5001
|
`);
|
|
4322
5002
|
process.exit(1);
|
|
4323
5003
|
}
|