halfcycle 0.3.7 → 0.3.8
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 +94 -15
- package/dist/bin.js +193 -71
- package/dist/bin.js.map +2 -2
- package/dist/close-phase.d.ts.map +1 -1
- package/dist/control-origin.d.ts +84 -0
- package/dist/control-origin.d.ts.map +1 -0
- package/dist/create-engagement.d.ts +3 -1
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/index.js +114 -6
- package/dist/index.js.map +2 -2
- package/dist/install.d.ts.map +1 -1
- package/package.json +5 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halfcycle",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Halfcycle Method bundle — resolution-stub slash commands, remote method-delivery registration, and governance-hook wiring for a Halfcycle engagement repo. It ships NO worker role files: a project's roles are authored from that project's own recorded decisions at orchestration kickoff (FX-2, W3-F-27).",
|
|
5
5
|
"commands": [
|
|
6
6
|
{ "name": "halfcycle-setup", "path": "../commands/halfcycle-setup.md" },
|
package/bin/bin.bundle.mjs
CHANGED
|
@@ -14608,7 +14608,31 @@ var firedGuardSchema = external_exports.object({
|
|
|
14608
14608
|
guardId: external_exports.string(),
|
|
14609
14609
|
patternRef: external_exports.string(),
|
|
14610
14610
|
severity: severitySchema,
|
|
14611
|
-
explanation: external_exports.string()
|
|
14611
|
+
explanation: external_exports.string(),
|
|
14612
|
+
/**
|
|
14613
|
+
* The repo-relative path of the file this firing was decided on. The matcher
|
|
14614
|
+
* has the file live at the moment it decides to fire; this is the field that
|
|
14615
|
+
* carries the answer out. It is the CLIENT's own coordinate — the wire type
|
|
14616
|
+
* still has no field for a matcher body, a channel, a version, a scope or a
|
|
14617
|
+
* guard's provenance, and physically cannot carry one.
|
|
14618
|
+
*
|
|
14619
|
+
* THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
|
|
14620
|
+
* firing produced from this contract version onward carries `path`, so on the
|
|
14621
|
+
* WIRE, absence means exactly one thing — a response from a guard service
|
|
14622
|
+
* older than this contract. It is declared optional because a client's guard
|
|
14623
|
+
* runner is built from its own checkout and calls a service that may be older
|
|
14624
|
+
* than it; a required field would make that pairing unparseable.
|
|
14625
|
+
*
|
|
14626
|
+
* The rule does NOT transfer to a store that persists a firing, where an
|
|
14627
|
+
* absent path has a second legitimate meaning: a row written before the
|
|
14628
|
+
* column existed. A persisted NULL is therefore not a writer bug.
|
|
14629
|
+
*
|
|
14630
|
+
* NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
|
|
14631
|
+
* file. A consumer that maps it onto a coordinate with its own path rule must
|
|
14632
|
+
* safe-parse it and decide what to do with a refusal, rather than assume the
|
|
14633
|
+
* two shapes agree.
|
|
14634
|
+
*/
|
|
14635
|
+
path: external_exports.string().optional()
|
|
14612
14636
|
}).strict();
|
|
14613
14637
|
var resultEnvelopeSchema = external_exports.object({
|
|
14614
14638
|
guardsFired: external_exports.array(firedGuardSchema),
|
|
@@ -16018,6 +16042,52 @@ This is not a service outage. It means the guard has never run here \u2014 not o
|
|
|
16018
16042
|
${remedy}
|
|
16019
16043
|
`;
|
|
16020
16044
|
}
|
|
16045
|
+
var CREDENTIAL_REJECTED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
|
|
16046
|
+
function classifyFailure(failure) {
|
|
16047
|
+
if (failure.kind === "contract-error") {
|
|
16048
|
+
return {
|
|
16049
|
+
kind: "contract",
|
|
16050
|
+
message: failure.message,
|
|
16051
|
+
label: "contract error",
|
|
16052
|
+
reason: `contract error: ${failure.message}`
|
|
16053
|
+
};
|
|
16054
|
+
}
|
|
16055
|
+
if (failure.kind === "wire-error" && CREDENTIAL_REJECTED_STATUSES.has(failure.statusCode)) {
|
|
16056
|
+
const label = `credential rejected (${failure.statusCode})`;
|
|
16057
|
+
return {
|
|
16058
|
+
kind: "credential",
|
|
16059
|
+
statusCode: failure.statusCode,
|
|
16060
|
+
message: failure.message,
|
|
16061
|
+
label,
|
|
16062
|
+
reason: `${label}: ${failure.message}`
|
|
16063
|
+
};
|
|
16064
|
+
}
|
|
16065
|
+
return {
|
|
16066
|
+
kind: "service",
|
|
16067
|
+
message: failure.message,
|
|
16068
|
+
// Unchanged wording for this class ON PURPOSE — a genuine outage still says
|
|
16069
|
+
// exactly what it said before, and still fails open locally. This task moved
|
|
16070
|
+
// ONE condition out of this branch; it did not redefine the branch.
|
|
16071
|
+
label: "infrastructure failure",
|
|
16072
|
+
reason: `infrastructure failure: ${failure.message}`
|
|
16073
|
+
};
|
|
16074
|
+
}
|
|
16075
|
+
var CREDENTIAL_REJECTED_EXIT_TOOL_USE = UNCONFIGURED_EXIT_TOOL_USE;
|
|
16076
|
+
var CREDENTIAL_REJECTED_EXIT_STOP_FAMILY = UNCONFIGURED_EXIT_STOP_FAMILY;
|
|
16077
|
+
function credentialRejectedMessage(statusCode, serviceMessage, remedy, label = "[Halfcycle]") {
|
|
16078
|
+
const forbidden = statusCode === 403;
|
|
16079
|
+
const statusName = forbidden ? "Forbidden" : "Unauthorized";
|
|
16080
|
+
const cause = forbidden ? `this credential is not authorised for the engagement this repository is sending` : `the credential this repository is sending is not one it accepts`;
|
|
16081
|
+
return `${label} GUARD CREDENTIAL REJECTED \u2014 the guard service answered and refused this credential.
|
|
16082
|
+
No evaluation was performed: the service returned ${statusCode} ${statusName} \u2014 ${serviceMessage}
|
|
16083
|
+
This is not a service outage. The guard service is up and replying; ${cause}, so this change was not checked.
|
|
16084
|
+
${remedy}
|
|
16085
|
+
`;
|
|
16086
|
+
}
|
|
16087
|
+
function emitCredentialRejected(statusCode, serviceMessage, exitCode) {
|
|
16088
|
+
writeSync(2, credentialRejectedMessage(statusCode, serviceMessage, HOOK_REMEDY));
|
|
16089
|
+
process.exitCode = exitCode;
|
|
16090
|
+
}
|
|
16021
16091
|
|
|
16022
16092
|
// dist/ci.js
|
|
16023
16093
|
async function runCi() {
|
|
@@ -16071,21 +16141,24 @@ async function runCi() {
|
|
|
16071
16141
|
changeSet
|
|
16072
16142
|
});
|
|
16073
16143
|
if (!clientResult.ok) {
|
|
16074
|
-
const
|
|
16075
|
-
const failureReason = isInfra ? `infrastructure failure: ${clientResult.message}` : `contract error: ${clientResult.message}`;
|
|
16144
|
+
const failure = classifyFailure(clientResult);
|
|
16076
16145
|
await emitRunRecord({
|
|
16077
16146
|
engagementId: guardEngagementId,
|
|
16078
16147
|
runType: "ci",
|
|
16079
16148
|
outcome: outcomeForClientFailure(clientResult.kind),
|
|
16080
|
-
failureReason,
|
|
16149
|
+
failureReason: failure.reason,
|
|
16081
16150
|
changeSet
|
|
16082
16151
|
});
|
|
16083
|
-
if (
|
|
16084
|
-
process.stderr.write(
|
|
16152
|
+
if (failure.kind === "credential") {
|
|
16153
|
+
process.stderr.write(credentialRejectedMessage(failure.statusCode, failure.message, CI_REMEDY, "[Halfcycle CI]"));
|
|
16154
|
+
return 1;
|
|
16155
|
+
}
|
|
16156
|
+
if (failure.kind === "service") {
|
|
16157
|
+
process.stderr.write(`[Halfcycle CI] Infrastructure failure: Guard service unavailable \u2014 ${failure.message}
|
|
16085
16158
|
This is NOT a guard violation. Fix the infrastructure issue and re-run.
|
|
16086
16159
|
`);
|
|
16087
16160
|
} else {
|
|
16088
|
-
process.stderr.write(`[Halfcycle CI] Infrastructure failure (contract error): ${
|
|
16161
|
+
process.stderr.write(`[Halfcycle CI] Infrastructure failure (contract error): ${failure.message}
|
|
16089
16162
|
`);
|
|
16090
16163
|
}
|
|
16091
16164
|
return 1;
|
|
@@ -16189,16 +16262,19 @@ async function runPostToolUse() {
|
|
|
16189
16262
|
changeSet
|
|
16190
16263
|
});
|
|
16191
16264
|
if (!clientResult.ok) {
|
|
16192
|
-
const
|
|
16193
|
-
const errorLabel = isInfra ? "infrastructure failure" : "contract error";
|
|
16265
|
+
const failure = classifyFailure(clientResult);
|
|
16194
16266
|
await emitRunRecord({
|
|
16195
16267
|
engagementId: guardEngagementId,
|
|
16196
16268
|
runType: "hook",
|
|
16197
16269
|
outcome: outcomeForClientFailure(clientResult.kind),
|
|
16198
|
-
failureReason:
|
|
16270
|
+
failureReason: failure.reason,
|
|
16199
16271
|
changeSet
|
|
16200
16272
|
});
|
|
16201
|
-
|
|
16273
|
+
if (failure.kind === "credential") {
|
|
16274
|
+
emitCredentialRejected(failure.statusCode, failure.message, CREDENTIAL_REJECTED_EXIT_TOOL_USE);
|
|
16275
|
+
return;
|
|
16276
|
+
}
|
|
16277
|
+
emitInfraWarning(failure.reason);
|
|
16202
16278
|
return;
|
|
16203
16279
|
}
|
|
16204
16280
|
const telemetryConfig = readTelemetryConfig();
|
|
@@ -16442,17 +16518,20 @@ async function runSessionDiff(input, kind) {
|
|
|
16442
16518
|
changeSet
|
|
16443
16519
|
});
|
|
16444
16520
|
if (!clientResult.ok) {
|
|
16445
|
-
const
|
|
16446
|
-
const errorLabel = isInfra ? "infrastructure failure" : "contract error";
|
|
16521
|
+
const failure = classifyFailure(clientResult);
|
|
16447
16522
|
await emitRunRecord({
|
|
16448
16523
|
engagementId: guardEngagementId,
|
|
16449
16524
|
runType: "hook",
|
|
16450
16525
|
outcome: outcomeForClientFailure(clientResult.kind),
|
|
16451
|
-
failureReason:
|
|
16526
|
+
failureReason: failure.reason,
|
|
16452
16527
|
changeSet,
|
|
16453
16528
|
diffBase
|
|
16454
16529
|
});
|
|
16455
|
-
|
|
16530
|
+
if (failure.kind === "credential") {
|
|
16531
|
+
emitCredentialRejected(failure.statusCode, failure.message, CREDENTIAL_REJECTED_EXIT_STOP_FAMILY);
|
|
16532
|
+
return;
|
|
16533
|
+
}
|
|
16534
|
+
emitInfraWarning(failure.reason);
|
|
16456
16535
|
return;
|
|
16457
16536
|
}
|
|
16458
16537
|
const telemetryConfig = readTelemetryConfig();
|
package/dist/bin.js
CHANGED
|
@@ -375,7 +375,31 @@ var firedGuardSchema = z2.object({
|
|
|
375
375
|
guardId: z2.string(),
|
|
376
376
|
patternRef: z2.string(),
|
|
377
377
|
severity: severitySchema,
|
|
378
|
-
explanation: z2.string()
|
|
378
|
+
explanation: z2.string(),
|
|
379
|
+
/**
|
|
380
|
+
* The repo-relative path of the file this firing was decided on. The matcher
|
|
381
|
+
* has the file live at the moment it decides to fire; this is the field that
|
|
382
|
+
* carries the answer out. It is the CLIENT's own coordinate — the wire type
|
|
383
|
+
* still has no field for a matcher body, a channel, a version, a scope or a
|
|
384
|
+
* guard's provenance, and physically cannot carry one.
|
|
385
|
+
*
|
|
386
|
+
* THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
|
|
387
|
+
* firing produced from this contract version onward carries `path`, so on the
|
|
388
|
+
* WIRE, absence means exactly one thing — a response from a guard service
|
|
389
|
+
* older than this contract. It is declared optional because a client's guard
|
|
390
|
+
* runner is built from its own checkout and calls a service that may be older
|
|
391
|
+
* than it; a required field would make that pairing unparseable.
|
|
392
|
+
*
|
|
393
|
+
* The rule does NOT transfer to a store that persists a firing, where an
|
|
394
|
+
* absent path has a second legitimate meaning: a row written before the
|
|
395
|
+
* column existed. A persisted NULL is therefore not a writer bug.
|
|
396
|
+
*
|
|
397
|
+
* NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
|
|
398
|
+
* file. A consumer that maps it onto a coordinate with its own path rule must
|
|
399
|
+
* safe-parse it and decide what to do with a refusal, rather than assume the
|
|
400
|
+
* two shapes agree.
|
|
401
|
+
*/
|
|
402
|
+
path: z2.string().optional()
|
|
379
403
|
}).strict();
|
|
380
404
|
var resultEnvelopeSchema = z2.object({
|
|
381
405
|
guardsFired: z2.array(firedGuardSchema),
|
|
@@ -489,6 +513,7 @@ var acceptanceOutcomeSchema = z4.object({
|
|
|
489
513
|
findings: z4.number().int().nonnegative(),
|
|
490
514
|
findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
|
|
491
515
|
}).strict();
|
|
516
|
+
var PHASE_CLOSE_REASON_MAX = 1900;
|
|
492
517
|
var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
|
|
493
518
|
z4.object({
|
|
494
519
|
decision: z4.literal("done-when-met"),
|
|
@@ -499,8 +524,13 @@ var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
|
|
|
499
524
|
decision: z4.literal("override"),
|
|
500
525
|
/** Who is closing it anyway. */
|
|
501
526
|
actor: z4.string(),
|
|
502
|
-
/**
|
|
503
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Why, in their own words. Bounded: the reason is kept on the phase's
|
|
529
|
+
* permanent record of its own close, and that record refuses anything
|
|
530
|
+
* longer — so an over-long reason is refused here, before the close is
|
|
531
|
+
* attempted, rather than failing the close itself.
|
|
532
|
+
*/
|
|
533
|
+
reason: z4.string().max(PHASE_CLOSE_REASON_MAX)
|
|
504
534
|
}).strict()
|
|
505
535
|
]);
|
|
506
536
|
var acceptPhaseRequestSchema = z4.object({
|
|
@@ -586,7 +616,18 @@ var DEVICE_AUTH_STATUS = {
|
|
|
586
616
|
/** Confirm: the shared confirmation secret was missing or wrong. 401. */
|
|
587
617
|
UNAUTHORIZED: "unauthorized",
|
|
588
618
|
/** Confirm: the code was already approved, declined or claimed. 409. */
|
|
589
|
-
ALREADY_DECIDED: "already-decided"
|
|
619
|
+
ALREADY_DECIDED: "already-decided",
|
|
620
|
+
/**
|
|
621
|
+
* Confirm: an approval named a signed-in identity whose account has not accepted
|
|
622
|
+
* the terms of service and privacy policy currently in force (T-18, #588). 400.
|
|
623
|
+
*
|
|
624
|
+
* NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
|
|
625
|
+
* says nothing about the code itself — the code is still `pending` after this
|
|
626
|
+
* response, exactly as it was before the request, so the SAME userCode can be
|
|
627
|
+
* confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
|
|
628
|
+
* `acceptTerms` is how a second confirm says it did.
|
|
629
|
+
*/
|
|
630
|
+
TERMS_REQUIRED: "terms-required"
|
|
590
631
|
};
|
|
591
632
|
var DEVICE_POLL_REFUSALS = [
|
|
592
633
|
DEVICE_AUTH_STATUS.DECLINED,
|
|
@@ -619,7 +660,8 @@ var deviceAuthRefusalSchema = z5.object({
|
|
|
619
660
|
DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
|
|
620
661
|
DEVICE_AUTH_STATUS.NOT_CONFIGURED,
|
|
621
662
|
DEVICE_AUTH_STATUS.UNAUTHORIZED,
|
|
622
|
-
DEVICE_AUTH_STATUS.ALREADY_DECIDED
|
|
663
|
+
DEVICE_AUTH_STATUS.ALREADY_DECIDED,
|
|
664
|
+
DEVICE_AUTH_STATUS.TERMS_REQUIRED
|
|
623
665
|
]),
|
|
624
666
|
message: z5.string().min(1)
|
|
625
667
|
}).strict();
|
|
@@ -632,7 +674,8 @@ var deviceConfirmRequestSchema = z5.object({
|
|
|
632
674
|
userCode: z5.string().min(1),
|
|
633
675
|
decision: z5.enum(["approve", "decline"]),
|
|
634
676
|
externalAuthId: z5.string().min(1).optional(),
|
|
635
|
-
email: z5.string().optional()
|
|
677
|
+
email: z5.string().optional(),
|
|
678
|
+
acceptTerms: z5.boolean().optional()
|
|
636
679
|
}).strict();
|
|
637
680
|
var deviceConfirmRecordedSchema = z5.object({
|
|
638
681
|
status: z5.literal(DEVICE_AUTH_STATUS.RECORDED),
|
|
@@ -801,6 +844,18 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
|
|
|
801
844
|
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
802
845
|
return "written";
|
|
803
846
|
}
|
|
847
|
+
function writeOwned(targetAbsPath, targetRepoRoot, content) {
|
|
848
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
849
|
+
if (!isAllowlisted(rel)) {
|
|
850
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
851
|
+
}
|
|
852
|
+
if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
|
|
853
|
+
return "skipped";
|
|
854
|
+
}
|
|
855
|
+
mkdirSync4(dirname2(targetAbsPath), { recursive: true });
|
|
856
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
857
|
+
return "written";
|
|
858
|
+
}
|
|
804
859
|
function record(report, outcome, rel) {
|
|
805
860
|
const bucket = {
|
|
806
861
|
written: report.writtenPaths,
|
|
@@ -921,6 +976,42 @@ if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
|
921
976
|
HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
|
|
922
977
|
MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
|
|
923
978
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
979
|
+
|
|
980
|
+
# ---------------------------------------------------------------------------
|
|
981
|
+
# STANDING GUARD-COVERAGE STATEMENT (T-27).
|
|
982
|
+
#
|
|
983
|
+
# A repository with no credential on this machine is UNGUARDED: the PostToolUse
|
|
984
|
+
# hook will find nothing to evaluate with, and it says so once per edit, after
|
|
985
|
+
# the edit. This says it once, up front, before anything is written \u2014 which is
|
|
986
|
+
# the one thing a session-start hook can do that nothing else can.
|
|
987
|
+
#
|
|
988
|
+
# Everything below runs in a SUBSHELL: it sources a credential store, and a
|
|
989
|
+
# session-start hook must not leak an engagement's variables into whatever the
|
|
990
|
+
# editor runs next. Any failure inside it is swallowed; this script's exit
|
|
991
|
+
# status is 0 either way, and the marker above has already been written.
|
|
992
|
+
# ---------------------------------------------------------------------------
|
|
993
|
+
${engagementResolutionShell()}
|
|
994
|
+
|
|
995
|
+
(
|
|
996
|
+
if halfcycle_env_file "$REPO_ROOT"; then
|
|
997
|
+
set -a
|
|
998
|
+
. "$HALFCYCLE_ENV_FILE"
|
|
999
|
+
set +a
|
|
1000
|
+
fi
|
|
1001
|
+
hc_missing=""
|
|
1002
|
+
for hc_key in ${GUARD_ENV_KEYS.join(" ")}; do
|
|
1003
|
+
eval "hc_value=\\\${$hc_key:-}"
|
|
1004
|
+
if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
|
|
1005
|
+
done
|
|
1006
|
+
if [ -n "$hc_missing" ]; then
|
|
1007
|
+
echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
|
|
1008
|
+
echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
|
|
1009
|
+
echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
|
|
1010
|
+
echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
|
|
1011
|
+
echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
|
|
1012
|
+
echo "[halfcycle] and needs nothing configured first."
|
|
1013
|
+
fi
|
|
1014
|
+
) 2>/dev/null || true
|
|
924
1015
|
exit 0
|
|
925
1016
|
`;
|
|
926
1017
|
}
|
|
@@ -934,6 +1025,13 @@ function generateUserPromptReminderHook() {
|
|
|
934
1025
|
# one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
|
|
935
1026
|
# test governs it \u2014 reword there and here together, in the same commit.
|
|
936
1027
|
#
|
|
1028
|
+
# SILENT WHEN THE TOOL IT NAMES IS NOT THERE. The sentence tells the session to
|
|
1029
|
+
# call halfcycle_resolve, which arrives over the Halfcycle MCP server registered
|
|
1030
|
+
# in .mcp.json. If this repository carries no such registration, the instruction
|
|
1031
|
+
# cannot be followed, and a per-prompt instruction that cannot be followed is
|
|
1032
|
+
# worse than silence. The standing "no coverage" statement belongs in the
|
|
1033
|
+
# session-start banner, which says it once; this hook just stops talking.
|
|
1034
|
+
#
|
|
937
1035
|
# FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
|
|
938
1036
|
# cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
|
|
939
1037
|
# hold the turn). But a write failure here (a closed stdout, say) must still
|
|
@@ -941,6 +1039,26 @@ function generateUserPromptReminderHook() {
|
|
|
941
1039
|
# than propagate.
|
|
942
1040
|
trap 'exit 0' ERR
|
|
943
1041
|
set -e
|
|
1042
|
+
|
|
1043
|
+
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
1044
|
+
# session's cwd \u2014 a session started in a subdirectory must find the same root
|
|
1045
|
+
# .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
|
|
1046
|
+
HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
1047
|
+
HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
|
|
1048
|
+
HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
|
|
1049
|
+
|
|
1050
|
+
# Presence of the FILE is not enough \u2014 a developer's own .mcp.json with no
|
|
1051
|
+
# Halfcycle server in it registers no halfcycle_resolve. \`grep\` is guarded with
|
|
1052
|
+
# \`|| true\` because a non-match is a status, not an error, and \`set -e\` would
|
|
1053
|
+
# otherwise route it through the trap (same outcome here, but by accident).
|
|
1054
|
+
HC_REGISTERED=""
|
|
1055
|
+
if [ -f "$HC_REGISTRATION" ]; then
|
|
1056
|
+
HC_REGISTERED=$(tr -d '\\n' < "$HC_REGISTRATION" | grep -c '"${MCP_SERVER_KEY}"[[:space:]]*:' || true)
|
|
1057
|
+
fi
|
|
1058
|
+
if [ -z "$HC_REGISTERED" ] || [ "$HC_REGISTERED" = "0" ]; then
|
|
1059
|
+
exit 0
|
|
1060
|
+
fi
|
|
1061
|
+
|
|
944
1062
|
printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
|
|
945
1063
|
exit 0
|
|
946
1064
|
`;
|
|
@@ -1022,8 +1140,13 @@ GUARD_ENGAGEMENT_ID=<argType:runtime>
|
|
|
1022
1140
|
# TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
|
|
1023
1141
|
# CONTROL plane \u2014 it serves engagement creation and the board's first-visit
|
|
1024
1142
|
# code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
|
|
1025
|
-
# nothing else here.
|
|
1026
|
-
#
|
|
1143
|
+
# nothing else here. The platform reports the second on create, so it is written
|
|
1144
|
+
# for you and .mcp.json carries the composed address.
|
|
1145
|
+
#
|
|
1146
|
+
# NEITHER IS SOMETHING YOU NEED TO SET (T-27). The installer talks to Halfcycle's
|
|
1147
|
+
# own control plane by default; HALFCYCLE_SERVICE_URL OVERRIDES that address and
|
|
1148
|
+
# exists for a self-hosted or development plane. It is not a prerequisite for
|
|
1149
|
+
# \`npx halfcycle\`, and nothing in the product asks you to find its value.
|
|
1027
1150
|
HALFCYCLE_SERVICE_URL=<argType:runtime>
|
|
1028
1151
|
HALFCYCLE_MCP_URL=<argType:runtime>
|
|
1029
1152
|
HALFCYCLE_TOKEN=<argType:runtime>
|
|
@@ -1036,6 +1159,7 @@ CONTROL_TELEMETRY_URL=<argType:runtime>
|
|
|
1036
1159
|
`;
|
|
1037
1160
|
}
|
|
1038
1161
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1162
|
+
var MCP_SERVER_KEY = "halfcycle";
|
|
1039
1163
|
var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
1040
1164
|
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}"'`;
|
|
1041
1165
|
function generateMcpHeadersHelper() {
|
|
@@ -1148,7 +1272,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1148
1272
|
if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
|
|
1149
1273
|
base.mcpServers = {};
|
|
1150
1274
|
}
|
|
1151
|
-
base.mcpServers[
|
|
1275
|
+
base.mcpServers[MCP_SERVER_KEY] = {
|
|
1152
1276
|
type: "http",
|
|
1153
1277
|
url: mcpEndpointUrl(mcpOrigin),
|
|
1154
1278
|
headersHelper: MCP_HEADERS_HELPER_COMMAND
|
|
@@ -1370,7 +1494,7 @@ async function install(options) {
|
|
|
1370
1494
|
}
|
|
1371
1495
|
const vendoredBinSrc = resolveVendoredBinary();
|
|
1372
1496
|
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
1373
|
-
record(report,
|
|
1497
|
+
record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
|
|
1374
1498
|
const settingsPath = join5(targetRepo, ".claude", "settings.json");
|
|
1375
1499
|
const settingsPreexisted = existsSync3(settingsPath);
|
|
1376
1500
|
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
@@ -1451,6 +1575,18 @@ async function install(options) {
|
|
|
1451
1575
|
};
|
|
1452
1576
|
}
|
|
1453
1577
|
|
|
1578
|
+
// dist/control-origin.js
|
|
1579
|
+
var DEFAULT_CONTROL_ORIGIN = "https://control.halfcycle.ai";
|
|
1580
|
+
function resolveControlOrigin(env = process.env) {
|
|
1581
|
+
const configured = env["HALFCYCLE_SERVICE_URL"]?.trim();
|
|
1582
|
+
if (configured)
|
|
1583
|
+
return { origin: configured.replace(/\/+$/, ""), source: "environment" };
|
|
1584
|
+
return { origin: DEFAULT_CONTROL_ORIGIN, source: "default" };
|
|
1585
|
+
}
|
|
1586
|
+
function controlOriginNote(resolved) {
|
|
1587
|
+
return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane; set HALFCYCLE_SERVICE_URL to use another)`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1454
1590
|
// dist/create-engagement.js
|
|
1455
1591
|
var CreateEngagementRefused = class extends Error {
|
|
1456
1592
|
status;
|
|
@@ -1508,7 +1644,7 @@ function planeRefusal(detail) {
|
|
|
1508
1644
|
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
1509
1645
|
};
|
|
1510
1646
|
}
|
|
1511
|
-
var CONTROL_ORIGIN_HINT =
|
|
1647
|
+
var CONTROL_ORIGIN_HINT = `With nothing configured this CLI talks to ${DEFAULT_CONTROL_ORIGIN}; HALFCYCLE_SERVICE_URL overrides that, and is only for a self-hosted or development plane. Whichever is in use, it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you do not configure it.`;
|
|
1512
1648
|
async function createEngagement(baseUrl, name, credential) {
|
|
1513
1649
|
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
|
|
1514
1650
|
}
|
|
@@ -2500,7 +2636,7 @@ async function closePhase(credential, phase, outcome, close) {
|
|
|
2500
2636
|
}
|
|
2501
2637
|
const parsedClose = phaseCloseDecisionSchema.safeParse(close);
|
|
2502
2638
|
if (!parsedClose.success) {
|
|
2503
|
-
throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => i.message).join("; ")}.`);
|
|
2639
|
+
throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => `${i.path.join(".") || "(decision)"}: ${i.message}`).join("; ")}.`);
|
|
2504
2640
|
}
|
|
2505
2641
|
const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
|
|
2506
2642
|
let res;
|
|
@@ -2774,45 +2910,43 @@ ${USAGE}`);
|
|
|
2774
2910
|
return;
|
|
2775
2911
|
}
|
|
2776
2912
|
try {
|
|
2777
|
-
const
|
|
2778
|
-
const
|
|
2779
|
-
|
|
2913
|
+
const controlOrigin = resolveControlOrigin(process.env);
|
|
2914
|
+
const serviceUrl = controlOrigin.origin;
|
|
2915
|
+
process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
|
|
2916
|
+
`);
|
|
2917
|
+
const pinned = readPinnedEngagement(targetRepo);
|
|
2918
|
+
const requestedId = engagementIdArg ?? pinned?.engagementId;
|
|
2919
|
+
const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
2920
|
+
let engagementId;
|
|
2780
2921
|
let credential;
|
|
2781
|
-
if (pinned) {
|
|
2782
|
-
|
|
2922
|
+
if (reusable !== void 0 && pinned !== null) {
|
|
2923
|
+
engagementId = pinned.engagementId;
|
|
2924
|
+
credential = reusable;
|
|
2783
2925
|
process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
|
|
2784
2926
|
`);
|
|
2785
|
-
if (
|
|
2786
|
-
const joined = await joinPinnedEngagement(serviceUrl, pinned.engagementId);
|
|
2787
|
-
credential = {
|
|
2788
|
-
serviceUrl,
|
|
2789
|
-
token: joined.sessionToken,
|
|
2790
|
-
mcpUrl: joined.mcpUrl,
|
|
2791
|
-
guardUrl: joined.guardUrl,
|
|
2792
|
-
// T-10 — optional; absent on a plane that has not configured
|
|
2793
|
-
// CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
|
|
2794
|
-
// an absent value, never `undefined` verbatim.
|
|
2795
|
-
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
2796
|
-
};
|
|
2797
|
-
process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
|
|
2798
|
-
`);
|
|
2799
|
-
} else if (credential === void 0) {
|
|
2800
|
-
process.stdout.write(`[halfcycle] This machine holds no credential for it, and HALFCYCLE_SERVICE_URL is not
|
|
2801
|
-
[halfcycle] set \u2014 so there is no plane to ask for one. Nothing will be minted, and the
|
|
2802
|
-
[halfcycle] board link and MCP calls will not work until it is. Looked in
|
|
2803
|
-
[halfcycle] ${engagementEnvPath(pinned.engagementId)}.
|
|
2804
|
-
[halfcycle] Set that variable and re-run to get a credential of your own for this
|
|
2805
|
-
[halfcycle] engagement. To start a NEW engagement instead, remove
|
|
2806
|
-
[halfcycle] .halfcycle/bundle.json first.
|
|
2807
|
-
`);
|
|
2808
|
-
} else if (pinned.fromLegacyEnvLocal) {
|
|
2927
|
+
if (pinned.fromLegacyEnvLocal) {
|
|
2809
2928
|
process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
|
|
2810
2929
|
[halfcycle] credentials moved out of the tree. It is being copied to
|
|
2811
2930
|
[halfcycle] ${engagementEnvPath(pinned.engagementId)} and taken back out of .env.local.
|
|
2812
2931
|
[halfcycle] Anything else that file holds is untouched.
|
|
2813
2932
|
`);
|
|
2814
2933
|
}
|
|
2815
|
-
} else if (
|
|
2934
|
+
} else if (requestedId !== void 0) {
|
|
2935
|
+
const joined = await joinPinnedEngagement(serviceUrl, requestedId);
|
|
2936
|
+
engagementId = joined.engagementId;
|
|
2937
|
+
credential = {
|
|
2938
|
+
serviceUrl,
|
|
2939
|
+
token: joined.sessionToken,
|
|
2940
|
+
mcpUrl: joined.mcpUrl,
|
|
2941
|
+
guardUrl: joined.guardUrl,
|
|
2942
|
+
// T-10 — optional; absent on a plane that has not configured
|
|
2943
|
+
// CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
|
|
2944
|
+
// an absent value, never `undefined` verbatim.
|
|
2945
|
+
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
2946
|
+
};
|
|
2947
|
+
process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
|
|
2948
|
+
`);
|
|
2949
|
+
} else {
|
|
2816
2950
|
const created = await createOwnedEngagement(serviceUrl);
|
|
2817
2951
|
engagementId = created.engagementId;
|
|
2818
2952
|
credential = {
|
|
@@ -2863,44 +2997,32 @@ ${USAGE}`);
|
|
|
2863
2997
|
[halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
|
|
2864
2998
|
`);
|
|
2865
2999
|
}
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
2870
|
-
`);
|
|
2871
|
-
} else {
|
|
2872
|
-
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
2873
|
-
[halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not your HALFCYCLE_SERVICE_URL (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
|
|
2874
|
-
`);
|
|
2875
|
-
}
|
|
2876
|
-
}
|
|
2877
|
-
if (credential) {
|
|
2878
|
-
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
|
|
3000
|
+
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
3001
|
+
if (probe.reached) {
|
|
3002
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
2879
3003
|
`);
|
|
2880
3004
|
} else {
|
|
2881
|
-
|
|
2882
|
-
|
|
3005
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
3006
|
+
[halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
|
|
2883
3007
|
`);
|
|
2884
3008
|
}
|
|
2885
|
-
|
|
2886
|
-
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
|
|
3009
|
+
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
|
|
2887
3010
|
`);
|
|
2888
|
-
|
|
3011
|
+
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
|
|
2889
3012
|
`);
|
|
2890
|
-
|
|
3013
|
+
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
|
|
2891
3014
|
`);
|
|
2892
|
-
|
|
2893
|
-
}
|
|
2894
|
-
if (credential) {
|
|
2895
|
-
try {
|
|
2896
|
-
const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
|
|
2897
|
-
process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
|
|
3015
|
+
process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
|
|
2898
3016
|
`);
|
|
2899
|
-
|
|
3017
|
+
reportClaudeCodeVersion();
|
|
3018
|
+
try {
|
|
3019
|
+
const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
|
|
3020
|
+
process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
|
|
2900
3021
|
`);
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
|
|
3022
|
+
process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
|
|
3023
|
+
`);
|
|
3024
|
+
} catch {
|
|
3025
|
+
process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
|
|
2904
3026
|
}
|
|
2905
3027
|
process.exit(0);
|
|
2906
3028
|
} catch (err) {
|