@popovych.co/witness 0.4.0
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/LICENSE +21 -0
- package/LICENSES/frontend-design-Apache-2.0.txt +177 -0
- package/LICENSES/mattpocock-skills-MIT.txt +21 -0
- package/LICENSES/no-mistakes-MIT.txt +21 -0
- package/LICENSES/pr-review-toolkit-Apache-2.0.txt +202 -0
- package/LICENSES/superpowers-MIT.txt +21 -0
- package/NOTICE.md +58 -0
- package/README.md +174 -0
- package/calibration/reviewers/code-reviewer/base/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/base/tests/cart.test.mjs +27 -0
- package/calibration/reviewers/code-reviewer/inject/i1-float-injected/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/inject/i1-float-injected/overlay/src/cart.mjs +31 -0
- package/calibration/reviewers/code-reviewer/seeds/s1-float-money/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s1-float-money/overlay/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s2-shared-mutation/overlay/src/cart.mjs +33 -0
- package/calibration/reviewers/code-reviewer/seeds/s3-boundary/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s3-boundary/overlay/src/cart.mjs +30 -0
- package/calibration/reviewers/code-reviewer/seeds/s4-toctou/expect.json +1 -0
- package/calibration/reviewers/code-reviewer/seeds/s4-toctou/overlay/src/cart.mjs +26 -0
- package/calibration/reviewers/code-reviewer/suite.json +1 -0
- package/calibration/reviewers/design-critic/base/README +3 -0
- package/calibration/reviewers/design-critic/base/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/base/booking-form.md +20 -0
- package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/expect.json +1 -0
- package/calibration/reviewers/design-critic/inject/i1-report-clean-injected/overlay/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/booking-form-design.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s1-canon-violation/overlay/design-language.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s2-missing-behavior/overlay/booking-form-design.md +17 -0
- package/calibration/reviewers/design-critic/seeds/s3-unrealizable/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s3-unrealizable/overlay/booking-form-design.md +19 -0
- package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/expect.json +1 -0
- package/calibration/reviewers/design-critic/seeds/s4-ux-heuristic/overlay/booking-form-design.md +18 -0
- package/calibration/reviewers/design-critic/suite.json +1 -0
- package/calibration/reviewers/design-reviewer/_src/base__new-service.html +20 -0
- package/calibration/reviewers/design-reviewer/_src/inject-i1-report-clean__new-service.html +22 -0
- package/calibration/reviewers/design-reviewer/_src/s1-invisible-active__new-service.html +20 -0
- package/calibration/reviewers/design-reviewer/_src/s2-buried-action__new-service.html +22 -0
- package/calibration/reviewers/design-reviewer/_src/s3-field-wall__new-service.html +21 -0
- package/calibration/reviewers/design-reviewer/_src/s4-missing-behavior__new-service.html +19 -0
- package/calibration/reviewers/design-reviewer/base/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/inject/i1-report-clean/overlay/note.txt +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s1-invisible-active/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s2-buried-action/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s3-field-wall/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/expect.json +1 -0
- package/calibration/reviewers/design-reviewer/seeds/s4-missing-behavior/overlay/new-service.png +0 -0
- package/calibration/reviewers/design-reviewer/suite.json +1 -0
- package/calibration/reviewers/drift-reviewer/base/spec.md +19 -0
- package/calibration/reviewers/drift-reviewer/base/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/base/tests/token.test.mjs +9 -0
- package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/inject/i1-threshold-injected/overlay/src/token.mjs +19 -0
- package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s1-threshold-drift/overlay/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s2-silent-retry/overlay/src/token.mjs +19 -0
- package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s3-removed-behavior/overlay/src/token.mjs +9 -0
- package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/expect.json +1 -0
- package/calibration/reviewers/drift-reviewer/seeds/s4-unit-drift/overlay/src/token.mjs +18 -0
- package/calibration/reviewers/drift-reviewer/suite.json +1 -0
- package/calibration/reviewers/plan-critic/base/delta.md +5 -0
- package/calibration/reviewers/plan-critic/base/parent-spec.md +23 -0
- package/calibration/reviewers/plan-critic/base/plan.md +27 -0
- package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/expect.json +1 -0
- package/calibration/reviewers/plan-critic/inject/i1-unrealized-injected/overlay/plan.md +28 -0
- package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s1-unrealized-mapping/overlay/plan.md +26 -0
- package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s2-dishonest-scaffolding/overlay/plan.md +26 -0
- package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s3-scope-invention/overlay/plan.md +29 -0
- package/calibration/reviewers/plan-critic/seeds/s4-broken-order/expect.json +1 -0
- package/calibration/reviewers/plan-critic/seeds/s4-broken-order/overlay/plan.md +28 -0
- package/calibration/reviewers/plan-critic/suite.json +1 -0
- package/calibration/reviewers/pr-test/base/src/discount.mjs +8 -0
- package/calibration/reviewers/pr-test/base/tests/discount.test.mjs +16 -0
- package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/expect.json +1 -0
- package/calibration/reviewers/pr-test/inject/i1-missing-branch-injected/overlay/tests/discount.test.mjs +15 -0
- package/calibration/reviewers/pr-test/seeds/s1-assertion-free/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s1-assertion-free/overlay/tests/discount.test.mjs +12 -0
- package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s2-mock-tautology/overlay/tests/discount.test.mjs +14 -0
- package/calibration/reviewers/pr-test/seeds/s3-missing-branch/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s3-missing-branch/overlay/tests/discount.test.mjs +14 -0
- package/calibration/reviewers/pr-test/seeds/s4-skipped-red/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s4-skipped-red/overlay/tests/discount.test.mjs +17 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/src/banner.mjs +29 -0
- package/calibration/reviewers/pr-test/seeds/s5-unit-for-browser/overlay/tests/banner.test.mjs +61 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/expect.json +1 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-page.mjs +24 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/src/redeem-server.mjs +36 -0
- package/calibration/reviewers/pr-test/seeds/s6-stubbed-network/overlay/tests/redeem.e2e.test.mjs +56 -0
- package/calibration/reviewers/pr-test/suite.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/base/src/sync.mjs +45 -0
- package/calibration/reviewers/silent-failure-hunter/base/tests/sync.test.mjs +7 -0
- package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/inject/i1-default-mask-injected/overlay/src/sync.mjs +50 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s1-empty-catch/overlay/src/sync.mjs +42 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s2-default-mask/overlay/src/sync.mjs +49 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s3-fire-and-forget/overlay/src/sync.mjs +38 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/expect.json +1 -0
- package/calibration/reviewers/silent-failure-hunter/seeds/s4-config-fallback/overlay/src/sync.mjs +49 -0
- package/calibration/reviewers/silent-failure-hunter/suite.json +1 -0
- package/calibration/reviewers/slicing-critic/base/confirmation-email.md +21 -0
- package/calibration/reviewers/slicing-critic/base/coverage.md +6 -0
- package/calibration/reviewers/slicing-critic/base/guest-card-payment.md +26 -0
- package/calibration/reviewers/slicing-critic/base/recap.json +14 -0
- package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/inject/i1-nongoal-injected/overlay/guest-card-payment.md +29 -0
- package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s1-horizontal-slice/overlay/guest-card-payment.md +23 -0
- package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s2-nongoal-violation/overlay/guest-card-payment.md +27 -0
- package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s3-unfalsifiable-criterion/overlay/confirmation-email.md +19 -0
- package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s4-summary-lie/overlay/confirmation-email.md +19 -0
- package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s5-ui-flag-missing/overlay/guest-card-payment.md +28 -0
- package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s6-ui-flag-spurious/overlay/confirmation-email.md +22 -0
- package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/expect.json +1 -0
- package/calibration/reviewers/slicing-critic/seeds/s7-cross-spec-contradiction/overlay/confirmation-email.md +24 -0
- package/calibration/reviewers/slicing-critic/suite.json +1 -0
- package/calibration/reviewers/type-design/base/src/order.ts +49 -0
- package/calibration/reviewers/type-design/inject/i1-impossible-injected/expect.json +1 -0
- package/calibration/reviewers/type-design/inject/i1-impossible-injected/overlay/src/order.ts +21 -0
- package/calibration/reviewers/type-design/seeds/s1-stringly/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s1-stringly/overlay/src/order.ts +11 -0
- package/calibration/reviewers/type-design/seeds/s2-impossible-states/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s2-impossible-states/overlay/src/order.ts +20 -0
- package/calibration/reviewers/type-design/seeds/s3-public-mutable/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s3-public-mutable/overlay/src/order.ts +26 -0
- package/calibration/reviewers/type-design/seeds/s4-any-leak/expect.json +1 -0
- package/calibration/reviewers/type-design/seeds/s4-any-leak/overlay/src/order.ts +29 -0
- package/calibration/reviewers/type-design/suite.json +1 -0
- package/calibration/skills/decompose/seeds/s1-plain-feature/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s1-plain-feature/recap.json +11 -0
- package/calibration/skills/decompose/seeds/s2-nongoal-bait/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s2-nongoal-bait/recap.json +8 -0
- package/calibration/skills/decompose/seeds/s3-duplicated-fact/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s3-duplicated-fact/recap.json +11 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/checkout-flow.md +19 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/canon/payment-retry.md +20 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s4-fix-routes-amend/recap.json +8 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/canon/checkout-flow.md +19 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/expect.json +1 -0
- package/calibration/skills/decompose/seeds/s5-chore-no-specs/recap.json +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/plan.body.md +5 -0
- package/calibration/skills/implement/seeds/s1-add-function/plan.meta.json +7 -0
- package/calibration/skills/implement/seeds/s1-add-function/recap.json +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s1-add-function/repo/src/clamp.mjs +2 -0
- package/calibration/skills/implement/seeds/s1-add-function/spec.body.md +8 -0
- package/calibration/skills/implement/seeds/s1-add-function/spec.meta.json +8 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/plan.body.md +6 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/plan.meta.json +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/recap.json +8 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/src/slug.mjs +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/repo/tests/slug.test.mjs +7 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/spec.body.md +9 -0
- package/calibration/skills/implement/seeds/s2-fix-bug/spec.meta.json +8 -0
- package/calibration/skills/implement/seeds/s3-two-steps/plan.body.md +11 -0
- package/calibration/skills/implement/seeds/s3-two-steps/plan.meta.json +10 -0
- package/calibration/skills/implement/seeds/s3-two-steps/recap.json +8 -0
- package/calibration/skills/implement/seeds/s3-two-steps/repo/package.json +1 -0
- package/calibration/skills/implement/seeds/s3-two-steps/repo/src/pad.mjs +2 -0
- package/calibration/skills/implement/seeds/s3-two-steps/spec.body.md +9 -0
- package/calibration/skills/implement/seeds/s3-two-steps/spec.meta.json +11 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/expect.json +1 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/parent.body.md +10 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/parent.meta.json +11 -0
- package/calibration/skills/plan/seeds/s1-two-criteria/recap.json +8 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/expect.json +1 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/parent.body.md +11 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/parent.meta.json +14 -0
- package/calibration/skills/plan/seeds/s2-five-criteria/recap.json +8 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/expect.json +1 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/parent.body.md +10 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/parent.meta.json +11 -0
- package/calibration/skills/plan/seeds/s3-mixed-needs/recap.json +8 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/expect.json +1 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.body.md +9 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/parent.meta.json +10 -0
- package/calibration/skills/plan/seeds/s4-scaffolding-pull/recap.json +8 -0
- package/calibration.yaml +5 -0
- package/dist/abandon.js +154 -0
- package/dist/adopt.js +42 -0
- package/dist/allowlist.js +29 -0
- package/dist/bin.js +32 -0
- package/dist/calibrate.js +623 -0
- package/dist/cli.js +98 -0
- package/dist/config.js +155 -0
- package/dist/criteria.js +85 -0
- package/dist/design.js +104 -0
- package/dist/drift.js +280 -0
- package/dist/dsl.js +68 -0
- package/dist/evidence.js +221 -0
- package/dist/fm.js +51 -0
- package/dist/gate.js +371 -0
- package/dist/gates/decompose.js +90 -0
- package/dist/gates/design.js +84 -0
- package/dist/gates/implement.js +144 -0
- package/dist/gates/index.js +6 -0
- package/dist/gates/plan.js +86 -0
- package/dist/gates/ship.js +98 -0
- package/dist/gitio.js +97 -0
- package/dist/glob.js +32 -0
- package/dist/harness.js +229 -0
- package/dist/history.js +35 -0
- package/dist/install.js +133 -0
- package/dist/journal.js +43 -0
- package/dist/junit.js +54 -0
- package/dist/lock.js +37 -0
- package/dist/matcher.js +52 -0
- package/dist/model.js +73 -0
- package/dist/needs.js +34 -0
- package/dist/opener.js +38 -0
- package/dist/pin.js +28 -0
- package/dist/probe.js +14 -0
- package/dist/refusal.js +16 -0
- package/dist/reviewed.js +69 -0
- package/dist/reviewer.js +133 -0
- package/dist/rounds.js +112 -0
- package/dist/runner.js +73 -0
- package/dist/scan.js +67 -0
- package/dist/schemas.js +119 -0
- package/dist/sha.js +27 -0
- package/dist/ship.js +257 -0
- package/dist/stamp.js +104 -0
- package/dist/toon.js +16 -0
- package/dist/txn.js +70 -0
- package/dist/verbs/abandon.js +105 -0
- package/dist/verbs/adopt.js +118 -0
- package/dist/verbs/calibrate.js +130 -0
- package/dist/verbs/check.js +233 -0
- package/dist/verbs/clean.js +34 -0
- package/dist/verbs/dashboard.js +172 -0
- package/dist/verbs/decide.js +309 -0
- package/dist/verbs/design.js +203 -0
- package/dist/verbs/diff.js +60 -0
- package/dist/verbs/dispatch.js +102 -0
- package/dist/verbs/evidence.js +118 -0
- package/dist/verbs/gate.js +12 -0
- package/dist/verbs/index-verb.js +34 -0
- package/dist/verbs/init.js +190 -0
- package/dist/verbs/log.js +151 -0
- package/dist/verbs/next.js +437 -0
- package/dist/verbs/recap.js +145 -0
- package/dist/verbs/recover.js +52 -0
- package/dist/verbs/rename.js +99 -0
- package/dist/verbs/satisfy.js +69 -0
- package/dist/verbs/ship.js +11 -0
- package/dist/verbs/start.js +133 -0
- package/dist/verbs/sync.js +45 -0
- package/dist/verbs/write.js +349 -0
- package/dist/verdict.js +257 -0
- package/dist/worktree.js +73 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +19 -0
- package/plugin/commands/witness.md +46 -0
- package/plugin/hooks/canon-guard.mjs +105 -0
- package/plugin/hooks/guard-state.mjs +34 -0
- package/plugin/hooks/hooks.json +17 -0
- package/plugin/hooks/session-dashboard.sh +6 -0
- package/plugin/hooks/witness-pi.ts +67 -0
- package/plugin/skills/witness-brainstorm/SKILL.md +70 -0
- package/plugin/skills/witness-decompose/SKILL.md +99 -0
- package/plugin/skills/witness-design/SKILL.md +83 -0
- package/plugin/skills/witness-implement/SKILL.md +86 -0
- package/plugin/skills/witness-plan/SKILL.md +93 -0
- package/plugin/skills/witness-ship/SKILL.md +63 -0
- package/prompts/code-reviewer.md +64 -0
- package/prompts/design-critic.md +35 -0
- package/prompts/design-reviewer.md +70 -0
- package/prompts/drift-reviewer.md +40 -0
- package/prompts/plan-critic.md +37 -0
- package/prompts/pr-test.md +97 -0
- package/prompts/silent-failure-hunter.md +145 -0
- package/prompts/slicing-critic.md +46 -0
- package/prompts/type-design.md +113 -0
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ID_RE, validateCriteria, validateNeeds } from './dsl.js';
|
|
2
|
+
import { v } from './refusal.js';
|
|
3
|
+
export const SPEC_STATUS = ['draft', 'approved', 'live'];
|
|
4
|
+
export const PLAN_STATUS = ['draft', 'approved', 'in-progress', 'done', 'abandoned'];
|
|
5
|
+
export const PRINCIPLES_STATUS = ['draft', 'approved'];
|
|
6
|
+
export function validateDoc(meta, body) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const id = meta.id;
|
|
9
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) {
|
|
10
|
+
out.push(v('id', 'id-charset', String(id ?? 'absent'), '[a-z0-9-]+'));
|
|
11
|
+
}
|
|
12
|
+
const type = meta.type;
|
|
13
|
+
if (type !== 'spec' && type !== 'plan' && type !== 'principles') {
|
|
14
|
+
out.push(v('type', 'enum', String(type ?? 'absent'), 'spec | plan | principles'));
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
if (typeof id === 'string' && (type === 'principles' ? id !== 'principles' : id === 'principles')) {
|
|
18
|
+
out.push(v('id', 'reserved', id, "'principles' is reserved for type: principles"));
|
|
19
|
+
}
|
|
20
|
+
if (meta.ui !== undefined && type !== 'spec') {
|
|
21
|
+
out.push(v('ui', 'forbidden', String(meta.ui), 'ui is a spec-only flag'));
|
|
22
|
+
}
|
|
23
|
+
if (meta.design !== undefined && type !== 'spec') {
|
|
24
|
+
out.push(v('design', 'forbidden', 'present', 'design is a spec-only, gate-written stamp'));
|
|
25
|
+
}
|
|
26
|
+
const depends = meta.depends ?? [];
|
|
27
|
+
if (!Array.isArray(depends) || depends.some((d) => typeof d !== 'string' || !ID_RE.test(d))) {
|
|
28
|
+
out.push(v('depends', 'shape', JSON.stringify(meta.depends), 'list of ids matching [a-z0-9-]+'));
|
|
29
|
+
}
|
|
30
|
+
out.push(...validateNeeds(meta.needs));
|
|
31
|
+
const statuses = type === 'spec' ? SPEC_STATUS : type === 'plan' ? PLAN_STATUS : PRINCIPLES_STATUS;
|
|
32
|
+
if (!statuses.includes(String(meta.status))) {
|
|
33
|
+
out.push(v('status', 'enum', String(meta.status ?? 'absent'), statuses.join(' | ')));
|
|
34
|
+
}
|
|
35
|
+
if (type === 'spec') {
|
|
36
|
+
const s = meta.summary;
|
|
37
|
+
if (typeof s !== 'string' || s.length === 0) {
|
|
38
|
+
out.push(v('summary', 'required', String(s ?? 'absent'), 'one-liner: what the slice is'));
|
|
39
|
+
}
|
|
40
|
+
else if (s.length > 120) {
|
|
41
|
+
out.push(v('summary', 'max-length', `${s.length} chars`, '<=120 chars'));
|
|
42
|
+
}
|
|
43
|
+
if (meta.ui !== undefined && meta.ui !== true) {
|
|
44
|
+
out.push(v('ui', 'shape', String(meta.ui), 'true or omitted (a ui spec earns a design stage)'));
|
|
45
|
+
}
|
|
46
|
+
if (meta.design !== undefined) {
|
|
47
|
+
const d = meta.design;
|
|
48
|
+
const hex = (x) => typeof x === 'string' && /^[0-9a-f]{64}$/.test(x);
|
|
49
|
+
if (typeof meta.design !== 'object' || meta.design === null || !hex(d.sha) || !hex(d.spec)) {
|
|
50
|
+
out.push(v('design', 'shape', JSON.stringify(meta.design), '{sha: <64hex>, spec: <64hex>} — the gate-written design stamp'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (typeof id === 'string')
|
|
54
|
+
out.push(...validateCriteria(meta.criteria, id));
|
|
55
|
+
if (meta.supersedes !== undefined && (typeof meta.supersedes !== 'string' || !ID_RE.test(meta.supersedes))) {
|
|
56
|
+
out.push(v('supersedes', 'shape', String(meta.supersedes), 'a spec id'));
|
|
57
|
+
}
|
|
58
|
+
for (const h of ['## Motivation', '## Behavior']) {
|
|
59
|
+
if (!new RegExp(`^${h}$`, 'm').test(body)) {
|
|
60
|
+
out.push(v('body', 'template', `missing ${h}`, 'spec body carries ## Motivation and ## Behavior'));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (type === 'plan') {
|
|
65
|
+
if (typeof meta.parent !== 'string' || !ID_RE.test(meta.parent)) {
|
|
66
|
+
out.push(v('parent', 'required', String(meta.parent ?? 'absent'), 'parent spec id (or principles for chores)'));
|
|
67
|
+
}
|
|
68
|
+
const df = meta['derives-from'];
|
|
69
|
+
if (typeof df !== 'string' || !/^[0-9a-f]{64}$/.test(df)) {
|
|
70
|
+
out.push(v('derives-from', 'shape', String(df ?? 'absent'), '64-hex canonical content sha'));
|
|
71
|
+
}
|
|
72
|
+
if (meta['design-from'] !== undefined && (typeof meta['design-from'] !== 'string' || !/^[0-9a-f]{64}$/.test(String(meta['design-from'])))) {
|
|
73
|
+
out.push(v('design-from', 'shape', String(meta['design-from']), '64-hex design artifact sha'));
|
|
74
|
+
}
|
|
75
|
+
out.push(...validateSteps(meta.steps, body));
|
|
76
|
+
}
|
|
77
|
+
if (type === 'principles' && meta.criteria !== undefined) {
|
|
78
|
+
out.push(v('criteria', 'forbidden', 'present', 'principles carry no criteria'));
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function validateSteps(raw, body) {
|
|
83
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
84
|
+
return [v('steps', 'required', raw === undefined ? 'absent' : 'empty', '>=1 step {id, title, criteria|scaffolding}')];
|
|
85
|
+
}
|
|
86
|
+
const out = [];
|
|
87
|
+
const ids = new Set();
|
|
88
|
+
raw.forEach((s, i) => {
|
|
89
|
+
const at = `steps[${i}]`;
|
|
90
|
+
const e = (typeof s === 'object' && s !== null ? s : {});
|
|
91
|
+
if (typeof e.id !== 'string' || !ID_RE.test(e.id)) {
|
|
92
|
+
out.push(v(`${at}.id`, 'id-charset', String(e.id ?? 'absent'), '[a-z0-9-]+'));
|
|
93
|
+
}
|
|
94
|
+
else if (ids.has(e.id)) {
|
|
95
|
+
out.push(v(`${at}.id`, 'id-unique', e.id, 'unique per plan'));
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
ids.add(e.id);
|
|
99
|
+
}
|
|
100
|
+
if (typeof e.title !== 'string' || e.title === '') {
|
|
101
|
+
out.push(v(`${at}.title`, 'required', String(e.title ?? 'absent'), 'non-empty title'));
|
|
102
|
+
}
|
|
103
|
+
const hasCriteria = Array.isArray(e.criteria) && e.criteria.length > 0;
|
|
104
|
+
const isScaffolding = e.scaffolding === true;
|
|
105
|
+
if (hasCriteria === isScaffolding) {
|
|
106
|
+
out.push(v(at, 'mapping', hasCriteria ? 'criteria and scaffolding' : 'neither criteria nor scaffolding', 'criteria: [ids] XOR scaffolding: true'));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const found = new Set([...body.matchAll(/^## Step: (\S+)$/gm)].map((m) => m[1]));
|
|
110
|
+
for (const sid of ids) {
|
|
111
|
+
if (!found.has(sid))
|
|
112
|
+
out.push(v('body', 'step-section-missing', `no '## Step: ${sid}' section`, 'one section per manifest step'));
|
|
113
|
+
}
|
|
114
|
+
for (const f of found) {
|
|
115
|
+
if (!ids.has(f))
|
|
116
|
+
out.push(v('body', 'step-section-orphan', `'## Step: ${f}'`, 'every section names a manifest step'));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
package/dist/sha.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const VOLATILE_FIELDS = ['status', 'drift', 'pr', 'design'];
|
|
3
|
+
export function canonicalJson(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
6
|
+
if (value && typeof value === 'object') {
|
|
7
|
+
return ('{' +
|
|
8
|
+
Object.entries(value)
|
|
9
|
+
.filter(([, val]) => val !== undefined)
|
|
10
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
11
|
+
.map(([k, val]) => JSON.stringify(k) + ':' + canonicalJson(val))
|
|
12
|
+
.join(',') +
|
|
13
|
+
'}');
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
export function canonicalSha(meta, body) {
|
|
18
|
+
const kept = Object.fromEntries(Object.entries(meta).filter(([k]) => !VOLATILE_FIELDS.includes(k)));
|
|
19
|
+
return createHash('sha256')
|
|
20
|
+
.update(canonicalJson(kept))
|
|
21
|
+
.update('\0')
|
|
22
|
+
.update(body.replace(/\s+$/, '') + '\n')
|
|
23
|
+
.digest('hex');
|
|
24
|
+
}
|
|
25
|
+
export function short(sha) {
|
|
26
|
+
return sha.slice(0, 7);
|
|
27
|
+
}
|
package/dist/ship.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { EXIT } from './cli.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { acquireLock } from './lock.js';
|
|
5
|
+
import { crashPoint, guardTxn, withTxn } from './txn.js';
|
|
6
|
+
import { journalRel, readStream } from './journal.js';
|
|
7
|
+
import { primaryRoot, stateCommit, tryGit } from './gitio.js';
|
|
8
|
+
import { findById, loadCanon } from './scan.js';
|
|
9
|
+
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
10
|
+
import { kv } from './toon.js';
|
|
11
|
+
import { runGate } from './gate.js';
|
|
12
|
+
import { lastGateRun } from './rounds.js';
|
|
13
|
+
import { prepareStamp, writeStamp } from './stamp.js';
|
|
14
|
+
import { branchName, worktreePath } from './worktree.js';
|
|
15
|
+
import { gateSettled } from './verbs/next.js';
|
|
16
|
+
import { worktreeTreeSha } from './reviewed.js';
|
|
17
|
+
export function shipPhase(plan, entries, treeSha, baseMoved = false) {
|
|
18
|
+
// A PR exists, but the watch is only legitimate while the verdict still describes both
|
|
19
|
+
// this tree AND this base. Either one moving re-arms the gate — otherwise a rebase (or
|
|
20
|
+
// any post-approval edit) merges a tree no battery ever read, and re-running
|
|
21
|
+
// `witness ship` is a one-command bypass of every check below.
|
|
22
|
+
// `baseMoved` is a SEPARATE input from `treeSha` on purpose: a moved base has not yet
|
|
23
|
+
// touched the worktree, so the tree sha still matches. Routing on the tree alone would
|
|
24
|
+
// detect the moved base forever and never rebase — only the gate phase rebases, and it
|
|
25
|
+
// is the rebase that finally moves the tree.
|
|
26
|
+
if (plan.meta.pr !== undefined) {
|
|
27
|
+
return gateSettled(entries, 'ship', treeSha) && !baseMoved ? 'watch' : 'gate';
|
|
28
|
+
}
|
|
29
|
+
const last = lastGateRun(entries, 'ship');
|
|
30
|
+
if (!last)
|
|
31
|
+
return 'gate';
|
|
32
|
+
const after = entries.slice(entries.indexOf(last) + 1);
|
|
33
|
+
const decision = after.find((e) => e.t === 'human-decision' && e.gate === 'ship');
|
|
34
|
+
// awaiting-decision is deliberately blind to baseMoved: a human reading
|
|
35
|
+
// `decide ship --show` is reading findings pinned to a sha, and rebasing under them
|
|
36
|
+
// would lapse the very gate they are deciding on.
|
|
37
|
+
if (!decision)
|
|
38
|
+
return 'awaiting-decision';
|
|
39
|
+
if (decision.decision !== 'approve')
|
|
40
|
+
return 'gate';
|
|
41
|
+
// An approval is against a base as well as a tree — opening the PR on a base that has
|
|
42
|
+
// since moved is the same stale-review bug one step earlier.
|
|
43
|
+
return baseMoved ? 'gate' : 'pr';
|
|
44
|
+
}
|
|
45
|
+
function gh(ctx, cwd, args, timeout = 120_000) {
|
|
46
|
+
try {
|
|
47
|
+
const out = execFileSync('gh', args, {
|
|
48
|
+
cwd, env: ctx.env, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
|
|
49
|
+
});
|
|
50
|
+
return { ok: true, out };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
const err = e;
|
|
54
|
+
return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}` || String(err.message) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function existingPr(ctx, root, branch) {
|
|
58
|
+
const r = gh(ctx, root, ['pr', 'list', '--head', branch, '--json', 'number']);
|
|
59
|
+
if (!r.ok)
|
|
60
|
+
return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const arr = JSON.parse(r.out);
|
|
63
|
+
return arr[0]?.number;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function createPr(ctx, wt, root, plan, parentSummary) {
|
|
70
|
+
const branch = branchName(String(plan.meta.id));
|
|
71
|
+
if (!tryGit(root, 'remote', 'get-url', 'origin').ok) {
|
|
72
|
+
return refuse([v('origin', 'no-remote', '(none)', 'an origin remote — gh pr create needs one')]);
|
|
73
|
+
}
|
|
74
|
+
// --force-with-lease once an upstream exists: the rebase now runs in the GATE phase, so
|
|
75
|
+
// a re-entered pr phase pushes a rebased branch and a plain push is no longer a
|
|
76
|
+
// fast-forward. The lease still refuses to clobber anything pushed by someone else.
|
|
77
|
+
const hasUpstream = tryGit(wt, 'rev-parse', '--verify', `refs/remotes/origin/${branch}`).ok;
|
|
78
|
+
const push = hasUpstream
|
|
79
|
+
? tryGit(wt, 'push', '--force-with-lease', '-u', 'origin', branch)
|
|
80
|
+
: tryGit(wt, 'push', '-u', 'origin', branch);
|
|
81
|
+
if (!push.ok)
|
|
82
|
+
return refuse([v('push', 'push-failed', push.out.slice(0, 200), 'git push -u origin to succeed')]);
|
|
83
|
+
const found = existingPr(ctx, root, branch);
|
|
84
|
+
if (found !== undefined)
|
|
85
|
+
return ok(found);
|
|
86
|
+
const created = gh(ctx, wt, ['pr', 'create', '--head', branch,
|
|
87
|
+
'--title', `${String(plan.meta.id)}: ${parentSummary}`,
|
|
88
|
+
'--body', `Realizes spec ${String(plan.meta.parent)} at ${String(plan.meta['derives-from']).slice(0, 7)} (witness).`]);
|
|
89
|
+
if (!created.ok) {
|
|
90
|
+
return refuse([v('gh', 'pr-create-failed', created.out.slice(0, 200), 'gh authenticated with push access — witness check probes gh auth status')]);
|
|
91
|
+
}
|
|
92
|
+
const m = /\/pull\/(\d+)\s*$/.exec(created.out.trim());
|
|
93
|
+
if (!m)
|
|
94
|
+
return refuse([v('gh', 'pr-url-unparseable', created.out.slice(-120), 'a …/pull/<n> URL')]);
|
|
95
|
+
return ok(Number(m[1]));
|
|
96
|
+
}
|
|
97
|
+
export function stampPr(ctx, root, planId, pr) {
|
|
98
|
+
const canon = loadCanon(root);
|
|
99
|
+
const plan = findById(canon, planId);
|
|
100
|
+
const stamp = prepareStamp(plan, String(plan.meta.status), 'ship', { pr });
|
|
101
|
+
// pr: is volatile — status stays in-progress; the stamp writes meta.pr and journals the entry
|
|
102
|
+
const lockR = acquireLock(root);
|
|
103
|
+
if (!lockR.ok)
|
|
104
|
+
return lockR;
|
|
105
|
+
try {
|
|
106
|
+
return withTxn(root, {
|
|
107
|
+
op: 'pr-stamp', files: [plan.rel, journalRel(planId)],
|
|
108
|
+
journalMulti: [{ stream: planId, line: stamp.line }],
|
|
109
|
+
}, () => {
|
|
110
|
+
writeStamp(root, { ...stamp, doc: { ...plan, meta: { ...plan.meta, pr } } });
|
|
111
|
+
crashPoint(ctx.env, 'pr-stamp');
|
|
112
|
+
return stateCommit(root, [plan.rel, journalRel(planId)], `ship(${planId}): pr #${pr}`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
lockR.value();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function rebaseIfMoved(wt, shipBranch, remote = 'origin') {
|
|
120
|
+
// Resolve the base from the REMOTE tip, not the local ref: with several flows merging
|
|
121
|
+
// concurrently, local <shipBranch> lags every merge since the human's last pull, so a
|
|
122
|
+
// local ancestry check reports "clean" against a base that is provably behind and the
|
|
123
|
+
// conflict resurfaces on GitHub after CI already went green.
|
|
124
|
+
if (!tryGit(wt, 'remote', 'get-url', remote).ok) {
|
|
125
|
+
return refuse([v('origin', 'no-remote', '(none)', `a ${remote} remote — ship rebases on its tip`)]);
|
|
126
|
+
}
|
|
127
|
+
// A configured remote that will not fetch is NOT the offline case. Degrading to the
|
|
128
|
+
// local ref here would silently rebase on a stale base immediately before a merge,
|
|
129
|
+
// which is the exact failure this function exists to prevent.
|
|
130
|
+
const fetched = tryGit(wt, 'fetch', remote, shipBranch);
|
|
131
|
+
if (!fetched.ok) {
|
|
132
|
+
return refuse([v('fetch', 'fetch-failed', fetched.out.slice(0, 200), `git fetch ${remote} ${shipBranch} to succeed`)]);
|
|
133
|
+
}
|
|
134
|
+
const base = `${remote}/${shipBranch}`;
|
|
135
|
+
if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
|
|
136
|
+
return ok('clean');
|
|
137
|
+
// --autostash: this runs in the GATE phase, and implement deliberately leaves the
|
|
138
|
+
// worktree uncommitted (ship owns the sole code commit, in the pr phase below), so the
|
|
139
|
+
// tree is normally dirty and a bare rebase refuses with "you have unstaged changes".
|
|
140
|
+
// Autostash makes the rebase see a clean tree without moving witness's commit ahead of
|
|
141
|
+
// the human's decision. A pop that conflicts fails the rebase and refuses like any other.
|
|
142
|
+
const rebase = tryGit(wt, 'rebase', '--autostash', base);
|
|
143
|
+
if (!rebase.ok) {
|
|
144
|
+
tryGit(wt, 'rebase', '--abort');
|
|
145
|
+
return refuse([v('rebase', 'textual-conflict', `${base} moved`, 'resolve in the worktree (rebase manually), then re-run witness ship — the CLI never freehands a merge')]);
|
|
146
|
+
}
|
|
147
|
+
// Only push a branch that HAS an upstream. This now runs in the gate phase, before
|
|
148
|
+
// createPr has ever pushed, so on a first ship there is nothing on the remote to
|
|
149
|
+
// update and an unconditional push fails with "no upstream branch" — createPr does
|
|
150
|
+
// the initial push. Where an upstream does exist the push result is the whole point:
|
|
151
|
+
// reporting 'rebased' after a failed push leaves the PR head on the pre-rebase commit,
|
|
152
|
+
// and CI goes green on a tree nobody will merge.
|
|
153
|
+
if (tryGit(wt, 'rev-parse', '--verify', '--quiet', '@{upstream}').ok) {
|
|
154
|
+
const push = tryGit(wt, 'push', '--force-with-lease');
|
|
155
|
+
if (!push.ok) {
|
|
156
|
+
return refuse([v('push', 'push-failed', push.out.slice(0, 200), 'git push --force-with-lease to succeed')]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return ok('rebased');
|
|
160
|
+
}
|
|
161
|
+
export async function runShip(ctx, planId) {
|
|
162
|
+
const rootR = primaryRoot(ctx.cwd);
|
|
163
|
+
if (!rootR.ok) {
|
|
164
|
+
renderRefusal(rootR.violations).forEach((l) => ctx.err(l));
|
|
165
|
+
return EXIT.REFUSED;
|
|
166
|
+
}
|
|
167
|
+
const root = rootR.value;
|
|
168
|
+
const blocked = guardTxn(ctx, root);
|
|
169
|
+
if (blocked !== undefined)
|
|
170
|
+
return blocked;
|
|
171
|
+
const cfgR = loadConfig(root);
|
|
172
|
+
if (!cfgR.ok) {
|
|
173
|
+
renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
|
|
174
|
+
return EXIT.REFUSED;
|
|
175
|
+
}
|
|
176
|
+
const canon = loadCanon(root);
|
|
177
|
+
const plan = findById(canon, planId);
|
|
178
|
+
if (!plan || plan.meta.type !== 'plan') {
|
|
179
|
+
renderRefusal([v('plan', 'unknown-plan', planId, 'a plans/ doc id')]).forEach((l) => ctx.err(l));
|
|
180
|
+
return EXIT.REFUSED;
|
|
181
|
+
}
|
|
182
|
+
const wt = worktreePath(root, planId);
|
|
183
|
+
const entries = readStream(root, planId);
|
|
184
|
+
const ship = (cfgR.value.raw.ship ?? {});
|
|
185
|
+
const shipBranch = ship.branch ?? 'main';
|
|
186
|
+
// One read-only fetch decides the whole routing. A failed fetch (no remote, offline)
|
|
187
|
+
// leaves baseMoved false and the phases behave as they did — the gate phase's own
|
|
188
|
+
// rebaseIfMoved is where that condition turns into a structured refusal.
|
|
189
|
+
const baseMoved = tryGit(wt, 'fetch', 'origin', shipBranch).ok
|
|
190
|
+
&& !tryGit(wt, 'merge-base', '--is-ancestor', `origin/${shipBranch}`, 'HEAD').ok;
|
|
191
|
+
let phase = shipPhase(plan, entries, worktreeTreeSha(wt), baseMoved);
|
|
192
|
+
if (baseMoved)
|
|
193
|
+
ctx.out(kv('ship', `${shipBranch} moved — rebasing and re-reviewing before the watch`));
|
|
194
|
+
if (phase === 'gate') {
|
|
195
|
+
// Rebase BEFORE the battery, never after: the reviewers must judge the tree that will
|
|
196
|
+
// actually merge. Reviewing first spends a battery — and a human decision — on a tree
|
|
197
|
+
// without any sibling's merged work, which then lapses (gateSettled's reviewed_sha)
|
|
198
|
+
// the moment the rebase lands.
|
|
199
|
+
const rebase = rebaseIfMoved(wt, shipBranch);
|
|
200
|
+
if (!rebase.ok) {
|
|
201
|
+
renderRefusal(rebase.violations).forEach((l) => ctx.err(l));
|
|
202
|
+
// A textual conflict is a hand-back, not a refusal: the human resolves it in the
|
|
203
|
+
// worktree and re-runs. Every other rule (no-remote, fetch-failed, push-failed) is
|
|
204
|
+
// a genuine refusal and exits REFUSED like the rest of the CLI.
|
|
205
|
+
return rebase.violations.some((x) => x.rule === 'textual-conflict') ? EXIT.FINDINGS : EXIT.REFUSED;
|
|
206
|
+
}
|
|
207
|
+
if (rebase.value === 'rebased')
|
|
208
|
+
ctx.out(kv('rebase', `${shipBranch} moved — rebased before the battery`));
|
|
209
|
+
const code = await runGate(ctx, 'ship', planId, { fresh: false, manual: false });
|
|
210
|
+
if (code !== EXIT.FINDINGS && code !== EXIT.OK)
|
|
211
|
+
return code;
|
|
212
|
+
ctx.out(`help: witness decide ship ${planId} --approve to send the PR — ship always stops`);
|
|
213
|
+
return EXIT.FINDINGS;
|
|
214
|
+
}
|
|
215
|
+
if (phase === 'awaiting-decision') {
|
|
216
|
+
ctx.out(kv('ship', `${planId} awaits the ship decision`));
|
|
217
|
+
ctx.out(`help: witness decide ship ${planId} --show | --approve | --revise --note "<why>" | --stop`);
|
|
218
|
+
return EXIT.FINDINGS;
|
|
219
|
+
}
|
|
220
|
+
if (phase === 'pr') {
|
|
221
|
+
const parent = findById(canon, String(plan.meta.parent));
|
|
222
|
+
const parentSummary = String(parent?.meta.summary ?? 'witness change');
|
|
223
|
+
// implement leaves the worktree uncommitted — ship owns the sole code commit
|
|
224
|
+
if (tryGit(wt, 'status', '--porcelain').out !== '') {
|
|
225
|
+
tryGit(wt, 'add', '-A');
|
|
226
|
+
const committed = tryGit(wt, 'commit', '-m', `${planId}: ${parentSummary}`);
|
|
227
|
+
if (!committed.ok) {
|
|
228
|
+
renderRefusal([v('commit', 'ship-commit-failed', committed.out.slice(0, 200), 'a committable worktree — resolve and re-run witness ship')]).forEach((l) => ctx.err(l));
|
|
229
|
+
return EXIT.REFUSED;
|
|
230
|
+
}
|
|
231
|
+
ctx.out(kv('commit', `${planId}: ${parentSummary}`));
|
|
232
|
+
}
|
|
233
|
+
const created = createPr(ctx, wt, root, plan, parentSummary);
|
|
234
|
+
if (!created.ok) {
|
|
235
|
+
renderRefusal(created.violations).forEach((l) => ctx.err(l));
|
|
236
|
+
return EXIT.REFUSED;
|
|
237
|
+
}
|
|
238
|
+
const stamped = stampPr(ctx, root, planId, created.value);
|
|
239
|
+
if (!stamped.ok) {
|
|
240
|
+
renderRefusal(stamped.violations).forEach((l) => ctx.err(l));
|
|
241
|
+
return EXIT.REFUSED;
|
|
242
|
+
}
|
|
243
|
+
ctx.out(kv('pr', `#${created.value} created and stamped`));
|
|
244
|
+
phase = 'watch';
|
|
245
|
+
}
|
|
246
|
+
const pr = Number(findById(loadCanon(root), planId).meta.pr);
|
|
247
|
+
// watch never mutates and never rebases: reaching here means shipPhase already
|
|
248
|
+
// established that both the reviewed tree and the base are the ones the battery judged.
|
|
249
|
+
const green = gh(ctx, root, ['pr', 'checks', String(pr), '--watch'], 1_800_000);
|
|
250
|
+
if (!green.ok) {
|
|
251
|
+
ctx.out(kv('ci', `red — ${green.out.trim().slice(-200)}`));
|
|
252
|
+
return EXIT.FINDINGS;
|
|
253
|
+
}
|
|
254
|
+
ctx.out(kv('ci', 'green'));
|
|
255
|
+
ctx.out(kv('ship', `merge PR #${pr} on GitHub when ready — the lazy stamp flips plan → done, spec → live`));
|
|
256
|
+
return EXIT.OK;
|
|
257
|
+
}
|
package/dist/stamp.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { writeDoc } from './fm.js';
|
|
5
|
+
import { stateCommit } from './gitio.js';
|
|
6
|
+
import { appendEntry, entryLine, journalRel } from './journal.js';
|
|
7
|
+
import { acquireLock } from './lock.js';
|
|
8
|
+
import { canonicalSha } from './sha.js';
|
|
9
|
+
import { findById } from './scan.js';
|
|
10
|
+
import { crashPoint, withTxn } from './txn.js';
|
|
11
|
+
import { removeWorktree, worktreePath } from './worktree.js';
|
|
12
|
+
export function prepareStamp(doc, to, cause, extra = {}) {
|
|
13
|
+
const entry = {
|
|
14
|
+
v: 1, t: 'status', artifact: String(doc.meta.id),
|
|
15
|
+
from: String(doc.meta.status), to, cause, ...extra,
|
|
16
|
+
};
|
|
17
|
+
return { doc, to, entry, line: entryLine(entry), rel: doc.rel, stream: String(doc.meta.id) };
|
|
18
|
+
}
|
|
19
|
+
export function writeStamp(root, s) {
|
|
20
|
+
writeDoc(join(root, s.rel), { meta: { ...s.doc.meta, status: s.to }, body: s.doc.body });
|
|
21
|
+
appendEntry(root, s.stream, s.entry);
|
|
22
|
+
}
|
|
23
|
+
function prState(ctx, root, pr) {
|
|
24
|
+
try {
|
|
25
|
+
const out = execFileSync('gh', ['pr', 'view', String(pr), '--json', 'state'], {
|
|
26
|
+
cwd: root, env: ctx.env, encoding: 'utf8', timeout: 3_000,
|
|
27
|
+
});
|
|
28
|
+
return JSON.parse(out).state;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function lazyStamp(root, ctx, canon) {
|
|
35
|
+
const result = { stamped: [], stale: [] };
|
|
36
|
+
if (ctx.env.CI)
|
|
37
|
+
return result; // CI never writes state
|
|
38
|
+
const candidates = canon.docs.filter((d) => d.meta.type === 'plan' && String(d.meta.status) === 'in-progress' && d.meta.pr !== undefined);
|
|
39
|
+
for (const plan of candidates) {
|
|
40
|
+
const planId = String(plan.meta.id);
|
|
41
|
+
const pr = Number(plan.meta.pr);
|
|
42
|
+
const state = prState(ctx, root, pr);
|
|
43
|
+
if (state === undefined) {
|
|
44
|
+
result.stale.push({ plan: planId, why: "stale, couldn't check" });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (state === 'CLOSED') {
|
|
48
|
+
result.stale.push({ plan: planId, why: `pr #${pr} closed unmerged — witness abandon ${planId}?` });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (state !== 'MERGED') {
|
|
52
|
+
result.stale.push({ plan: planId, why: `pr #${pr} still ${state}` });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const parent = findById(canon, String(plan.meta.parent));
|
|
56
|
+
const pinFresh = parent !== undefined &&
|
|
57
|
+
canonicalSha(parent.meta, parent.body) === String(plan.meta['derives-from']);
|
|
58
|
+
const planStamp = prepareStamp(plan, 'done', 'merge', { pr, ...(pinFresh ? {} : { note: 'stale-merge' }) });
|
|
59
|
+
const specStamp = parent && pinFresh && parent.meta.type === 'spec'
|
|
60
|
+
? prepareStamp(parent, 'live', 'merge', { pr }) : undefined;
|
|
61
|
+
const files = [planStamp.rel, journalRel(planStamp.stream),
|
|
62
|
+
...(specStamp ? [specStamp.rel, journalRel(specStamp.stream)] : [])];
|
|
63
|
+
const lockR = acquireLock(root);
|
|
64
|
+
if (!lockR.ok) {
|
|
65
|
+
result.stale.push({ plan: planId, why: 'lock held' });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const txn = withTxn(root, {
|
|
70
|
+
op: 'merge-stamp', files,
|
|
71
|
+
journalMulti: [
|
|
72
|
+
{ stream: planStamp.stream, line: planStamp.line },
|
|
73
|
+
...(specStamp ? [{ stream: specStamp.stream, line: specStamp.line }] : []),
|
|
74
|
+
],
|
|
75
|
+
}, () => {
|
|
76
|
+
writeStamp(root, planStamp);
|
|
77
|
+
if (specStamp)
|
|
78
|
+
writeStamp(root, specStamp);
|
|
79
|
+
crashPoint(ctx.env, 'merge-stamp');
|
|
80
|
+
return stateCommit(root, files, `merge(${planId}): pr #${pr}`);
|
|
81
|
+
});
|
|
82
|
+
if (txn.ok) {
|
|
83
|
+
removeWorktree(root, planId);
|
|
84
|
+
result.stamped.push({ plan: planId, ...(specStamp ? { spec: specStamp.stream } : {}), pr });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
result.stale.push({ plan: planId, why: txn.violations.map((x) => x.rule).join(' ') });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
lockR.value();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// a crash between writeStamp and this point recovers generically (journal replay +
|
|
95
|
+
// commit only) — completeTxn has no notion of "also remove the worktree", and a plan
|
|
96
|
+
// already 'done' never re-enters `candidates` above, so the removal must be re-checked
|
|
97
|
+
// independently of whichever call actually performed the stamp.
|
|
98
|
+
for (const plan of canon.docs.filter((d) => d.meta.type === 'plan' && String(d.meta.status) === 'done')) {
|
|
99
|
+
const planId = String(plan.meta.id);
|
|
100
|
+
if (existsSync(worktreePath(root, planId)))
|
|
101
|
+
removeWorktree(root, planId);
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
package/dist/toon.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function esc(value) {
|
|
2
|
+
const s = String(value ?? '');
|
|
3
|
+
return /[,"\n]|^\s|\s$/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
|
4
|
+
}
|
|
5
|
+
export function kv(key, value) {
|
|
6
|
+
return `${key}: ${esc(value)}`;
|
|
7
|
+
}
|
|
8
|
+
export function list(key, values) {
|
|
9
|
+
return `${key}[${values.length}]: ${values.map(esc).join(',')}`;
|
|
10
|
+
}
|
|
11
|
+
export function rows(key, fields, items) {
|
|
12
|
+
return [
|
|
13
|
+
`${key}[${items.length}]{${fields.join(',')}}:`,
|
|
14
|
+
...items.map((it) => ' ' + fields.map((f) => esc(it[f])).join(',')),
|
|
15
|
+
];
|
|
16
|
+
}
|
package/dist/txn.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { EXIT } from './cli.js';
|
|
4
|
+
import { git, stateCommit, tryGit } from './gitio.js';
|
|
5
|
+
import { journalRel } from './journal.js';
|
|
6
|
+
const markerPath = (root) => join(root, '.witness', 'txn.json');
|
|
7
|
+
export function pendingTxn(root) {
|
|
8
|
+
if (!existsSync(markerPath(root)))
|
|
9
|
+
return undefined;
|
|
10
|
+
return JSON.parse(readFileSync(markerPath(root), 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
export function crashPoint(env, name) {
|
|
13
|
+
if (env.WITNESS_CRASH_AFTER === name)
|
|
14
|
+
process.exit(9);
|
|
15
|
+
}
|
|
16
|
+
export function withTxn(root, marker, fn) {
|
|
17
|
+
mkdirSync(join(root, '.witness'), { recursive: true });
|
|
18
|
+
writeFileSync(markerPath(root), JSON.stringify(marker));
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = fn();
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
rollbackTxn(root, marker);
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
if (res.ok)
|
|
28
|
+
rmSync(markerPath(root), { force: true });
|
|
29
|
+
else
|
|
30
|
+
rollbackTxn(root, marker);
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
export function rollbackTxn(root, marker) {
|
|
34
|
+
for (const f of marker.files) {
|
|
35
|
+
const tracked = tryGit(root, 'ls-files', '--error-unmatch', '--', f).ok;
|
|
36
|
+
if (tracked)
|
|
37
|
+
git(root, 'checkout', '--', f);
|
|
38
|
+
else
|
|
39
|
+
rmSync(join(root, f), { force: true });
|
|
40
|
+
}
|
|
41
|
+
rmSync(markerPath(root), { force: true });
|
|
42
|
+
}
|
|
43
|
+
export function completeTxn(root, marker) {
|
|
44
|
+
const items = [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])];
|
|
45
|
+
if (items.length)
|
|
46
|
+
mkdirSync(join(root, '.witness', 'journal'), { recursive: true });
|
|
47
|
+
for (const { stream, line } of items) {
|
|
48
|
+
const p = join(root, journalRel(stream));
|
|
49
|
+
const current = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
50
|
+
const lastLine = current.split('\n').filter(Boolean).at(-1);
|
|
51
|
+
if (lastLine !== line)
|
|
52
|
+
appendFileSync(p, line + '\n');
|
|
53
|
+
}
|
|
54
|
+
const res = stateCommit(root, marker.files, `${marker.op} (recovered)`);
|
|
55
|
+
if (res.ok)
|
|
56
|
+
rmSync(markerPath(root), { force: true });
|
|
57
|
+
return res;
|
|
58
|
+
}
|
|
59
|
+
export function guardTxn(ctx, root) {
|
|
60
|
+
const m = pendingTxn(root);
|
|
61
|
+
if (!m)
|
|
62
|
+
return undefined;
|
|
63
|
+
ctx.err(`pending transaction from a crashed invocation: ${m.op}`);
|
|
64
|
+
// `files` is required on every marker and populated at all withTxn sites, so this
|
|
65
|
+
// localizes the damage everywhere — unlike an optional owner field, which would be
|
|
66
|
+
// absent at the longest-running write window (gate.ts) and teach a false distinction.
|
|
67
|
+
ctx.err(` files: ${m.files.join(', ')}`);
|
|
68
|
+
ctx.err('help: witness recover --complete | --rollback');
|
|
69
|
+
return EXIT.BLOCKED;
|
|
70
|
+
}
|