halfcycle 0.3.17 → 0.3.19

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfcycle",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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
  {
package/README.md CHANGED
@@ -42,7 +42,7 @@ A method document cannot fix that, because a document cannot interrupt you. This
42
42
 
43
43
  Credit where it is due, and it is due. GStack, Matt Pocock's skills, GitHub Spec Kit, BMAD, OpenSpec and claude-flow are genuinely good, and between them they taught this whole category that the bottleneck is discipline rather than model intelligence. We took two things from them deliberately and say so: the one-question-at-a-time interrogation shape, and an install that is one command and then stays out of your way.
44
44
 
45
- If you are one person on a two week project, install one of those instead. They are excellent at exactly that, and you may not need any of this yet.
45
+ The objection this usually meets is that it must be too much for a small project. It would be, if there were one setting. A folder you download has exactly one, so whoever wrote it had to choose who to disappoint, and it is usually the small project that gets buried and quietly drops the process in week three. This reads the work instead and sizes itself to it, which is the section further down.
46
46
 
47
47
  Writing the process down was never the hard part. What a folder you download structurally cannot have is these four things.
48
48
 
@@ -39,6 +39,9 @@ function readConfig() {
39
39
  }
40
40
  };
41
41
  }
42
+ function readNeverConfiguredReason() {
43
+ return process.env["HALFCYCLE_NOT_THIS_ACCOUNT"] === "1" ? "not-this-account" : "no-credential-yet";
44
+ }
42
45
  function readExcerptConfig() {
43
46
  const raw = process.env["HALFCYCLE_SOURCE_EXCERPTS"];
44
47
  const enabled = (raw ?? "").trim().toLowerCase() === "true";
@@ -14594,6 +14597,80 @@ function date4(params) {
14594
14597
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
14595
14598
  config(en_default());
14596
14599
 
14600
+ // ../events/dist/evaluation.js
14601
+ var envRefSchema = external_exports.object({
14602
+ name: external_exports.string(),
14603
+ argType: external_exports.enum(["build", "runtime", "both"])
14604
+ }).strict();
14605
+ var sqlWriteSchema = external_exports.object({
14606
+ kind: external_exports.enum(["insert", "update", "upsert", "delete"]),
14607
+ table: external_exports.string(),
14608
+ whereColumns: external_exports.array(external_exports.string())
14609
+ }).strict();
14610
+ var schemaConstraintSchema = external_exports.object({
14611
+ table: external_exports.string(),
14612
+ kind: external_exports.enum(["unique", "check", "foreignKey"]),
14613
+ constraintName: external_exports.string().optional()
14614
+ }).strict();
14615
+ var numericColumnSchema = external_exports.object({
14616
+ table: external_exports.string(),
14617
+ column: external_exports.string(),
14618
+ pgType: external_exports.enum(["numeric", "bigint", "decimal"]),
14619
+ wireField: external_exports.string().optional(),
14620
+ hasRegisteredCoercion: external_exports.boolean().optional()
14621
+ }).strict();
14622
+ var thirdPartyEndpointSchema = external_exports.object({
14623
+ signature: external_exports.string(),
14624
+ provider: external_exports.string().optional(),
14625
+ hasCapturedFixture: external_exports.boolean()
14626
+ }).strict();
14627
+ var sourceExcerptSchema = external_exports.object({
14628
+ /** The declared construct the excerpt was cut around (a derived-fact anchor). */
14629
+ construct: external_exports.enum(["sqlWrite", "thirdPartyEndpoint", "envRef"]),
14630
+ /** 1-based start line in the changed file (best-effort). */
14631
+ startLine: external_exports.number().int().nonnegative(),
14632
+ /** The bounded excerpt text — capped CLIENT-SIDE in the runner before send. */
14633
+ text: external_exports.string()
14634
+ }).strict();
14635
+ var diffMetadataSchema = external_exports.object({
14636
+ path: external_exports.string(),
14637
+ astShapes: external_exports.array(external_exports.string()),
14638
+ envRefs: external_exports.array(envRefSchema),
14639
+ sqlWrites: external_exports.array(sqlWriteSchema),
14640
+ endpointSigs: external_exports.array(external_exports.string()),
14641
+ schemaConstraints: external_exports.array(schemaConstraintSchema),
14642
+ numericColumns: external_exports.array(numericColumnSchema),
14643
+ thirdPartyEndpoints: external_exports.array(thirdPartyEndpointSchema),
14644
+ // Present only when you have turned source excerpts on. Left out
14645
+ // entirely otherwise — never sent as an empty list.
14646
+ sourceExcerpts: external_exports.array(sourceExcerptSchema).optional()
14647
+ }).strict();
14648
+ var changeSetSchema = external_exports.object({
14649
+ files: external_exports.array(diffMetadataSchema)
14650
+ }).strict();
14651
+ var NON_CAPABILITY_DESCRIPTOR_FIELDS = [
14652
+ "path",
14653
+ "sourceExcerpts"
14654
+ ];
14655
+ var CLIENT_CAPABILITY_VOCABULARY = Object.keys(diffMetadataSchema.shape).filter((field) => !NON_CAPABILITY_DESCRIPTOR_FIELDS.includes(field));
14656
+ function isClientCapability(value) {
14657
+ return CLIENT_CAPABILITY_VOCABULARY.includes(value);
14658
+ }
14659
+ var evaluationRequestSchema = external_exports.object({
14660
+ engagementId: external_exports.string(),
14661
+ changeSet: changeSetSchema,
14662
+ /**
14663
+ * What this client can supply: the names of the descriptor kinds it fills in
14664
+ * for each changed file. A check that needs a kind this client does not send
14665
+ * is reported by name as not run, with the missing kind named, instead of
14666
+ * quietly reporting nothing.
14667
+ *
14668
+ * Omitting the field means "unknown", never "everything" — a client that
14669
+ * says nothing is treated as an older one, not a capable one.
14670
+ */
14671
+ clientCapabilities: external_exports.array(external_exports.string()).optional()
14672
+ }).strict();
14673
+
14597
14674
  // ../core/dist/guard-record.js
14598
14675
  var severitySchema = external_exports.enum(["info", "warn", "block"]);
14599
14676
  var channelSchema = external_exports.enum(["stable", "candidate", "rented"]);
@@ -14641,11 +14718,26 @@ var firedGuardSchema = external_exports.object({
14641
14718
  */
14642
14719
  path: external_exports.string().optional()
14643
14720
  }).strict();
14721
+ var notRunCheckSchema = external_exports.object({
14722
+ /** Which check did not run, by the same reference a fired check carries. */
14723
+ patternRef: external_exports.string(),
14724
+ /**
14725
+ * Why it could not run, in plain words — what this client did not send, or
14726
+ * that the check's kind is not implemented yet. Never the check's own rule.
14727
+ */
14728
+ reason: external_exports.string()
14729
+ }).strict();
14644
14730
  var resultEnvelopeSchema = external_exports.object({
14645
14731
  guardsFired: external_exports.array(firedGuardSchema),
14646
14732
  severity: severitySchema.nullable(),
14647
14733
  blocking: external_exports.boolean(),
14648
- explanation: external_exports.string()
14734
+ explanation: external_exports.string(),
14735
+ /**
14736
+ * The checks that could not run on this edit, each named with the reason.
14737
+ * Absent when every check ran — so an evaluation where nothing was skipped
14738
+ * looks exactly as it always has, and an absent list is never an empty one.
14739
+ */
14740
+ notRun: external_exports.array(notRunCheckSchema).optional()
14649
14741
  }).strict();
14650
14742
  var wireErrorSchema = external_exports.object({
14651
14743
  statusCode: external_exports.number(),
@@ -14653,6 +14745,62 @@ var wireErrorSchema = external_exports.object({
14653
14745
  message: external_exports.string()
14654
14746
  }).strict();
14655
14747
 
14748
+ // ../events/dist/phase-identity.js
14749
+ var PHASE_SEGMENT_PREFIX = "phase-";
14750
+ function isValidPhaseIdentity(identity) {
14751
+ if (typeof identity === "number")
14752
+ return Number.isInteger(identity) && identity >= 0;
14753
+ if (typeof identity !== "string")
14754
+ return false;
14755
+ if (identity === "")
14756
+ return false;
14757
+ if (identity.includes("/"))
14758
+ return false;
14759
+ if (identity.includes("\\"))
14760
+ return false;
14761
+ if (identity.includes("\0"))
14762
+ return false;
14763
+ if (identity.startsWith("."))
14764
+ return false;
14765
+ return true;
14766
+ }
14767
+ var InvalidPhaseIdentityError = class extends Error {
14768
+ /** The value that was refused, exactly as supplied. */
14769
+ identity;
14770
+ constructor(identity, allowedLocation) {
14771
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
14772
+ const where = allowedLocation === void 0 ? "" : `
14773
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
14774
+ super(`that phase identity cannot name ${subject}.
14775
+ identity: ${describeIdentity(identity)}` + where);
14776
+ this.name = "InvalidPhaseIdentityError";
14777
+ this.identity = identity;
14778
+ }
14779
+ };
14780
+ function renderPhaseSegment(identity) {
14781
+ if (!isValidPhaseIdentity(identity)) {
14782
+ throw new InvalidPhaseIdentityError(identity);
14783
+ }
14784
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
14785
+ }
14786
+ function describeIdentity(identity) {
14787
+ if (identity === "")
14788
+ return "(empty)";
14789
+ if (typeof identity === "string")
14790
+ return identity;
14791
+ if (identity === null)
14792
+ return "null";
14793
+ if (identity === void 0)
14794
+ return "none supplied";
14795
+ if (typeof identity === "number" || typeof identity === "boolean")
14796
+ return String(identity);
14797
+ if (Array.isArray(identity))
14798
+ return "(a list)";
14799
+ if (typeof identity === "object")
14800
+ return "(an object)";
14801
+ return `(a ${typeof identity})`;
14802
+ }
14803
+
14656
14804
  // dist/client.js
14657
14805
  var REQUEST_TIMEOUT_MS = 1e4;
14658
14806
  async function evaluate(url2, token, request, fetchFn = globalThis.fetch) {
@@ -15780,6 +15928,16 @@ function buildChangeSet(files, context = {}) {
15780
15928
  });
15781
15929
  return { files: fileMetadata };
15782
15930
  }
15931
+ function deriveClientCapabilities(changeSet) {
15932
+ const seen = /* @__PURE__ */ new Set();
15933
+ for (const file2 of changeSet.files) {
15934
+ for (const key of Object.keys(file2)) {
15935
+ if (isClientCapability(key))
15936
+ seen.add(key);
15937
+ }
15938
+ }
15939
+ return [...seen];
15940
+ }
15783
15941
  function produceExcerpts(diffContent, descriptors, caps) {
15784
15942
  const excerpts = [];
15785
15943
  const seen = /* @__PURE__ */ new Set();
@@ -15911,26 +16069,22 @@ async function emitTelemetry(run, config2, logger, fetchFn = globalThis.fetch) {
15911
16069
  await postTelemetry(run, config2.controlTelemetryUrl, config2.controlTelemetryToken, logger, fetchFn);
15912
16070
  }
15913
16071
  }
15914
- function normalizePhaseBaseName(phase) {
15915
- if (phase === void 0)
15916
- return null;
15917
- const trimmed = phase.trim();
15918
- if (trimmed === "")
15919
- return null;
15920
- const match = /^(?:phase-)?(\d+)$/.exec(trimmed);
15921
- if (!match)
15922
- return null;
15923
- return `phase-${match[1]}`;
15924
- }
15925
16072
  function localLogPath(run, config2) {
15926
16073
  const logDir = config2.guardEvalLogDir ?? join4(homedir(), ".halfcycle", "guard-eval-log");
15927
- const base = normalizePhaseBaseName(run.phase);
15928
- const fileName = base !== null ? `${base}.jsonl` : "unphased.jsonl";
16074
+ const phase = run.phase;
16075
+ const fileName = phase === void 0 || phase === "" ? "unphased.jsonl" : `${renderPhaseSegment(phase)}.jsonl`;
15929
16076
  return join4(logDir, fileName);
15930
16077
  }
15931
16078
  function appendToLocalLog(run, config2, logger) {
16079
+ let logPath;
16080
+ try {
16081
+ logPath = localLogPath(run, config2);
16082
+ } catch (err) {
16083
+ const msg = err instanceof Error ? err.message : String(err);
16084
+ logger.warn(`[Halfcycle telemetry] Phase name refused, nothing written (dropped): ${msg}`);
16085
+ return;
16086
+ }
15932
16087
  try {
15933
- const logPath = localLogPath(run, config2);
15934
16088
  mkdirSync2(dirname2(logPath), { recursive: true });
15935
16089
  appendFileSync(logPath, JSON.stringify(run) + "\n", "utf-8");
15936
16090
  } catch (err) {
@@ -16045,8 +16199,8 @@ This is not a service outage: the guard service answered. The suspect is this re
16045
16199
  }
16046
16200
  var UNCONFIGURED_EXIT_TOOL_USE = 2;
16047
16201
  var UNCONFIGURED_EXIT_STOP_FAMILY = 1;
16048
- function emitNeverConfigured(missing, exitCode) {
16049
- writeSync(2, neverConfiguredMessage(missing, HOOK_REMEDY));
16202
+ function emitNeverConfigured(missing, exitCode, reason) {
16203
+ writeSync(2, neverConfiguredReport(missing, reason));
16050
16204
  process.exitCode = exitCode;
16051
16205
  }
16052
16206
  var HOOK_REMEDY = `Fix it by running "npx halfcycle" in this repository: it may open your browser to sign in first, then writes this engagement's credentials to ~/.halfcycle \u2014 outside the repository \u2014 and the hook loads them itself.`;
@@ -16058,6 +16212,18 @@ This is not a service outage. It means the guard has never run here \u2014 not o
16058
16212
  ${remedy}
16059
16213
  `;
16060
16214
  }
16215
+ function notThisAccountReport(label) {
16216
+ return `${label} This project belongs to another Halfcycle account. Halfcycle is Solo today \u2014 one person works on a project at a time \u2014 so no credential exists for this machine and none will be issued. Nothing here needs fixing; this copy simply is not checked. Ask the project's owner for view access, or \u2014 if this account is wrong \u2014 sign in as the one that owns the project and run "npx halfcycle" again.
16217
+ `;
16218
+ }
16219
+ function neverConfiguredReport(missing, reason, label = "[Halfcycle]") {
16220
+ switch (reason) {
16221
+ case "no-credential-yet":
16222
+ return neverConfiguredMessage(missing, HOOK_REMEDY, label);
16223
+ case "not-this-account":
16224
+ return notThisAccountReport(label);
16225
+ }
16226
+ }
16061
16227
  var CREDENTIAL_REJECTED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
16062
16228
  function classifyFailure(failure) {
16063
16229
  if (failure.kind === "contract-error") {
@@ -16104,6 +16270,14 @@ function emitCredentialRejected(statusCode, serviceMessage, exitCode) {
16104
16270
  writeSync(2, credentialRejectedMessage(statusCode, serviceMessage, HOOK_REMEDY));
16105
16271
  process.exitCode = exitCode;
16106
16272
  }
16273
+ function notRunReport(notRun, label = "[Halfcycle]") {
16274
+ if (!notRun || notRun.length === 0)
16275
+ return "";
16276
+ const count = notRun.length;
16277
+ const noun = count === 1 ? "check" : "checks";
16278
+ const lines = notRun.map((entry) => ` [NOT RUN] ${entry.patternRef}: ${entry.reason}`);
16279
+ return [`${label} ${count} ${noun} did not run on this change:`, ...lines].join("\n");
16280
+ }
16107
16281
 
16108
16282
  // dist/ci.js
16109
16283
  async function runCi() {
@@ -16154,7 +16328,8 @@ async function runCi() {
16154
16328
  const { guardServiceUrl, guardServiceToken, guardEngagementId } = configResult.config;
16155
16329
  const clientResult = await evaluate(guardServiceUrl, guardServiceToken, {
16156
16330
  engagementId: guardEngagementId,
16157
- changeSet
16331
+ changeSet,
16332
+ clientCapabilities: deriveClientCapabilities(changeSet)
16158
16333
  });
16159
16334
  if (!clientResult.ok) {
16160
16335
  const failure = classifyFailure(clientResult);
@@ -16183,9 +16358,9 @@ async function runCi() {
16183
16358
  const run = buildGuardEvalRun({
16184
16359
  engagementId: guardEngagementId,
16185
16360
  runType: "ci",
16186
- // Phase source (D6, HALFCYCLE_PHASE) — emit.ts normalises this to the exact
16187
- // phase-<N>.jsonl filename the Build-Record reader expects (#61 seam). Absent
16188
- // at CI time → the run is unphased (lands in unphased.jsonl).
16361
+ // The phase this run belongs to, as the client named it — it names the
16362
+ // guard-eval log file the run is appended to. Absent at CI time → the run
16363
+ // is unphased and lands in unphased.jsonl.
16189
16364
  phase: telemetryConfig.phase,
16190
16365
  outcome: "evaluated",
16191
16366
  changeSet,
@@ -16202,6 +16377,9 @@ async function runCi() {
16202
16377
  return actOnEnvelope(clientResult.envelope);
16203
16378
  }
16204
16379
  function actOnEnvelope(envelope) {
16380
+ const notRun = notRunReport(envelope.notRun, "[Halfcycle CI]");
16381
+ if (notRun)
16382
+ process.stdout.write(notRun + "\n");
16205
16383
  if (envelope.guardsFired.length === 0) {
16206
16384
  return 0;
16207
16385
  }
@@ -16269,13 +16447,14 @@ async function runPostToolUse() {
16269
16447
  const configResult = readConfig();
16270
16448
  if (!configResult.config) {
16271
16449
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet });
16272
- emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_TOOL_USE);
16450
+ emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_TOOL_USE, readNeverConfiguredReason());
16273
16451
  return;
16274
16452
  }
16275
16453
  const { guardServiceUrl, guardServiceToken, guardEngagementId } = configResult.config;
16276
16454
  const clientResult = await evaluate(guardServiceUrl, guardServiceToken, {
16277
16455
  engagementId: guardEngagementId,
16278
- changeSet
16456
+ changeSet,
16457
+ clientCapabilities: deriveClientCapabilities(changeSet)
16279
16458
  });
16280
16459
  if (!clientResult.ok) {
16281
16460
  const failure = classifyFailure(clientResult);
@@ -16301,8 +16480,8 @@ async function runPostToolUse() {
16301
16480
  const run = buildGuardEvalRun({
16302
16481
  engagementId: guardEngagementId,
16303
16482
  runType: "hook",
16304
- // Phase source (D6, HALFCYCLE_PHASE) — emit.ts normalises this to the exact
16305
- // phase-<N>.jsonl filename the Build-Record reader expects (#61 seam).
16483
+ // The phase this run belongs to, as the client named it — it names the
16484
+ // guard-eval log file the run is appended to. Absent → the run is unphased.
16306
16485
  phase: telemetryConfig.phase,
16307
16486
  outcome: "evaluated",
16308
16487
  changeSet,
@@ -16319,18 +16498,23 @@ async function runPostToolUse() {
16319
16498
  actOnEnvelope2(clientResult.envelope);
16320
16499
  }
16321
16500
  function actOnEnvelope2(envelope) {
16501
+ const notRun = notRunReport(envelope.notRun);
16502
+ const notRunBlock = notRun ? `
16503
+ ${notRun}` : "";
16322
16504
  if (envelope.guardsFired.length === 0) {
16505
+ if (notRun)
16506
+ emitWarning(notRun);
16323
16507
  return;
16324
16508
  }
16325
16509
  if (envelope.blocking) {
16326
16510
  const guardLines = envelope.guardsFired.map((g) => ` [${g.severity.toUpperCase()}] ${g.patternRef}: ${g.explanation}`).join("\n");
16327
16511
  const reason = `Guard blocked: ${envelope.explanation}
16328
- ${guardLines}`;
16512
+ ${guardLines}${notRunBlock}`;
16329
16513
  emitBlock(reason);
16330
16514
  } else {
16331
16515
  const guardLines = envelope.guardsFired.map((g) => ` [${g.severity.toUpperCase()}] ${g.patternRef}: ${g.explanation}`).join("\n");
16332
16516
  emitWarning(`[Halfcycle] Guard warning: ${envelope.explanation}
16333
- ${guardLines}`);
16517
+ ${guardLines}${notRunBlock}`);
16334
16518
  }
16335
16519
  }
16336
16520
  function readStdin() {
@@ -16529,13 +16713,14 @@ async function runSessionDiff(input, kind) {
16529
16713
  const configResult = readConfig();
16530
16714
  if (!configResult.config) {
16531
16715
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet, diffBase });
16532
- emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_STOP_FAMILY);
16716
+ emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_STOP_FAMILY, readNeverConfiguredReason());
16533
16717
  return;
16534
16718
  }
16535
16719
  const { guardServiceUrl, guardServiceToken, guardEngagementId } = configResult.config;
16536
16720
  const clientResult = await evaluate(guardServiceUrl, guardServiceToken, {
16537
16721
  engagementId: guardEngagementId,
16538
- changeSet
16722
+ changeSet,
16723
+ clientCapabilities: deriveClientCapabilities(changeSet)
16539
16724
  });
16540
16725
  if (!clientResult.ok) {
16541
16726
  const failure = classifyFailure(clientResult);
@@ -16579,20 +16764,26 @@ async function runSessionDiff(input, kind) {
16579
16764
  actOnEnvelope3(clientResult.envelope, kind, input.stop_hook_active === true);
16580
16765
  }
16581
16766
  function actOnEnvelope3(envelope, kind, neverBlock) {
16582
- if (envelope.guardsFired.length === 0)
16767
+ const notRun = notRunReport(envelope.notRun);
16768
+ const notRunBlock = notRun ? `
16769
+ ${notRun}` : "";
16770
+ if (envelope.guardsFired.length === 0) {
16771
+ if (notRun)
16772
+ emitWarning(notRun);
16583
16773
  return;
16774
+ }
16584
16775
  const where = kind === "stop" ? "session stop" : "session stop";
16585
16776
  const guardLines = envelope.guardsFired.map((g) => ` [${g.severity.toUpperCase()}] ${g.patternRef}: ${g.explanation}`).join("\n");
16586
16777
  if (envelope.blocking && !neverBlock) {
16587
16778
  emitBlock(`Guard blocked at ${where}: ${envelope.explanation}
16588
- ${guardLines}`);
16779
+ ${guardLines}${notRunBlock}`);
16589
16780
  } else if (envelope.blocking && neverBlock) {
16590
16781
  emitWarning(`[Halfcycle] Guard would block at ${where} but the stop was already continued (stop_hook_active) \u2014 surfacing as a warning:
16591
16782
  ${envelope.explanation}
16592
- ${guardLines}`);
16783
+ ${guardLines}${notRunBlock}`);
16593
16784
  } else {
16594
16785
  emitWarning(`[Halfcycle] Guard warning at ${where}: ${envelope.explanation}
16595
- ${guardLines}`);
16786
+ ${guardLines}${notRunBlock}`);
16596
16787
  }
16597
16788
  }
16598
16789