@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/harness.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CLAUDE_THINKING_BUDGET, parsePin } from './pin.js';
|
|
4
|
+
import { ok, refuse, v } from './refusal.js';
|
|
5
|
+
export const HARNESSES = ['claude-code', 'pi'];
|
|
6
|
+
export const DEFAULT_HARNESS = 'claude-code';
|
|
7
|
+
// The six shipped stage skills, by directory name. `check`'s visibility probe asks
|
|
8
|
+
// whether the resolved harness can see all of them; a partial install is a warn, not
|
|
9
|
+
// a pass, because the missing one is always the stage you are about to reach.
|
|
10
|
+
export const STAGE_SKILLS = [
|
|
11
|
+
'witness-brainstorm', 'witness-decompose', 'witness-design',
|
|
12
|
+
'witness-implement', 'witness-plan', 'witness-ship',
|
|
13
|
+
];
|
|
14
|
+
function parseClaudeEnvelope(stdout) {
|
|
15
|
+
try {
|
|
16
|
+
const envelope = JSON.parse(stdout);
|
|
17
|
+
if (typeof envelope.result !== 'string')
|
|
18
|
+
throw new Error('missing result');
|
|
19
|
+
return ok({ text: envelope.result });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return refuse([v('claude', 'envelope-unparseable', stdout.slice(0, 120), 'a --output-format json envelope carrying a result string')]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// pi --mode json emits an NDJSON event stream; the terminal agent_end event carries the
|
|
26
|
+
// full message history. Provider failures arrive IN-stream (stopReason: "error") — the
|
|
27
|
+
// process can still exit 0, so exit-code checks alone cannot detect them.
|
|
28
|
+
function parsePiEnvelope(stdout) {
|
|
29
|
+
let end;
|
|
30
|
+
for (const line of stdout.split('\n')) {
|
|
31
|
+
if (!line.startsWith('{'))
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const evt = JSON.parse(line);
|
|
35
|
+
if (evt.type === 'agent_end')
|
|
36
|
+
end = evt;
|
|
37
|
+
}
|
|
38
|
+
catch { /* interleaved non-JSON output — skip the line, keep scanning */ }
|
|
39
|
+
}
|
|
40
|
+
const assistant = end?.messages?.filter((m) => m.role === 'assistant').at(-1);
|
|
41
|
+
if (assistant?.stopReason === 'error') {
|
|
42
|
+
return refuse([v('pi', 'reviewer-invocation', (assistant.errorMessage ?? 'provider error').slice(0, 200), 'a provider the pinned model can reach — check auth and billing for that provider')]);
|
|
43
|
+
}
|
|
44
|
+
const text = (assistant?.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n');
|
|
45
|
+
if (text === '') {
|
|
46
|
+
return refuse([v('pi', 'envelope-unparseable', stdout.slice(0, 120), 'a --mode json event stream whose agent_end carries assistant text')]);
|
|
47
|
+
}
|
|
48
|
+
return ok({ text });
|
|
49
|
+
}
|
|
50
|
+
// Revision 10: a typed constant, not `harness/<name>.json`. Every consumer is
|
|
51
|
+
// TypeScript and the name set is closed by loadHarness's refusal, so a data file bought
|
|
52
|
+
// extensibility the design forbids — while costing an unchecked `as Harness` cast, a
|
|
53
|
+
// registry-missing path, packageRoot(), and a packaging failure mode NO test can catch:
|
|
54
|
+
// drop the dir from package.json `files` and every verb refuses in the published
|
|
55
|
+
// package while the whole suite stays green, because vitest reads from the repo root.
|
|
56
|
+
const REGISTRY = {
|
|
57
|
+
'claude-code': {
|
|
58
|
+
name: 'claude-code',
|
|
59
|
+
launch: 'claude',
|
|
60
|
+
relay: '/clear',
|
|
61
|
+
settings: '.claude/settings.json',
|
|
62
|
+
bundled: true,
|
|
63
|
+
payload: [
|
|
64
|
+
{ from: 'plugin/commands/witness.md', to: '.claude/commands/witness.md' },
|
|
65
|
+
{ from: 'plugin/hooks/canon-guard.mjs', to: '.claude/hooks/canon-guard.mjs' },
|
|
66
|
+
{ from: 'plugin/hooks/guard-state.mjs', to: '.claude/hooks/guard-state.mjs' },
|
|
67
|
+
{ from: 'plugin/hooks/session-dashboard.sh', to: '.claude/hooks/session-dashboard.sh' },
|
|
68
|
+
],
|
|
69
|
+
skills: { project: '.claude/skills', global: '.claude/skills' },
|
|
70
|
+
reviewer: {
|
|
71
|
+
spawn(pin) {
|
|
72
|
+
const args = ['-p', '--output-format', 'json'];
|
|
73
|
+
const env = {};
|
|
74
|
+
if (pin !== undefined) {
|
|
75
|
+
args.push('--model', pin.model);
|
|
76
|
+
if (pin.thinking !== 'off')
|
|
77
|
+
env.MAX_THINKING_TOKENS = String(CLAUDE_THINKING_BUDGET[pin.thinking]);
|
|
78
|
+
}
|
|
79
|
+
return { cmd: 'claude', args, env };
|
|
80
|
+
},
|
|
81
|
+
parseEnvelope: parseClaudeEnvelope,
|
|
82
|
+
},
|
|
83
|
+
worker: {
|
|
84
|
+
// claude gates every tool behind an approval prompt, which no headless run can
|
|
85
|
+
// answer — the bypass flag is what makes print mode able to edit at all.
|
|
86
|
+
spawn: (prompt) => ({ cmd: 'claude', args: ['-p', prompt, '--dangerously-skip-permissions'], env: {} }),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
pi: {
|
|
90
|
+
name: 'pi',
|
|
91
|
+
launch: 'pi',
|
|
92
|
+
defaultProvider: 'anthropic',
|
|
93
|
+
relay: '/new',
|
|
94
|
+
bundled: false,
|
|
95
|
+
payload: [
|
|
96
|
+
{ from: 'plugin/commands/witness.md', to: '.pi/prompts/witness.md' },
|
|
97
|
+
{ from: 'plugin/hooks/canon-guard.mjs', to: '.pi/extensions/canon-guard.mjs' },
|
|
98
|
+
// Source-tree sibling of canon-guard.mjs, exactly as it is in .pi/extensions/:
|
|
99
|
+
// the adapter imports the core with ONE static relative specifier, and a
|
|
100
|
+
// specifier that only resolves after install is a trap no unit test can hold.
|
|
101
|
+
{ from: 'plugin/hooks/witness-pi.ts', to: '.pi/extensions/witness.ts' },
|
|
102
|
+
{ from: 'plugin/hooks/session-dashboard.sh', to: '.pi/extensions/session-dashboard.sh' },
|
|
103
|
+
],
|
|
104
|
+
skills: { project: '.pi/skills', global: '.pi/agent/skills' },
|
|
105
|
+
// Hermetic (Decision 88): every omitted flag here is a machine-local variable that
|
|
106
|
+
// would silently change reviewer behavior — this machine's `defaultThinkingLevel:
|
|
107
|
+
// xhigh` was the probable true cause of row 87's "stalls on long prompts".
|
|
108
|
+
reviewer: {
|
|
109
|
+
spawn(pin) {
|
|
110
|
+
const args = ['-p', '--mode', 'json', '--no-session', '--no-extensions',
|
|
111
|
+
'--no-skills', '--no-context-files', '--thinking', pin?.thinking ?? 'off'];
|
|
112
|
+
if (pin !== undefined)
|
|
113
|
+
args.push('--model', `${pin.provider ?? 'anthropic'}/${pin.model}`);
|
|
114
|
+
return { cmd: 'pi', args, env: {} };
|
|
115
|
+
},
|
|
116
|
+
parseEnvelope: parsePiEnvelope,
|
|
117
|
+
},
|
|
118
|
+
worker: {
|
|
119
|
+
// No bypass flag exists or is needed: pi's built-in tools are not approval-gated
|
|
120
|
+
// (`pi --help` lists only --approve/--no-approve, which govern trusting
|
|
121
|
+
// project-local files). --no-session keeps a calibration run out of the user's
|
|
122
|
+
// session store; the worker otherwise keeps its skills and context files, which
|
|
123
|
+
// are exactly what the implement seed is measuring.
|
|
124
|
+
spawn: (prompt) => ({ cmd: 'pi', args: ['-p', prompt, '--no-session'], env: {} }),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
export function loadHarness(name) {
|
|
129
|
+
const harness = REGISTRY[name];
|
|
130
|
+
if (harness === undefined) {
|
|
131
|
+
return refuse([v('harness', 'unknown-harness', name, HARNESSES.join(' | '))]);
|
|
132
|
+
}
|
|
133
|
+
return ok(harness);
|
|
134
|
+
}
|
|
135
|
+
const relabel = (violations, field) => violations.map((x) => ({ ...x, field }));
|
|
136
|
+
// Decision 5. Detection is the authority; config is the fallback. A config-authority
|
|
137
|
+
// default in a fresh repo emits a runnable-LOOKING, unrunnable handoff behind a warning
|
|
138
|
+
// that gets scrolled past — bug B2's exact shape. Detection tests PRESENCE: neither
|
|
139
|
+
// CLAUDECODE=1 nor PI_CODING_AGENT=true is a documented value contract.
|
|
140
|
+
//
|
|
141
|
+
// Deliberately NOT wired into loadConfig: every verb calls that, so an invalid
|
|
142
|
+
// `harness:` there would brick `witness check` on a key nothing read. Verbs that need
|
|
143
|
+
// a harness ask for one; `check` reports a malformed config value as a finding.
|
|
144
|
+
export function resolveHarness(env, raw) {
|
|
145
|
+
const override = env.WITNESS_HARNESS;
|
|
146
|
+
if (override !== undefined && override !== '') {
|
|
147
|
+
const r = loadHarness(override);
|
|
148
|
+
return r.ok ? ok({ harness: r.value, source: 'env' }) : refuse(relabel(r.violations, 'WITNESS_HARNESS'));
|
|
149
|
+
}
|
|
150
|
+
if (env.PI_CODING_AGENT !== undefined) {
|
|
151
|
+
const r = loadHarness('pi');
|
|
152
|
+
return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
|
|
153
|
+
}
|
|
154
|
+
if (env.CLAUDECODE !== undefined) {
|
|
155
|
+
const r = loadHarness('claude-code');
|
|
156
|
+
return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
|
|
157
|
+
}
|
|
158
|
+
const configured = raw.harness;
|
|
159
|
+
if (configured !== undefined) {
|
|
160
|
+
const r = loadHarness(String(configured));
|
|
161
|
+
return r.ok ? ok({ harness: r.value, source: 'config' }) : refuse(r.violations);
|
|
162
|
+
}
|
|
163
|
+
const r = loadHarness(DEFAULT_HARNESS);
|
|
164
|
+
return r.ok ? ok({ harness: r.value, source: 'default' }) : refuse(r.violations);
|
|
165
|
+
}
|
|
166
|
+
// Decision 9: the model flag is a renderer, not a string. Pi's default provider is
|
|
167
|
+
// `google`, so a bare `--model claude-opus-5` in a Pi handoff resolves wrong or not at
|
|
168
|
+
// all — and a relay session silently running an unpinned model corrupts what row 83's
|
|
169
|
+
// pins and the calibration story rest on.
|
|
170
|
+
//
|
|
171
|
+
// Revision 9: the provider is the HARNESS's default, never a config key.
|
|
172
|
+
//
|
|
173
|
+
// Extended by Decision 88: the flag renders over the PARSED pin, not the raw string.
|
|
174
|
+
// Treating the whole string as {model} emitted `--model anthropic/google/gemini-3.6-pro`
|
|
175
|
+
// for a provider-qualified pin — pastes cleanly, resolves to nothing: bug B2's shape.
|
|
176
|
+
// Pi renders the provider and its native `:thinking` suffix; claude-code renders the
|
|
177
|
+
// bare id and carries non-off thinking as a MAX_THINKING_TOKENS prefix on the line.
|
|
178
|
+
function modelArg(harness, model) {
|
|
179
|
+
if (model === undefined || model === '')
|
|
180
|
+
return '';
|
|
181
|
+
const parsed = parsePin('gates.model', model);
|
|
182
|
+
if (!parsed.ok)
|
|
183
|
+
return ''; // stagePin refused upstream; render nothing rather than garbage
|
|
184
|
+
const pin = parsed.value;
|
|
185
|
+
if (harness.name === 'pi') {
|
|
186
|
+
const suffix = pin.thinking === 'off' ? '' : `:${pin.thinking}`;
|
|
187
|
+
return ` --model ${pin.provider ?? harness.defaultProvider ?? ''}/${pin.model}${suffix}`;
|
|
188
|
+
}
|
|
189
|
+
return ` --model ${pin.model}`;
|
|
190
|
+
}
|
|
191
|
+
// Single quotes: a double-quoted form trips toon esc() quoting and emits an
|
|
192
|
+
// unpasteable line (see the note this replaces at verbs/next.ts:69).
|
|
193
|
+
export function handoffLine(harness, home, model) {
|
|
194
|
+
const parsed = model !== undefined && model !== '' ? parsePin('gates.model', model) : undefined;
|
|
195
|
+
const budget = harness.name === 'claude-code' && parsed?.ok === true && parsed.value.thinking !== 'off'
|
|
196
|
+
? `MAX_THINKING_TOKENS=${CLAUDE_THINKING_BUDGET[parsed.value.thinking]} `
|
|
197
|
+
: '';
|
|
198
|
+
return `cd '${home}' && ${budget}${harness.launch}${modelArg(harness, model)} '/witness'`;
|
|
199
|
+
}
|
|
200
|
+
// No comma: toon's esc() quotes any value containing one (toon.ts:3), and a quoted
|
|
201
|
+
// `relay: "/clear, then /witness"` is what the implement skill would then print
|
|
202
|
+
// verbatim — the same class of defect as the double-quoted handoff note above.
|
|
203
|
+
export function relayLine(harness) {
|
|
204
|
+
return `${harness.relay} then /witness`;
|
|
205
|
+
}
|
|
206
|
+
// Decision 14. Pi resolves project skills at resolve(cwd, '.pi', 'skills') with no
|
|
207
|
+
// upward walk, and implement runs with cwd inside .witness/worktrees/<plan-id> — an
|
|
208
|
+
// untracked directory the installer never touched. A project-scope install therefore
|
|
209
|
+
// loses every skill in the stage that does the most work.
|
|
210
|
+
export function skillsVisibility(env, root, harness) {
|
|
211
|
+
const home = env.HOME ?? env.USERPROFILE ?? '';
|
|
212
|
+
const has = (dir) => STAGE_SKILLS.every((s) => existsSync(join(dir, s, 'SKILL.md')));
|
|
213
|
+
if (home !== '' && has(join(home, harness.skills.global)))
|
|
214
|
+
return 'global';
|
|
215
|
+
if (has(join(root, harness.skills.project)))
|
|
216
|
+
return 'project-only';
|
|
217
|
+
return 'absent';
|
|
218
|
+
}
|
|
219
|
+
// Harness-compat rung of pin validation. Grammar and alias checks live in stagePin
|
|
220
|
+
// (model.ts); THIS check needs the harness, which stagePin deliberately never resolves.
|
|
221
|
+
export function validatePin(harness, field, raw) {
|
|
222
|
+
const r = parsePin(field, raw);
|
|
223
|
+
if (!r.ok)
|
|
224
|
+
return r;
|
|
225
|
+
if (harness.name === 'claude-code' && r.value.provider !== undefined) {
|
|
226
|
+
return refuse([v(field, 'provider-unrunnable', raw, 'a bare model id — the claude CLI cannot run provider-qualified models')]);
|
|
227
|
+
}
|
|
228
|
+
return r;
|
|
229
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { splitDoc } from './fm.js';
|
|
2
|
+
import { tryGit } from './gitio.js';
|
|
3
|
+
import { canonicalSha } from './sha.js';
|
|
4
|
+
export function contentAtSha(root, rel, sha) {
|
|
5
|
+
const log = tryGit(root, 'log', '--format=%H', '--', rel);
|
|
6
|
+
if (!log.ok || log.out === '')
|
|
7
|
+
return undefined;
|
|
8
|
+
for (const commit of log.out.split('\n')) {
|
|
9
|
+
const shown = tryGit(root, 'show', `${commit}:${rel}`);
|
|
10
|
+
if (!shown.ok)
|
|
11
|
+
continue;
|
|
12
|
+
const parsed = splitDoc(shown.out + '\n');
|
|
13
|
+
if (!parsed.ok)
|
|
14
|
+
continue;
|
|
15
|
+
if (canonicalSha(parsed.value.meta, parsed.value.body) === sha)
|
|
16
|
+
return parsed.value;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
export function baseForSpec(root, canon, specId, excludePlanId) {
|
|
21
|
+
const candidates = canon.docs.filter((d) => d.meta.type === 'plan' &&
|
|
22
|
+
d.meta.parent === specId &&
|
|
23
|
+
typeof d.meta['derives-from'] === 'string' &&
|
|
24
|
+
d.meta.id !== excludePlanId);
|
|
25
|
+
if (candidates.length === 0)
|
|
26
|
+
return { kind: 'empty' };
|
|
27
|
+
const stamped = candidates.map((p) => {
|
|
28
|
+
const res = tryGit(root, 'log', '-1', '--format=%ct', '--', p.rel);
|
|
29
|
+
const at = res.ok && res.out !== '' ? Number(res.out) : Number.POSITIVE_INFINITY;
|
|
30
|
+
return { p, at };
|
|
31
|
+
});
|
|
32
|
+
stamped.sort((a, b) => b.at - a.at || String(b.p.meta.id).localeCompare(String(a.p.meta.id)));
|
|
33
|
+
const latest = stamped[0].p;
|
|
34
|
+
return { kind: 'plan-pin', sha: String(latest.meta['derives-from']), planId: String(latest.meta.id) };
|
|
35
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { tryGit } from './gitio.js';
|
|
5
|
+
import { ok, refuse, v } from './refusal.js';
|
|
6
|
+
// dist/install.js and src/install.ts both sit one level under the package root, so the
|
|
7
|
+
// same '..' works built and under vitest — the pattern model.ts:14 already uses. This is
|
|
8
|
+
// the only place that still needs it: locating the shipped `plugin/` tree, which is a
|
|
9
|
+
// genuine data dir already listed in package.json `files`.
|
|
10
|
+
export function packageRoot() {
|
|
11
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
}
|
|
13
|
+
// Project-scope hook commands. ${CLAUDE_PLUGIN_ROOT} exists only for plugin installs;
|
|
14
|
+
// a project install anchors on $CLAUDE_PROJECT_DIR, which Claude Code substitutes in
|
|
15
|
+
// hook commands (verified against 2.1.220).
|
|
16
|
+
const GUARD_CMD = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-state.mjs"';
|
|
17
|
+
const DASHBOARD_CMD = 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/session-dashboard.sh"';
|
|
18
|
+
const PIN = /@popovych\.co\/witness@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g;
|
|
19
|
+
// Revision 6. Committing the payload is load-bearing: the implement stage runs with cwd
|
|
20
|
+
// inside .witness/worktrees/<plan-id>, which is a checkout of the branch, so only
|
|
21
|
+
// committed files reach it. A gitignored target therefore has exactly one honest answer
|
|
22
|
+
// — refuse. `git add -f` would override a rule the human wrote down; writing without
|
|
23
|
+
// committing would leave the payload in the primary root and the worktree with no guard,
|
|
24
|
+
// while every `check` reads clean. Called BEFORE the lock and before any scaffold write,
|
|
25
|
+
// so a refusal leaves nothing half-installed.
|
|
26
|
+
export function preflightPayload(root, harness) {
|
|
27
|
+
const ignored = [...harness.payload.map((p) => p.to), ...(harness.settings ? [harness.settings] : [])]
|
|
28
|
+
.filter((rel) => tryGit(root, 'check-ignore', '-q', '--', rel).ok);
|
|
29
|
+
if (ignored.length === 0)
|
|
30
|
+
return ok(undefined);
|
|
31
|
+
return refuse(ignored.map((rel) => v(rel, 'payload-ignored', 'matched by .gitignore', 'a committable path — worktrees are branch checkouts, so only committed payloads reach ' +
|
|
32
|
+
'.witness/worktrees/<plan-id>; un-ignore it, or install a different agent here')));
|
|
33
|
+
}
|
|
34
|
+
// A shipped file and an installed file that differ ONLY by version pin are the same
|
|
35
|
+
// file, one upgrade apart. Substituting the installed pin into the shipped text and
|
|
36
|
+
// comparing is exact — no heuristics, no diffing.
|
|
37
|
+
function pinOnlyDifference(shipped, installed) {
|
|
38
|
+
const installedPin = installed.match(PIN)?.[0];
|
|
39
|
+
if (installedPin === undefined)
|
|
40
|
+
return false;
|
|
41
|
+
return shipped.replace(PIN, installedPin) === installed;
|
|
42
|
+
}
|
|
43
|
+
// Revision 1: SYNC, not install-once. These files are witness's artifacts that happen
|
|
44
|
+
// to live in the user's repo, and the engine file's pin is the single point deciding
|
|
45
|
+
// which CLI version the entire pipeline runs — so an install-once rule pinned every repo
|
|
46
|
+
// to whatever version first touched it, forever, with `payload: already installed`
|
|
47
|
+
// printed on every attempt to fix it. A file the human actually edited is still theirs:
|
|
48
|
+
// report it, never clobber it.
|
|
49
|
+
export function installPayload(root, harness) {
|
|
50
|
+
const pre = preflightPayload(root, harness);
|
|
51
|
+
if (!pre.ok)
|
|
52
|
+
return refuse(pre.violations);
|
|
53
|
+
const out = { written: [], restamped: [], modified: [] };
|
|
54
|
+
for (const { from, to } of harness.payload) {
|
|
55
|
+
const src = join(packageRoot(), from);
|
|
56
|
+
if (!existsSync(src)) {
|
|
57
|
+
return refuse([v('payload', 'source-missing', from, 'a file shipped in the witness tarball — reinstall @popovych.co/witness')]);
|
|
58
|
+
}
|
|
59
|
+
const shipped = readFileSync(src, 'utf8');
|
|
60
|
+
const dst = join(root, to);
|
|
61
|
+
if (!existsSync(dst)) {
|
|
62
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
63
|
+
writeFileSync(dst, shipped);
|
|
64
|
+
out.written.push(to);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const installed = readFileSync(dst, 'utf8');
|
|
68
|
+
if (installed === shipped)
|
|
69
|
+
continue;
|
|
70
|
+
if (pinOnlyDifference(shipped, installed)) {
|
|
71
|
+
writeFileSync(dst, shipped);
|
|
72
|
+
out.restamped.push(to);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.modified.push(to);
|
|
76
|
+
}
|
|
77
|
+
return ok(out);
|
|
78
|
+
}
|
|
79
|
+
function hasCommand(entries, command) {
|
|
80
|
+
return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
|
|
81
|
+
}
|
|
82
|
+
// Merge, never replace: .claude/settings.json is the human's file and may already carry
|
|
83
|
+
// their own hooks, model pin and permissions. We append only what is missing, matched
|
|
84
|
+
// by command string, and preserve every other key byte for byte.
|
|
85
|
+
export function mergeSettings(existing) {
|
|
86
|
+
let doc = {};
|
|
87
|
+
if (existing !== undefined && existing.trim() !== '') {
|
|
88
|
+
try {
|
|
89
|
+
doc = JSON.parse(existing);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Unparseable settings are the human's problem to fix; refusing to touch them is
|
|
93
|
+
// safer than rewriting them. Signal "no change" and let init report it.
|
|
94
|
+
return { text: existing, changed: false };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const hooks = (doc.hooks ?? {});
|
|
98
|
+
let changed = false;
|
|
99
|
+
const pre = [...(hooks.PreToolUse ?? [])];
|
|
100
|
+
for (const matcher of ['Edit|Write|MultiEdit', 'Bash']) {
|
|
101
|
+
if (!pre.some((e) => e.matcher === matcher && hasCommand([e], GUARD_CMD))) {
|
|
102
|
+
pre.push({ matcher, hooks: [{ type: 'command', command: GUARD_CMD }] });
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const start = [...(hooks.SessionStart ?? [])];
|
|
107
|
+
if (!hasCommand(start, DASHBOARD_CMD)) {
|
|
108
|
+
start.push({ hooks: [{ type: 'command', command: DASHBOARD_CMD }] });
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
if (!changed)
|
|
112
|
+
return { text: existing ?? '', changed: false };
|
|
113
|
+
doc.hooks = { ...hooks, PreToolUse: pre, SessionStart: start };
|
|
114
|
+
return { text: `${JSON.stringify(doc, null, 2)}\n`, changed: true };
|
|
115
|
+
}
|
|
116
|
+
// The config rung of Decision 5's ladder. Recorded once, on the run that installs the
|
|
117
|
+
// first payload set; a later `--agent` for a second harness leaves it alone, because
|
|
118
|
+
// detection outranks it anyway and rewriting config is not what that run was asked for.
|
|
119
|
+
export function recordHarness(configText, name) {
|
|
120
|
+
if (/^harness:/m.test(configText))
|
|
121
|
+
return { text: configText, changed: false };
|
|
122
|
+
const suffix = configText.endsWith('\n') ? '' : '\n';
|
|
123
|
+
return { text: `${configText}${suffix}harness: ${name} # detection wins; this is the fallback\n`, changed: true };
|
|
124
|
+
}
|
|
125
|
+
export function writeSettings(root, rel, text) {
|
|
126
|
+
const p = join(root, rel);
|
|
127
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
128
|
+
writeFileSync(p, text);
|
|
129
|
+
}
|
|
130
|
+
export function readIfExists(root, rel) {
|
|
131
|
+
const p = join(root, rel);
|
|
132
|
+
return existsSync(p) ? readFileSync(p, 'utf8') : undefined;
|
|
133
|
+
}
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const dir = (root) => join(root, '.witness', 'journal');
|
|
4
|
+
export const journalRel = (id) => `.witness/journal/${id}.jsonl`;
|
|
5
|
+
export function streamExists(root, id) {
|
|
6
|
+
return existsSync(join(root, journalRel(id)));
|
|
7
|
+
}
|
|
8
|
+
export const entryLine = (entry) => JSON.stringify({ v: 1, ...entry });
|
|
9
|
+
export function appendEntry(root, id, entry) {
|
|
10
|
+
mkdirSync(dir(root), { recursive: true });
|
|
11
|
+
const line = entryLine(entry);
|
|
12
|
+
appendFileSync(join(root, journalRel(id)), line + '\n');
|
|
13
|
+
return line;
|
|
14
|
+
}
|
|
15
|
+
export function readStream(root, id) {
|
|
16
|
+
if (!streamExists(root, id))
|
|
17
|
+
return [];
|
|
18
|
+
return readFileSync(join(root, journalRel(id)), 'utf8')
|
|
19
|
+
.split('\n')
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((l) => JSON.parse(l));
|
|
22
|
+
}
|
|
23
|
+
export function latestRecap(root, effort) {
|
|
24
|
+
const recaps = readStream(root, effort).filter((e) => e.t === 'recap');
|
|
25
|
+
return recaps.at(-1);
|
|
26
|
+
}
|
|
27
|
+
export function effortStreams(root) {
|
|
28
|
+
if (!existsSync(dir(root)))
|
|
29
|
+
return [];
|
|
30
|
+
return readdirSync(dir(root))
|
|
31
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
32
|
+
.map((f) => f.slice(0, -'.jsonl'.length))
|
|
33
|
+
.filter((id) => readStream(root, id)[0]?.t === 'recap')
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
36
|
+
export function effortAbandoned(entries) {
|
|
37
|
+
return entries.some((e) => e.t === 'human-decision' && e.decision === 'abandon-effort');
|
|
38
|
+
}
|
|
39
|
+
export function policyPins(entries) {
|
|
40
|
+
return entries
|
|
41
|
+
.filter((e) => e.t === 'policy-pin')
|
|
42
|
+
.map((e, i) => ({ ordinal: Number(e.ordinal ?? i + 1), text: String(e.text ?? '') }));
|
|
43
|
+
}
|
package/dist/junit.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { globToRegExp, walkFiles } from './glob.js';
|
|
4
|
+
import { ok, refuse, v } from './refusal.js';
|
|
5
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
6
|
+
function decode(s) {
|
|
7
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, e) => {
|
|
8
|
+
if (e.startsWith('#x') || e.startsWith('#X'))
|
|
9
|
+
return String.fromCodePoint(Number.parseInt(e.slice(2), 16));
|
|
10
|
+
if (e.startsWith('#'))
|
|
11
|
+
return String.fromCodePoint(Number.parseInt(e.slice(1), 10));
|
|
12
|
+
return ENTITIES[e] ?? whole;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function attrs(tag) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
18
|
+
out[m[1] ?? ''] = decode(m[2] ?? m[3] ?? '');
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function parseJUnit(xml) {
|
|
23
|
+
const clean = xml.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '').replace(/<!--[\s\S]*?-->/g, '');
|
|
24
|
+
const out = [];
|
|
25
|
+
const re = /<testcase\b([^>]*?)(\/)?>/g;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(clean)) !== null) {
|
|
28
|
+
const a = attrs(m[1] ?? '');
|
|
29
|
+
let status = 'passed';
|
|
30
|
+
if (m[2] !== '/') {
|
|
31
|
+
const end = clean.indexOf('</testcase>', re.lastIndex);
|
|
32
|
+
const inner = clean.slice(re.lastIndex, end === -1 ? undefined : end);
|
|
33
|
+
if (/<(?:failure|error)\b/.test(inner))
|
|
34
|
+
status = 'failed';
|
|
35
|
+
else if (/<skipped\b/.test(inner))
|
|
36
|
+
status = 'skipped';
|
|
37
|
+
if (end !== -1)
|
|
38
|
+
re.lastIndex = end + '</testcase>'.length;
|
|
39
|
+
}
|
|
40
|
+
out.push({ name: a.name ?? '', classname: a.classname ?? '', status });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export function reportFiles(root, glob) {
|
|
45
|
+
const re = globToRegExp(glob);
|
|
46
|
+
return walkFiles(root).filter((f) => re.test(f));
|
|
47
|
+
}
|
|
48
|
+
export function mergeReports(root, glob) {
|
|
49
|
+
const files = reportFiles(root, glob);
|
|
50
|
+
if (files.length === 0) {
|
|
51
|
+
return refuse([v('criteria.report', 'no-reports', `junit:${glob} matched no files`, 'at least one report — did the suite run?')]);
|
|
52
|
+
}
|
|
53
|
+
return ok(files.flatMap((f) => parseJUnit(readFileSync(join(root, f), 'utf8'))));
|
|
54
|
+
}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ok, refuse, v } from './refusal.js';
|
|
4
|
+
const lockPath = (root) => join(root, '.witness', 'lock');
|
|
5
|
+
function isAlive(pid) {
|
|
6
|
+
try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function acquireLock(root, pid = process.pid) {
|
|
15
|
+
mkdirSync(join(root, '.witness'), { recursive: true });
|
|
16
|
+
const p = lockPath(root);
|
|
17
|
+
const release = () => rmSync(p, { force: true });
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(p, JSON.stringify({ pid }), { flag: 'wx' });
|
|
20
|
+
return ok(release);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
let holder;
|
|
24
|
+
try {
|
|
25
|
+
holder = JSON.parse(readFileSync(p, 'utf8')).pid;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
holder = undefined;
|
|
29
|
+
}
|
|
30
|
+
if (holder !== undefined && holder !== pid && isAlive(holder)) {
|
|
31
|
+
return refuse([v('.witness/lock', 'locked', `held by pid ${holder}`, 'one clone, one writer — wait for the other witness invocation')]);
|
|
32
|
+
}
|
|
33
|
+
rmSync(p, { force: true });
|
|
34
|
+
writeFileSync(p, JSON.stringify({ pid }), { flag: 'wx' });
|
|
35
|
+
return ok(release);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/matcher.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { globToRegExp } from './glob.js';
|
|
4
|
+
import { git } from './gitio.js';
|
|
5
|
+
export function normalizeName(s) {
|
|
6
|
+
return s.toLowerCase().replace(/_/g, '-');
|
|
7
|
+
}
|
|
8
|
+
const CHARSET = /[a-z0-9-]/;
|
|
9
|
+
const ALNUM = /[a-z0-9]/;
|
|
10
|
+
export function matchesTag(testName, specId) {
|
|
11
|
+
const n = normalizeName(testName);
|
|
12
|
+
for (const form of [`@spec:${specId}`, `spec-${specId}`]) {
|
|
13
|
+
for (let i = n.indexOf(form); i !== -1; i = n.indexOf(form, i + 1)) {
|
|
14
|
+
const before = i === 0 ? undefined : n[i - 1];
|
|
15
|
+
const after = n[i + form.length];
|
|
16
|
+
const beforeOk = form.startsWith('@') || before === undefined || !ALNUM.test(before);
|
|
17
|
+
const afterOk = after === undefined || !CHARSET.test(after);
|
|
18
|
+
if (beforeOk && afterOk)
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
export function extractCanonicalTags(text) {
|
|
25
|
+
return [...text.matchAll(/@spec:([a-z0-9-]+)/g)].map((m) => m[1] ?? '').filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
const MAX_SCAN_BYTES = 512 * 1024;
|
|
28
|
+
export function sourceTags(root, excludes) {
|
|
29
|
+
const res = { counts: new Map(), files: new Map() };
|
|
30
|
+
const skip = excludes.map(globToRegExp);
|
|
31
|
+
const listed = git(root, 'ls-files', '--cached', '--others', '--exclude-standard')
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
for (const rel of new Set(listed)) {
|
|
35
|
+
if (skip.some((re) => re.test(rel)))
|
|
36
|
+
continue;
|
|
37
|
+
const abs = join(root, rel);
|
|
38
|
+
if (!existsSync(abs) || statSync(abs).size > MAX_SCAN_BYTES)
|
|
39
|
+
continue;
|
|
40
|
+
const content = readFileSync(abs, 'utf8');
|
|
41
|
+
if (content.includes('\0'))
|
|
42
|
+
continue;
|
|
43
|
+
for (const tag of extractCanonicalTags(content)) {
|
|
44
|
+
res.counts.set(tag, (res.counts.get(tag) ?? 0) + 1);
|
|
45
|
+
const list = res.files.get(tag) ?? [];
|
|
46
|
+
if (!list.includes(rel))
|
|
47
|
+
list.push(rel);
|
|
48
|
+
res.files.set(tag, list);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { parsePin } from './pin.js';
|
|
6
|
+
import { ok, refuse, v } from './refusal.js';
|
|
7
|
+
export const MODEL_ALIASES = ['sonnet', 'opus', 'haiku', 'fable', 'default', 'latest'];
|
|
8
|
+
export const SESSION_DEFAULT = 'session-default';
|
|
9
|
+
export function shippedMatrixPath() {
|
|
10
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'calibration.yaml');
|
|
11
|
+
}
|
|
12
|
+
// Per-(harness, model) calibration (Decision 88): a pi-invoked reviewer on the same
|
|
13
|
+
// model id is a DIFFERENT reviewer. Legacy top-level `models:` predates the harness
|
|
14
|
+
// dimension and was only ever measured through claude -p — it reads as claude-code.
|
|
15
|
+
function readModels(path, harness) {
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return [];
|
|
18
|
+
const doc = parse(readFileSync(path, 'utf8'));
|
|
19
|
+
const scoped = doc?.matrices?.[harness]?.models;
|
|
20
|
+
if (Array.isArray(scoped))
|
|
21
|
+
return scoped.map(String);
|
|
22
|
+
return harness === 'claude-code' && Array.isArray(doc?.models) ? doc.models.map(String) : [];
|
|
23
|
+
}
|
|
24
|
+
export function loadMatrix(root, harness) {
|
|
25
|
+
return {
|
|
26
|
+
shipped: readModels(shippedMatrixPath(), harness),
|
|
27
|
+
local: readModels(join(root, '.witness', 'calibration.local.yaml'), harness),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// the stage's model pin from config — per-gate wins over the global gates.model;
|
|
31
|
+
// the same pin drives that stage's gate reviewers AND its worker agent (implement)
|
|
32
|
+
export function stagePin(cfg, gate) {
|
|
33
|
+
const gates = (cfg.raw.gates ?? {});
|
|
34
|
+
const gateBlock = gate !== undefined && typeof gates[gate] === 'object' && gates[gate] !== null
|
|
35
|
+
? gates[gate]
|
|
36
|
+
: undefined;
|
|
37
|
+
const pinRaw = gateBlock?.model ?? gates.model;
|
|
38
|
+
const pinField = gateBlock?.model !== undefined ? `gates.${gate}.model` : 'gates.model';
|
|
39
|
+
const pin = pinRaw === undefined ? undefined : String(pinRaw);
|
|
40
|
+
if (pin !== undefined) {
|
|
41
|
+
const parsed = parsePin(pinField, pin);
|
|
42
|
+
if (!parsed.ok)
|
|
43
|
+
return refuse(parsed.violations);
|
|
44
|
+
if (MODEL_ALIASES.includes(parsed.value.model)) {
|
|
45
|
+
return refuse([v(pinField, 'alias-refused', pin, 'an exact model id — aliases re-point under the calibration (Decision 55)')]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ok(pin);
|
|
49
|
+
}
|
|
50
|
+
export function resolveModel(cfg, matrix, gate) {
|
|
51
|
+
const pinR = stagePin(cfg, gate);
|
|
52
|
+
if (!pinR.ok)
|
|
53
|
+
return refuse(pinR.violations);
|
|
54
|
+
const pin = pinR.value;
|
|
55
|
+
const calibrated = [...matrix.shipped, ...matrix.local.filter((m) => !matrix.shipped.includes(m))];
|
|
56
|
+
const chain = [];
|
|
57
|
+
if (pin !== undefined)
|
|
58
|
+
chain.push(pin);
|
|
59
|
+
for (const m of calibrated)
|
|
60
|
+
if (!chain.includes(m))
|
|
61
|
+
chain.push(m);
|
|
62
|
+
chain.push(SESSION_DEFAULT);
|
|
63
|
+
const calibrationOf = (id) => matrix.shipped.includes(id) ? 'shipped' : matrix.local.includes(id) ? 'local' : 'none';
|
|
64
|
+
const head = chain[0];
|
|
65
|
+
const headLabel = head === SESSION_DEFAULT ? '(session default)' : head;
|
|
66
|
+
const matrixEmpty = matrix.shipped.length === 0 && matrix.local.length === 0;
|
|
67
|
+
const warning = calibrationOf(head) !== 'none'
|
|
68
|
+
? undefined
|
|
69
|
+
: matrixEmpty
|
|
70
|
+
? `calibration matrix is empty — no calibrated model exists yet; ${headLabel} runs uncalibrated`
|
|
71
|
+
: `reviewer model ${headLabel} is below the model floor — no calibration matrix entry covers it`;
|
|
72
|
+
return ok({ chain, calibrationOf, warning });
|
|
73
|
+
}
|