halfcycle 0.3.23 → 0.3.25
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 +1003 -245
- 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 +619 -197
- 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/index.js +370 -126
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts +14 -11
- package/dist/install.d.ts.map +1 -1
- package/dist/merge-settings.d.ts +22 -7
- 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,16 +376,184 @@ 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 crewFaceHeadSchema =
|
|
284
|
-
var crewFaceAntennaSchema =
|
|
285
|
-
var crewFaceEyesSchema =
|
|
286
|
-
var crewFaceMouthSchema =
|
|
287
|
-
var crewFaceSchema =
|
|
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({
|
|
288
557
|
// The outline of the head. Members who do the same kind of work share one, so a
|
|
289
558
|
// team is recognisable before any name is read.
|
|
290
559
|
head: crewFaceHeadSchema,
|
|
@@ -295,17 +564,17 @@ var crewFaceSchema = z5.object({
|
|
|
295
564
|
// The mouth.
|
|
296
565
|
mouth: crewFaceMouthSchema,
|
|
297
566
|
// Whether this member is drawn with ears.
|
|
298
|
-
ears:
|
|
567
|
+
ears: z6.boolean()
|
|
299
568
|
}).strict();
|
|
300
|
-
var crewMemberSchema =
|
|
569
|
+
var crewMemberSchema = z6.object({
|
|
301
570
|
// The agent's name. The same twenty-one on every project, so a name is a
|
|
302
571
|
// vocabulary a returning client already knows.
|
|
303
|
-
callsign:
|
|
572
|
+
callsign: z6.string().min(1).max(32),
|
|
304
573
|
// Which of the three headings this member reads under.
|
|
305
574
|
group: crewGroupSchema,
|
|
306
575
|
// The kind of work this member takes. A task names a discipline, and only
|
|
307
576
|
// agents holding it can be dispatched to it.
|
|
308
|
-
discipline:
|
|
577
|
+
discipline: z6.string().min(1).max(CREW_DISCIPLINE_MAX),
|
|
309
578
|
// How much thinking this member brings. There is no field naming the supplier
|
|
310
579
|
// behind it, on this shape or on any other.
|
|
311
580
|
tier: crewTierSchema,
|
|
@@ -470,8 +739,8 @@ var CREW_ROSTER_SIZE = CREW_ROSTER.length;
|
|
|
470
739
|
var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
|
|
471
740
|
|
|
472
741
|
// ../events/dist/device-auth.js
|
|
473
|
-
import { z as
|
|
474
|
-
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");
|
|
475
744
|
var DEVICE_AUTH_STATUS = {
|
|
476
745
|
/** The poll: minted, nobody has acted yet. Keep polling. 200. */
|
|
477
746
|
PENDING: "pending",
|
|
@@ -497,7 +766,7 @@ var DEVICE_AUTH_STATUS = {
|
|
|
497
766
|
ALREADY_DECIDED: "already-decided",
|
|
498
767
|
/**
|
|
499
768
|
* Confirm: an approval named a signed-in identity whose account has not accepted
|
|
500
|
-
* the terms of service and privacy policy currently in force
|
|
769
|
+
* the terms of service and privacy policy currently in force. 400.
|
|
501
770
|
*
|
|
502
771
|
* NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
|
|
503
772
|
* says nothing about the code itself — the code is still `pending` after this
|
|
@@ -512,25 +781,25 @@ var DEVICE_POLL_REFUSALS = [
|
|
|
512
781
|
DEVICE_AUTH_STATUS.EXPIRED,
|
|
513
782
|
DEVICE_AUTH_STATUS.UNKNOWN
|
|
514
783
|
];
|
|
515
|
-
var deviceAuthStartResponseSchema =
|
|
516
|
-
deviceCode:
|
|
517
|
-
userCode:
|
|
518
|
-
verificationUrl:
|
|
519
|
-
expiresAt:
|
|
520
|
-
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()
|
|
521
790
|
}).strict();
|
|
522
|
-
var devicePollPendingSchema =
|
|
523
|
-
status:
|
|
524
|
-
pollIntervalMs:
|
|
791
|
+
var devicePollPendingSchema = z7.object({
|
|
792
|
+
status: z7.literal(DEVICE_AUTH_STATUS.PENDING),
|
|
793
|
+
pollIntervalMs: z7.number().int().positive()
|
|
525
794
|
}).strict();
|
|
526
|
-
var devicePollApprovedSchema =
|
|
527
|
-
status:
|
|
528
|
-
accountId:
|
|
529
|
-
credential:
|
|
530
|
-
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
|
|
531
800
|
}).strict();
|
|
532
|
-
var deviceAuthRefusalSchema =
|
|
533
|
-
status:
|
|
801
|
+
var deviceAuthRefusalSchema = z7.object({
|
|
802
|
+
status: z7.enum([
|
|
534
803
|
DEVICE_AUTH_STATUS.DECLINED,
|
|
535
804
|
DEVICE_AUTH_STATUS.EXPIRED,
|
|
536
805
|
DEVICE_AUTH_STATUS.UNKNOWN,
|
|
@@ -541,25 +810,25 @@ var deviceAuthRefusalSchema = z6.object({
|
|
|
541
810
|
DEVICE_AUTH_STATUS.ALREADY_DECIDED,
|
|
542
811
|
DEVICE_AUTH_STATUS.TERMS_REQUIRED
|
|
543
812
|
]),
|
|
544
|
-
message:
|
|
813
|
+
message: z7.string().min(1)
|
|
545
814
|
}).strict();
|
|
546
|
-
var devicePollResponseSchema =
|
|
815
|
+
var devicePollResponseSchema = z7.union([
|
|
547
816
|
devicePollPendingSchema,
|
|
548
817
|
devicePollApprovedSchema,
|
|
549
818
|
deviceAuthRefusalSchema
|
|
550
819
|
]);
|
|
551
|
-
var deviceConfirmRequestSchema =
|
|
552
|
-
userCode:
|
|
553
|
-
decision:
|
|
554
|
-
externalAuthId:
|
|
555
|
-
email:
|
|
556
|
-
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()
|
|
557
826
|
}).strict();
|
|
558
|
-
var deviceConfirmRecordedSchema =
|
|
559
|
-
status:
|
|
560
|
-
accountId:
|
|
827
|
+
var deviceConfirmRecordedSchema = z7.object({
|
|
828
|
+
status: z7.literal(DEVICE_AUTH_STATUS.RECORDED),
|
|
829
|
+
accountId: z7.string().min(1).optional()
|
|
561
830
|
}).strict();
|
|
562
|
-
var deviceConfirmResponseSchema =
|
|
831
|
+
var deviceConfirmResponseSchema = z7.union([
|
|
563
832
|
deviceConfirmRecordedSchema,
|
|
564
833
|
deviceAuthRefusalSchema
|
|
565
834
|
]);
|
|
@@ -588,25 +857,25 @@ function loopbackVerificationUrl(verificationUrl, target) {
|
|
|
588
857
|
}
|
|
589
858
|
|
|
590
859
|
// ../events/dist/join-refusal.js
|
|
591
|
-
import { z as
|
|
860
|
+
import { z as z8 } from "zod";
|
|
592
861
|
var NOT_THIS_ACCOUNT_REASON = "not-this-account";
|
|
593
862
|
var JOIN_REFUSAL_STATUS = 403;
|
|
594
|
-
var notThisAccountRefusalSchema =
|
|
595
|
-
statusCode:
|
|
596
|
-
error:
|
|
597
|
-
message:
|
|
598
|
-
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)
|
|
599
868
|
}).strict();
|
|
600
|
-
var joinRefusalSchema =
|
|
869
|
+
var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
|
|
601
870
|
function isNotThisAccountRefusal(body) {
|
|
602
871
|
return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
|
|
603
872
|
}
|
|
604
873
|
|
|
605
874
|
// ../events/dist/account-identity.js
|
|
606
|
-
import { z as
|
|
607
|
-
var accountIdentitySchema =
|
|
608
|
-
accountId:
|
|
609
|
-
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()
|
|
610
879
|
}).strict();
|
|
611
880
|
function safeParseAccountIdentity(payload) {
|
|
612
881
|
return accountIdentitySchema.safeParse(payload);
|
|
@@ -673,6 +942,21 @@ function describeIdentity(identity) {
|
|
|
673
942
|
return `(a ${typeof identity})`;
|
|
674
943
|
}
|
|
675
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
|
+
|
|
676
960
|
// dist/engagement-credential.js
|
|
677
961
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
678
962
|
import { platform as platform2 } from "node:os";
|
|
@@ -683,27 +967,14 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
683
967
|
import { homedir, platform } from "node:os";
|
|
684
968
|
import { dirname, join } from "node:path";
|
|
685
969
|
function halfcycleHome(home) {
|
|
686
|
-
return join(home ?? homedir(),
|
|
970
|
+
return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
|
|
687
971
|
}
|
|
688
972
|
function accountStorePath(home) {
|
|
689
|
-
return join(halfcycleHome(home),
|
|
690
|
-
}
|
|
691
|
-
function normaliseOrigin(serviceUrl) {
|
|
692
|
-
return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
|
|
973
|
+
return join(halfcycleHome(home), ACCOUNT_STORE_FILENAME);
|
|
693
974
|
}
|
|
694
975
|
function readStore(home) {
|
|
695
|
-
let raw;
|
|
696
976
|
try {
|
|
697
|
-
|
|
698
|
-
} catch {
|
|
699
|
-
return { version: 1, accounts: {} };
|
|
700
|
-
}
|
|
701
|
-
try {
|
|
702
|
-
const parsed = JSON.parse(raw);
|
|
703
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
|
|
704
|
-
return { version: 1, accounts: {} };
|
|
705
|
-
}
|
|
706
|
-
return { version: 1, accounts: parsed.accounts ?? {} };
|
|
977
|
+
return parseAccountStoreText(readFileSync(accountStorePath(home), "utf-8"));
|
|
707
978
|
} catch {
|
|
708
979
|
return { version: 1, accounts: {} };
|
|
709
980
|
}
|
|
@@ -749,8 +1020,9 @@ function forgetStoredCredential(serviceUrl, home) {
|
|
|
749
1020
|
}
|
|
750
1021
|
|
|
751
1022
|
// dist/engagement-credential.js
|
|
752
|
-
var ENGAGEMENTS_DIR =
|
|
753
|
-
var ENV_FILENAME =
|
|
1023
|
+
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1024
|
+
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1025
|
+
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
754
1026
|
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
755
1027
|
function engagementStateDir(engagementId, home) {
|
|
756
1028
|
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
@@ -758,7 +1030,6 @@ function engagementStateDir(engagementId, home) {
|
|
|
758
1030
|
function engagementEnvPath(engagementId, home) {
|
|
759
1031
|
return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
|
|
760
1032
|
}
|
|
761
|
-
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
762
1033
|
function notThisAccountMarkerPath(engagementId, home) {
|
|
763
1034
|
return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
|
|
764
1035
|
}
|
|
@@ -785,82 +1056,8 @@ var ENGAGEMENT_ENV_KEYS = [
|
|
|
785
1056
|
"CONTROL_TELEMETRY_URL"
|
|
786
1057
|
];
|
|
787
1058
|
var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
791
|
-
}
|
|
792
|
-
function unquote(value) {
|
|
793
|
-
const v = value.trim();
|
|
794
|
-
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
795
|
-
return v.slice(1, -1).split(`'\\''`).join(`'`);
|
|
796
|
-
}
|
|
797
|
-
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
798
|
-
return v.slice(1, -1);
|
|
799
|
-
return v;
|
|
800
|
-
}
|
|
801
|
-
function parseEnvText(raw) {
|
|
802
|
-
const out = {};
|
|
803
|
-
for (const line of raw.split("\n")) {
|
|
804
|
-
const eq = line.indexOf("=");
|
|
805
|
-
if (eq === -1)
|
|
806
|
-
continue;
|
|
807
|
-
const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
|
|
808
|
-
if (key === "" || key.startsWith("#"))
|
|
809
|
-
continue;
|
|
810
|
-
out[key] = unquote(line.slice(eq + 1));
|
|
811
|
-
}
|
|
812
|
-
return out;
|
|
813
|
-
}
|
|
814
|
-
function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
815
|
-
const desired = new Map(keys.map((k) => {
|
|
816
|
-
const value = values[k];
|
|
817
|
-
return [k, value === void 0 ? "" : value];
|
|
818
|
-
}));
|
|
819
|
-
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
820
|
-
const written = keys.filter((k) => desired.get(k) !== null);
|
|
821
|
-
if (existing === null) {
|
|
822
|
-
if (written.length === 0)
|
|
823
|
-
return "";
|
|
824
|
-
return `${ENV_HEADER}
|
|
825
|
-
` + written.map(line).join("\n") + "\n";
|
|
826
|
-
}
|
|
827
|
-
const seen = /* @__PURE__ */ new Set();
|
|
828
|
-
const out = [];
|
|
829
|
-
for (const existingLine of existing.split("\n")) {
|
|
830
|
-
const eq = existingLine.indexOf("=");
|
|
831
|
-
if (eq === -1) {
|
|
832
|
-
out.push(existingLine);
|
|
833
|
-
continue;
|
|
834
|
-
}
|
|
835
|
-
const lhs = existingLine.slice(0, eq);
|
|
836
|
-
const exported = /^\s*export\s+/.exec(lhs);
|
|
837
|
-
const prefix = exported === null ? "" : exported[0];
|
|
838
|
-
const key = lhs.slice(prefix.length).trim();
|
|
839
|
-
if (!desired.has(key)) {
|
|
840
|
-
out.push(existingLine);
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
seen.add(key);
|
|
844
|
-
if (desired.get(key) === null)
|
|
845
|
-
continue;
|
|
846
|
-
out.push(`${prefix}${line(key)}`);
|
|
847
|
-
}
|
|
848
|
-
const missing = written.filter((k) => !seen.has(k));
|
|
849
|
-
if (missing.length > 0) {
|
|
850
|
-
const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
|
|
851
|
-
`;
|
|
852
|
-
const block = header + missing.map(line).join("\n");
|
|
853
|
-
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
854
|
-
if (trailingBlank)
|
|
855
|
-
out.splice(out.length - 1, 0, block);
|
|
856
|
-
else
|
|
857
|
-
out.push(`
|
|
858
|
-
${block}`);
|
|
859
|
-
}
|
|
860
|
-
let result = out.join("\n");
|
|
861
|
-
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
862
|
-
result += "\n";
|
|
863
|
-
return result;
|
|
1059
|
+
function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
1060
|
+
return reconcileEnvText(existing, values, keys);
|
|
864
1061
|
}
|
|
865
1062
|
function readEngagementEnv(engagementId, home) {
|
|
866
1063
|
try {
|
|
@@ -878,7 +1075,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
|
|
|
878
1075
|
} catch {
|
|
879
1076
|
existing = null;
|
|
880
1077
|
}
|
|
881
|
-
const reconciled =
|
|
1078
|
+
const reconciled = reconcileEnvText2(existing, values, keys);
|
|
882
1079
|
if (existing === null && reconciled === "")
|
|
883
1080
|
return "skipped";
|
|
884
1081
|
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
@@ -904,7 +1101,7 @@ function applyOwnerOnly(path, dir) {
|
|
|
904
1101
|
function engagementResolutionShell() {
|
|
905
1102
|
return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
|
|
906
1103
|
# THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
|
|
907
|
-
# $HOME
|
|
1104
|
+
# $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
|
|
908
1105
|
# written owner-only by \`npx halfcycle\`. What this repository holds is the
|
|
909
1106
|
# engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
|
|
910
1107
|
# the pin, then name the file. There is no jq here and node is not dependable on a
|
|
@@ -935,10 +1132,10 @@ halfcycle_env_file() {
|
|
|
935
1132
|
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
936
1133
|
return 1
|
|
937
1134
|
fi
|
|
938
|
-
if [ -f "$HOME
|
|
1135
|
+
if [ -f "$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
|
|
939
1136
|
HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
|
|
940
1137
|
fi
|
|
941
|
-
hc_env="$HOME
|
|
1138
|
+
hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
|
|
942
1139
|
if [ ! -f "$hc_env" ]; then
|
|
943
1140
|
HALFCYCLE_ENV_PROBLEM="no-credential"
|
|
944
1141
|
return 1
|
|
@@ -1129,7 +1326,7 @@ function resolveVendoredBinary() {
|
|
|
1129
1326
|
if (existsSync3(candidate))
|
|
1130
1327
|
return candidate;
|
|
1131
1328
|
}
|
|
1132
|
-
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(", ")}.`);
|
|
1133
1330
|
}
|
|
1134
1331
|
var WRITE_ALLOWLIST = [
|
|
1135
1332
|
".claude/commands",
|
|
@@ -1278,6 +1475,15 @@ function generateSettingsJson() {
|
|
|
1278
1475
|
timeout: 5
|
|
1279
1476
|
}
|
|
1280
1477
|
]
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
hooks: [
|
|
1481
|
+
{
|
|
1482
|
+
type: "command",
|
|
1483
|
+
command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
|
|
1484
|
+
timeout: 10
|
|
1485
|
+
}
|
|
1486
|
+
]
|
|
1281
1487
|
}
|
|
1282
1488
|
],
|
|
1283
1489
|
UserPromptSubmit: [
|
|
@@ -1347,9 +1553,9 @@ function generateGuardRunnerWrapper() {
|
|
|
1347
1553
|
${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
|
|
1348
1554
|
#
|
|
1349
1555
|
# Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
|
|
1350
|
-
# values when Claude Code fires PostToolUse / Stop / SubagentStop,
|
|
1351
|
-
# SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path
|
|
1352
|
-
# 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.
|
|
1353
1559
|
#
|
|
1354
1560
|
# The runner itself reads only process.env, so env loading lives here in the hook
|
|
1355
1561
|
# wiring rather than inside the binary.
|
|
@@ -1364,6 +1570,16 @@ ${engagementResolutionShell()}
|
|
|
1364
1570
|
# the runner say what it found \u2014 its never-configured branch is loud and non-zero
|
|
1365
1571
|
# on purpose, and a wrapper that exited quietly here would hide it.
|
|
1366
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
|
|
1367
1583
|
set -a
|
|
1368
1584
|
. "$HALFCYCLE_ENV_FILE"
|
|
1369
1585
|
set +a
|
|
@@ -1550,19 +1766,36 @@ function generateCiStanza() {
|
|
|
1550
1766
|
# .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
|
|
1551
1767
|
# one \u2014 the only prerequisite is Node 20 to run the bundled binary.
|
|
1552
1768
|
#
|
|
1769
|
+
# THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
|
|
1770
|
+
# to look up, and no address to set. The \`permissions\` block below is the whole
|
|
1771
|
+
# configuration: it lets the job ask GitHub for a short-lived signed token naming
|
|
1772
|
+
# the repository it is running in, and Halfcycle trusts the name GitHub signs
|
|
1773
|
+
# rather than anything the job says about itself. That token is traded for a
|
|
1774
|
+
# credential that lives for minutes, and nothing is kept at either end.
|
|
1775
|
+
#
|
|
1776
|
+
# BOTH PERMISSION LINES, NOT JUST THE SECOND. Declaring any permission
|
|
1777
|
+
# replaces the defaults rather than adding to them, so a block naming only
|
|
1778
|
+
# \`id-token\` takes read access away from the checkout step and a private
|
|
1779
|
+
# repository stops checking out before the guard is reached. If your workflow
|
|
1780
|
+
# already has a \`permissions\` block, add \`id-token: write\` to it and leave the
|
|
1781
|
+
# rest alone.
|
|
1782
|
+
#
|
|
1783
|
+
# ONE THING TO DO FIRST, ONCE, ON YOUR OWN MACHINE. In this project, run
|
|
1784
|
+
#
|
|
1785
|
+
# npx halfcycle ci bind <owner>/<repo>
|
|
1786
|
+
#
|
|
1787
|
+
# naming this repository as GitHub spells it (for example acme/widgets). That
|
|
1788
|
+
# tells Halfcycle this project's CI runs from that repository, and it is the only
|
|
1789
|
+
# thing that makes a run mean anything: without it, Halfcycle has a signed
|
|
1790
|
+
# statement of which repository the job is in and no idea whose project that is.
|
|
1791
|
+
# The check says exactly that, and names the command, if you skip it.
|
|
1792
|
+
#
|
|
1553
1793
|
# THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
|
|
1554
1794
|
# product talks to has one address, the same for every user, and it ships in the
|
|
1555
1795
|
# tools you already have: the installer, the guard hook and the binary this job
|
|
1556
1796
|
# runs each know where to go. If something tells you to set a Halfcycle URL, it is
|
|
1557
1797
|
# out of date.
|
|
1558
1798
|
#
|
|
1559
|
-
# AND THERE IS NOTHING TO SET ON YOUR OWN MACHINE EITHER. \`npx halfcycle\` wrote
|
|
1560
|
-
# this engagement's credential to $HOME/.halfcycle, owner-only, outside every
|
|
1561
|
-
# checkout, and the guard hook reads it from there. A CI job is the one place that
|
|
1562
|
-
# store cannot be reached \u2014 no browser, no per-user home \u2014 which is why the two
|
|
1563
|
-
# names below exist at all, and why they are the only two: a credential, and the id
|
|
1564
|
-
# of your own engagement.
|
|
1565
|
-
#
|
|
1566
1799
|
# WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
|
|
1567
1800
|
# commits pushed straight to the default branch, which is the shape most
|
|
1568
1801
|
# Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
|
|
@@ -1573,6 +1806,9 @@ function generateCiStanza() {
|
|
|
1573
1806
|
#
|
|
1574
1807
|
# halfcycle-guard-ci:
|
|
1575
1808
|
# runs-on: ubuntu-latest
|
|
1809
|
+
# permissions:
|
|
1810
|
+
# contents: read
|
|
1811
|
+
# id-token: write
|
|
1576
1812
|
# steps:
|
|
1577
1813
|
# - uses: actions/checkout@v4
|
|
1578
1814
|
# with:
|
|
@@ -1584,20 +1820,15 @@ function generateCiStanza() {
|
|
|
1584
1820
|
# with:
|
|
1585
1821
|
# node-version: '20'
|
|
1586
1822
|
# - name: Halfcycle guard CI check
|
|
1587
|
-
# env:
|
|
1588
|
-
#
|
|
1589
|
-
# # token and its id. Both were printed by the installer that generated
|
|
1590
|
-
# # this file. There is no address to configure \u2014 the guard service this
|
|
1591
|
-
# # job talks to ships inside the binary below.
|
|
1592
|
-
# GUARD_SERVICE_TOKEN: \${{ secrets.GUARD_SERVICE_TOKEN }}
|
|
1593
|
-
# GUARD_ENGAGEMENT_ID: \${{ secrets.GUARD_ENGAGEMENT_ID }}
|
|
1823
|
+
# # No env block, on purpose: this step holds no secret. The permissions
|
|
1824
|
+
# # above are what authenticate it.
|
|
1594
1825
|
# run: node ./.halfcycle/bin/bin.bundle.mjs ci
|
|
1595
1826
|
#
|
|
1596
1827
|
# The job prints the diff base it used and how many files it evaluated, on every
|
|
1597
1828
|
# run. If that line says 0 files on a commit that changed something, the base is
|
|
1598
|
-
# wrong \u2014 set HALFCYCLE_DIFF_BASE in
|
|
1599
|
-
#
|
|
1600
|
-
#
|
|
1829
|
+
# wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
|
|
1830
|
+
# to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
|
|
1831
|
+
# right value).
|
|
1601
1832
|
#
|
|
1602
1833
|
# EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
|
|
1603
1834
|
# changes to it would be replaced the next time you run \`npx halfcycle\`. It is
|
|
@@ -2213,6 +2444,7 @@ var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped
|
|
|
2213
2444
|
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.";
|
|
2214
2445
|
var DEADLINE_SLACK_INTERVALS = 1;
|
|
2215
2446
|
var STILL_WAITING_MS = 15e3;
|
|
2447
|
+
var DEFAULT_SIGN_IN_REASON = "create an engagement";
|
|
2216
2448
|
function defaultWrite(text) {
|
|
2217
2449
|
process.stdout.write(text);
|
|
2218
2450
|
}
|
|
@@ -2402,7 +2634,7 @@ async function signIn(serviceUrl, deps = {}) {
|
|
|
2402
2634
|
if (isNonInteractive(env)) {
|
|
2403
2635
|
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_NON_INTERACTIVE_MESSAGE);
|
|
2404
2636
|
}
|
|
2405
|
-
write(`[halfcycle] Halfcycle needs an account before it can
|
|
2637
|
+
write(`[halfcycle] Halfcycle needs an account before it can ${deps.reason ?? DEFAULT_SIGN_IN_REASON} \u2014 signing you in through your browser.
|
|
2406
2638
|
`);
|
|
2407
2639
|
const bind = await bindLoopback(env);
|
|
2408
2640
|
let closed = false;
|
|
@@ -2559,6 +2791,12 @@ async function joinEngagement(baseUrl, engagementId, credential) {
|
|
|
2559
2791
|
route: "POST /engagements/:engagementId/join"
|
|
2560
2792
|
});
|
|
2561
2793
|
}
|
|
2794
|
+
function withDeclaredTelemetryKey(raw) {
|
|
2795
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
2796
|
+
return null;
|
|
2797
|
+
const obj = raw;
|
|
2798
|
+
return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
|
|
2799
|
+
}
|
|
2562
2800
|
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
2563
2801
|
const bearer = credential?.trim();
|
|
2564
2802
|
let res;
|
|
@@ -2583,7 +2821,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2583
2821
|
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
|
|
2584
2822
|
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2585
2823
|
}
|
|
2586
|
-
const
|
|
2824
|
+
const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
|
|
2825
|
+
const declared = engagementCredentialResponseSchema.safeParse(candidate);
|
|
2826
|
+
const body = declared.success ? declared.data : candidate;
|
|
2587
2827
|
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
2588
2828
|
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2589
2829
|
}
|
|
@@ -2602,6 +2842,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2602
2842
|
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.`);
|
|
2603
2843
|
}
|
|
2604
2844
|
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
2845
|
+
if (!declared.success) {
|
|
2846
|
+
const fields = declared.error.issues.map((issue) => {
|
|
2847
|
+
const unrecognised = issue.keys;
|
|
2848
|
+
if (unrecognised && unrecognised.length > 0) {
|
|
2849
|
+
return `unexpected: ${unrecognised.map(String).join(", ")}`;
|
|
2850
|
+
}
|
|
2851
|
+
return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
|
|
2852
|
+
}).join(", ");
|
|
2853
|
+
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.`);
|
|
2854
|
+
}
|
|
2605
2855
|
return {
|
|
2606
2856
|
engagementId: body.engagementId,
|
|
2607
2857
|
sessionToken: body.sessionToken,
|
|
@@ -2940,7 +3190,8 @@ function currentEnvironment(quiet2) {
|
|
|
2940
3190
|
// dist/confirm-identity.js
|
|
2941
3191
|
var IDENTITY_TIMEOUT_MS = 5e3;
|
|
2942
3192
|
var IDENTITY_DECLINED = "identity-declined";
|
|
2943
|
-
|
|
3193
|
+
var CREDENTIAL_REFUSED = 401;
|
|
3194
|
+
async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
2944
3195
|
const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
|
|
2945
3196
|
const controller = new AbortController();
|
|
2946
3197
|
const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
|
|
@@ -2950,15 +3201,18 @@ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
|
2950
3201
|
signal: controller.signal
|
|
2951
3202
|
});
|
|
2952
3203
|
if (!res.ok)
|
|
2953
|
-
return void 0;
|
|
3204
|
+
return { identity: void 0, refused: res.status === CREDENTIAL_REFUSED };
|
|
2954
3205
|
const parsed = safeParseAccountIdentity(await res.json());
|
|
2955
|
-
return parsed.success ? parsed.data : void 0;
|
|
3206
|
+
return { identity: parsed.success ? parsed.data : void 0, refused: false };
|
|
2956
3207
|
} catch {
|
|
2957
|
-
return void 0;
|
|
3208
|
+
return { identity: void 0, refused: false };
|
|
2958
3209
|
} finally {
|
|
2959
3210
|
clearTimeout(timer);
|
|
2960
3211
|
}
|
|
2961
3212
|
}
|
|
3213
|
+
async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
3214
|
+
return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
|
|
3215
|
+
}
|
|
2962
3216
|
function accountLabel(identity, storedAccountId, verbose2 = false) {
|
|
2963
3217
|
const accountId = identity?.accountId ?? storedAccountId;
|
|
2964
3218
|
if (identity?.email !== void 0 && identity.email.trim() !== "") {
|
|
@@ -3412,7 +3666,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3412
3666
|
"",
|
|
3413
3667
|
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
3414
3668
|
"",
|
|
3415
|
-
"> This Build Record was assembled automatically at phase close by
|
|
3669
|
+
"> 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.",
|
|
3416
3670
|
"",
|
|
3417
3671
|
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
3418
3672
|
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
@@ -3432,7 +3686,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3432
3686
|
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
3433
3687
|
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
3434
3688
|
"",
|
|
3435
|
-
guardsList ? `Guards evaluated (
|
|
3689
|
+
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.",
|
|
3436
3690
|
"",
|
|
3437
3691
|
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
3438
3692
|
"",
|
|
@@ -3444,7 +3698,7 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
3444
3698
|
""
|
|
3445
3699
|
];
|
|
3446
3700
|
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
3447
|
-
lines.push(`**
|
|
3701
|
+
lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
3448
3702
|
}
|
|
3449
3703
|
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"}`, "");
|
|
3450
3704
|
return lines.join("\n");
|
|
@@ -3837,15 +4091,115 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
|
|
|
3837
4091
|
return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
|
|
3838
4092
|
}
|
|
3839
4093
|
|
|
4094
|
+
// dist/ci-bind.js
|
|
4095
|
+
function parseCiRepositoryArg(arg) {
|
|
4096
|
+
const parts = arg.split("/");
|
|
4097
|
+
if (parts.length !== 2)
|
|
4098
|
+
return null;
|
|
4099
|
+
const [owner, repo] = parts;
|
|
4100
|
+
if (owner === void 0 || owner.trim() === "")
|
|
4101
|
+
return null;
|
|
4102
|
+
if (repo === void 0 || repo.trim() === "")
|
|
4103
|
+
return null;
|
|
4104
|
+
return { owner, repo };
|
|
4105
|
+
}
|
|
4106
|
+
var CiBindRefused = class extends Error {
|
|
4107
|
+
status;
|
|
4108
|
+
constructor(status, message) {
|
|
4109
|
+
super(message);
|
|
4110
|
+
this.status = status;
|
|
4111
|
+
this.name = "CiBindRefused";
|
|
4112
|
+
}
|
|
4113
|
+
/** Is this the ONE arm signing in again can fix — see this file's header. */
|
|
4114
|
+
get authRefused() {
|
|
4115
|
+
return this.status === 401;
|
|
4116
|
+
}
|
|
4117
|
+
/**
|
|
4118
|
+
* Is this "ours to fix, try again", not "yours to fix"? The question is not
|
|
4119
|
+
* mechanical (did a handler run) — it is what a developer reading the message
|
|
4120
|
+
* does next. `502`/`504` are the reverse proxy in front of the published origin
|
|
4121
|
+
* answering for a control plane that is down or slow, with an HTML body this
|
|
4122
|
+
* file's own `requestBinding` cannot parse, so the message would otherwise
|
|
4123
|
+
* degrade to a bare "the URL returned 502". `503` is `resolveAccount`'s own
|
|
4124
|
+
* fail-closed refusal when it could not reach the token store — the caller's
|
|
4125
|
+
* credential was never actually checked, and its posture is emphatic this is NOT
|
|
4126
|
+
* a "no". **`500` belongs beside them, not with the 4xx arms.** It is an
|
|
4127
|
+
* unhandled throw: nothing decided the request's merits, and the bind may even
|
|
4128
|
+
* have half-happened if the throw landed after a commit. On this route the
|
|
4129
|
+
* body is the same degraded "the URL returned 500" a 502/504 produces — an
|
|
4130
|
+
* internal path echoed back with no advice — so `refused (500)` would send a
|
|
4131
|
+
* developer to re-check ownership, the repository name and whether it is bound
|
|
4132
|
+
* elsewhere: every 4xx question, none of them this status's actual cause.
|
|
4133
|
+
*
|
|
4134
|
+
* So this is every `5xx`, not an enumerated set of three — the boundary is
|
|
4135
|
+
* "did any handler decide yes or no", and a 4xx is the only family that ever did.
|
|
4136
|
+
*/
|
|
4137
|
+
get unavailable() {
|
|
4138
|
+
return this.status >= 500 && this.status < 600;
|
|
4139
|
+
}
|
|
4140
|
+
};
|
|
4141
|
+
async function requestBinding(method, serviceUrl, engagementId, repository, credential, noun) {
|
|
4142
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/ci-bindings/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
|
|
4143
|
+
let res;
|
|
4144
|
+
try {
|
|
4145
|
+
res = await fetch(url, { method, headers: { authorization: `Bearer ${credential}` } });
|
|
4146
|
+
} catch (err) {
|
|
4147
|
+
throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. Nothing was ${noun}.`);
|
|
4148
|
+
}
|
|
4149
|
+
if (res.status === 204)
|
|
4150
|
+
return;
|
|
4151
|
+
const body = await res.json().catch(() => null);
|
|
4152
|
+
const message = typeof body?.message === "string" ? body.message : `${url} returned ${res.status}.`;
|
|
4153
|
+
throw new CiBindRefused(res.status, message);
|
|
4154
|
+
}
|
|
4155
|
+
async function setCiBinding(action, serviceUrl, engagementId, repository, deps = {}) {
|
|
4156
|
+
const noun = action === "bind" ? "bound" : "unbound";
|
|
4157
|
+
const signInDeps = {
|
|
4158
|
+
...deps,
|
|
4159
|
+
reason: deps.reason ?? (action === "bind" ? "trust this repository for this engagement's CI" : "stop trusting this repository for this engagement's CI")
|
|
4160
|
+
};
|
|
4161
|
+
let credential = await obtainCredential(serviceUrl, signInDeps);
|
|
4162
|
+
for (; ; ) {
|
|
4163
|
+
try {
|
|
4164
|
+
await requestBinding(action === "bind" ? "PUT" : "DELETE", serviceUrl, engagementId, repository, credential.credential, noun);
|
|
4165
|
+
return;
|
|
4166
|
+
} catch (err) {
|
|
4167
|
+
if (!(err instanceof CiBindRefused) || !err.authRefused)
|
|
4168
|
+
throw err;
|
|
4169
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
|
|
4170
|
+
if (replacement === null) {
|
|
4171
|
+
throw new SignInRefused(`ci-${action}-${err.status}`, `${err.message} ${refusedCredentialRemedy(credential.source)} Nothing was ${noun}.`);
|
|
4172
|
+
}
|
|
4173
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
4174
|
+
`);
|
|
4175
|
+
credential = replacement;
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
function bindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4180
|
+
return setCiBinding("bind", serviceUrl, engagementId, repository, deps);
|
|
4181
|
+
}
|
|
4182
|
+
function unbindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4183
|
+
return setCiBinding("unbind", serviceUrl, engagementId, repository, deps);
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3840
4186
|
// dist/cli-contract.js
|
|
3841
|
-
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase"];
|
|
4187
|
+
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "ci"];
|
|
3842
4188
|
var CONTRACTS = [
|
|
3843
|
-
// `install`, `check-drift
|
|
3844
|
-
// flags. They are declared so the verb set has ONE home — a remedy
|
|
3845
|
-
// this CLI does not have is the same defect as one missing a flag.
|
|
4189
|
+
// `install`, `check-drift`, `build-record` and `ci` take positionals and no
|
|
4190
|
+
// required flags. They are declared so the verb set has ONE home — a remedy
|
|
4191
|
+
// naming a verb this CLI does not have is the same defect as one missing a flag.
|
|
4192
|
+
//
|
|
4193
|
+
// `ci`'s two forms (`ci bind <owner>/<repo>`, `ci unbind <owner>/<repo>`) take a
|
|
4194
|
+
// sub-action and a repository as POSITIONALS, not flags — this table answers only
|
|
4195
|
+
// *which flags must an invocation carry*, and neither form has one (T-11,
|
|
4196
|
+
// `ci-oidc-token-exchange`). The route it calls declares no wire shape either
|
|
4197
|
+
// (path segments in, `204` out — T-05), so there is no schema for an argument
|
|
4198
|
+
// here to disagree with.
|
|
3846
4199
|
{ verb: "install", required: [], conditional: [] },
|
|
3847
4200
|
{ verb: "check-drift", required: [], conditional: [] },
|
|
3848
4201
|
{ verb: "build-record", required: [], conditional: [] },
|
|
4202
|
+
{ verb: "ci", required: [], conditional: [] },
|
|
3849
4203
|
{
|
|
3850
4204
|
verb: "open-phase",
|
|
3851
4205
|
required: [
|
|
@@ -3879,7 +4233,7 @@ var CONTRACTS = [
|
|
|
3879
4233
|
{
|
|
3880
4234
|
flag: "--verdict",
|
|
3881
4235
|
takesValue: true,
|
|
3882
|
-
missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict
|
|
4236
|
+
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.`
|
|
3883
4237
|
},
|
|
3884
4238
|
{
|
|
3885
4239
|
flag: "--actor",
|
|
@@ -4038,10 +4392,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
|
|
|
4038
4392
|
fact("Claude Code", probes.claudeCodeVersion()),
|
|
4039
4393
|
fact("Project", project, name),
|
|
4040
4394
|
fact("Status", status, installed === void 0 ? "new" : "installed"),
|
|
4041
|
-
// `Website`, NOT `Plane`
|
|
4042
|
-
//
|
|
4043
|
-
//
|
|
4044
|
-
// answers *which Halfcycle is this*, and an address is the answer they recognise.
|
|
4395
|
+
// `Website`, NOT `Plane` — see this function's docblock. (No issue number here:
|
|
4396
|
+
// a comment beside a property survives into the published entry, unlike a
|
|
4397
|
+
// leading docblock, which esbuild drops.)
|
|
4045
4398
|
fact("Website", probes.websiteHost(env))
|
|
4046
4399
|
].filter((f) => f !== void 0);
|
|
4047
4400
|
return {
|
|
@@ -4121,6 +4474,8 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
|
|
|
4121
4474
|
halfcycle build-record <phase> [--repo <root>]
|
|
4122
4475
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
4123
4476
|
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
4477
|
+
halfcycle ci bind <owner>/<repo> [--repo <root>]
|
|
4478
|
+
halfcycle ci unbind <owner>/<repo> [--repo <root>]
|
|
4124
4479
|
|
|
4125
4480
|
--quiet / -q: print no opening banner (any command)
|
|
4126
4481
|
--verbose / -v: print full run detail (paths written, merged, skipped) on install
|
|
@@ -4135,7 +4490,7 @@ function isHalfcycleMonorepo(dir) {
|
|
|
4135
4490
|
}
|
|
4136
4491
|
async function main() {
|
|
4137
4492
|
const [cmd, ...rest] = args;
|
|
4138
|
-
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
|
|
4493
|
+
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase|ci)$/.test(cmd);
|
|
4139
4494
|
const installArm = cmd === "install" || cmd === void 0 || bareTarget;
|
|
4140
4495
|
const positionals = cmd === "install" ? rest : args;
|
|
4141
4496
|
const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
|
|
@@ -4176,7 +4531,9 @@ ${USAGE}`);
|
|
|
4176
4531
|
}
|
|
4177
4532
|
const pinned = readPinnedEngagement(targetRepo);
|
|
4178
4533
|
const requestedId = engagementIdArg ?? pinned?.engagementId;
|
|
4179
|
-
const
|
|
4534
|
+
const held = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
4535
|
+
const heldAnswer = held !== void 0 ? await askAccount(held.serviceUrl, held.token) : void 0;
|
|
4536
|
+
const reusable = heldAnswer?.refused === true ? void 0 : held;
|
|
4180
4537
|
let engagementId;
|
|
4181
4538
|
let credential;
|
|
4182
4539
|
let notThisAccountMessage;
|
|
@@ -4322,7 +4679,7 @@ ${USAGE}`);
|
|
|
4322
4679
|
`);
|
|
4323
4680
|
}
|
|
4324
4681
|
reportClaudeCodeVersion(verbose);
|
|
4325
|
-
const identity = actingIdentity ?? (credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
|
|
4682
|
+
const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
|
|
4326
4683
|
const written = result.writtenPaths.length;
|
|
4327
4684
|
const merged = result.mergedPaths.length;
|
|
4328
4685
|
process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
|
|
@@ -4335,6 +4692,8 @@ ${USAGE}`);
|
|
|
4335
4692
|
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
4336
4693
|
`);
|
|
4337
4694
|
process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
|
|
4695
|
+
`);
|
|
4696
|
+
process.stdout.write(`[halfcycle] To check changes in CI too: run \`npx halfcycle ci bind <owner>/<repo>\` in this folder once, then paste the job from .halfcycle/ci-stanza.yml into a workflow \u2014 there is no secret to store.
|
|
4338
4697
|
`);
|
|
4339
4698
|
}
|
|
4340
4699
|
if (credential) {
|
|
@@ -4593,6 +4952,69 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
|
|
|
4593
4952
|
return;
|
|
4594
4953
|
}
|
|
4595
4954
|
process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
|
|
4955
|
+
`);
|
|
4956
|
+
process.exit(1);
|
|
4957
|
+
}
|
|
4958
|
+
return;
|
|
4959
|
+
}
|
|
4960
|
+
if (cmd === "ci") {
|
|
4961
|
+
const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
|
|
4962
|
+
const positionals2 = rest.filter((token, i) => !token.startsWith("--") && !(i > 0 && rest[i - 1] === "--repo"));
|
|
4963
|
+
const action = positionals2[0];
|
|
4964
|
+
const repositoryArg = positionals2[1];
|
|
4965
|
+
if (action !== "bind" && action !== "unbind") {
|
|
4966
|
+
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");
|
|
4967
|
+
process.exit(2);
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4970
|
+
if (repositoryArg === void 0) {
|
|
4971
|
+
process.stderr.write(`halfcycle ci ${action}: name the repository, as owner/repo.
|
|
4972
|
+
`);
|
|
4973
|
+
process.exit(2);
|
|
4974
|
+
return;
|
|
4975
|
+
}
|
|
4976
|
+
const repository = parseCiRepositoryArg(repositoryArg);
|
|
4977
|
+
if (repository === null) {
|
|
4978
|
+
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.
|
|
4979
|
+
`);
|
|
4980
|
+
process.exit(2);
|
|
4981
|
+
return;
|
|
4982
|
+
}
|
|
4983
|
+
const pin = readBundlePin(repoRoot);
|
|
4984
|
+
if (pin === null || pin.engagementId === "") {
|
|
4985
|
+
process.stderr.write(`halfcycle ci ${action}: ${join11(repoRoot, ".halfcycle", "bundle.json")} names no Halfcycle engagement. Run \`npx halfcycle\` here first.
|
|
4986
|
+
`);
|
|
4987
|
+
process.exit(1);
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
try {
|
|
4991
|
+
const controlOrigin = resolveControlOrigin(process.env);
|
|
4992
|
+
const label = `${repository.owner}/${repository.repo}`;
|
|
4993
|
+
if (action === "bind") {
|
|
4994
|
+
await bindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4995
|
+
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.
|
|
4996
|
+
`);
|
|
4997
|
+
} else {
|
|
4998
|
+
await unbindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4999
|
+
process.stdout.write(`[halfcycle] ${label} no longer trusts this engagement's CI.
|
|
5000
|
+
`);
|
|
5001
|
+
}
|
|
5002
|
+
process.exit(0);
|
|
5003
|
+
} catch (err) {
|
|
5004
|
+
if (err instanceof SignInRefused) {
|
|
5005
|
+
process.stderr.write(`halfcycle ci ${action}: ${err.message}
|
|
5006
|
+
`);
|
|
5007
|
+
process.exit(1);
|
|
5008
|
+
return;
|
|
5009
|
+
}
|
|
5010
|
+
if (err instanceof CiBindRefused) {
|
|
5011
|
+
const label = err.unavailable ? `temporarily unavailable (${err.status})` : `refused (${err.status})`;
|
|
5012
|
+
process.stderr.write(`halfcycle ci ${action}: ${label}. ${err.message}
|
|
5013
|
+
`);
|
|
5014
|
+
process.exit(1);
|
|
5015
|
+
return;
|
|
5016
|
+
}
|
|
5017
|
+
process.stderr.write(`halfcycle ci ${action} failed: ${err instanceof Error ? err.message : String(err)}
|
|
4596
5018
|
`);
|
|
4597
5019
|
process.exit(1);
|
|
4598
5020
|
}
|