@azure-id/orc 1.9.1 → 2.0.2
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/CHANGELOG.md +390 -0
- package/README-id.md +20 -36
- package/README.md +24 -34
- package/bin/build-agents.js +206 -109
- package/bin/cli.js +760 -62
- package/bin/gotcha-import.js +1081 -0
- package/bin/gotcha.js +1286 -0
- package/bin/graph-query.js +1 -1
- package/bin/graph.js +717 -717
- package/bin/habit.js +1453 -0
- package/bin/mockrun-catalog.js +281 -276
- package/bin/onboarding-content.js +4 -4
- package/bin/pricing.json +207 -200
- package/bin/run-undo.js +398 -0
- package/bin/trace-write.js +657 -0
- package/bin/verify-contracts.js +527 -81
- package/bin/verify-package.js +58 -11
- package/bin/webui/api.js +26 -0
- package/bin/webui/app.html +239 -232
- package/bin/webui/css/00-tokens.css +110 -92
- package/bin/webui/css/04-motion.css +249 -152
- package/bin/webui/css/06-responsive.css +203 -178
- package/bin/webui/css/panels/behaviour.css +205 -0
- package/bin/webui/css/panels/knowledge.css +563 -116
- package/bin/webui/fixtures/behaviour.js +532 -0
- package/bin/webui/fixtures/extra.js +2036 -2036
- package/bin/webui/fixtures/hookui.js +5 -5
- package/bin/webui/fixtures/index.js +34 -0
- package/bin/webui/fixtures/knowledge.js +7 -1
- package/bin/webui/fixtures/settings.js +305 -305
- package/bin/webui/fixtures/stats.js +16 -0
- package/bin/webui/i18n/en/behaviour.json +143 -0
- package/bin/webui/i18n/en/knowledge.json +117 -2
- package/bin/webui/i18n/en/nav.json +25 -24
- package/bin/webui/i18n/en/tour.json +37 -35
- package/bin/webui/i18n/id/behaviour.json +143 -0
- package/bin/webui/i18n/id/knowledge.json +117 -2
- package/bin/webui/i18n/id/nav.json +25 -24
- package/bin/webui/i18n/id/tour.json +37 -35
- package/bin/webui/js/01-i18n.js +155 -154
- package/bin/webui/js/90-tour.js +498 -494
- package/bin/webui/js/91-shortcuts.js +126 -126
- package/bin/webui/js/99-boot.js +121 -118
- package/bin/webui/js/panels/behaviour.js +1022 -0
- package/bin/webui/js/panels/knowledge.js +552 -7
- package/bin/webui/js/panels/overview.js +13 -4
- package/mock-run/INDEX.md +109 -107
- package/mock-run/context-combiner.md +100 -100
- package/mock-run/gotcha-import.md +118 -0
- package/mock-run/habits.md +129 -0
- package/mock-run/orc-budget.md +534 -534
- package/mock-run/orc-challenge-council.md +262 -262
- package/mock-run/orc-quick.md +6 -1
- package/mock-run/orc-ultra.md +103 -103
- package/package.json +1 -1
- package/templates/agents/MODEL-MAPPING.md +49 -49
- package/templates/agents/orc-advisor-opus-5-xhigh.md +50 -56
- package/templates/agents/orc-analyze-mini-opus-5-med.md +55 -60
- package/templates/agents/orc-analyze-mini-sonnet-5-high.md +55 -58
- package/templates/agents/orc-challenge-advisor-opus-5-med.md +75 -75
- package/templates/agents/orc-challenge-contrarian-opus-5-high.md +110 -110
- package/templates/agents/orc-challenge-executor-opus-5-med.md +114 -114
- package/templates/agents/orc-challenge-expansionist-opus-5-med.md +112 -112
- package/templates/agents/orc-challenge-judge-opus-5-high.md +132 -132
- package/templates/agents/orc-challenge-outsider-opus-5-low.md +109 -109
- package/templates/agents/orc-challenge-principles-opus-5-high.md +109 -109
- package/templates/agents/orc-challenge-reader-opus-5-low.md +90 -90
- package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
- package/templates/agents/orc-claude-writer-opus-5-med.md +48 -55
- package/templates/agents/orc-context-combiner-opus-5-high.md +78 -88
- package/templates/agents/orc-doc-checker-opus-5-low.md +108 -108
- package/templates/agents/orc-doc-writer-opus-5-med.md +134 -134
- package/templates/agents/orc-executor-haiku-4-5.md +14 -6
- package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
- package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
- package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
- package/templates/agents/orc-executor-opus-5-high.md +15 -7
- package/templates/agents/orc-executor-opus-5-low.md +15 -7
- package/templates/agents/orc-executor-opus-5-med.md +15 -7
- package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
- package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
- package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
- package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
- package/templates/agents/orc-judge-opus-5-xhigh.md +81 -85
- package/templates/agents/orc-learn-writer-opus-5-low.md +67 -73
- package/templates/agents/orc-pattern-codifier-opus-5-med.md +58 -65
- package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
- package/templates/agents/orc-planner-mini-opus-5-med.md +4 -7
- package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -3
- package/templates/agents/orc-planner-opus-5-med.md +157 -160
- package/templates/agents/orc-recon-opus-5-low.md +3 -10
- package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
- package/templates/agents/orc-retro-opus-5-med.md +8 -10
- package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
- package/templates/agents/orc-reviewer-opus-5-med.md +96 -60
- package/templates/agents/orc-scout-opus-5-low.md +35 -40
- package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
- package/templates/agents/orc-system-analyst-opus-5-high.md +115 -120
- package/templates/agents/orc-test-author-opus-5-med.md +70 -71
- package/templates/agents/orc-test-designer-opus-5-high.md +158 -158
- package/templates/agents/orc-test-interpreter-opus-5-low.md +130 -130
- package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
- package/templates/agents/orc-verifier-opus-5-med.md +77 -69
- package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
- package/templates/agents/orc-wiki-scanner-opus-5-med.md +74 -81
- package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
- package/templates/commands/orc-analyze.md +13 -21
- package/templates/commands/orc-fast.md +10 -15
- package/templates/commands/orc-poly.md +12 -21
- package/templates/commands/orc-pr-driver.md +11 -30
- package/templates/commands/orc-pr-setup.md +10 -31
- package/templates/commands/orc-route.md +11 -41
- package/templates/commands/orc-test.md +5 -60
- package/templates/commands/orc-ultra.md +17 -17
- package/templates/commands/orc-verify.md +11 -11
- package/templates/hooks/README.md +36 -2
- package/templates/hooks/orc-effort-guard.js +178 -178
- package/templates/hooks/orc-session-hook.js +264 -0
- package/templates/hooks/orc-statusline.js +9 -7
- package/templates/skills/_shared/README.md +9 -0
- package/templates/skills/_shared/code-graph.md +48 -56
- package/templates/skills/_shared/config-precedence.md +200 -198
- package/templates/skills/_shared/extra-dispatch.md +1331 -1346
- package/templates/skills/_shared/gotchas.md +228 -177
- package/templates/skills/_shared/habits.md +101 -0
- package/templates/skills/_shared/lane-contract.md +84 -0
- package/templates/skills/_shared/opus5-only.md +8 -8
- package/templates/skills/_shared/phases/README.md +142 -83
- package/templates/skills/_shared/phases/analyst-gates.md +125 -136
- package/templates/skills/_shared/phases/execution.md +8 -14
- package/templates/skills/_shared/phases/house-rules.md +27 -32
- package/templates/skills/_shared/phases/intake.md +127 -133
- package/templates/skills/_shared/phases/mock-example.md +46 -56
- package/templates/skills/_shared/phases/plan-handoff.md +91 -97
- package/templates/skills/_shared/phases/planning.md +7 -17
- package/templates/skills/_shared/phases/preflight.md +19 -42
- package/templates/skills/_shared/phases/review.md +23 -27
- package/templates/skills/_shared/phases/rules.md +18 -42
- package/templates/skills/_shared/phases/scoring.md +55 -65
- package/templates/skills/_shared/phases/security-checklist.md +46 -50
- package/templates/skills/_shared/phases/security.md +45 -55
- package/templates/skills/_shared/phases/ship.md +6 -15
- package/templates/skills/_shared/phases/stop-resume.md +2 -5
- package/templates/skills/_shared/phases/summary.md +73 -48
- package/templates/skills/_shared/phases/testgen.md +41 -51
- package/templates/skills/_shared/phases/trace-verbs.md +433 -0
- package/templates/skills/_shared/phases/trace.md +136 -367
- package/templates/skills/_shared/phases/verify.md +62 -70
- package/templates/skills/_shared/phases/wave-grouping.md +128 -133
- package/templates/skills/_shared/phases/wiki-consult.md +10 -6
- package/templates/skills/_shared/read-ladder.md +2 -55
- package/templates/skills/_shared/return-validation.md +17 -70
- package/templates/skills/_shared/review-slice.md +79 -0
- package/templates/skills/_shared/smoke-gate.md +46 -28
- package/templates/skills/context-combiner/SKILL.md +16 -45
- package/templates/skills/context-combiner/schemas/combined-report.md +78 -78
- package/templates/skills/orc/README.md +2 -2
- package/templates/skills/orc/SKILL.md +41 -69
- package/templates/skills/orc/config.md +9 -9
- package/templates/skills/orc/examples/full-run-mock.md +73 -73
- package/templates/skills/orc/references/effort-and-mode.md +222 -222
- package/templates/skills/orc/references/pattern-gate.md +89 -89
- package/templates/skills/orc/references/phases/intake.md +41 -47
- package/templates/skills/orc/references/phases/integration.md +13 -19
- package/templates/skills/orc/references/preflight-report.md +10 -11
- package/templates/skills/orc/references/ultra-mode.md +11 -9
- package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
- package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
- package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
- package/templates/skills/orc/subskills/orc-planner/SKILL.md +2 -2
- package/templates/skills/orc/subskills/orc-planner-mini/SKILL.md +121 -121
- package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +35 -76
- package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
- package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
- package/templates/skills/orc/subskills/orc-testgen/SKILL.md +34 -45
- package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
- package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
- package/templates/skills/orc-advisor/SKILL.md +56 -60
- package/templates/skills/orc-analyze/SKILL.md +32 -68
- package/templates/skills/orc-analyze/examples/analyze-mock.md +42 -42
- package/templates/skills/orc-analyze/schemas/report-audit.md +84 -83
- package/templates/skills/orc-analyze/schemas/report-prose.md +64 -63
- package/templates/skills/orc-analyze/schemas/report-requirement.md +78 -78
- package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
- package/templates/skills/orc-challenge/README.md +142 -142
- package/templates/skills/orc-challenge/examples/council-full-roster.md +273 -273
- package/templates/skills/orc-challenge/references/council.md +315 -315
- package/templates/skills/orc-challenge/references/intake.md +171 -171
- package/templates/skills/orc-claude/SKILL.md +14 -14
- package/templates/skills/orc-diy/README.md +174 -143
- package/templates/skills/orc-diy/SKILL.md +23 -74
- package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
- package/templates/skills/orc-diy/references/flow-schema.md +1 -1
- package/templates/skills/orc-doc/README.md +229 -229
- package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +325 -325
- package/templates/skills/orc-doc/references/chunking.md +527 -527
- package/templates/skills/orc-fast/SKILL.md +45 -73
- package/templates/skills/orc-judge/SKILL.md +77 -82
- package/templates/skills/orc-learn/SKILL.md +14 -14
- package/templates/skills/orc-learn/examples/learn-run-mock.md +61 -61
- package/templates/skills/orc-mini/SKILL.md +65 -113
- package/templates/skills/orc-pattern/SKILL.md +27 -48
- package/templates/skills/orc-poly/SKILL.md +32 -61
- package/templates/skills/orc-pr-driver/SKILL.md +25 -51
- package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
- package/templates/skills/orc-pr-setup/SKILL.md +20 -45
- package/templates/skills/orc-quick/README.md +43 -2
- package/templates/skills/orc-quick/SKILL.md +76 -107
- package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
- package/templates/skills/orc-quick/references/gh-mode.md +48 -1
- package/templates/skills/orc-quick/references/look.md +3 -1
- package/templates/skills/orc-retro/SKILL.md +19 -18
- package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
- package/templates/skills/orc-route/SKILL.md +25 -45
- package/templates/skills/orc-test/SKILL.md +16 -37
- package/templates/skills/orc-verify/SKILL.md +21 -39
- package/templates/skills/orc-verify/examples/verify-mock.md +33 -33
- package/templates/skills/orc-wait/SKILL.md +156 -163
- package/templates/skills/orc-wiki/references/extra.md +1 -1
- package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
package/bin/habit.js
ADDED
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── `orc habit` — the habit engine (v2.0.0 W2) ─────────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// ORC asks the same questions in every run. This module reads the ANSWERS the
|
|
5
|
+
// lanes already wrote to the run traces (the `ASK` verb), finds the ones a
|
|
6
|
+
// person gives the same way again and again, and PROPOSES them as a usual
|
|
7
|
+
// answer. It never applies anything without a yes: there is no automatic
|
|
8
|
+
// level (DE-4, DE-16). An accepted `apply` habit is a RANK in the resolver
|
|
9
|
+
// (`learned`, between your config file and the shipped default) — it never
|
|
10
|
+
// writes `.claude/orc.config.yaml` (DE-6).
|
|
11
|
+
//
|
|
12
|
+
// Three files, and only one of them is user data:
|
|
13
|
+
// <log_dir>/*.txt the `ASK` lines — the source, written by the trace writer
|
|
14
|
+
// .claude/orc/habits-state.json your decisions (accept / decline / never / forget) + history
|
|
15
|
+
// .claude/orc/habits-cache.json the parsed `ASK` events, keyed by the trace file list — delete it any time
|
|
16
|
+
// None is in the install manifest (`isPrunable` matches only skills/, commands/,
|
|
17
|
+
// agents/, hooks/), so `orc update`, `--prune` and `doctor --fix` never touch them.
|
|
18
|
+
//
|
|
19
|
+
// Routed by bin/cli.js (`orc habit …`), and read by `orc lane config` and
|
|
20
|
+
// `orc config list` for the learned rank — the resolver stays the ONE resolver.
|
|
21
|
+
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
const crypto = require("crypto");
|
|
25
|
+
const { execFileSync } = require("child_process");
|
|
26
|
+
|
|
27
|
+
// ── The registry (DATA, the `LANE_CALLS` shape) ─────────────────────────────
|
|
28
|
+
// One row per decision point: 30 points in 26 spec rows (`04-habits-spec.md`
|
|
29
|
+
// §2.2). A qid this table does not know is DROPPED by the parser and counted by
|
|
30
|
+
// `orc habit doctor`; a lookup of an unknown point answers class `never`.
|
|
31
|
+
//
|
|
32
|
+
// file the payload file (under templates/skills/) that marks the question
|
|
33
|
+
// `(H <qid>)` — a string, or a list when several lanes ask it. The
|
|
34
|
+
// two-way test (test/cli/habit-registry.test.js) holds the two together.
|
|
35
|
+
//
|
|
36
|
+
// class apply — an accepted habit answers `key` at the learned rank
|
|
37
|
+
// suggest — an accepted habit marks one option `→ usual` and puts it
|
|
38
|
+
// first; the question is STILL asked
|
|
39
|
+
// never — observed and shown, never proposed
|
|
40
|
+
// careful the option in the careful direction, or null. A habit AWAY from it
|
|
41
|
+
// is downgraded: `apply` → `never`, `suggest` stays `suggest`.
|
|
42
|
+
// values (apply only) option → the config value it answers. An option with no
|
|
43
|
+
// value cannot be applied, so a habit toward it is class `never`.
|
|
44
|
+
// ctx the ctx keys that may split this point into separate habits.
|
|
45
|
+
// never_when a ctx value in which this point is never proposed.
|
|
46
|
+
// never_option an option a habit toward is never proposed, not even as a
|
|
47
|
+
// suggestion (a risk the user must take each time).
|
|
48
|
+
// options null + `open: "agent"` = the lane's own agent menu (an ORC agent name).
|
|
49
|
+
const ASK_POINTS = {
|
|
50
|
+
"orc.intake.testgen": { lanes: ["orc"], phase: "intake", file: "orc/references/phases/intake.md", title: "Author test cases before ship", options: { author: "author tests", skip: "skip tests" }, class: "apply", careful: "author", key: "generate_tests", values: { author: "true" }, ctx: [], why: "toward authoring tests only — a habit to skip them is never proposed" },
|
|
51
|
+
"mini.phase-t.testgen": { lanes: ["orc-mini"], phase: "phase-t", file: "orc-mini/SKILL.md", title: "Author test cases before ship", options: { author: "author tests", skip: "skip tests" }, class: "apply", careful: "author", key: "generate_tests", values: { author: "true" }, ctx: [], why: "toward authoring tests only — a habit to skip them is never proposed" },
|
|
52
|
+
"orc.phase-1.planner": { lanes: ["orc"], phase: "phase-1", file: "_shared/phases/planning.md", title: "Which planner writes the plan", options: { superpowers: "Superpowers", openspec: "OpenSpec", planner: "ORC planner", self: "plan in session" }, class: "suggest", careful: null, key: null, ctx: [], why: "a planner choice has no careful side, so the habit only orders the offer" },
|
|
53
|
+
"orc.phase-2.execution": { lanes: ["orc"], phase: "phase-2", file: "_shared/phases/scoring.md", title: "How the waves execute", options: { sequential: "sequential", parallel: "parallel", worktrees: "worktrees" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
54
|
+
"orc.phase-2.pause": { lanes: ["orc"], phase: "phase-2", file: "_shared/phases/scoring.md", title: "How often the run pauses", options: { "every-1": "pause every wave", "every-2": "pause every 2 waves", straight: "run straight through" }, class: "apply", careful: "every-1", key: "batch_pause_every", values: { "every-1": "1" }, ctx: [], why: "toward more pauses only — a habit to pause less is never applied" },
|
|
55
|
+
"orc.phase-3.pattern": { lanes: ["orc", "orc-diy"], phase: "phase-3", file: ["orc/references/pattern-gate.md", "orc-diy/references/blocks/pattern.md"], title: "Learn the code pattern on a cache miss", options: { learn: "learn the pattern", agnostic: "stay pattern-agnostic" }, class: "suggest", careful: "learn", key: null, ctx: [], why: "pattern_findings stays manual: a scan spends money, so the habit only orders the offer" },
|
|
56
|
+
"orc.phase-5.pattern-missing": { lanes: ["orc"], phase: "phase-5", file: "_shared/phases/review.md", title: "A missing pattern at review", options: { paste: "paste it", file: "name a file", none: "no pattern" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
57
|
+
"orc.phase-5-5.security": { lanes: ["orc", "orc-diy"], phase: "phase-5-5", file: "_shared/phases/security.md", title: "Security review", options: { run: "run the security pass", skip: "skip it" }, class: "apply", careful: "run", key: "security_review", values: { run: "on" }, ctx: ["risk"], why: "toward `on` only — a habit to skip security is never applied" },
|
|
58
|
+
"orc.phase-6-7.mock": { lanes: ["orc"], phase: "phase-6-7", file: "_shared/phases/mock-example.md", title: "Mocked runnable example", options: { build: "build the example", skip: "skip it" }, class: "apply", careful: null, key: "mock_example", values: { build: "on", skip: "off" }, ctx: [], why: "a mock example has no careful side, so both answers can be learned" },
|
|
59
|
+
"mini.phase-x.mock": { lanes: ["orc-mini"], phase: "phase-x", file: "orc-mini/SKILL.md", title: "Mocked runnable example", options: { build: "build the example", skip: "skip it" }, class: "apply", careful: null, key: "mock_example", values: { build: "on", skip: "off" }, ctx: [], why: "a mock example has no careful side, so both answers can be learned" },
|
|
60
|
+
"fast.f3-5.mock": { lanes: ["orc-fast"], phase: "f3-5", file: "orc-fast/SKILL.md", title: "Mocked runnable example", options: { build: "build the example", skip: "skip it" }, class: "apply", careful: null, key: "mock_example", values: { build: "on", skip: "off" }, ctx: [], why: "a mock example has no careful side, so both answers can be learned" },
|
|
61
|
+
"orc.phase-7.p2-batch": { lanes: ["orc"], phase: "phase-7", file: "_shared/phases/summary.md", title: "Fix the P2 findings", options: { fix: "fix them", skip: "skip them" }, class: "suggest", careful: "fix", key: null, ctx: [], why: "the habit only orders the offer" },
|
|
62
|
+
"orc.phase-7.p3-batch": { lanes: ["orc"], phase: "phase-7", file: "_shared/phases/summary.md", title: "Fix the P3 findings", options: { fix: "fix them", skip: "skip them" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
63
|
+
"orc.phase-8.stack": { lanes: ["orc"], phase: "phase-8", file: "_shared/phases/ship.md", title: "Stacked PRs or one PR", options: { stack: "stacked PRs", single: "one PR" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
64
|
+
"orc.phase-8.ship": { lanes: ["orc"], phase: "phase-8", file: "_shared/phases/ship.md", title: "How the change ships", options: { pr: "open a PR", commit: "commit only", leave: "leave it uncommitted" }, class: "suggest", careful: null, key: null, ctx: ["branch"], why: "shipping is irreversible, so the habit only orders the offer" },
|
|
65
|
+
"mini.phase-8.ship": { lanes: ["orc-mini"], phase: "phase-8", file: "orc-mini/SKILL.md", title: "How the change ships", options: { pr: "open a PR", commit: "commit only", leave: "leave it uncommitted" }, class: "suggest", careful: null, key: null, ctx: ["branch"], why: "shipping is irreversible, so the habit only orders the offer" },
|
|
66
|
+
"fast.f4.ship": { lanes: ["orc-fast"], phase: "f4", file: "orc-fast/SKILL.md", title: "How the change ships", options: { pr: "open a PR", commit: "commit only", leave: "leave it uncommitted" }, class: "suggest", careful: null, key: null, ctx: ["branch"], why: "shipping is irreversible, so the habit only orders the offer" },
|
|
67
|
+
"any.ship.review-before-push": { lanes: ["orc-mini", "orc-fast"], phase: "ship", file: ["orc-mini/SKILL.md", "orc-fast/SKILL.md"], title: "Code review before push", options: { review: "review first", push: "push directly" }, class: "apply", careful: "review", key: "review_before_push", values: { review: "on" }, ctx: ["branch"], why: "toward a review only — a habit to push without one is never applied" },
|
|
68
|
+
"orc.phase-8.wiki-refresh": { lanes: ["orc"], phase: "phase-8", file: "_shared/phases/ship.md", title: "Refresh the wiki after ship", options: { refresh: "refresh now", later: "later" }, class: "suggest", careful: null, key: null, ctx: [], why: "a refresh spends money, so the habit only orders the offer" },
|
|
69
|
+
"any.stop.where": { lanes: ["orc", "orc-mini", "orc-wait"], phase: "stop", file: ["_shared/phases/stop-resume.md", "orc-mini/SKILL.md", "orc-wait/SKILL.md"], title: "Where to continue after a stop", options: { here: "continue here", fresh: "a fresh session" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
70
|
+
"mini.intake.tdd": { lanes: ["orc-mini"], phase: "intake", file: "orc-mini/SKILL.md", title: "Test-driven loop", options: { yes: "use TDD", no: "no TDD" }, class: "apply", careful: "yes", key: "mini_tdd", values: { yes: "on" }, ctx: [], why: "toward TDD only — a habit to drop it is never applied" },
|
|
71
|
+
"mini.phase-1.complexity": { lanes: ["orc-mini"], phase: "phase-1", file: "orc-mini/SKILL.md", title: "Switch to full /orc on a complex change", options: { switch: "switch to /orc", continue: "continue in mini" }, class: "suggest", careful: "switch", key: null, ctx: ["risk"], never_when: { risk: "cited" }, why: "never proposed when a risk is cited" },
|
|
72
|
+
"mini.phase-m.risk-high": { lanes: ["orc-mini"], phase: "phase-m", file: "orc-mini/SKILL.md", title: "A high-risk change at the smoke gate", options: { review: "review it", test: "test it", ship: "ship it" }, class: "suggest", careful: "review", key: null, ctx: [], why: "the habit only orders the offer" },
|
|
73
|
+
"quick.q2.gate.code": { lanes: ["orc-quick"], phase: "q2", file: "orc-quick/SKILL.md", title: "Which agent writes the code", options: null, open: "agent", class: "suggest", careful: null, key: null, ctx: ["kind"], why: "a dispatch gate is still asked every time — the habit only fills the `→ suggested` line" },
|
|
74
|
+
"quick.q2.gate.recon": { lanes: ["orc-quick"], phase: "q2", file: "orc-quick/SKILL.md", title: "Which agent reads the code", options: null, open: "agent", class: "suggest", careful: null, key: null, ctx: ["kind"], why: "a dispatch gate is still asked every time — the habit only fills the `→ suggested` line" },
|
|
75
|
+
"quick.q3.offer.tests": { lanes: ["orc-quick"], phase: "q3", file: "orc-quick/SKILL.md", title: "Update the tests", options: { update: "update the tests", skip: "skip" }, class: "apply", careful: "update", key: "quick_update_tests", values: { update: "on" }, ctx: ["kind"], why: "toward updating tests only — `off` is not offered" },
|
|
76
|
+
"quick.q3.offer.review": { lanes: ["orc-quick"], phase: "q3", file: "orc-quick/SKILL.md", title: "Code review before commit", options: { "review-first": "review first", "commit-direct": "commit directly", stop: "stop" }, class: "suggest", careful: "review-first", key: null, ctx: ["kind", "branch"], why: "a review is a dispatch, so its gate is still asked — the habit only orders the offer" },
|
|
77
|
+
"quick.q1.too-big": { lanes: ["orc-quick"], phase: "q1", file: "orc-quick/SKILL.md", title: "A request too big for quick", options: { mini: "move to /orc-mini", "keep-going": "keep going" }, class: "suggest", careful: null, key: null, ctx: [], why: "the habit only orders the offer" },
|
|
78
|
+
"fast.f0.stale-wiki": { lanes: ["orc-fast"], phase: "f0", file: "orc-fast/SKILL.md", title: "A stale wiki at preflight", options: { refresh: "refresh the wiki", mini: "fall back to /orc-mini", continue: "continue" }, class: "suggest", careful: null, key: null, ctx: [], never_option: ["continue"], why: "a habit toward `continue` is never proposed (a stale wiki is a risk taken each time); otherwise the habit only orders the offer" },
|
|
79
|
+
"any.analysis.depth": { lanes: ["orc", "orc-analyze"], phase: "analysis", file: ["orc/references/phases/intake.md", "orc-analyze/SKILL.md"], title: "Analysis depth", options: { standard: "standard", deep: "deep" }, class: "suggest", careful: null, key: null, ctx: [], why: "deep analysis needs consent every time, so the habit only orders the offer" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Not habits: the answer to a proposal itself (`ASK habit.proposal :: …`,
|
|
83
|
+
// §4.2). Known, so the parser keeps it and the doctor does not call it unknown,
|
|
84
|
+
// but it never becomes a row — a habit about accepting habits feeds itself.
|
|
85
|
+
const HABIT_META_QIDS = ["habit.proposal"];
|
|
86
|
+
|
|
87
|
+
// ── The rule (§3) — CLI constants, not config keys (the `complexity` precedent)
|
|
88
|
+
// `/orc-retro` can move a number from data; nobody tunes one by feel. There is
|
|
89
|
+
// ONE threshold: propose. The research's second "act" threshold is NOT built —
|
|
90
|
+
// no level applies a habit without a yes (DE-4).
|
|
91
|
+
const HABIT_RULE = {
|
|
92
|
+
window: 30, // answers per (qid, ctx bucket)
|
|
93
|
+
max_age_days: 180,
|
|
94
|
+
half_life_answers: 10, // recency: weight × 0.5 ^ (answers-ago / 10)
|
|
95
|
+
weights: { user: 1.0, override: 1.0, ledger: 1.0, learned: 0.25, preselected: 0.25 },
|
|
96
|
+
excluded_by: ["config", "default"],
|
|
97
|
+
min_n: 5, // raw n = answers at FULL weight — a pre-selected answer is not raw evidence
|
|
98
|
+
lb: 0.55, // Wilson lower bound at 95 %
|
|
99
|
+
z: 1.96,
|
|
100
|
+
last_agree: 3,
|
|
101
|
+
stale_overrides: 2, // an applied habit is stale on 2 overrides in the last 3
|
|
102
|
+
stale_of: 3,
|
|
103
|
+
expiry_days: 90, // an applied habit not USED for 90 days is stale
|
|
104
|
+
cooldown_answers: 10, // a declined habit may come back after 10 more answers AND 14 days
|
|
105
|
+
cooldown_days: 14,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const HABIT_MODES = ["off", "observe", "propose"];
|
|
109
|
+
// The keys this release added for habits. Under `habits: off` they are left
|
|
110
|
+
// out of `orc lane config` entirely, so the answer is byte-identical to the
|
|
111
|
+
// release before (DE-16: off = zero bytes).
|
|
112
|
+
const HABIT_KEYS = ["habits", "review_before_push", "mini_tdd", "quick_update_tests"];
|
|
113
|
+
const HABIT_FAMILIES = ["habits", "offers"];
|
|
114
|
+
const BY_SET = ["user", "ledger", "learned", "config", "default"];
|
|
115
|
+
const CTX_VALUES = {
|
|
116
|
+
branch: ["main", "feature", "fix", "release", "other"],
|
|
117
|
+
kind: ["feature", "defect", "pr", "read", "refactor"],
|
|
118
|
+
size: ["s", "m", "l"],
|
|
119
|
+
risk: ["none", "cited"],
|
|
120
|
+
};
|
|
121
|
+
const STATE_WORDS = ["observed", "proposed", "applied", "declined", "never-ask", "stale", "shadowed", "never"];
|
|
122
|
+
const DAY = 86400000;
|
|
123
|
+
|
|
124
|
+
// ── The parser (§1.1) ───────────────────────────────────────────────────────
|
|
125
|
+
// Tail regex: the trace writer prefixes `[DDMMYY HH:MM:SS.mmm] <lane>` and the
|
|
126
|
+
// ASK body is everything after the verb. CRLF-safe.
|
|
127
|
+
const ASK_RE = /(?:^|\s)ASK\s+(\S+)\s+::\s+(.*?)\s*$/;
|
|
128
|
+
const OPT_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
129
|
+
// `QUESTION count=<n> :: <topic>` (trace-verbs.md) — a subagent stopped to ask.
|
|
130
|
+
const QUESTION_RE = /(?:^|\s)QUESTION\s+count=(\d+)/;
|
|
131
|
+
// A dispatch-gate menu also lists a third-party slot (`deepseek-v4-flash`) or an
|
|
132
|
+
// ad-hoc model, so any plain id is an offered option; only `chose=` is counted.
|
|
133
|
+
const AGENT_RE = /^[a-z0-9][a-z0-9.:-]*$/;
|
|
134
|
+
|
|
135
|
+
function pointFor(qid) {
|
|
136
|
+
if (ASK_POINTS[qid]) return ASK_POINTS[qid];
|
|
137
|
+
return { lanes: [], phase: null, file: null, title: qid, options: {}, class: "never", careful: null, key: null, ctx: [], why: "not in the registry — an unknown point is never proposed", unknown: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// → null (no ASK on this line) · { ok:false, qid, error } · { ok:true, … }
|
|
141
|
+
function parseAskLine(line) {
|
|
142
|
+
const text = String(line || "").replace(/\r$/, "");
|
|
143
|
+
const m = ASK_RE.exec(text);
|
|
144
|
+
if (!m) return null;
|
|
145
|
+
const qid = m[1];
|
|
146
|
+
const bad = (error) => ({ ok: false, qid, error, line: text });
|
|
147
|
+
const f = {};
|
|
148
|
+
for (const tok of m[2].split(/\s+/)) {
|
|
149
|
+
const eq = tok.indexOf("=");
|
|
150
|
+
if (eq <= 0) return bad(`"${tok}" is not a field=value pair`);
|
|
151
|
+
const k = tok.slice(0, eq);
|
|
152
|
+
if (f[k] !== undefined) return bad(`field ${k} appears twice`);
|
|
153
|
+
f[k] = tok.slice(eq + 1);
|
|
154
|
+
}
|
|
155
|
+
for (const k of Object.keys(f))
|
|
156
|
+
if (!["offered", "rec", "chose", "by", "pre", "ctx"].includes(k)) return bad(`unknown field ${k}`);
|
|
157
|
+
for (const k of ["offered", "rec", "chose", "by"]) if (!f[k]) return bad(`missing ${k}=`);
|
|
158
|
+
const offered = f.offered.split("|");
|
|
159
|
+
const meta = HABIT_META_QIDS.includes(qid);
|
|
160
|
+
const pt = ASK_POINTS[qid];
|
|
161
|
+
const shape = pt && pt.open === "agent" ? AGENT_RE : OPT_RE;
|
|
162
|
+
for (const o of offered) if (!shape.test(o)) return bad(`offered option "${o}" is not an option id`);
|
|
163
|
+
if (pt && pt.options) for (const o of offered) if (!pt.options[o]) return bad(`offered option "${o}" is not an option of ${qid}`);
|
|
164
|
+
if (f.rec !== "none" && !offered.includes(f.rec)) return bad(`rec=${f.rec} is not an offered option`);
|
|
165
|
+
if (f.chose !== "other" && !offered.includes(f.chose)) return bad(`chose=${f.chose} is not an offered option`);
|
|
166
|
+
if (!BY_SET.includes(f.by)) return bad(`by=${f.by} is outside ${BY_SET.join("|")}`);
|
|
167
|
+
if (f.pre !== undefined && !offered.includes(f.pre)) return bad(`pre=${f.pre} is not an offered option`);
|
|
168
|
+
const ctx = {};
|
|
169
|
+
if (f.ctx !== undefined) {
|
|
170
|
+
for (const kv of f.ctx.split(",")) {
|
|
171
|
+
const [k, v] = kv.split("=");
|
|
172
|
+
if (!CTX_VALUES[k]) return bad(`ctx key ${k} is outside the closed set`);
|
|
173
|
+
if (!CTX_VALUES[k].includes(v)) return bad(`ctx ${k}=${v} is outside ${CTX_VALUES[k].join("|")}`);
|
|
174
|
+
ctx[k] = v;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, qid, meta, known: !!pt || meta, offered, rec: f.rec, chose: f.chose, by: f.by, pre: f.pre === undefined ? null : f.pre, ctx };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── The statistics (§3.2 – §3.3) ────────────────────────────────────────────
|
|
181
|
+
function wilsonLB(p, n, z = HABIT_RULE.z) {
|
|
182
|
+
if (!(n > 0)) return 0;
|
|
183
|
+
const z2 = z * z;
|
|
184
|
+
return (p + z2 / (2 * n) - z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))) / (1 + z2 / n);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Kish effective sample size.
|
|
188
|
+
function kish(w) {
|
|
189
|
+
const s = w.reduce((a, b) => a + b, 0);
|
|
190
|
+
const s2 = w.reduce((a, b) => a + b * b, 0);
|
|
191
|
+
return s2 ? (s * s) / s2 : 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The rule on plain counts (the research's worked values, §3.3).
|
|
195
|
+
function ruleOnCounts(k, n) {
|
|
196
|
+
const lb = wilsonLB(k / n, n);
|
|
197
|
+
return { lb: Math.round(lb * 1000) / 1000, propose: n >= HABIT_RULE.min_n && lb >= HABIT_RULE.lb };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// How much ONE answer counts as evidence (§3.2).
|
|
201
|
+
function evidenceWeight(a) {
|
|
202
|
+
const W = HABIT_RULE.weights;
|
|
203
|
+
if (HABIT_RULE.excluded_by.includes(a.by)) return 0;
|
|
204
|
+
if (a.by === "learned") return W.learned;
|
|
205
|
+
if (a.by === "ledger") return W.ledger;
|
|
206
|
+
if (a.pre && a.chose !== a.pre) return W.override;
|
|
207
|
+
if (a.pre && a.chose === a.pre) return W.preselected;
|
|
208
|
+
return W.user;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// answers: oldest → newest, already windowed. Returns the computed row numbers.
|
|
212
|
+
function evaluate(answers) {
|
|
213
|
+
const list = answers.slice(-HABIT_RULE.window);
|
|
214
|
+
const L = list.length;
|
|
215
|
+
const ev = list.map(evidenceWeight);
|
|
216
|
+
const rec = list.map((_, i) => Math.pow(0.5, (L - 1 - i) / HABIT_RULE.half_life_answers));
|
|
217
|
+
const w = ev.map((e, i) => e * rec[i]);
|
|
218
|
+
const byOpt = {};
|
|
219
|
+
const cnt = {};
|
|
220
|
+
list.forEach((a, i) => {
|
|
221
|
+
byOpt[a.chose] = (byOpt[a.chose] || 0) + w[i];
|
|
222
|
+
cnt[a.chose] = (cnt[a.chose] || 0) + 1;
|
|
223
|
+
});
|
|
224
|
+
const sw = w.reduce((x, y) => x + y, 0);
|
|
225
|
+
let top = null;
|
|
226
|
+
for (const [o, v] of Object.entries(byOpt)) if (top === null || v > byOpt[top]) top = o;
|
|
227
|
+
const p = sw ? (byOpt[top] || 0) / sw : 0;
|
|
228
|
+
const srec = rec.reduce((x, y) => x + y, 0);
|
|
229
|
+
// Kish over the weights, scaled by the recency-weighted mean evidence: a
|
|
230
|
+
// pre-selected answer counts as a quarter of a choice in n too, not only in p.
|
|
231
|
+
const n_eff = srec ? kish(w) * (sw / srec) : 0;
|
|
232
|
+
const rawN = ev.filter((e) => e >= 1).length;
|
|
233
|
+
const lb = wilsonLB(p, n_eff);
|
|
234
|
+
const last = list.slice(-HABIT_RULE.last_agree);
|
|
235
|
+
const last_agree = last.length === HABIT_RULE.last_agree && last.every((a) => a.chose === top);
|
|
236
|
+
const passes = top !== null && top !== "other" && rawN >= HABIT_RULE.min_n && lb >= HABIT_RULE.lb && last_agree;
|
|
237
|
+
return {
|
|
238
|
+
total: L,
|
|
239
|
+
n: rawN,
|
|
240
|
+
top,
|
|
241
|
+
count: top === null ? 0 : cnt[top],
|
|
242
|
+
counts: cnt,
|
|
243
|
+
p: round3(p),
|
|
244
|
+
n_eff: round3(n_eff),
|
|
245
|
+
lb: round3(lb),
|
|
246
|
+
last_agree,
|
|
247
|
+
passes,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function round3(x) {
|
|
252
|
+
return Math.round(x * 1000) / 1000;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The ratchet (§2.1 "Direction"): a habit AWAY from `careful` never becomes an
|
|
256
|
+
// `apply`, and an option with no config value cannot be applied at all. So a
|
|
257
|
+
// habit can only make ORC MORE careful without a person choosing it.
|
|
258
|
+
function effectiveClass(pt, top, bucket) {
|
|
259
|
+
if (!pt || pt.unknown || pt.class === "never") return "never";
|
|
260
|
+
if (pt.never_when && bucket)
|
|
261
|
+
for (const [k, v] of Object.entries(pt.never_when)) if (bucket[k] === v) return "never";
|
|
262
|
+
if (pt.never_option && pt.never_option.includes(top)) return "never";
|
|
263
|
+
if (top === null || top === "other") return pt.class === "apply" ? "never" : pt.class;
|
|
264
|
+
if (pt.class === "apply") {
|
|
265
|
+
if (pt.careful && top !== pt.careful) return "never";
|
|
266
|
+
if (!pt.values || pt.values[top] === undefined) return "never";
|
|
267
|
+
}
|
|
268
|
+
return pt.class;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Ids, buckets, branch ────────────────────────────────────────────────────
|
|
272
|
+
function bucketOf(pt, ctx) {
|
|
273
|
+
const out = {};
|
|
274
|
+
for (const k of pt.ctx || []) if (ctx && ctx[k] !== undefined) out[k] = ctx[k];
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
function bucketKey(b) {
|
|
278
|
+
return Object.keys(b)
|
|
279
|
+
.sort()
|
|
280
|
+
.map((k) => `${k}=${b[k]}`)
|
|
281
|
+
.join(",");
|
|
282
|
+
}
|
|
283
|
+
// A STABLE id computed from what the habit is, so a read never has to write
|
|
284
|
+
// one down: the same point in the same context is the same habit, always.
|
|
285
|
+
function habitId(qid, bucket) {
|
|
286
|
+
return "H-" + crypto.createHash("sha1").update(qid + "|" + bucketKey(bucket || {})).digest("hex").slice(0, 6);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// §5: the CLI computes the branch class, so no lane passes it.
|
|
290
|
+
function branchClass(name) {
|
|
291
|
+
if (!name) return null;
|
|
292
|
+
if (/^(main|master|trunk)$/.test(name)) return "main";
|
|
293
|
+
if (/^(feat|feature)\//.test(name)) return "feature";
|
|
294
|
+
if (/^(fix|bugfix|hotfix)\//.test(name)) return "fix";
|
|
295
|
+
if (/^release\//.test(name)) return "release";
|
|
296
|
+
return "other";
|
|
297
|
+
}
|
|
298
|
+
function currentBranch(projectRoot) {
|
|
299
|
+
try {
|
|
300
|
+
const b = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectRoot, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
|
|
301
|
+
return b && b !== "HEAD" ? b : null;
|
|
302
|
+
} catch (_) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Files ───────────────────────────────────────────────────────────────────
|
|
308
|
+
function statePaths(claudeDir) {
|
|
309
|
+
return {
|
|
310
|
+
state: path.join(claudeDir, "orc", "habits-state.json"),
|
|
311
|
+
cache: path.join(claudeDir, "orc", "habits-cache.json"),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function emptyState() {
|
|
315
|
+
return { version: 1, purged_at: null, habits: {}, history: [] };
|
|
316
|
+
}
|
|
317
|
+
function readState(claudeDir) {
|
|
318
|
+
try {
|
|
319
|
+
const j = JSON.parse(fs.readFileSync(statePaths(claudeDir).state, "utf8"));
|
|
320
|
+
return Object.assign(emptyState(), j, { habits: j.habits || {}, history: j.history || [] });
|
|
321
|
+
} catch (_) {
|
|
322
|
+
return emptyState();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function writeState(claudeDir, st) {
|
|
326
|
+
const p = statePaths(claudeDir).state;
|
|
327
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
328
|
+
const tmp = p + ".tmp";
|
|
329
|
+
fs.writeFileSync(tmp, JSON.stringify(st, null, 2) + "\n");
|
|
330
|
+
fs.renameSync(tmp, p);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function habitsMode(map) {
|
|
334
|
+
const v = map && Object.prototype.hasOwnProperty.call(map, "habits") ? String(map.habits) : "off";
|
|
335
|
+
return HABIT_MODES.includes(v) ? v : "off";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Collecting the ASK events (REUSES cli.js's listTraces + traceTs) ────────
|
|
339
|
+
// A run is excluded when it read untrusted context (a PR body or a peer wiki
|
|
340
|
+
// was in the slice), when it aborted, or when it was a dry run (§3.1).
|
|
341
|
+
function runExcluded(text) {
|
|
342
|
+
if (/\buntrusted_context\b/.test(text)) return "untrusted_context";
|
|
343
|
+
if (/\bFINISH\b[^\n]*\babort/i.test(text)) return "aborted";
|
|
344
|
+
if (/\bdry[-_]run\b/i.test(text)) return "dry-run";
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collect(claudeDir, deps) {
|
|
349
|
+
const { dir, runs } = deps.listTraces(claudeDir);
|
|
350
|
+
const sig = crypto
|
|
351
|
+
.createHash("sha1")
|
|
352
|
+
.update(JSON.stringify(runs.map((r) => [r.name, r.mtime])))
|
|
353
|
+
.digest("hex");
|
|
354
|
+
const cp = statePaths(claudeDir).cache;
|
|
355
|
+
try {
|
|
356
|
+
const c = JSON.parse(fs.readFileSync(cp, "utf8"));
|
|
357
|
+
if (c && c.sig === sig && c.version === 3) return Object.assign({ log_dir: dir, cached: true }, c.data);
|
|
358
|
+
} catch (_) {}
|
|
359
|
+
const events = [];
|
|
360
|
+
const bad = [];
|
|
361
|
+
const unknown = {};
|
|
362
|
+
const excluded = [];
|
|
363
|
+
// v2.0.0 W6b (Q7) — every INCLUDED run, a zero-question run too, so
|
|
364
|
+
// `orc stats` can say questions per run in the same pass over the logs.
|
|
365
|
+
// `questions` = the subagent `QUESTION count=<n>` lines, summed.
|
|
366
|
+
const seen = [];
|
|
367
|
+
for (const r of runs) {
|
|
368
|
+
let text = "";
|
|
369
|
+
try {
|
|
370
|
+
text = fs.readFileSync(r.path, "utf8");
|
|
371
|
+
} catch (_) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const why = runExcluded(text);
|
|
375
|
+
if (why) {
|
|
376
|
+
if (/(?:^|\s)ASK\s/m.test(text)) excluded.push({ run: r.name, reason: why });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const row = { run: r.name, lane: r.lane, date: r.date || null, at: r.mtime || 0, questions: 0 };
|
|
380
|
+
seen.push(row);
|
|
381
|
+
for (const line of text.split(/\r?\n/)) {
|
|
382
|
+
const q = QUESTION_RE.exec(line);
|
|
383
|
+
if (q) row.questions += Number(q[1]) || 0;
|
|
384
|
+
const a = parseAskLine(line);
|
|
385
|
+
if (!a) continue;
|
|
386
|
+
if (!a.ok) {
|
|
387
|
+
bad.push({ run: r.name, error: a.error, line: a.line });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!a.known) {
|
|
391
|
+
(unknown[a.qid] ||= { qid: a.qid, count: 0, runs: [] }).count++;
|
|
392
|
+
if (!unknown[a.qid].runs.includes(r.name)) unknown[a.qid].runs.push(r.name);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const ts = deps.traceTs(line) || r.mtime || 0;
|
|
396
|
+
events.push({ run: r.name, lane: r.lane, at: ts, qid: a.qid, offered: a.offered, rec: a.rec, chose: a.chose, by: a.by, pre: a.pre, ctx: a.ctx });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
events.sort((x, y) => x.at - y.at);
|
|
400
|
+
const data = { traces: runs.length, events, bad, unknown: Object.values(unknown), excluded, runs: seen };
|
|
401
|
+
try {
|
|
402
|
+
fs.mkdirSync(path.dirname(cp), { recursive: true });
|
|
403
|
+
fs.writeFileSync(cp, JSON.stringify({ version: 3, sig, data }) + "\n");
|
|
404
|
+
} catch (_) {}
|
|
405
|
+
return Object.assign({ log_dir: dir, cached: false }, data);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Questions per run (v2.0.0 W6b, 08 Q7) ───────────────────────────────────
|
|
409
|
+
// `orc stats --json` → questions{}. The KPI for "fewer questions": computed from the SAME pass as the habits
|
|
410
|
+
// (`collect`), never a second parser. ASKED = a question the user saw: an ASK
|
|
411
|
+
// line with `by=user|learned` (learned = shown with the habit's pre-selection)
|
|
412
|
+
// plus every subagent `QUESTION count=<n>`. ANSWERED FOR YOU = `by=ledger|config|
|
|
413
|
+
// default`: the question was not shown. A run with no question counts as 0.
|
|
414
|
+
const ASKED_BY = ["user", "learned"];
|
|
415
|
+
const TREND_MAX = 20;
|
|
416
|
+
function median(xs) {
|
|
417
|
+
if (!xs.length) return null;
|
|
418
|
+
const a = xs.slice().sort((x, y) => x - y);
|
|
419
|
+
const m = Math.floor(a.length / 2);
|
|
420
|
+
return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
|
|
421
|
+
}
|
|
422
|
+
// → null (a pre-W6b cache shape) · { runs, per_run_p50, per_run_trend[], asked, answered_for_you, subagent_questions, by{}, by_lane{}, by_point{} }
|
|
423
|
+
function questionStats(col, opts) {
|
|
424
|
+
opts = opts || {};
|
|
425
|
+
if (!col || !Array.isArray(col.runs)) return null;
|
|
426
|
+
const runs = col.runs.filter((r) => !opts.since || !r.date || r.date >= opts.since);
|
|
427
|
+
const per = new Map(runs.map((r) => [r.run, { run: r.run, lane: r.lane, date: r.date, at: r.at, asked: r.questions || 0, answered_for_you: 0, subagent: r.questions || 0 }]));
|
|
428
|
+
const by = Object.fromEntries(BY_SET.map((b) => [b, 0]));
|
|
429
|
+
const byPoint = {};
|
|
430
|
+
for (const e of col.events || []) {
|
|
431
|
+
const row = per.get(e.run);
|
|
432
|
+
if (!row) continue;
|
|
433
|
+
by[e.by] = (by[e.by] || 0) + 1;
|
|
434
|
+
const pt = (byPoint[e.qid] ||= { asked: 0, answered_for_you: 0, lanes: [] });
|
|
435
|
+
if (!pt.lanes.includes(e.lane)) pt.lanes.push(e.lane);
|
|
436
|
+
if (ASKED_BY.includes(e.by)) {
|
|
437
|
+
row.asked++;
|
|
438
|
+
pt.asked++;
|
|
439
|
+
} else {
|
|
440
|
+
row.answered_for_you++;
|
|
441
|
+
pt.answered_for_you++;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Oldest first by the trace NAME (its date + HHMMSS), then mtime: a copied
|
|
445
|
+
// log dir has every mtime the same.
|
|
446
|
+
const keyOf = (r) => `${r.date || ""} ${(/-(\d{6})\.txt$/.exec(r.run) || [])[1] || ""}`;
|
|
447
|
+
const rows = [...per.values()].sort((x, y) => keyOf(x).localeCompare(keyOf(y)) || x.at - y.at);
|
|
448
|
+
const byLane = {};
|
|
449
|
+
for (const r of rows) {
|
|
450
|
+
const l = (byLane[r.lane] ||= { runs: 0, asked: 0, answered_for_you: 0, counts: [] });
|
|
451
|
+
l.runs++;
|
|
452
|
+
l.asked += r.asked;
|
|
453
|
+
l.answered_for_you += r.answered_for_you;
|
|
454
|
+
l.counts.push(r.asked);
|
|
455
|
+
}
|
|
456
|
+
for (const l of Object.values(byLane)) {
|
|
457
|
+
l.per_run_p50 = median(l.counts);
|
|
458
|
+
delete l.counts;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
runs: rows.length,
|
|
462
|
+
per_run_p50: median(rows.map((r) => r.asked)),
|
|
463
|
+
per_run_trend: rows.slice(-TREND_MAX).map((r) => ({ run: r.run, lane: r.lane, date: r.date, asked: r.asked, answered_for_you: r.answered_for_you })),
|
|
464
|
+
asked: rows.reduce((n, r) => n + r.asked, 0),
|
|
465
|
+
answered_for_you: rows.reduce((n, r) => n + r.answered_for_you, 0),
|
|
466
|
+
subagent_questions: rows.reduce((n, r) => n + r.subagent, 0),
|
|
467
|
+
by,
|
|
468
|
+
by_lane: byLane,
|
|
469
|
+
by_point: byPoint,
|
|
470
|
+
rule: "asked = ASK by=user|learned + QUESTION count=<n>; answered for you = ASK by=ledger|config|default",
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Rows (§4) ───────────────────────────────────────────────────────────────
|
|
475
|
+
function dm(ts) {
|
|
476
|
+
const d = new Date(ts);
|
|
477
|
+
return `${String(d.getDate()).padStart(2, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
478
|
+
}
|
|
479
|
+
function label(pt, o) {
|
|
480
|
+
return (pt.options && pt.options[o]) || o;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The counts sentence (§3.4): counts, never percentages or Wilson values.
|
|
484
|
+
function countsLine(pt, ev, list) {
|
|
485
|
+
if (ev.top === null) return null;
|
|
486
|
+
const others = list.filter((a) => a.chose !== ev.top);
|
|
487
|
+
let tail = "";
|
|
488
|
+
if (others.length === 1) tail = ` (one "${label(pt, others[0].chose)}" on ${dm(others[0].at)})`;
|
|
489
|
+
else if (others.length > 1) tail = ` (${others.length} other answers, the last "${label(pt, others[others.length - 1].chose)}" on ${dm(others[others.length - 1].at)})`;
|
|
490
|
+
return `You chose ${label(pt, ev.top)} in ${ev.count} of your last ${ev.total} answers to "${pt.title}"${tail}.`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── The panel fields (v2.0.0 W7, `07-behaviour-panel-spec.md` §4) ──────────
|
|
494
|
+
// Every value below is the CLI's. The panel renders it and computes nothing.
|
|
495
|
+
function optionRows(pt, ev) {
|
|
496
|
+
const counts = ev.counts || {};
|
|
497
|
+
return Object.entries(counts)
|
|
498
|
+
.map(([v, n]) => ({ v, label: label(pt, v), n }))
|
|
499
|
+
.sort((a, b) => (b.v === ev.top) - (a.v === ev.top) || b.n - a.n || a.v.localeCompare(b.v));
|
|
500
|
+
}
|
|
501
|
+
// The last 10 counted answers, oldest first: 1 = the top choice, 0 = another
|
|
502
|
+
// choice, 0.25 = Enter on a pre-selected top choice (it counts at 0.25, §3.2).
|
|
503
|
+
function streakOf(list, top) {
|
|
504
|
+
return list.slice(-10).map((a) => (a.chose !== top ? 0 : a.pre && a.chose === a.pre ? 0.25 : 1));
|
|
505
|
+
}
|
|
506
|
+
function effectLine(pt, state, eff, top, rec) {
|
|
507
|
+
const opt = label(pt, (rec && rec.option) || top);
|
|
508
|
+
if (state === "applied")
|
|
509
|
+
return pt.class === "apply" ? `ORC answers ${pt.key} with ${opt} at the learned rank. Your config file still wins.` : `ORC puts ${opt} first and marks it → usual. The question is still asked.`;
|
|
510
|
+
if (state === "stale") return `ORC paused this habit. The question is asked again until you decide.`;
|
|
511
|
+
if (eff === "never") return `ORC shows this and never proposes it: ${pt.why}.`;
|
|
512
|
+
if (!top) return null;
|
|
513
|
+
return pt.class === "apply" && eff === "apply"
|
|
514
|
+
? `If you accept, ORC answers ${pt.key} with ${opt} at the learned rank. Your config file still wins.`
|
|
515
|
+
: `If you accept, ORC puts ${opt} first and marks it → usual. The question is still asked.`;
|
|
516
|
+
}
|
|
517
|
+
// The value a person types to set this key themself, when the habit is class
|
|
518
|
+
// never. Only a value the CLI can name: an unknown one gets no command.
|
|
519
|
+
function manualValue(pt, top) {
|
|
520
|
+
if (!pt.key) return null;
|
|
521
|
+
if (pt.values && pt.values[top] !== undefined) return pt.values[top];
|
|
522
|
+
const safe = pt.careful && pt.values ? pt.values[pt.careful] : undefined;
|
|
523
|
+
return safe === "on" ? "off" : safe === "true" ? "false" : null;
|
|
524
|
+
}
|
|
525
|
+
function commandsFor(id, pt, state, eff, top) {
|
|
526
|
+
const c = { accept: null, decline: null, never: null, forget: null, reset: null, manual: null };
|
|
527
|
+
if (state === "proposed") {
|
|
528
|
+
c.accept = `orc habit accept ${id}`;
|
|
529
|
+
c.decline = `orc habit decline ${id}`;
|
|
530
|
+
c.never = `orc habit decline ${id} --never`;
|
|
531
|
+
} else if (state === "applied" || state === "stale") c.forget = `orc habit forget ${id}`;
|
|
532
|
+
else if (state === "declined" || state === "never-ask") c.reset = `orc habit reset ${id}`;
|
|
533
|
+
else if (state === "never") {
|
|
534
|
+
const v = manualValue(pt, top);
|
|
535
|
+
c.manual = v === null ? null : `orc config set ${pt.key} ${v}`;
|
|
536
|
+
}
|
|
537
|
+
return c;
|
|
538
|
+
}
|
|
539
|
+
// The inverse of one history entry — the command that undoes it.
|
|
540
|
+
function inverseOf(h) {
|
|
541
|
+
if (!/^H-/.test(String(h.id))) return null;
|
|
542
|
+
if (h.to === "applied") return `orc habit forget ${h.id}`;
|
|
543
|
+
if (h.to === "declined" || h.to === "never-ask") return `orc habit reset ${h.id}`;
|
|
544
|
+
if (h.to === "observed" && h.from === "applied") return `orc habit accept ${h.id}`;
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// The panel's numbers for ONE window (`show`). Runs are dated by their trace
|
|
549
|
+
// file's mtime; the heat map is local time, Monday first.
|
|
550
|
+
const WEEKS = 12;
|
|
551
|
+
function panelStats(col, rows, windowDays, now, lane) {
|
|
552
|
+
const inLane = (x) => !lane || x.lane === lane;
|
|
553
|
+
const within = (from, to) => (x) => x.at >= from && x.at < to;
|
|
554
|
+
const span = windowDays ? windowDays * DAY : null;
|
|
555
|
+
const cur = span ? within(now - span, now + DAY) : () => true;
|
|
556
|
+
const prev = span ? within(now - 2 * span, now - span) : null;
|
|
557
|
+
const runsAll = (col.runs || []).filter(inLane);
|
|
558
|
+
const evAll = col.events.filter(inLane);
|
|
559
|
+
const asked = (runs, evs) => {
|
|
560
|
+
const ids = new Set(runs.map((r) => r.run));
|
|
561
|
+
const n = evs.filter((e) => ids.has(e.run) && ASKED_BY.includes(e.by)).length + runs.reduce((s, r) => s + (r.questions || 0), 0);
|
|
562
|
+
return runs.length ? Math.round((n / runs.length) * 10) / 10 : null;
|
|
563
|
+
};
|
|
564
|
+
const saved = (evs) => evs.filter((e) => !ASKED_BY.includes(e.by)).length;
|
|
565
|
+
const runsCur = runsAll.filter(cur);
|
|
566
|
+
const evCur = evAll.filter(cur);
|
|
567
|
+
const runsPrev = prev ? runsAll.filter(prev) : null;
|
|
568
|
+
const evPrev = prev ? evAll.filter(prev) : null;
|
|
569
|
+
const tiles = {
|
|
570
|
+
runs: runsCur.length,
|
|
571
|
+
answers: evCur.length,
|
|
572
|
+
saved: saved(evCur),
|
|
573
|
+
saved_prev: evPrev ? saved(evPrev) : null,
|
|
574
|
+
qpr: asked(runsCur, evCur),
|
|
575
|
+
qpr_prev: runsPrev ? asked(runsPrev, evPrev) : null,
|
|
576
|
+
};
|
|
577
|
+
const heat = Array.from({ length: 7 }, () => Array(24).fill(0));
|
|
578
|
+
for (const r of runsCur) {
|
|
579
|
+
if (!r.at) continue;
|
|
580
|
+
const d = new Date(r.at);
|
|
581
|
+
heat[(d.getDay() + 6) % 7][d.getHours()]++;
|
|
582
|
+
}
|
|
583
|
+
const byLane = {};
|
|
584
|
+
for (const r of runsCur) byLane[r.lane] = (byLane[r.lane] || 0) + 1;
|
|
585
|
+
const lanes = Object.entries(byLane)
|
|
586
|
+
.map(([l, n]) => ({ lane: l, n }))
|
|
587
|
+
.sort((a, b) => b.n - a.n || a.lane.localeCompare(b.lane));
|
|
588
|
+
const weeks = [];
|
|
589
|
+
const qpr = [];
|
|
590
|
+
const how = [];
|
|
591
|
+
for (let w = WEEKS - 1; w >= 0; w--) {
|
|
592
|
+
const f = within(now - (w + 1) * 7 * DAY, now - w * 7 * DAY + (w ? 0 : DAY));
|
|
593
|
+
const rw = runsAll.filter(f);
|
|
594
|
+
const ew = evAll.filter(f);
|
|
595
|
+
weeks.push(new Date(now - (w + 1) * 7 * DAY).toISOString().slice(0, 10));
|
|
596
|
+
qpr.push(asked(rw, ew));
|
|
597
|
+
const h = Object.fromEntries(BY_SET.map((b) => [b, 0]));
|
|
598
|
+
for (const e of ew) h[e.by] = (h[e.by] || 0) + 1;
|
|
599
|
+
how.push(h);
|
|
600
|
+
}
|
|
601
|
+
// The portrait: 3–5 sentences, every one composed here.
|
|
602
|
+
const portrait = [];
|
|
603
|
+
const total = runsCur.length;
|
|
604
|
+
if (lanes.length) portrait.push({ k: "lanes", line: `You run most work through ${lanes[0].lane} (${Math.round((lanes[0].n / total) * 100)}% of ${total} runs).` });
|
|
605
|
+
let peak = null;
|
|
606
|
+
heat.forEach((row, d) => row.forEach((v, h) => { if (v && (!peak || v > peak.v)) peak = { d, h, v }; }));
|
|
607
|
+
if (peak) portrait.push({ k: "time", line: `Your busiest hour is ${DAY_NAMES[peak.d]} ${String(peak.h).padStart(2, "0")}:00 (${peak.v} run${peak.v === 1 ? "" : "s"}).` });
|
|
608
|
+
if (tiles.qpr !== null)
|
|
609
|
+
portrait.push({
|
|
610
|
+
k: "questions",
|
|
611
|
+
line: tiles.qpr_prev !== null && tiles.qpr_prev !== undefined
|
|
612
|
+
? `You answered ${tiles.qpr} questions per run, against ${tiles.qpr_prev} in the window before.`
|
|
613
|
+
: `You answered ${tiles.qpr} questions per run.`,
|
|
614
|
+
});
|
|
615
|
+
for (const r of rows.filter((x) => x.state === "applied").slice(0, 2))
|
|
616
|
+
portrait.push({ k: "usual", line: `${r.title}: usually ${label(ASK_POINTS[r.qid], r.option)} (${r.count} of ${r.total}).` });
|
|
617
|
+
const proposed = rows.filter((x) => x.state === "proposed").length;
|
|
618
|
+
if (proposed) portrait.push({ k: "proposal", line: `${proposed} habit${proposed === 1 ? " is" : "s are"} ready for your decision.` });
|
|
619
|
+
return { tiles, heat, lanes, weeks, qpr, how, portrait: portrait.slice(0, 5) };
|
|
620
|
+
}
|
|
621
|
+
const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
622
|
+
|
|
623
|
+
// What the habits do on the NEXT run, per lane — predictability is the point.
|
|
624
|
+
function nextRun(rows) {
|
|
625
|
+
const out = [];
|
|
626
|
+
for (const r of rows) {
|
|
627
|
+
if (r.state !== "applied") continue;
|
|
628
|
+
const pt = ASK_POINTS[r.qid];
|
|
629
|
+
const opt = label(pt, r.option);
|
|
630
|
+
const line = r.class === "apply" ? `${pt.key}: ${r.value} — learned (${r.id})` : `"${r.title}" offers ${opt} first, marked → usual (${r.id})`;
|
|
631
|
+
for (const l of pt.lanes) out.push({ lane: l, id: r.id, qid: r.qid, line });
|
|
632
|
+
}
|
|
633
|
+
return out.sort((a, b) => a.lane.localeCompare(b.lane) || a.id.localeCompare(b.id));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// `show` under `habits: off` reads NOTHING new — only the cache file an earlier
|
|
637
|
+
// on-period left, so the panel can say "your earlier answers are kept".
|
|
638
|
+
function keptAnswers(claudeDir) {
|
|
639
|
+
try {
|
|
640
|
+
const c = JSON.parse(fs.readFileSync(statePaths(claudeDir).cache, "utf8"));
|
|
641
|
+
return c && c.data && Array.isArray(c.data.events) ? c.data.events.length : 0;
|
|
642
|
+
} catch (_) {
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// The order the CLI sends rows in for the panel: the ones that need you first.
|
|
648
|
+
const PANEL_ORDER = ["proposed", "stale", "applied", "observed", "shadowed", "declined", "never-ask", "never"];
|
|
649
|
+
|
|
650
|
+
function computeRows(claudeDir, deps, opts = {}) {
|
|
651
|
+
const now = opts.now || Date.now();
|
|
652
|
+
const map = opts.map || {};
|
|
653
|
+
const mode = opts.mode || habitsMode(map);
|
|
654
|
+
const col = opts.collected || collect(claudeDir, deps);
|
|
655
|
+
const st = readState(claudeDir);
|
|
656
|
+
const maxAge = (opts.window_days || HABIT_RULE.max_age_days) * DAY;
|
|
657
|
+
const groups = new Map();
|
|
658
|
+
const add = (id, qid, b, e) => {
|
|
659
|
+
if (!groups.has(id)) groups.set(id, { id, qid, bucket: b, events: [] });
|
|
660
|
+
groups.get(id).events.push(e);
|
|
661
|
+
};
|
|
662
|
+
for (const e of col.events) {
|
|
663
|
+
if (HABIT_META_QIDS.includes(e.qid)) continue;
|
|
664
|
+
const pt = ASK_POINTS[e.qid];
|
|
665
|
+
if (!pt) continue;
|
|
666
|
+
const b = bucketOf(pt, e.ctx);
|
|
667
|
+
add(habitId(e.qid, b), e.qid, b, e);
|
|
668
|
+
// The "any context" row holds EVERY answer to the question; a bucket with
|
|
669
|
+
// context is a refinement of it, never a split of its evidence.
|
|
670
|
+
if (Object.keys(b).length) add(habitId(e.qid, {}), e.qid, {}, e);
|
|
671
|
+
}
|
|
672
|
+
// A decision on a habit with no evidence left keeps its row: an applied
|
|
673
|
+
// habit you can no longer see is a setting nobody can undo.
|
|
674
|
+
for (const [id, r] of Object.entries(st.habits))
|
|
675
|
+
if (!groups.has(id) && ASK_POINTS[r.qid]) groups.set(id, { id, qid: r.qid, bucket: r.bucket || {}, events: [] });
|
|
676
|
+
const rows = [];
|
|
677
|
+
for (const g of groups.values()) {
|
|
678
|
+
const pt = ASK_POINTS[g.qid];
|
|
679
|
+
const rec = st.habits[g.id] || null;
|
|
680
|
+
const cutoff = Math.max((rec && rec.cutoff) || 0, st.purged_at || 0);
|
|
681
|
+
const live = g.events.filter((e) => e.at >= cutoff);
|
|
682
|
+
const counted = live.filter((e) => !HABIT_RULE.excluded_by.includes(e.by) && e.at >= now - maxAge);
|
|
683
|
+
const list = counted.slice(-HABIT_RULE.window);
|
|
684
|
+
const ev = evaluate(list);
|
|
685
|
+
const eff = effectiveClass(pt, ev.top, g.bucket);
|
|
686
|
+
const shadowed = pt.class === "apply" && pt.key && Object.prototype.hasOwnProperty.call(map, pt.key);
|
|
687
|
+
let state = "observed";
|
|
688
|
+
let why = null;
|
|
689
|
+
let stale_reason = null;
|
|
690
|
+
let stale_detail = null;
|
|
691
|
+
let last_used = null;
|
|
692
|
+
if (rec && rec.state === "applied") {
|
|
693
|
+
const after = counted.filter((e) => e.at > rec.at);
|
|
694
|
+
const last = after.slice(-HABIT_RULE.stale_of);
|
|
695
|
+
const overrides = last.filter((e) => e.chose !== rec.option).length;
|
|
696
|
+
last_used = Math.max(rec.renewed_at || rec.at, ...live.filter((e) => e.at > rec.at && e.chose === rec.option && e.by !== "default").map((e) => e.at));
|
|
697
|
+
if (overrides >= HABIT_RULE.stale_overrides) {
|
|
698
|
+
state = "stale";
|
|
699
|
+
stale_reason = "override";
|
|
700
|
+
const o = last.filter((e) => e.chose !== rec.option).pop().chose;
|
|
701
|
+
stale_detail = { option: o, overrides, of: last.length };
|
|
702
|
+
why = `you chose ${label(pt, o)} ${overrides} of the last ${last.length} times instead of your usual ${label(pt, rec.option)} — paused, not flipped`;
|
|
703
|
+
} else if (now - last_used > HABIT_RULE.expiry_days * DAY) {
|
|
704
|
+
state = "stale";
|
|
705
|
+
stale_reason = "expired";
|
|
706
|
+
why = `not used for ${HABIT_RULE.expiry_days} days — paused until you keep it`;
|
|
707
|
+
} else {
|
|
708
|
+
state = "applied";
|
|
709
|
+
why = pt.class === "apply" ? `answers ${pt.key} at the learned rank` : `marks ${label(pt, rec.option)} as your usual; the question is still asked`;
|
|
710
|
+
}
|
|
711
|
+
} else if (rec && rec.state === "never-ask") {
|
|
712
|
+
state = "never-ask";
|
|
713
|
+
why = "you said never — still observed, never proposed";
|
|
714
|
+
} else if (rec && rec.state === "declined") {
|
|
715
|
+
const since = counted.filter((e) => e.at > rec.at).length;
|
|
716
|
+
const days = (now - rec.at) / DAY;
|
|
717
|
+
if (since < HABIT_RULE.cooldown_answers || days < HABIT_RULE.cooldown_days) {
|
|
718
|
+
state = "declined";
|
|
719
|
+
why = `declined — it can come back after ${HABIT_RULE.cooldown_answers} more answers (${since} so far) and ${HABIT_RULE.cooldown_days} days (${Math.floor(days)} so far)`;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (eff === "never" && state !== "applied" && state !== "stale") {
|
|
723
|
+
state = "never";
|
|
724
|
+
why = pt.careful && ev.top && ev.top !== pt.careful && pt.class === "apply"
|
|
725
|
+
? `your usual answer (${label(pt, ev.top)}) is away from the careful side, so it is never applied — ${pt.why}`
|
|
726
|
+
: pt.why;
|
|
727
|
+
}
|
|
728
|
+
if (state === "observed") {
|
|
729
|
+
if (ev.passes) {
|
|
730
|
+
state = mode === "propose" ? "proposed" : "observed";
|
|
731
|
+
why = mode === "propose" ? "the rule passes — proposed once, at the end of a run" : "the rule passes — `habits: observe` never proposes";
|
|
732
|
+
} else {
|
|
733
|
+
why = !ev.top
|
|
734
|
+
? "no counted answers yet"
|
|
735
|
+
: ev.n < HABIT_RULE.min_n
|
|
736
|
+
? `${ev.n} full-weight answers — the rule needs ${HABIT_RULE.min_n}`
|
|
737
|
+
: !ev.last_agree
|
|
738
|
+
? `the last ${HABIT_RULE.last_agree} answers do not agree`
|
|
739
|
+
: `not consistent enough yet (${ev.count} of ${ev.total})`;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (shadowed) {
|
|
743
|
+
why = `your config sets ${pt.key}: ${map[pt.key]} — the file wins, so this habit is shown and inert (was: ${state})`;
|
|
744
|
+
state = "shadowed";
|
|
745
|
+
}
|
|
746
|
+
const applied = state === "applied" && rec;
|
|
747
|
+
rows.push({
|
|
748
|
+
id: g.id,
|
|
749
|
+
qid: g.qid,
|
|
750
|
+
lanes: pt.lanes,
|
|
751
|
+
title: pt.title,
|
|
752
|
+
class: pt.class,
|
|
753
|
+
effective_class: eff,
|
|
754
|
+
careful: pt.careful,
|
|
755
|
+
key: pt.key,
|
|
756
|
+
bucket: g.bucket,
|
|
757
|
+
state,
|
|
758
|
+
why,
|
|
759
|
+
stale_reason,
|
|
760
|
+
stale_detail,
|
|
761
|
+
top: ev.top,
|
|
762
|
+
top_label: ev.top ? label(pt, ev.top) : null,
|
|
763
|
+
count: ev.count,
|
|
764
|
+
total: ev.total,
|
|
765
|
+
n: ev.n,
|
|
766
|
+
n_eff: ev.n_eff,
|
|
767
|
+
p: ev.p,
|
|
768
|
+
lb: ev.lb,
|
|
769
|
+
last_agree: ev.last_agree,
|
|
770
|
+
passes: ev.passes,
|
|
771
|
+
proposable: ev.passes && eff !== "never" && !shadowed && !(rec && ["applied", "never-ask"].includes(rec.state) && state !== "stale") && state !== "declined",
|
|
772
|
+
option: rec && rec.option ? rec.option : null,
|
|
773
|
+
value: applied && pt.class === "apply" ? pt.values[rec.option] : null,
|
|
774
|
+
decided_at: rec ? rec.at : null,
|
|
775
|
+
last_used,
|
|
776
|
+
line: countsLine(pt, ev, list),
|
|
777
|
+
rule: ev.top ? `Wilson95 LB ${ev.lb} (n_eff ${ev.n_eff}, raw n ${ev.n}) — proposes at ≥ ${HABIT_RULE.lb} with n ≥ ${HABIT_RULE.min_n} and the last ${HABIT_RULE.last_agree} agreeing` : null,
|
|
778
|
+
undo: rec && rec.state !== "observed" ? `orc habit forget ${g.id}` : null,
|
|
779
|
+
// v2.0.0 W7 — what the Behaviour panel draws, computed HERE so the panel
|
|
780
|
+
// derives nothing: not a share, not a streak, not a command.
|
|
781
|
+
options: optionRows(pt, ev),
|
|
782
|
+
share: ev.total ? round3(ev.count / ev.total) : 0,
|
|
783
|
+
streak: streakOf(list, ev.top),
|
|
784
|
+
context: bucketKey(g.bucket),
|
|
785
|
+
effect: effectLine(pt, state, eff, ev.top, rec),
|
|
786
|
+
commands: commandsFor(g.id, pt, state, eff, ev.top),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
rows.sort((a, b) => b.lb - a.lb || a.id.localeCompare(b.id));
|
|
790
|
+
return { rows, collected: col, state: st, mode };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// At most ONE proposal per run: the proposed or stale row with the highest LB.
|
|
794
|
+
function pickProposal(rows, lane) {
|
|
795
|
+
const cands = rows.filter((r) => (r.state === "proposed" || r.state === "stale") && (!lane || r.lanes.includes(lane)));
|
|
796
|
+
if (!cands.length) return null;
|
|
797
|
+
const r = cands.sort((a, b) => b.lb - a.lb)[0];
|
|
798
|
+
const pt = ASK_POINTS[r.qid];
|
|
799
|
+
const kind = r.state === "stale" ? "re-ask" : "propose";
|
|
800
|
+
const line =
|
|
801
|
+
kind === "propose"
|
|
802
|
+
? `${r.line} Make it your usual? [yes · not now · never]`
|
|
803
|
+
: r.stale_reason === "expired"
|
|
804
|
+
? `You have not used your usual ${label(pt, r.option)} for ${HABIT_RULE.expiry_days} days. Keep it · ask every time?`
|
|
805
|
+
: `You chose ${label(pt, r.stale_detail.option)} ${r.stale_detail.overrides} of the last ${r.stale_detail.of} times instead of your usual ${label(pt, r.option)}. Switch to ${label(pt, r.stale_detail.option)} · keep ${label(pt, r.option)} · ask every time?`;
|
|
806
|
+
return {
|
|
807
|
+
id: r.id,
|
|
808
|
+
qid: r.qid,
|
|
809
|
+
kind,
|
|
810
|
+
option: kind === "propose" ? r.top : r.option,
|
|
811
|
+
line,
|
|
812
|
+
yes: kind === "propose" ? `orc habit accept ${r.id}` : r.stale_reason === "expired" ? `orc habit accept ${r.id} --keep` : `orc habit accept ${r.id}`,
|
|
813
|
+
keep: kind === "re-ask" ? `orc habit accept ${r.id} --keep` : null,
|
|
814
|
+
later: kind === "propose" ? `orc habit decline ${r.id}` : null,
|
|
815
|
+
never: kind === "propose" ? `orc habit decline ${r.id} --never` : `orc habit forget ${r.id}`,
|
|
816
|
+
trace: `ASK habit.proposal :: offered=yes|later|never rec=none chose=<yes|later|never> by=user`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ── The learned rank (§5) — read by `orc lane config` and `orc config list` ─
|
|
821
|
+
// An applied `apply` habit whose ctx matches this run answers its key at rank 3:
|
|
822
|
+
// below your config file, above the shipped default. A ctx key the CLI cannot
|
|
823
|
+
// know at preflight (kind, size, risk) never matches, so such a habit is shown
|
|
824
|
+
// and not applied — the conservative answer.
|
|
825
|
+
function learnedFor(claudeDir, deps, map, lane, computed) {
|
|
826
|
+
const c = computed || computeRows(claudeDir, deps, { map });
|
|
827
|
+
const branch = branchClass(currentBranch(path.join(claudeDir, "..")));
|
|
828
|
+
const out = {};
|
|
829
|
+
for (const r of c.rows) {
|
|
830
|
+
if (r.state !== "applied" || r.class !== "apply" || r.value === null) continue;
|
|
831
|
+
const pt = ASK_POINTS[r.qid];
|
|
832
|
+
if (lane && !pt.lanes.includes(lane)) continue;
|
|
833
|
+
let match = true;
|
|
834
|
+
for (const [k, v] of Object.entries(r.bucket)) if (k !== "branch" || v !== branch) match = false;
|
|
835
|
+
if (!match || out[pt.key]) continue;
|
|
836
|
+
out[pt.key] = { id: r.id, qid: r.qid, value: r.value, option: r.option, count: r.count, total: r.total };
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// The BRIEF `habits{}` block `orc lane config` carries when habits is not off.
|
|
842
|
+
function laneBlock(claudeDir, deps, map, lane) {
|
|
843
|
+
const c = computeRows(claudeDir, deps, { map });
|
|
844
|
+
const learned = learnedFor(claudeDir, deps, map, lane, c);
|
|
845
|
+
const mine = c.rows.filter((r) => r.lanes.includes(lane));
|
|
846
|
+
const suggestions = mine
|
|
847
|
+
.filter((r) => r.state === "applied" && r.class === "suggest")
|
|
848
|
+
.map((r) => ({ id: r.id, qid: r.qid, option: r.option, bucket: r.bucket, mark: `→ usual (${r.count} of ${r.total})` }));
|
|
849
|
+
const proposal = c.mode === "propose" ? pickProposal(c.rows, lane) : null;
|
|
850
|
+
const learnedList = Object.entries(learned).map(([key, l]) => ({
|
|
851
|
+
key,
|
|
852
|
+
value: l.value,
|
|
853
|
+
id: l.id,
|
|
854
|
+
qid: l.qid,
|
|
855
|
+
count: l.count,
|
|
856
|
+
total: l.total,
|
|
857
|
+
undo: `orc habit forget ${l.id}`,
|
|
858
|
+
}));
|
|
859
|
+
const block = {
|
|
860
|
+
mode: c.mode,
|
|
861
|
+
learned: learnedList,
|
|
862
|
+
suggestions,
|
|
863
|
+
proposal,
|
|
864
|
+
line:
|
|
865
|
+
`habits: ${c.mode} — ${learnedList.length} learned, ${suggestions.length} usual mark(s)` +
|
|
866
|
+
(c.mode === "propose" ? (proposal ? ", 1 proposal for the end of the run" : ", no proposal") : ", never proposes"),
|
|
867
|
+
};
|
|
868
|
+
return { block, learned };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function announceLine(key, l) {
|
|
872
|
+
return `${key}: ${l.value} — learned (${l.id}, ${l.count} of ${l.total}) · undo: orc habit forget ${l.id}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── Soft preferences (§7, DE-8) — `orc habit repo` ─────────────────────────
|
|
876
|
+
// No question is asked: the repo already knows. Computed from `git log -n 100`
|
|
877
|
+
// and the tree, with no model. A signal becomes a PROPOSED soft preference only
|
|
878
|
+
// at share ≥ 0.8 over ≥ 20 samples, with the evidence in the row. An ACCEPTED
|
|
879
|
+
// one rides in `orc rules slice` as the LEARNED tier — below your own
|
|
880
|
+
// `.claude/orc/rules.md`, which stays written by a human only. It is re-checked
|
|
881
|
+
// on use: when it no longer matches 4 of the last 5 samples it is `stale` and
|
|
882
|
+
// leaves the card. Decisions live in habits-state.json under `soft`.
|
|
883
|
+
const SOFT_RULE = { share: 0.8, min_n: 20, recheck_of: 5, recheck_need: 4, commits: 100, branches: 30 };
|
|
884
|
+
const CC_RE = /^([a-z][a-z0-9-]*)(\(([^)]+)\))?(!)?: \S/;
|
|
885
|
+
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/;
|
|
886
|
+
const TRAILER_RE = /^([A-Za-z][A-Za-z-]+): .+$/;
|
|
887
|
+
const BRANCH_SKIP = new Set(["HEAD", "main", "master", "develop", "dev", "trunk", "origin"]);
|
|
888
|
+
const BRANCH_RE = /^([a-z]+)\/[a-z0-9][a-z0-9._-]*$/;
|
|
889
|
+
|
|
890
|
+
function git(projectRoot, argv) {
|
|
891
|
+
try {
|
|
892
|
+
return execFileSync("git", argv, { cwd: projectRoot, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
893
|
+
} catch (_) {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// The body WITHOUT its trailers — a `Co-Authored-By:` line is a tool's, not a body.
|
|
899
|
+
function bodyOf(body) {
|
|
900
|
+
return String(body || "")
|
|
901
|
+
.split("\n")
|
|
902
|
+
.filter((l) => l.trim() && !TRAILER_RE.test(l.trim()))
|
|
903
|
+
.join("\n")
|
|
904
|
+
.trim();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function repoCommits(projectRoot) {
|
|
908
|
+
const out = git(projectRoot, ["log", "-n", String(SOFT_RULE.commits), "--no-merges", "--format=%h%x1f%s%x1f%b%x1e"]);
|
|
909
|
+
if (out === null) return null;
|
|
910
|
+
return out
|
|
911
|
+
.split("\x1e")
|
|
912
|
+
.map((r) => r.replace(/^\s+/, ""))
|
|
913
|
+
.filter(Boolean)
|
|
914
|
+
.map((r) => {
|
|
915
|
+
const [sha, subject, body] = r.split("\x1f");
|
|
916
|
+
return { sha, subject: subject || "", body: body || "" };
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function repoBranches(projectRoot) {
|
|
921
|
+
const out = git(projectRoot, ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads", "refs/remotes"]);
|
|
922
|
+
if (out === null) return [];
|
|
923
|
+
const seen = new Set();
|
|
924
|
+
const list = [];
|
|
925
|
+
for (let b of out.split("\n").map((x) => x.trim()).filter(Boolean)) {
|
|
926
|
+
if (/^[^/]+\/HEAD$/.test(b)) continue;
|
|
927
|
+
b = b.replace(/^(origin|upstream)\//, "");
|
|
928
|
+
if (BRANCH_SKIP.has(b) || seen.has(b)) continue;
|
|
929
|
+
seen.add(b);
|
|
930
|
+
list.push(b);
|
|
931
|
+
}
|
|
932
|
+
return list.slice(0, SOFT_RULE.branches);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function testStyle(file) {
|
|
936
|
+
const f = String(file).replace(/\\/g, "/");
|
|
937
|
+
if (/\.spec\.[a-z0-9]+$/i.test(f)) return "spec";
|
|
938
|
+
if (/\.test\.[a-z0-9]+$/i.test(f)) return "test";
|
|
939
|
+
if (/(^|\/)test_[^/]+\.py$/.test(f)) return "test_py";
|
|
940
|
+
if (/_test\.(py|go)$/.test(f)) return "_test";
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
const TEST_STYLE_WORDS = { spec: "`*.spec.<ext>`", test: "`*.test.<ext>`", test_py: "`test_*.py`", _test: "`*_test.<ext>`" };
|
|
944
|
+
|
|
945
|
+
// Each signal: its samples (newest first), the value it would propose, a
|
|
946
|
+
// predicate for a value, and the sentence the card carries. The predicate is
|
|
947
|
+
// what the re-check on use runs against the ACCEPTED value.
|
|
948
|
+
const SOFT_SIGNALS = {
|
|
949
|
+
"commit.conventional": {
|
|
950
|
+
title: "Conventional Commits subjects",
|
|
951
|
+
samples: (d) => d.commits,
|
|
952
|
+
value: () => "conventional",
|
|
953
|
+
match: (c) => CC_RE.test(c.subject),
|
|
954
|
+
rule: (d) => {
|
|
955
|
+
const types = [...new Set(d.commits.map((c) => (CC_RE.exec(c.subject) || [])[1]).filter(Boolean))].slice(0, 6);
|
|
956
|
+
return `Write the commit subject as a Conventional Commit: \`type(scope): subject\` (types seen here: ${types.join(", ")}). Keep it at or under ${d.facts.subject_p90} characters.`;
|
|
957
|
+
},
|
|
958
|
+
ev: (c) => c.sha,
|
|
959
|
+
},
|
|
960
|
+
"commit.scope": {
|
|
961
|
+
title: "a scope in the commit type",
|
|
962
|
+
samples: (d) => d.commits.filter((c) => CC_RE.test(c.subject)),
|
|
963
|
+
value: () => "scope",
|
|
964
|
+
match: (c) => !!(CC_RE.exec(c.subject) || [])[3],
|
|
965
|
+
rule: () => "Put a scope in the commit type: `type(scope): subject`.",
|
|
966
|
+
ev: (c) => c.sha,
|
|
967
|
+
},
|
|
968
|
+
"commit.ticket": {
|
|
969
|
+
title: "a ticket id in the commit",
|
|
970
|
+
samples: (d) => d.commits,
|
|
971
|
+
value: () => "ticket",
|
|
972
|
+
match: (c) => TICKET_RE.test(c.subject) || TICKET_RE.test(c.body),
|
|
973
|
+
rule: () => "Name the ticket id (like ABC-123) in the commit message.",
|
|
974
|
+
ev: (c) => c.sha,
|
|
975
|
+
},
|
|
976
|
+
"commit.body": {
|
|
977
|
+
title: "a commit body",
|
|
978
|
+
samples: (d) => d.commits,
|
|
979
|
+
value: () => "body",
|
|
980
|
+
match: (c) => !!bodyOf(c.body),
|
|
981
|
+
rule: () => "Write a body under the commit subject: what changed and why.",
|
|
982
|
+
ev: (c) => c.sha,
|
|
983
|
+
},
|
|
984
|
+
"branch.naming": {
|
|
985
|
+
title: "`<type>/<slug>` branch names",
|
|
986
|
+
samples: (d) => d.branches,
|
|
987
|
+
value: () => "type/slug",
|
|
988
|
+
match: (b) => BRANCH_RE.test(b),
|
|
989
|
+
rule: (d) => {
|
|
990
|
+
const types = [...new Set(d.branches.map((b) => (BRANCH_RE.exec(b) || [])[1]).filter(Boolean))].slice(0, 6);
|
|
991
|
+
return `Name a new branch \`<type>/<slug>\` (types seen here: ${types.join(", ")}).`;
|
|
992
|
+
},
|
|
993
|
+
ev: (b) => b,
|
|
994
|
+
},
|
|
995
|
+
"test.naming": {
|
|
996
|
+
title: "one test file naming style",
|
|
997
|
+
samples: (d) => d.tests,
|
|
998
|
+
value: (d) => {
|
|
999
|
+
const c = {};
|
|
1000
|
+
for (const f of d.tests) c[testStyle(f)] = (c[testStyle(f)] || 0) + 1;
|
|
1001
|
+
return Object.keys(c).sort((a, b) => c[b] - c[a] || (a < b ? -1 : 1))[0] || null;
|
|
1002
|
+
},
|
|
1003
|
+
match: (f, v) => testStyle(f) === v,
|
|
1004
|
+
rule: (d, v) => `Name a new test file ${TEST_STYLE_WORDS[v] || v}, like the tests already here.`,
|
|
1005
|
+
ev: (f) => f,
|
|
1006
|
+
// Re-checked against the test files the last commits ADDED, newest first.
|
|
1007
|
+
recent: (d) => d.recent_tests,
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
function repoData(projectRoot) {
|
|
1012
|
+
const commits = repoCommits(projectRoot);
|
|
1013
|
+
if (commits === null) return null;
|
|
1014
|
+
const lens = commits.map((c) => c.subject.length).sort((a, b) => a - b);
|
|
1015
|
+
const trailers = {};
|
|
1016
|
+
for (const c of commits) {
|
|
1017
|
+
const keys = new Set(String(c.body).split("\n").map((l) => (TRAILER_RE.exec(l.trim()) || [])[1]).filter(Boolean));
|
|
1018
|
+
for (const k of keys) trailers[k] = (trailers[k] || 0) + 1;
|
|
1019
|
+
}
|
|
1020
|
+
const files = (git(projectRoot, ["ls-files"]) || "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
1021
|
+
const added = (git(projectRoot, ["log", "-n", String(SOFT_RULE.commits), "--diff-filter=A", "--name-only", "--format="]) || "")
|
|
1022
|
+
.split("\n")
|
|
1023
|
+
.map((x) => x.trim())
|
|
1024
|
+
.filter((f) => f && testStyle(f));
|
|
1025
|
+
const prFile = [".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", "docs/pull_request_template.md", "pull_request_template.md"].find((f) =>
|
|
1026
|
+
fs.existsSync(path.join(projectRoot, f))
|
|
1027
|
+
);
|
|
1028
|
+
const prDir = fs.existsSync(path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE")) ? ".github/PULL_REQUEST_TEMPLATE/" : null;
|
|
1029
|
+
return {
|
|
1030
|
+
commits,
|
|
1031
|
+
branches: repoBranches(projectRoot),
|
|
1032
|
+
tests: files.filter((f) => testStyle(f)),
|
|
1033
|
+
recent_tests: added,
|
|
1034
|
+
facts: {
|
|
1035
|
+
commits: commits.length,
|
|
1036
|
+
subject_p90: lens.length ? lens[Math.min(lens.length - 1, Math.ceil(lens.length * 0.9) - 1)] : 0,
|
|
1037
|
+
trailers: Object.fromEntries(
|
|
1038
|
+
Object.entries(trailers)
|
|
1039
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
1040
|
+
.map(([k, n]) => [k, round3(n / (commits.length || 1))])
|
|
1041
|
+
),
|
|
1042
|
+
pr_template: prFile || prDir || null,
|
|
1043
|
+
},
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// The re-check on use: 4 of the last 5 samples must still match the ACCEPTED
|
|
1048
|
+
// value. Fewer than 5 samples: the same 0.8 share of what there is.
|
|
1049
|
+
function softRecheck(sig, d, value) {
|
|
1050
|
+
const recent = (sig.recent ? sig.recent(d) : sig.samples(d)).slice(0, SOFT_RULE.recheck_of);
|
|
1051
|
+
const matches = recent.filter((x) => sig.match(x, value)).length;
|
|
1052
|
+
const of = recent.length;
|
|
1053
|
+
const need = of >= SOFT_RULE.recheck_of ? SOFT_RULE.recheck_need : Math.ceil((of * SOFT_RULE.recheck_need) / SOFT_RULE.recheck_of);
|
|
1054
|
+
return { matches, of, holds: of === 0 || matches >= need };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const staleWhy = (rc) => `matches ${rc.matches} of the last ${rc.of} — it needs ${SOFT_RULE.recheck_need} of ${SOFT_RULE.recheck_of}`;
|
|
1058
|
+
|
|
1059
|
+
function softRows(claudeDir, projectRoot, mode) {
|
|
1060
|
+
const d = repoData(projectRoot);
|
|
1061
|
+
if (!d) return null;
|
|
1062
|
+
const st = readState(claudeDir);
|
|
1063
|
+
const soft = st.soft || {};
|
|
1064
|
+
const rows = [];
|
|
1065
|
+
for (const [id, sig] of Object.entries(SOFT_SIGNALS)) {
|
|
1066
|
+
const samples = sig.samples(d);
|
|
1067
|
+
const value = sig.value(d);
|
|
1068
|
+
const hits = value === null ? [] : samples.filter((x) => sig.match(x, value));
|
|
1069
|
+
const n = samples.length;
|
|
1070
|
+
const share = n ? round3(hits.length / n) : 0;
|
|
1071
|
+
const proposable = value !== null && n >= SOFT_RULE.min_n && share >= SOFT_RULE.share;
|
|
1072
|
+
const rec = soft[id] || null;
|
|
1073
|
+
let state = "observed";
|
|
1074
|
+
let why = null;
|
|
1075
|
+
let recheck = null;
|
|
1076
|
+
if (rec && rec.state === "applied") {
|
|
1077
|
+
recheck = softRecheck(sig, d, rec.value);
|
|
1078
|
+
state = recheck.holds ? "applied" : "stale";
|
|
1079
|
+
if (!recheck.holds) why = staleWhy(recheck);
|
|
1080
|
+
} else if (rec && (rec.state === "declined" || rec.state === "never-ask")) state = rec.state;
|
|
1081
|
+
else if (mode === "propose" && proposable) state = "proposed";
|
|
1082
|
+
if (state === "observed" && !proposable)
|
|
1083
|
+
why = n < SOFT_RULE.min_n ? `${n} sample(s) — a proposal needs ${SOFT_RULE.min_n}` : `share ${share} — a proposal needs ${SOFT_RULE.share}`;
|
|
1084
|
+
rows.push({
|
|
1085
|
+
id,
|
|
1086
|
+
title: sig.title,
|
|
1087
|
+
value,
|
|
1088
|
+
share,
|
|
1089
|
+
n,
|
|
1090
|
+
count: hits.length,
|
|
1091
|
+
evidence: hits.slice(0, 10).map(sig.ev),
|
|
1092
|
+
evidence_count: hits.length,
|
|
1093
|
+
proposable,
|
|
1094
|
+
state,
|
|
1095
|
+
why,
|
|
1096
|
+
recheck,
|
|
1097
|
+
rule: rec && rec.rule ? rec.rule : value === null ? null : sig.rule(d, value),
|
|
1098
|
+
undo: state === "applied" || state === "stale" ? `orc habit repo forget ${id}` : null,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
return { rows, facts: d.facts, state: st };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// What `orc rules slice` reads. null under `habits: off` — the slice then
|
|
1105
|
+
// carries no field and no byte. Git runs only when something was accepted.
|
|
1106
|
+
function learnedRules(claudeDir, projectRoot, map) {
|
|
1107
|
+
if (habitsMode(map) === "off") return null;
|
|
1108
|
+
const soft = readState(claudeDir).soft || {};
|
|
1109
|
+
const accepted = Object.keys(soft).filter((id) => soft[id] && soft[id].state === "applied" && SOFT_SIGNALS[id] && soft[id].rule);
|
|
1110
|
+
if (!accepted.length) return [];
|
|
1111
|
+
const d = repoData(projectRoot);
|
|
1112
|
+
return accepted.sort().map((id) => {
|
|
1113
|
+
const rc = d ? softRecheck(SOFT_SIGNALS[id], d, soft[id].value) : { matches: 0, of: 0, holds: true };
|
|
1114
|
+
return Object.assign({ id, rule: soft[id].rule, state: rc.holds ? "applied" : "stale", recheck: rc }, rc.holds ? {} : { why: staleWhy(rc) });
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ── The CLI (`orc habit …`, §9) ─────────────────────────────────────────────
|
|
1119
|
+
const USAGE =
|
|
1120
|
+
"Usage: orc habit show [--lane L] [--window 30d|90d|all] | log [--limit N] [--states] | points\n" +
|
|
1121
|
+
" orc habit why <H-id|qid> | accept <H-id> [--keep] | decline <H-id> [--never] | forget <H-id> | reset <H-id>\n" +
|
|
1122
|
+
" orc habit doctor | export | purge --yes [--json] [--dir <path>]\n" +
|
|
1123
|
+
" orc habit repo [accept <id> | decline <id> [--never] | forget <id>]\n" +
|
|
1124
|
+
" show exit 0 · 1 no ASK lines yet · 3 habits: off\n" +
|
|
1125
|
+
" log exit 0 · 1 empty\n" +
|
|
1126
|
+
" why exit 0 · 2 unknown id\n" +
|
|
1127
|
+
" accept exit 0 · 2 unknown · 3 habits: off · 4 not proposable (the refusal names why)\n" +
|
|
1128
|
+
" decline|forget|reset exit 0 · 2 unknown\n" +
|
|
1129
|
+
" doctor exit 0 clean · 1 findings\n" +
|
|
1130
|
+
" repo exit 0 · 1 not a git repo · 2 unknown id · 3 habits: off · 4 not proposable\n" +
|
|
1131
|
+
" ORC never applies a habit without your yes. There is no automatic level.";
|
|
1132
|
+
|
|
1133
|
+
function habitCmd(deps) {
|
|
1134
|
+
const { flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride } = deps;
|
|
1135
|
+
const pos = positionals();
|
|
1136
|
+
const sub = pos[1];
|
|
1137
|
+
const arg = pos[2];
|
|
1138
|
+
const claudeDir = resolveClaudeDir();
|
|
1139
|
+
const { map } = readOverride(claudeDir);
|
|
1140
|
+
const mode = habitsMode(map);
|
|
1141
|
+
const json = wantsJson();
|
|
1142
|
+
const out = (obj, code, human) => {
|
|
1143
|
+
if (json) emitJson(obj, code);
|
|
1144
|
+
else {
|
|
1145
|
+
(human || (() => console.log(JSON.stringify(obj, null, 2))))();
|
|
1146
|
+
process.exit(code);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
const fail = (code, reason, message, extra) => out(Object.assign({ ok: false, reason, message }, extra || {}), code, () => console.error(" " + message));
|
|
1150
|
+
const now = Date.now();
|
|
1151
|
+
const by = ["user", "lane", "panel"].includes(flag("--by")) ? flag("--by") : "user";
|
|
1152
|
+
|
|
1153
|
+
if (sub === "points") {
|
|
1154
|
+
const points = Object.entries(ASK_POINTS).map(([qid, p]) => Object.assign({ qid }, p));
|
|
1155
|
+
return out({ ok: true, count: points.length, points, meta_qids: HABIT_META_QIDS, classes: ["apply", "suggest", "never"], rule: HABIT_RULE }, 0, () => {
|
|
1156
|
+
console.log(`\nORC habit points (${points.length})\n`);
|
|
1157
|
+
for (const p of points) console.log(` ${p.qid.padEnd(30)} ${p.class.padEnd(8)} ${p.careful ? "careful=" + p.careful : ""}${p.key ? " key=" + p.key : ""} ${p.title}`);
|
|
1158
|
+
console.log("");
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (sub === "show") {
|
|
1163
|
+
if (mode === "off")
|
|
1164
|
+
return fail(3, "off", "habits is off. Turn it on with: orc config set habits observe (or propose). Nothing is ever applied without your yes.", {
|
|
1165
|
+
mode,
|
|
1166
|
+
modes: HABIT_MODES,
|
|
1167
|
+
kept: keptAnswers(claudeDir),
|
|
1168
|
+
on: HABIT_MODES.filter((m) => m !== "off").map((m) => `orc config set habits ${m}`),
|
|
1169
|
+
});
|
|
1170
|
+
const w = flag("--window");
|
|
1171
|
+
const window_days = w === "30d" ? 30 : w === "90d" ? 90 : null;
|
|
1172
|
+
const c = computeRows(claudeDir, deps, { map, mode, window_days: window_days || undefined });
|
|
1173
|
+
const lane = typeof flag("--lane") === "string" ? flag("--lane") : null;
|
|
1174
|
+
const rows = (lane ? c.rows.filter((r) => r.lanes.includes(lane)) : c.rows.slice()).sort((a, b) => PANEL_ORDER.indexOf(a.state) - PANEL_ORDER.indexOf(b.state));
|
|
1175
|
+
const counts = {};
|
|
1176
|
+
for (const s of STATE_WORDS) counts[s] = rows.filter((r) => r.state === s).length;
|
|
1177
|
+
const days = [];
|
|
1178
|
+
for (let d = 13; d >= 0; d--) {
|
|
1179
|
+
const day = new Date(now - d * DAY);
|
|
1180
|
+
const key = day.toISOString().slice(0, 10);
|
|
1181
|
+
days.push({ date: key, answers: c.collected.events.filter((e) => new Date(e.at).toISOString().slice(0, 10) === key).length });
|
|
1182
|
+
}
|
|
1183
|
+
const ps = panelStats(c.collected, rows, window_days, now, lane);
|
|
1184
|
+
const tiles = ps.tiles;
|
|
1185
|
+
const rhythm = { days, heat: ps.heat, lanes: ps.lanes, weeks: ps.weeks, qpr: ps.qpr, how: ps.how, by: BY_SET };
|
|
1186
|
+
const portrait = ps.portrait;
|
|
1187
|
+
const obj = {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
mode,
|
|
1190
|
+
lane,
|
|
1191
|
+
window: w || "all",
|
|
1192
|
+
log_dir: c.collected.log_dir,
|
|
1193
|
+
state_path: statePaths(claudeDir).state,
|
|
1194
|
+
cache_path: statePaths(claudeDir).cache,
|
|
1195
|
+
traces: c.collected.traces,
|
|
1196
|
+
answers: c.collected.events.length,
|
|
1197
|
+
excluded_runs: c.collected.excluded,
|
|
1198
|
+
count: rows.length,
|
|
1199
|
+
rows,
|
|
1200
|
+
tiles,
|
|
1201
|
+
rhythm,
|
|
1202
|
+
portrait,
|
|
1203
|
+
proposal: mode === "propose" ? pickProposal(rows, lane) : null,
|
|
1204
|
+
state_words: STATE_WORDS,
|
|
1205
|
+
rule: HABIT_RULE,
|
|
1206
|
+
modes: HABIT_MODES,
|
|
1207
|
+
classes: ["apply", "suggest", "never"],
|
|
1208
|
+
counts,
|
|
1209
|
+
next_run: nextRun(rows),
|
|
1210
|
+
files: [path.join(c.collected.log_dir, "*.txt"), statePaths(claudeDir).state],
|
|
1211
|
+
};
|
|
1212
|
+
const code = c.collected.events.length ? 0 : 1;
|
|
1213
|
+
return out(obj, code, () => {
|
|
1214
|
+
console.log(`\nORC habits (${mode}) ${obj.answers} answer(s) in ${obj.traces} trace(s)\n`);
|
|
1215
|
+
if (!rows.length) console.log(" No ASK lines yet. Run a lane; each question it asks is recorded in its trace.");
|
|
1216
|
+
for (const r of rows) {
|
|
1217
|
+
console.log(` ${r.id} ${r.state.padEnd(9)} ${r.qid}${Object.keys(r.bucket).length ? " [" + bucketKey(r.bucket) + "]" : ""}`);
|
|
1218
|
+
if (r.line) console.log(` ${r.line}`);
|
|
1219
|
+
if (r.why) console.log(` ↳ ${r.why}`);
|
|
1220
|
+
if (r.rule) console.log(` rule: ${r.rule}`);
|
|
1221
|
+
if (r.undo) console.log(` undo: ${r.undo}`);
|
|
1222
|
+
}
|
|
1223
|
+
console.log(`\n states: ${STATE_WORDS.map((s) => `${s} ${counts[s]}`).join(" · ")}`);
|
|
1224
|
+
console.log(` tiles: ${tiles.runs} run(s) · ${tiles.answers} answer(s) · ${tiles.saved} answered for you · ${tiles.qpr === null ? "—" : tiles.qpr} questions per run`);
|
|
1225
|
+
console.log(` rhythm (14 days): ${days.map((x) => x.answers).join(" ")}`);
|
|
1226
|
+
for (const p of portrait) console.log(` portrait: ${p.line}`);
|
|
1227
|
+
for (const x of obj.excluded_runs) console.log(` excluded run: ${x.run} (${x.reason})`);
|
|
1228
|
+
if (obj.proposal) console.log(`\n proposal: ${obj.proposal.line}\n yes: ${obj.proposal.yes}`);
|
|
1229
|
+
console.log("");
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (sub === "log") {
|
|
1234
|
+
const st = readState(claudeDir);
|
|
1235
|
+
if (flag("--states")) {
|
|
1236
|
+
const history = st.history.slice().reverse().map((h) => Object.assign({}, h, { inverse: inverseOf(h) }));
|
|
1237
|
+
return out({ ok: true, count: history.length, history }, history.length ? 0 : 1, () => {
|
|
1238
|
+
if (!history.length) console.log(" No habit decisions yet.");
|
|
1239
|
+
for (const h of history) console.log(` ${new Date(h.at).toISOString()} ${h.id} ${h.from} → ${h.to} by ${h.by}${h.reason ? " — " + h.reason : ""}`);
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const col = collect(claudeDir, deps);
|
|
1243
|
+
const lim = Number(flag("--limit")) > 0 ? Number(flag("--limit")) : 40;
|
|
1244
|
+
const events = col.events.slice().reverse().slice(0, lim).map((e) => Object.assign({ id: ASK_POINTS[e.qid] ? habitId(e.qid, bucketOf(ASK_POINTS[e.qid], e.ctx)) : null }, e));
|
|
1245
|
+
return out({ ok: true, count: events.length, total: col.events.length, events }, events.length ? 0 : 1, () => {
|
|
1246
|
+
if (!events.length) console.log(" No ASK lines yet.");
|
|
1247
|
+
for (const e of events) console.log(` ${new Date(e.at).toISOString()} ${e.qid} chose=${e.chose} by=${e.by}${e.pre ? " pre=" + e.pre : ""} (${e.run})`);
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (sub === "doctor") {
|
|
1252
|
+
const col = collect(claudeDir, deps);
|
|
1253
|
+
const recent = new Set(col.events.filter((e) => e.at >= now - 30 * DAY).map((e) => e.qid));
|
|
1254
|
+
const silent = Object.keys(ASK_POINTS).filter((q) => !recent.has(q));
|
|
1255
|
+
const findings = col.unknown.length + col.bad.length + silent.length;
|
|
1256
|
+
return out({ ok: true, clean: findings === 0, unknown_qids: col.unknown, bad_lines: col.bad, silent_points: silent, excluded_runs: col.excluded, count: findings }, findings ? 1 : 0, () => {
|
|
1257
|
+
console.log(`\norc habit doctor — ${findings ? findings + " finding(s)" : "clean"}\n`);
|
|
1258
|
+
for (const u of col.unknown) console.log(` unknown qid ${u.qid} — ${u.count} line(s) in ${u.runs.join(", ")}`);
|
|
1259
|
+
for (const b of col.bad) console.log(` bad ASK line in ${b.run}: ${b.error}`);
|
|
1260
|
+
if (silent.length) console.log(` ${silent.length} point(s) not emitted in 30 days: ${silent.join(", ")}`);
|
|
1261
|
+
console.log("");
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (sub === "export") {
|
|
1266
|
+
const col = collect(claudeDir, deps);
|
|
1267
|
+
return out({ ok: true, state_path: statePaths(claudeDir).state, state: readState(claudeDir), events: col.events }, 0);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (sub === "purge") {
|
|
1271
|
+
if (!flag("--yes")) return fail(1, "needs-yes", "orc habit purge removes every habit decision and makes every past answer stop counting. Add --yes to do it.");
|
|
1272
|
+
const p = statePaths(claudeDir);
|
|
1273
|
+
const st = emptyState();
|
|
1274
|
+
st.purged_at = now;
|
|
1275
|
+
st.history.push({ id: "*", from: "any", to: "purged", by, at: now, reason: "orc habit purge --yes" });
|
|
1276
|
+
writeState(claudeDir, st);
|
|
1277
|
+
try {
|
|
1278
|
+
fs.rmSync(p.cache, { force: true });
|
|
1279
|
+
} catch (_) {}
|
|
1280
|
+
return out({ ok: true, purged_at: now, state_path: p.state, cache_removed: true, traces_touched: false }, 0, () =>
|
|
1281
|
+
console.log(" Purged. Every past answer stops counting. The traces themselves are not touched.")
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (["why", "accept", "decline", "forget", "reset"].includes(sub)) {
|
|
1286
|
+
if (!arg) return fail(2, "usage", USAGE);
|
|
1287
|
+
const c = computeRows(claudeDir, deps, { map, mode });
|
|
1288
|
+
if (sub === "why") {
|
|
1289
|
+
const hits = c.rows.filter((r) => r.id === arg || r.qid === arg);
|
|
1290
|
+
if (!hits.length) return fail(2, "unknown", `unknown habit id or qid: ${arg}. See: orc habit show`);
|
|
1291
|
+
const habits = hits.map((r) => {
|
|
1292
|
+
const pt = ASK_POINTS[r.qid];
|
|
1293
|
+
const rec = c.state.habits[r.id];
|
|
1294
|
+
const cutoff = Math.max((rec && rec.cutoff) || 0, c.state.purged_at || 0);
|
|
1295
|
+
const evs = c.collected.events.filter((e) => e.qid === r.qid && bucketKey(bucketOf(pt, e.ctx)) === bucketKey(r.bucket));
|
|
1296
|
+
const countedList = evs.filter((e) => e.at >= cutoff && !HABIT_RULE.excluded_by.includes(e.by) && e.at >= now - HABIT_RULE.max_age_days * DAY).slice(-HABIT_RULE.window);
|
|
1297
|
+
const L = countedList.length;
|
|
1298
|
+
const answers = evs.map((e) => {
|
|
1299
|
+
const i = countedList.indexOf(e);
|
|
1300
|
+
const counted = i !== -1;
|
|
1301
|
+
const reason = counted
|
|
1302
|
+
? null
|
|
1303
|
+
: e.at < cutoff
|
|
1304
|
+
? "before a forget or purge"
|
|
1305
|
+
: HABIT_RULE.excluded_by.includes(e.by)
|
|
1306
|
+
? `by=${e.by} is not a choice`
|
|
1307
|
+
: e.at < now - HABIT_RULE.max_age_days * DAY
|
|
1308
|
+
? `older than ${HABIT_RULE.max_age_days} days`
|
|
1309
|
+
: `outside the last ${HABIT_RULE.window} answers`;
|
|
1310
|
+
const evw = evidenceWeight(e);
|
|
1311
|
+
const recw = counted ? Math.pow(0.5, (L - 1 - i) / HABIT_RULE.half_life_answers) : 0;
|
|
1312
|
+
return { run: e.run, at: e.at, chose: e.chose, by: e.by, pre: e.pre, ctx: e.ctx, counted, evidence: evw, recency: round3(recw), weight: round3(evw * recw), reason };
|
|
1313
|
+
});
|
|
1314
|
+
return Object.assign({}, r, { answers });
|
|
1315
|
+
});
|
|
1316
|
+
return out({ ok: true, query: arg, count: habits.length, habits }, 0, () => {
|
|
1317
|
+
for (const h of habits) {
|
|
1318
|
+
console.log(`\n ${h.id} ${h.qid} ${h.state}\n ${h.line || ""}\n ${h.rule || ""}`);
|
|
1319
|
+
for (const a of h.answers)
|
|
1320
|
+
console.log(` ${new Date(a.at).toISOString()} ${a.chose.padEnd(14)} by=${a.by}${a.pre ? " pre=" + a.pre : ""} weight ${a.weight}${a.reason ? " (not counted: " + a.reason + ")" : ""} ${a.run}`);
|
|
1321
|
+
}
|
|
1322
|
+
console.log("");
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const st = c.state;
|
|
1326
|
+
const row = c.rows.find((r) => r.id === arg);
|
|
1327
|
+
if (!row && !st.habits[arg]) return fail(2, "unknown", `unknown habit id: ${arg}. See: orc habit show`);
|
|
1328
|
+
const rec = st.habits[arg] || { id: arg, qid: row.qid, bucket: row.bucket, state: "observed" };
|
|
1329
|
+
const from = row ? row.state : rec.state;
|
|
1330
|
+
const commit = (to, patch, reason) => {
|
|
1331
|
+
Object.assign(rec, patch, { id: arg, qid: rec.qid || row.qid, bucket: rec.bucket || row.bucket });
|
|
1332
|
+
st.habits[arg] = rec;
|
|
1333
|
+
st.history.push({ id: arg, from, to, by, at: now, reason });
|
|
1334
|
+
writeState(claudeDir, st);
|
|
1335
|
+
return out({ ok: true, id: arg, qid: rec.qid, from, to, option: rec.option || null, value: rec.state === "applied" && ASK_POINTS[rec.qid].class === "apply" ? ASK_POINTS[rec.qid].values[rec.option] : null, undo: to === "applied" ? `orc habit forget ${arg}` : null }, 0, () =>
|
|
1336
|
+
console.log(` ${arg}: ${from} → ${to}${reason ? " — " + reason : ""}`)
|
|
1337
|
+
);
|
|
1338
|
+
};
|
|
1339
|
+
if (sub === "accept") {
|
|
1340
|
+
if (mode === "off") return fail(3, "off", "habits is off. Turn it on with: orc config set habits observe (or propose).", { id: arg });
|
|
1341
|
+
if (flag("--keep")) {
|
|
1342
|
+
if (!rec.option || rec.state !== "applied") return fail(4, "not-proposable", `${arg} has no usual answer to keep. See: orc habit why ${arg}`, { id: arg });
|
|
1343
|
+
return commit("applied", { renewed_at: now, at: now }, `kept ${rec.option}`);
|
|
1344
|
+
}
|
|
1345
|
+
if (!row) return fail(2, "unknown", `unknown habit id: ${arg}`);
|
|
1346
|
+
// A re-ask "Switch to Y" is answered by a yes to the NEW option. The rule
|
|
1347
|
+
// is not re-run (the overrides are the evidence), but the ratchet is.
|
|
1348
|
+
if (row.state === "stale" && row.stale_detail) {
|
|
1349
|
+
const y = row.stale_detail.option;
|
|
1350
|
+
const pt = ASK_POINTS[row.qid];
|
|
1351
|
+
if (effectiveClass(pt, y, row.bucket) === "never")
|
|
1352
|
+
return fail(4, "not-proposable", `${arg}: switching to ${y} is class never here — ${pt.why}`, { id: arg });
|
|
1353
|
+
return commit("applied", { state: "applied", option: y, at: now, renewed_at: now }, `switched to ${y} after ${row.stale_detail.overrides} overrides`);
|
|
1354
|
+
}
|
|
1355
|
+
if (row.effective_class === "never")
|
|
1356
|
+
return fail(4, "not-proposable", `${arg} is class never here — ${row.why}`, { id: arg, effective_class: row.effective_class });
|
|
1357
|
+
if (row.state === "shadowed") return fail(4, "not-proposable", `${arg} is shadowed — ${row.why}`, { id: arg });
|
|
1358
|
+
if (!row.passes) return fail(4, "not-proposable", `${arg}: the rule does not pass — ${row.why}`, { id: arg, lb: row.lb, n: row.n, last_agree: row.last_agree });
|
|
1359
|
+
return commit("applied", { state: "applied", option: row.top, at: now, renewed_at: now }, `usual: ${row.top} (${row.count} of ${row.total})`);
|
|
1360
|
+
}
|
|
1361
|
+
if (sub === "decline")
|
|
1362
|
+
return flag("--never")
|
|
1363
|
+
? commit("never-ask", { state: "never-ask", at: now }, "never ask")
|
|
1364
|
+
: commit("declined", { state: "declined", at: now }, "not now");
|
|
1365
|
+
if (sub === "forget") return commit("observed", { state: "observed", option: null, cutoff: now, at: now }, "forgotten — past answers stop counting");
|
|
1366
|
+
if (sub === "reset") return commit("observed", { state: "observed", at: now }, "reset — declined / never-ask cleared");
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (sub === "repo") {
|
|
1370
|
+
// DE-16: off = NOTHING — no git call, no row.
|
|
1371
|
+
if (mode === "off")
|
|
1372
|
+
return fail(3, "off", "habits is off. Turn it on with: orc config set habits observe (or propose). A soft preference is never applied without your yes.", { mode });
|
|
1373
|
+
const c = softRows(claudeDir, path.dirname(claudeDir), mode);
|
|
1374
|
+
if (!c) return fail(1, "not-git", "orc habit repo reads git history, and this folder is not a git repository.", { mode });
|
|
1375
|
+
if (!arg) {
|
|
1376
|
+
const obj = { ok: true, mode, rule: SOFT_RULE, facts: c.facts, count: c.rows.length, rows: c.rows, state_path: statePaths(claudeDir).state };
|
|
1377
|
+
return out(obj, 0, () => {
|
|
1378
|
+
console.log(`\nORC soft preferences (${mode}) from ${c.facts.commits} commit(s)\n`);
|
|
1379
|
+
for (const r of c.rows) {
|
|
1380
|
+
console.log(` ${r.id.padEnd(20)} ${r.state.padEnd(9)} share ${r.share} of ${r.n}${r.value ? " value=" + r.value : ""}`);
|
|
1381
|
+
if (r.rule && (r.state !== "observed" || r.proposable)) console.log(` rule: ${r.rule}`);
|
|
1382
|
+
if (r.why) console.log(` ↳ ${r.why}`);
|
|
1383
|
+
if (r.state === "proposed") console.log(` yes: orc habit repo accept ${r.id}`);
|
|
1384
|
+
if (r.undo) console.log(` undo: ${r.undo}`);
|
|
1385
|
+
}
|
|
1386
|
+
console.log(`\n subject p90: ${c.facts.subject_p90} chars · PR template: ${c.facts.pr_template || "none"}\n`);
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const id = pos[3];
|
|
1390
|
+
if (!["accept", "decline", "forget"].includes(arg)) return fail(2, "usage", USAGE);
|
|
1391
|
+
const row = c.rows.find((r) => r.id === id);
|
|
1392
|
+
if (!row) return fail(2, "unknown", `unknown soft preference: ${id || "(none)"}. One of: ${Object.keys(SOFT_SIGNALS).join(", ")}`);
|
|
1393
|
+
const st = c.state;
|
|
1394
|
+
st.soft = st.soft || {};
|
|
1395
|
+
const from = row.state;
|
|
1396
|
+
const commit = (to, rec, reason) => {
|
|
1397
|
+
if (rec) st.soft[id] = rec;
|
|
1398
|
+
else delete st.soft[id];
|
|
1399
|
+
st.history.push({ id, from, to, by, at: now, reason });
|
|
1400
|
+
writeState(claudeDir, st);
|
|
1401
|
+
return out({ ok: true, id, from, to, rule: rec ? rec.rule || null : null, undo: to === "applied" ? `orc habit repo forget ${id}` : null }, 0, () =>
|
|
1402
|
+
console.log(` ${id}: ${from} → ${to}${reason ? " — " + reason : ""}`)
|
|
1403
|
+
);
|
|
1404
|
+
};
|
|
1405
|
+
if (arg === "accept") {
|
|
1406
|
+
if (!row.proposable)
|
|
1407
|
+
return fail(4, "not-proposable", `${id}: ${row.why || "the rule does not pass"}. A soft preference needs a share of ${SOFT_RULE.share} over ${SOFT_RULE.min_n} samples.`, { id, share: row.share, n: row.n });
|
|
1408
|
+
return commit("applied", { id, state: "applied", value: row.value, rule: row.rule, share: row.share, n: row.n, evidence: row.evidence, at: now }, `share ${row.share} of ${row.n}`);
|
|
1409
|
+
}
|
|
1410
|
+
if (arg === "decline") {
|
|
1411
|
+
const to = flag("--never") ? "never-ask" : "declined";
|
|
1412
|
+
return commit(to, { id, state: to, at: now }, to === "never-ask" ? "never ask" : "not now");
|
|
1413
|
+
}
|
|
1414
|
+
return commit("observed", null, "forgotten — it leaves the rules card");
|
|
1415
|
+
}
|
|
1416
|
+
return fail(sub ? 2 : 1, "usage", USAGE);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
module.exports = {
|
|
1420
|
+
ASK_POINTS,
|
|
1421
|
+
HABIT_META_QIDS,
|
|
1422
|
+
HABIT_RULE,
|
|
1423
|
+
HABIT_MODES,
|
|
1424
|
+
HABIT_KEYS,
|
|
1425
|
+
HABIT_FAMILIES,
|
|
1426
|
+
STATE_WORDS,
|
|
1427
|
+
pointFor,
|
|
1428
|
+
parseAskLine,
|
|
1429
|
+
wilsonLB,
|
|
1430
|
+
kish,
|
|
1431
|
+
ruleOnCounts,
|
|
1432
|
+
evidenceWeight,
|
|
1433
|
+
evaluate,
|
|
1434
|
+
effectiveClass,
|
|
1435
|
+
habitId,
|
|
1436
|
+
bucketOf,
|
|
1437
|
+
branchClass,
|
|
1438
|
+
statePaths,
|
|
1439
|
+
readState,
|
|
1440
|
+
habitsMode,
|
|
1441
|
+
collect,
|
|
1442
|
+
questionStats,
|
|
1443
|
+
computeRows,
|
|
1444
|
+
pickProposal,
|
|
1445
|
+
learnedFor,
|
|
1446
|
+
laneBlock,
|
|
1447
|
+
announceLine,
|
|
1448
|
+
habitCmd,
|
|
1449
|
+
SOFT_RULE,
|
|
1450
|
+
SOFT_SIGNALS,
|
|
1451
|
+
softRows,
|
|
1452
|
+
learnedRules,
|
|
1453
|
+
};
|