release-skill 0.2.3 → 0.2.5

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.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +46 -0
  7. package/CONTRIBUTING.md +27 -0
  8. package/INSTALL.md +95 -139
  9. package/INSTALL.zh-CN.md +70 -121
  10. package/README.md +266 -913
  11. package/README.zh-CN.md +224 -535
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +19805 -17556
  15. package/adapters/claude/schemas/release-plan.schema.json +228 -0
  16. package/adapters/claude/schemas/release-project.schema.json +93 -0
  17. package/adapters/claude/schemas/release-run.schema.json +155 -2
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +19805 -17556
  20. package/adapters/codex/schemas/release-plan.schema.json +228 -0
  21. package/adapters/codex/schemas/release-project.schema.json +93 -0
  22. package/adapters/codex/schemas/release-run.schema.json +155 -2
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +19805 -17556
  25. package/adapters/kimi/schemas/release-plan.schema.json +228 -0
  26. package/adapters/kimi/schemas/release-project.schema.json +93 -0
  27. package/adapters/kimi/schemas/release-run.schema.json +155 -2
  28. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  29. package/adapters/workbuddy/bin/release-skill.bundle.mjs +19805 -17556
  30. package/adapters/workbuddy/schemas/release-plan.schema.json +228 -0
  31. package/adapters/workbuddy/schemas/release-project.schema.json +93 -0
  32. package/adapters/workbuddy/schemas/release-run.schema.json +155 -2
  33. package/bin/release-skill.bundle.mjs +19805 -17556
  34. package/package.json +1 -1
  35. package/schemas/release-plan.schema.json +228 -0
  36. package/schemas/release-project.schema.json +93 -0
  37. package/schemas/release-run.schema.json +155 -2
  38. package/scripts/sync-public-files.mjs +20 -9
  39. package/src/adapters/plugin-marketplace.mjs +1237 -403
  40. package/src/commands/prepare.mjs +454 -40
  41. package/src/commands/publish.mjs +169 -75
  42. package/src/commands/reconcile.mjs +92 -327
  43. package/src/commands/setup.mjs +148 -20
  44. package/src/commands/verify.mjs +454 -20
  45. package/src/core/baseline.mjs +8 -1
  46. package/src/core/checkpoints.mjs +50 -7
  47. package/src/core/config.mjs +15 -0
  48. package/src/core/errors.mjs +2 -0
  49. package/src/core/installation-contract.mjs +341 -0
  50. package/src/core/plan.mjs +158 -6
  51. package/src/core/skill-resource-closure.mjs +425 -0
  52. package/src/platforms/codebuddy.mjs +206 -280
  53. package/src/platforms/codex.mjs +369 -0
  54. package/src/platforms/kimi.mjs +191 -119
  55. package/src/platforms/registry.mjs +24 -6
@@ -41,11 +41,19 @@ import {
41
41
  reObservePreviousPublicBaseline,
42
42
  } from '../core/previous-public-baseline.mjs';
43
43
  import { createEvidenceWriter } from '../core/evidence.mjs';
44
+ import {
45
+ CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
46
+ assertSkillResourceClosureReceipt,
47
+ checkSkillResourceClosure,
48
+ createSkillResourceClosureReceipt,
49
+ } from '../core/skill-resource-closure.mjs';
44
50
  import {
45
51
  ADAPTER_ACTION_TYPE_MAP,
46
52
  TIER_TABLE,
47
53
  groupActionsByTier,
48
54
  sortActionsByCheckpointOrder,
55
+ isRemoteWriteAction,
56
+ isMarketplaceAction,
49
57
  } from '../core/checkpoints.mjs';
50
58
  import { appendRunState, createProductionRunDir, writeRunAtomic, resolveDefaultRunDir } from '../core/run.mjs';
51
59
  import {
@@ -53,6 +61,7 @@ import {
53
61
  GATE_FAILED,
54
62
  BASELINE_CHANGED,
55
63
  PARTIAL_RELEASE,
64
+ CONSUMER_VERIFICATION_DEFERRED,
56
65
  } from '../core/errors.mjs';
57
66
  import { assertTransition, PUBLISHING, PUBLISHED, PARTIAL } from '../core/state-machine.mjs';
58
67
  import { matchObservation } from '../adapters/contract.mjs';
@@ -81,13 +90,6 @@ const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
81
90
  // CHECKPOINT_ORDER, ADAPTER_ACTION_TYPE_MAP and TIER_TABLE live in
82
91
  // ../core/checkpoints.mjs (single source shared with reconcile.mjs; T3.1 §4.7).
83
92
 
84
- const MARKETPLACE_TYPES = new Set([
85
- 'claude-marketplace-install',
86
- 'codex-marketplace-install',
87
- 'kimi-marketplace-install',
88
- 'codebuddy-marketplace-install',
89
- ]);
90
-
91
93
  // ---------------------------------------------------------------------------
92
94
  // Helpers
93
95
  // ---------------------------------------------------------------------------
@@ -359,7 +361,7 @@ async function executeCheckpoint(action, adapterRegistry, context) {
359
361
  // (T1.1 PROPAGATING handling). A present-but-mismatched
360
362
  // observation (CONFLICTING) is never retried — it is an
361
363
  // authoritative conflict that must fail closed for human review.
362
- const observePolicy = MARKETPLACE_TYPES.has(planActionType)
364
+ const observePolicy = isMarketplaceAction(planActionType)
363
365
  ? clampPolicyToTimeout(DEFAULT_OBSERVE_RETRY_POLICY, action.parameters?.timeoutMs)
364
366
  : DEFAULT_OBSERVE_RETRY_POLICY;
365
367
  const retryOutcome = await observeWithRetry({
@@ -525,6 +527,7 @@ export async function publishRelease(options) {
525
527
  if (productionMode && !isProductionPlan) {
526
528
  throw new ReleaseError(GATE_FAILED, 'production publish requires a github-npm-v1 frozen plan');
527
529
  }
530
+ const verifiedFrozenSnapshots = new Map();
528
531
  if (isProductionPlan) {
529
532
  if (!plan.production.assetRoot || plan.production.assetRoot === '.') {
530
533
  throw new ReleaseError(GATE_FAILED, 'production plan requires a dedicated assetRoot');
@@ -570,6 +573,7 @@ export async function publishRelease(options) {
570
573
  snapshotPath: frozen.path,
571
574
  expectedDigest: frozen.manifestDigest,
572
575
  });
576
+ verifiedFrozenSnapshots.set(unit.id, snapshot.snapshotDir);
573
577
  assertInsideAssetRoot(assetRoot, snapshot.snapshotDir, 'frozen snapshot');
574
578
  const git = await verifyFrozenGitRepository({
575
579
  root,
@@ -603,6 +607,60 @@ export async function publishRelease(options) {
603
607
  await evidence.append({ phase: 'safety-gate', gate: 'frozen-artifacts', status: 'passed' });
604
608
  }
605
609
 
610
+ // =======================================================================
611
+ // Safety Gate 2c: Skill resource closure recheck
612
+ // Re-run the skill resource closure check on each unit's frozen snapshot.
613
+ // Requires findingCount=0 and receipt matches the plan.
614
+ // =======================================================================
615
+ if (plan.skillResourceClosure) {
616
+ await evidence.append({ phase: 'safety-gate', gate: 'skill-resource-closure', status: 'started' });
617
+ const expectedReceipts = plan.skillResourceClosure.unitReceipts ?? [];
618
+ if (plan.skillResourceClosure.checkerVersion !== SKILL_RESOURCE_CHECKER_VERSION) {
619
+ throw new ReleaseError(
620
+ GATE_FAILED,
621
+ `skill resource closure checker version mismatch: plan=${plan.skillResourceClosure.checkerVersion}, current=${SKILL_RESOURCE_CHECKER_VERSION}`,
622
+ );
623
+ }
624
+ if (expectedReceipts.length !== plan.units.length) {
625
+ throw new ReleaseError(GATE_FAILED, 'skill resource closure receipt set does not cover every release unit');
626
+ }
627
+
628
+ for (const [unitId, snapshotDir] of verifiedFrozenSnapshots) {
629
+ const expected = expectedReceipts.find((item) => item.unitId === unitId);
630
+ if (!expected) {
631
+ throw new ReleaseError(GATE_FAILED, `skill resource closure receipt missing for unit "${unitId}"`);
632
+ }
633
+ const closureResult = await checkSkillResourceClosure({
634
+ snapshotDir,
635
+ host: 'root',
636
+ });
637
+ if (closureResult.findings.length > 0) {
638
+ await evidence.append({
639
+ phase: 'safety-gate',
640
+ gate: 'skill-resource-closure',
641
+ status: 'failed',
642
+ unitId,
643
+ findingCount: closureResult.findings.length,
644
+ });
645
+ throw new ReleaseError(
646
+ GATE_FAILED,
647
+ `skill resource closure recheck failed for unit "${unitId}": ${closureResult.findings.length} finding(s)`,
648
+ { unitId, findings: closureResult.findings },
649
+ );
650
+ }
651
+ const observed = createSkillResourceClosureReceipt(closureResult, { unitId });
652
+ assertSkillResourceClosureReceipt(expected, observed, `unit "${unitId}"`);
653
+ }
654
+
655
+ await evidence.append({
656
+ phase: 'safety-gate',
657
+ gate: 'skill-resource-closure',
658
+ status: 'passed',
659
+ receiptCount: expectedReceipts.length,
660
+ recheckedReceiptCount: verifiedFrozenSnapshots.size,
661
+ });
662
+ }
663
+
606
664
  // =======================================================================
607
665
  // Safety Gates 3-7: Load and validate approval record (shared)
608
666
  // =======================================================================
@@ -892,21 +950,27 @@ export async function publishRelease(options) {
892
950
  const orderedActions = sortActionsByCheckpointOrder(publishingPlan.externalActions);
893
951
 
894
952
  // =======================================================================
895
- // Safety Gate 10: Global preflight - validate all actions before any execute
953
+ // Partition: marketplace actions are deferred to verify phase.
954
+ // They get a stable CONSUMER_VERIFICATION_DEFERRED checkpoint immediately;
955
+ // they never call marketplace adapter preflight/execute/observe/verify.
956
+ // =======================================================================
957
+ const remoteWriteActions = orderedActions.filter((a) => !isMarketplaceAction(a.type));
958
+
959
+ // =======================================================================
960
+ // Safety Gate 10: Global preflight - validate all non-marketplace actions
961
+ // before any execute. Marketplace actions are deferred and skip preflight.
896
962
  // =======================================================================
897
963
  await evidence.append({ phase: 'safety-gate', gate: 'global-preflight', status: 'started' });
898
964
 
899
- for (const action of orderedActions) {
965
+ for (const action of remoteWriteActions) {
900
966
  if (action.type === 'write-remote-identifier') continue;
901
967
 
902
968
  const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
903
969
  if (!adapterActionType) continue;
904
970
 
905
971
  const adapter = adapterRegistry.getAdapter(adapterActionType);
906
- const isMarketplace = MARKETPLACE_TYPES.has(action.type);
907
972
  const preflightContext = {
908
973
  externalWritesAuthorized: false,
909
- isolatedConsumerWritesAuthorized: isMarketplace,
910
974
  plan: publishingPlan,
911
975
  baseline: plan.baseline,
912
976
  root,
@@ -918,15 +982,11 @@ export async function publishRelease(options) {
918
982
  );
919
983
  if (preflightResult.status === 'PREFLIGHT_FAILED') {
920
984
  // T3.3 §4.6: an "occupied" preflight failure overlaps semantically with
921
- // "already consistent". For NON-marketplace action types, arbitrate with
922
- // a single read-only pre-observe using the SAME classifier as the
923
- // per-checkpoint path: CONSISTENT means the remote already satisfies the
924
- // frozen plan, so let the action through to its SKIPPED path instead of
925
- // failing the whole gate. Anything else (MISSING/CONFLICTING/UNOBSERVABLE)
926
- // keeps the fail-closed GATE_FAILED. Marketplace preflights are local
927
- // integrity checks, never remote occupation, so they are NEVER arbitrated
928
- // -- a CONSISTENT observation must not bypass their completeness gate.
929
- if (!isMarketplace && hasExpected(action.expected)) {
985
+ // "already consistent". Arbitrate with a single read-only pre-observe
986
+ // using the SAME classifier as the per-checkpoint path: CONSISTENT means
987
+ // the remote already satisfies the frozen plan, so let the action through
988
+ // to its SKIPPED path instead of failing the whole gate.
989
+ if (hasExpected(action.expected)) {
930
990
  let arbitration;
931
991
  try {
932
992
  arbitration = await adapter.observe(
@@ -961,15 +1021,27 @@ export async function publishRelease(options) {
961
1021
  await evidence.append({ phase: 'safety-gate', gate: 'global-preflight', status: 'passed' });
962
1022
 
963
1023
  // =======================================================================
964
- // Persist an append-only initial state before the first adapter execute.
1024
+ // Build checkpoints: marketplace actions get a deferred result immediately;
1025
+ // non-marketplace actions start as PENDING and are executed by tiers.
965
1026
  // =======================================================================
966
1027
  const runPath = join(runDir, 'release-run.json');
967
- const checkpoints = orderedActions.map((action) => ({
968
- actionId: action.id,
969
- actionType: action.type,
970
- status: 'PENDING',
971
- error: null,
972
- }));
1028
+ const checkpoints = orderedActions.map((action) => {
1029
+ if (isMarketplaceAction(action.type)) {
1030
+ return {
1031
+ actionId: action.id,
1032
+ actionType: action.type,
1033
+ status: 'DEFERRED',
1034
+ phase: 'post-publish-verification',
1035
+ reason: CONSUMER_VERIFICATION_DEFERRED,
1036
+ };
1037
+ }
1038
+ return {
1039
+ actionId: action.id,
1040
+ actionType: action.type,
1041
+ status: 'PENDING',
1042
+ error: null,
1043
+ };
1044
+ });
973
1045
 
974
1046
  const startedAt = clockFn();
975
1047
  const buildPersistedState = (status = PUBLISHING, finishedAt) => ({
@@ -987,10 +1059,14 @@ export async function publishRelease(options) {
987
1059
  : checkpoint.status === 'FAILED' ? 'failed'
988
1060
  : checkpoint.status === 'UNCERTAIN' ? 'uncertain'
989
1061
  : checkpoint.status === 'SKIPPED' ? 'skipped'
1062
+ : checkpoint.status === 'DEFERRED' ? 'deferred'
990
1063
  : 'pending',
991
1064
  ...(checkpoint.preObserve ? { preObserve: checkpoint.preObserve } : {}),
992
1065
  ...(checkpoint.postObserve ? { postObserve: checkpoint.postObserve } : {}),
993
1066
  ...(checkpoint.error ? { error: { code: 'GATE_FAILED', message: checkpoint.error } } : {}),
1067
+ ...(checkpoint.reason === CONSUMER_VERIFICATION_DEFERRED
1068
+ ? { reason: CONSUMER_VERIFICATION_DEFERRED, phase: checkpoint.phase ?? 'post-publish-verification' }
1069
+ : {}),
994
1070
  })),
995
1071
  startedAt,
996
1072
  ...(finishedAt ? { finishedAt } : {}),
@@ -1023,7 +1099,9 @@ export async function publishRelease(options) {
1023
1099
  // order). appendRunState's seq chain (run.mjs) is unchanged.
1024
1100
  // =======================================================================
1025
1101
  const checkpointByActionId = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
1026
- const { tiers, unknown } = groupActionsByTier(orderedActions);
1102
+ // Only non-marketplace actions participate in tier execution.
1103
+ // Marketplace actions are already deferred and excluded from tiers.
1104
+ const { tiers, unknown } = groupActionsByTier(remoteWriteActions);
1027
1105
  let stopped = false;
1028
1106
 
1029
1107
  // Fail closed on any action type the tier table does not recognize. Such a
@@ -1075,7 +1153,7 @@ export async function publishRelease(options) {
1075
1153
 
1076
1154
  // --- Tier execute: concurrent, allSettled semantics (no fail-fast).
1077
1155
  const results = await Promise.all(tierActions.map(async (action) => {
1078
- const isMarketplace = MARKETPLACE_TYPES.has(action.type);
1156
+ const isMarketplace = isMarketplaceAction(action.type);
1079
1157
  const actionContext = {
1080
1158
  externalWritesAuthorized: !isMarketplace,
1081
1159
  isolatedConsumerWritesAuthorized: isMarketplace,
@@ -1136,60 +1214,76 @@ export async function publishRelease(options) {
1136
1214
  // consistency pass. Both the branch tip and default-branch name are bound
1137
1215
  // in the frozen action expectations. A late change keeps the saga PARTIAL
1138
1216
  // and must be resolved by reconcile/human review.
1139
- if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
1140
- await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
1141
- for (let index = 0; index < orderedActions.length; index += 1) {
1142
- const action = orderedActions[index];
1143
- if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
1144
- const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
1145
- const adapter = adapterRegistry.getAdapter(adapterActionType);
1146
- let observed;
1147
- try {
1148
- observed = await adapter.observe(
1149
- { actionType: adapterActionType, ...action.parameters, expected: action.expected },
1150
- { externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
1151
- );
1152
- } catch (error) {
1153
- observed = { observation: null, error: error.message };
1217
+ //
1218
+ // IMPORTANT: the final status determination must read the LATEST checkpoint
1219
+ // state AFTER this consistency check, not use any previously cached boolean.
1220
+ // A late consistency failure that changes a checkpoint from SUCCEEDED to
1221
+ // FAILED must cause PARTIAL, not stale PUBLISHED.
1222
+ {
1223
+ // Build fresh remote-write checkpoint view (reads latest state)
1224
+ const remoteWriteCps = checkpoints.filter((cp) => isRemoteWriteAction(cp.actionType));
1225
+ const allRemoteConsistent = remoteWriteCps.every(
1226
+ (cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED',
1227
+ );
1228
+ if (allRemoteConsistent) {
1229
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
1230
+ for (let index = 0; index < orderedActions.length; index += 1) {
1231
+ const action = orderedActions[index];
1232
+ if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
1233
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
1234
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
1235
+ let observed;
1236
+ try {
1237
+ observed = await adapter.observe(
1238
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
1239
+ { externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
1240
+ );
1241
+ } catch (error) {
1242
+ observed = { observation: null, error: error.message };
1243
+ }
1244
+ const observation = observed?.observation;
1245
+ const observable = observation && !(observed.error && Object.keys(observation).length === 0);
1246
+ const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
1247
+ if (!comparison.matches) {
1248
+ checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
1249
+ checkpoints[index].error = observable
1250
+ ? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
1251
+ : `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
1252
+ await evidence.append({
1253
+ phase: 'safety-gate',
1254
+ gate: 'final-branch-consistency',
1255
+ status: 'failed',
1256
+ actionId: action.id,
1257
+ error: checkpoints[index].error,
1258
+ });
1259
+ break;
1260
+ }
1154
1261
  }
1155
- const observation = observed?.observation;
1156
- const observable = observation && !(observed.error && Object.keys(observation).length === 0);
1157
- const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
1158
- if (!comparison.matches) {
1159
- checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
1160
- checkpoints[index].error = observable
1161
- ? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
1162
- : `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
1163
- await evidence.append({
1164
- phase: 'safety-gate',
1165
- gate: 'final-branch-consistency',
1166
- status: 'failed',
1167
- actionId: action.id,
1168
- error: checkpoints[index].error,
1169
- });
1170
- break;
1262
+ // Re-read fresh state after consistency check
1263
+ const freshRemoteCps = checkpoints.filter((cp) => isRemoteWriteAction(cp.actionType));
1264
+ if (freshRemoteCps.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
1265
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
1171
1266
  }
1172
1267
  }
1173
- if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
1174
- await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
1175
- }
1176
1268
  }
1177
1269
 
1178
- // Determine overall status
1179
- const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
1180
- // SKIPPED counts as success: the remote was already consistent with the
1181
- // frozen plan, so an all-SKIPPED/SUCCEEDED run is a clean PUBLISHED (the
1182
- // idempotent ideal -- zero writes were even needed).
1183
- const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED');
1270
+ // Determine overall status from the LATEST checkpoint state.
1271
+ // Never use a cached boolean computed before the consistency check.
1272
+ // 远端写入动作的状态决定整体发布状态
1273
+ // 市场安装动作的结果记录在 run 中,但不阻止 PUBLISHED
1274
+ const remoteWriteCheckpointsFinal = checkpoints.filter((cp) => isRemoteWriteAction(cp.actionType));
1275
+ const remoteWriteAllSucceeded = remoteWriteCheckpointsFinal.every(
1276
+ (cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED',
1277
+ );
1278
+ const remoteWriteHasFailure = remoteWriteCheckpointsFinal.some(
1279
+ (cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN',
1280
+ );
1184
1281
 
1185
1282
  let overallStatus;
1186
- if (allSucceeded) {
1283
+ if (remoteWriteAllSucceeded) {
1187
1284
  overallStatus = PUBLISHED;
1188
1285
  publishingPlan.status = PUBLISHED;
1189
- } else if (hasFailure) {
1190
- // Once any execute was attempted, recovery must go through reconcile,
1191
- // even when no success was observed. Re-running publish could duplicate
1192
- // a write accepted just before a transport failure.
1286
+ } else if (remoteWriteHasFailure) {
1193
1287
  overallStatus = PARTIAL;
1194
1288
  publishingPlan.status = overallStatus;
1195
1289
  } else {