@workit-dev/core 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { configDir as workitConfigDir } from "./workit-paths.mjs";
|
|
4
|
+
import type { Logger } from "./logger";
|
|
5
|
+
import type { CommitFlavorPreset } from "./commit-flavors";
|
|
6
|
+
|
|
7
|
+
// Optional diagnostic seam: adapters install their host logger so config
|
|
8
|
+
// provenance events land in the same sanitized log stream.
|
|
9
|
+
let diagnosticLogger: Logger | undefined;
|
|
10
|
+
export const setDiagnosticLogger = (logger: Logger | undefined): void => {
|
|
11
|
+
diagnosticLogger = logger;
|
|
12
|
+
};
|
|
13
|
+
export const getDiagnosticLogger = (): Logger | undefined => diagnosticLogger;
|
|
14
|
+
|
|
15
|
+
export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
|
|
16
|
+
|
|
17
|
+
export type ToolkitConfig = {
|
|
18
|
+
locale: string;
|
|
19
|
+
localeOptions: string[];
|
|
20
|
+
branchPolicy: {
|
|
21
|
+
preset: BranchPreset;
|
|
22
|
+
allowed: string[];
|
|
23
|
+
protected: string[];
|
|
24
|
+
};
|
|
25
|
+
commitPolicy: { preset: CommitFlavorPreset; pattern?: string };
|
|
26
|
+
/** How an interactive launch handles a newer release (default `prompt`). */
|
|
27
|
+
updates?: UpdatesMode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `prompt` asks on an interactive launch, `auto` upgrades without asking, `off` never checks. */
|
|
31
|
+
export type UpdatesMode = "prompt" | "auto" | "off";
|
|
32
|
+
export const UPDATES_MODES: readonly UpdatesMode[] = ["prompt", "auto", "off"];
|
|
33
|
+
export const isUpdatesMode = (value: unknown): value is UpdatesMode =>
|
|
34
|
+
UPDATES_MODES.includes(value as UpdatesMode);
|
|
35
|
+
|
|
36
|
+
export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: string[] }> = {
|
|
37
|
+
gitflow: {
|
|
38
|
+
allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"],
|
|
39
|
+
protected: ["main", "develop", "master", "prod", "production"],
|
|
40
|
+
},
|
|
41
|
+
"github-flow": { allowed: ["*"], protected: ["main"] },
|
|
42
|
+
"trunk-based": { allowed: ["*"], protected: ["main"] },
|
|
43
|
+
custom: { allowed: [], protected: [] },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// RL-02: one shared preset merge helper. Changing the preset resets every
|
|
47
|
+
// derived policy field from PRESETS; only `custom` carries explicit values
|
|
48
|
+
// (falling back to the current policy when none are given).
|
|
49
|
+
export const mergePreset = (
|
|
50
|
+
preset: BranchPreset,
|
|
51
|
+
input: { allowed?: string[]; protectedNames?: string[] } = {},
|
|
52
|
+
current: { branchPolicy: ToolkitConfig["branchPolicy"] } = {
|
|
53
|
+
branchPolicy: { preset, allowed: [], protected: [] },
|
|
54
|
+
},
|
|
55
|
+
): ToolkitConfig["branchPolicy"] => {
|
|
56
|
+
const defs = PRESETS[preset];
|
|
57
|
+
return {
|
|
58
|
+
preset,
|
|
59
|
+
allowed:
|
|
60
|
+
preset === "custom" ? (input.allowed ?? current.branchPolicy.allowed) : [...defs.allowed],
|
|
61
|
+
protected:
|
|
62
|
+
preset === "custom"
|
|
63
|
+
? (input.protectedNames ?? current.branchPolicy.protected)
|
|
64
|
+
: [...defs.protected],
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Workit's config dir for this process (core/workit-paths.mjs). */
|
|
69
|
+
export const resolveConfigDir = (): string => workitConfigDir(process.env);
|
|
70
|
+
|
|
71
|
+
/** The path of a file in the config dir Workit reads, for every hint that names one. */
|
|
72
|
+
export const configFilePath = (name: string): string => path.join(resolveConfigDir(), name);
|
|
73
|
+
|
|
74
|
+
export const configDir = (): string => resolveConfigDir();
|
|
75
|
+
|
|
76
|
+
// Region subtags: 2-letter ISO 3166 alpha-2 or 3-digit UN M.49 (es-419 =
|
|
77
|
+
// Latinoamérica), per BCP-47 well-formedness for the tags this toolkit stores.
|
|
78
|
+
export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
79
|
+
|
|
80
|
+
const DEFAULTS: ToolkitConfig = {
|
|
81
|
+
locale: "en",
|
|
82
|
+
localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
|
|
83
|
+
branchPolicy: {
|
|
84
|
+
preset: "gitflow",
|
|
85
|
+
allowed: [...PRESETS.gitflow.allowed],
|
|
86
|
+
protected: [...PRESETS.gitflow.protected],
|
|
87
|
+
},
|
|
88
|
+
commitPolicy: { preset: "conventional" },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Configs whose branch policy nobody chose (no config.json, or one without a
|
|
92
|
+
// valid branchPolicy.preset). For them the repository's own branches pick the
|
|
93
|
+
// preset (resolveBranchPolicy's `repoPreset`), so a main-only repository is
|
|
94
|
+
// not held to gitflow's `develop`.
|
|
95
|
+
const unchosenBranchPolicy = new WeakSet<ToolkitConfig>([DEFAULTS]);
|
|
96
|
+
|
|
97
|
+
const readSafe = (p: string): string | null => {
|
|
98
|
+
try {
|
|
99
|
+
return readFileSync(p, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// RL-01: typed reader contract. Every config reader distinguishes missing from
|
|
106
|
+
// valid from malformed and reports the exact file path; risky consumers stop on
|
|
107
|
+
// malformed instead of silently falling back to defaults.
|
|
108
|
+
export type ReaderStatus = "missing" | "valid" | "malformed";
|
|
109
|
+
|
|
110
|
+
export type ReaderResult<T> = {
|
|
111
|
+
status: ReaderStatus;
|
|
112
|
+
path: string;
|
|
113
|
+
config?: T;
|
|
114
|
+
error?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// AR-07/CA-37: the one shared fail-closed shape rule for every object-config
|
|
118
|
+
// reader (config, setup-state, workspaces, doctor). A parseable non-object
|
|
119
|
+
// (null, scalar, array) is malformed, never defaults.
|
|
120
|
+
export const isConfigObject = (value: unknown): boolean =>
|
|
121
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
122
|
+
|
|
123
|
+
const parseConfigResult = (raw: string | null, file: string): ReaderResult<ToolkitConfig> => {
|
|
124
|
+
if (raw === null) return { status: "missing", path: file };
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return {
|
|
130
|
+
status: "malformed",
|
|
131
|
+
path: file,
|
|
132
|
+
error: `${file} is not valid JSON`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!isConfigObject(parsed)) {
|
|
136
|
+
return {
|
|
137
|
+
status: "malformed",
|
|
138
|
+
path: file,
|
|
139
|
+
error: `${file} is not a JSON object`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Only known keys are read: legacy fields such as the removed global
|
|
143
|
+
// `timezone` are tolerated and ignored, never an error.
|
|
144
|
+
const input = parsed as Partial<ToolkitConfig>;
|
|
145
|
+
const locale = LOCALE_RE.test(String(input.locale ?? ""))
|
|
146
|
+
? (input.locale as string)
|
|
147
|
+
: DEFAULTS.locale;
|
|
148
|
+
const preset = (
|
|
149
|
+
Object.hasOwn(PRESETS, input.branchPolicy?.preset as string)
|
|
150
|
+
? input.branchPolicy?.preset
|
|
151
|
+
: "gitflow"
|
|
152
|
+
) as BranchPreset;
|
|
153
|
+
const commitPreset = (
|
|
154
|
+
COMMIT_PRESETS.includes(String(input.commitPolicy?.preset ?? ""))
|
|
155
|
+
? input.commitPolicy?.preset
|
|
156
|
+
: "conventional"
|
|
157
|
+
) as CommitFlavorPreset;
|
|
158
|
+
const config: ToolkitConfig = {
|
|
159
|
+
locale,
|
|
160
|
+
localeOptions: Array.isArray(input.localeOptions)
|
|
161
|
+
? input.localeOptions
|
|
162
|
+
: DEFAULTS.localeOptions,
|
|
163
|
+
// RL-02/CA-23: the persisted preset is authoritative; derived allowed /
|
|
164
|
+
// protected fields always reset from PRESETS via the one shared merge.
|
|
165
|
+
branchPolicy: mergePreset(
|
|
166
|
+
preset,
|
|
167
|
+
{
|
|
168
|
+
allowed: Array.isArray(input.branchPolicy?.allowed)
|
|
169
|
+
? input.branchPolicy.allowed
|
|
170
|
+
: undefined,
|
|
171
|
+
protectedNames: Array.isArray(input.branchPolicy?.protected)
|
|
172
|
+
? input.branchPolicy.protected
|
|
173
|
+
: undefined,
|
|
174
|
+
},
|
|
175
|
+
DEFAULTS,
|
|
176
|
+
),
|
|
177
|
+
commitPolicy: {
|
|
178
|
+
preset: commitPreset,
|
|
179
|
+
...(typeof input.commitPolicy?.pattern === "string"
|
|
180
|
+
? { pattern: input.commitPolicy.pattern }
|
|
181
|
+
: {}),
|
|
182
|
+
},
|
|
183
|
+
...(isUpdatesMode(input.updates) ? { updates: input.updates } : {}),
|
|
184
|
+
};
|
|
185
|
+
if (!Object.hasOwn(PRESETS, input.branchPolicy?.preset as string))
|
|
186
|
+
unchosenBranchPolicy.add(config);
|
|
187
|
+
return { status: "valid", path: file, config };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
|
|
191
|
+
const file = path.join(dir ?? configDir(), "config.json");
|
|
192
|
+
return parseConfigResult(readSafe(file), file);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// RL-01: no silent fallback on malformed config — every consumer gets an
|
|
196
|
+
// exact-path diagnostic instead of defaults. Missing config still defaults.
|
|
197
|
+
export const readConfig = (): ToolkitConfig => {
|
|
198
|
+
const result = readConfigTyped();
|
|
199
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
200
|
+
return result.config ?? DEFAULTS;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const readConfigFromDir = (dir: string): ToolkitConfig => {
|
|
204
|
+
const result = readConfigTyped(dir);
|
|
205
|
+
if (result.status === "malformed") throw new Error(result.error);
|
|
206
|
+
return result.config ?? DEFAULTS;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export type ConfigInput = {
|
|
210
|
+
locale?: string;
|
|
211
|
+
localeOptions?: string[];
|
|
212
|
+
preset?: BranchPreset;
|
|
213
|
+
allowed?: string[];
|
|
214
|
+
protectedNames?: string[];
|
|
215
|
+
commitPolicy?: ToolkitConfig["commitPolicy"];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// RL-02: the single authoritative ToolkitConfig merge. CLI, OpenCode, and Cursor
|
|
219
|
+
// adapters all route their `config` writes through this so a preset switch
|
|
220
|
+
// resets every derived policy field identically everywhere.
|
|
221
|
+
export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
|
|
222
|
+
locale: input.locale ?? current.locale,
|
|
223
|
+
localeOptions: input.localeOptions ?? current.localeOptions,
|
|
224
|
+
branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
|
|
225
|
+
commitPolicy: input.commitPolicy ?? current.commitPolicy,
|
|
226
|
+
// Set only by `workit upgrade --set-updates`; every other write keeps it.
|
|
227
|
+
...(current.updates ? { updates: current.updates } : {}),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/** A rewrite keeps the file's `"$schema"` first (setup writes it; core/config-schemas.ts). */
|
|
231
|
+
export const writeConfig = (config: ToolkitConfig): void => {
|
|
232
|
+
const dir = configDir();
|
|
233
|
+
const file = path.join(dir, "config.json");
|
|
234
|
+
let schema: unknown;
|
|
235
|
+
try {
|
|
236
|
+
schema = (JSON.parse(readSafe(file) ?? "{}") as { $schema?: unknown }).$schema;
|
|
237
|
+
} catch {
|
|
238
|
+
schema = undefined;
|
|
239
|
+
}
|
|
240
|
+
mkdirSync(dir, { recursive: true });
|
|
241
|
+
const document = schema === undefined ? config : { $schema: schema, ...config };
|
|
242
|
+
writeFileSync(file, JSON.stringify(document, null, 2) + "\n", "utf8");
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Configuration provenance for startup diagnostics: where config.json came from
|
|
246
|
+
// and whether it parsed. Only paths + a malformed flag — never the file body.
|
|
247
|
+
export const describeConfigSource = (
|
|
248
|
+
dir: string = resolveConfigDir(),
|
|
249
|
+
): { source: string; config_dir: string; malformed: boolean } => {
|
|
250
|
+
const file = path.join(dir, "config.json");
|
|
251
|
+
if (!existsSync(file)) return { source: "defaults", config_dir: dir, malformed: false };
|
|
252
|
+
const raw = readSafe(file);
|
|
253
|
+
if (raw === null) return { source: "unreadable", config_dir: dir, malformed: true };
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(raw);
|
|
256
|
+
if (!isConfigObject(parsed)) {
|
|
257
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
258
|
+
}
|
|
259
|
+
return { source: "file", config_dir: dir, malformed: false };
|
|
260
|
+
} catch {
|
|
261
|
+
return { source: "defaults", config_dir: dir, malformed: true };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The global vcs.json path (WORKIT_VCS_CONFIG overrides it). */
|
|
266
|
+
export const vcsConfigPath = (): string =>
|
|
267
|
+
process.env.WORKIT_VCS_CONFIG ?? path.join(configDir(), "vcs.json");
|
|
268
|
+
|
|
269
|
+
/** An allowed-branch glob (`feature/*`) as a case-insensitive whole-name RegExp. */
|
|
270
|
+
export const branchGlobPattern = (glob: string): RegExp =>
|
|
271
|
+
new RegExp(`^${glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i");
|
|
272
|
+
|
|
273
|
+
export const resolveBranchPolicy = (
|
|
274
|
+
config: ToolkitConfig,
|
|
275
|
+
workspace?: { branchPolicy?: Record<string, any> } | null,
|
|
276
|
+
/** The preset the repository's branches imply (core/branch-policy.ts repoBranchPreset). */
|
|
277
|
+
repoPreset?: () => BranchPreset | null,
|
|
278
|
+
): {
|
|
279
|
+
preset: BranchPreset;
|
|
280
|
+
allowed: RegExp[];
|
|
281
|
+
protected: Set<string>;
|
|
282
|
+
integration: "pr" | "merge";
|
|
283
|
+
defaultTargetBranch: string;
|
|
284
|
+
/** No policy was configured; the repository's branches chose the preset. */
|
|
285
|
+
detected: boolean;
|
|
286
|
+
} => {
|
|
287
|
+
const wp = workspace?.branchPolicy ?? {};
|
|
288
|
+
// An invalid workspace preset (e.g. a typo) falls back to the global preset,
|
|
289
|
+
// preserving resolution order workspace > global > preset, instead of
|
|
290
|
+
// crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
|
|
291
|
+
const workspacePreset = Object.hasOwn(PRESETS, wp.preset);
|
|
292
|
+
// With no policy chosen anywhere, the repository's branches pick the preset
|
|
293
|
+
// (M10); the gitflow protected names stay protected regardless.
|
|
294
|
+
const detected =
|
|
295
|
+
!workspacePreset && unchosenBranchPolicy.has(config) ? (repoPreset?.() ?? null) : null;
|
|
296
|
+
const preset = (
|
|
297
|
+
workspacePreset ? wp.preset : (detected ?? config.branchPolicy?.preset ?? "gitflow")
|
|
298
|
+
) as BranchPreset;
|
|
299
|
+
// RL-02: the preset is authoritative — allowed/protected re-derive from the
|
|
300
|
+
// workspace's own values or the preset table, never the global config's, and
|
|
301
|
+
// the current config remains the `custom` fallback when no values are given.
|
|
302
|
+
const merged = mergePreset(
|
|
303
|
+
preset,
|
|
304
|
+
{
|
|
305
|
+
allowed: wp.allowed,
|
|
306
|
+
protectedNames: wp.protected,
|
|
307
|
+
},
|
|
308
|
+
config,
|
|
309
|
+
);
|
|
310
|
+
const allowed = merged.allowed.map(branchGlobPattern);
|
|
311
|
+
return {
|
|
312
|
+
preset,
|
|
313
|
+
allowed,
|
|
314
|
+
protected: new Set(
|
|
315
|
+
[...merged.protected, ...(detected ? PRESETS.gitflow.protected : [])].map((p) =>
|
|
316
|
+
p.toLowerCase(),
|
|
317
|
+
),
|
|
318
|
+
),
|
|
319
|
+
integration: wp.integration === "merge" ? "merge" : "pr",
|
|
320
|
+
// CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
|
|
321
|
+
defaultTargetBranch:
|
|
322
|
+
preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
|
|
323
|
+
detected: detected !== null,
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const COMMIT_PRESETS: readonly string[] = [
|
|
328
|
+
"conventional",
|
|
329
|
+
"gitmoji",
|
|
330
|
+
"ticket-prefix",
|
|
331
|
+
"freeform",
|
|
332
|
+
"custom",
|
|
333
|
+
"auto",
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
/** Workspace commitPolicy override wins, else global — mirrors
|
|
337
|
+
* resolveBranchPolicy's workspace > global order, including the invalid-preset
|
|
338
|
+
* fallback instead of a crash. */
|
|
339
|
+
export const resolveCommitPolicy = (
|
|
340
|
+
config: ToolkitConfig,
|
|
341
|
+
workspace?: { commitPolicy?: Record<string, any> } | null,
|
|
342
|
+
): { preset: CommitFlavorPreset; pattern?: string } => {
|
|
343
|
+
const wp = workspace?.commitPolicy ?? {};
|
|
344
|
+
if (COMMIT_PRESETS.includes(String(wp.preset ?? ""))) {
|
|
345
|
+
return {
|
|
346
|
+
preset: wp.preset as CommitFlavorPreset,
|
|
347
|
+
...(typeof wp.pattern === "string" ? { pattern: wp.pattern } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return config.commitPolicy ?? { preset: "conventional" };
|
|
351
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Read-only repository and provider context (`workit_context` on OpenCode,
|
|
2
|
+
// Pi and MCP). A read needs no grant: it changes nothing locally or remotely.
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import {
|
|
5
|
+
changelogContext,
|
|
6
|
+
docsRefreshContext,
|
|
7
|
+
isSafeContextRange,
|
|
8
|
+
prReadyContext,
|
|
9
|
+
releaseNotesContext,
|
|
10
|
+
} from "./repo-context";
|
|
11
|
+
import { gitContext } from "./git";
|
|
12
|
+
import { fetchGitHubIssueBody, fetchGitLabIssueBody } from "./tracker-issues";
|
|
13
|
+
import { context as youTrackContext, fetchYouTrackIssueBody } from "./youtrack";
|
|
14
|
+
import { failure, success, type Result } from "./task-contract";
|
|
15
|
+
|
|
16
|
+
export const contextReadSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
kind: z.enum([
|
|
19
|
+
"git",
|
|
20
|
+
"pr",
|
|
21
|
+
"youtrack",
|
|
22
|
+
"github_issue",
|
|
23
|
+
"gitlab_issue",
|
|
24
|
+
"changelog",
|
|
25
|
+
"release",
|
|
26
|
+
"affected",
|
|
27
|
+
]),
|
|
28
|
+
range: z.string().optional(),
|
|
29
|
+
issueId: z.string().optional(),
|
|
30
|
+
issueUrl: z.string().optional(),
|
|
31
|
+
issueRef: z.string().optional(),
|
|
32
|
+
mode: z.string().optional(),
|
|
33
|
+
specPath: z.string().optional(),
|
|
34
|
+
planPath: z.string().optional(),
|
|
35
|
+
cwd: z.string().min(1).optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
export type ContextReadPayload = z.infer<typeof contextReadSchema>;
|
|
39
|
+
|
|
40
|
+
/** The tool input schema hosts register for `workit_context`. */
|
|
41
|
+
export const contextReadJsonSchema = (): Record<string, unknown> => {
|
|
42
|
+
const { $schema: _schema, ...schema } = z.toJSONSchema(contextReadSchema, {
|
|
43
|
+
target: "draft-2020-12",
|
|
44
|
+
}) as Record<string, unknown>;
|
|
45
|
+
return schema;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const parseContextRead = (value: unknown): Result<ContextReadPayload> => {
|
|
49
|
+
const parsed = contextReadSchema.safeParse(value);
|
|
50
|
+
return parsed.success
|
|
51
|
+
? success(null, null, parsed.data)
|
|
52
|
+
: failure("invalid_input", "context payload is invalid", {
|
|
53
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
54
|
+
path: issue.path.join("."),
|
|
55
|
+
reason: issue.message,
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Read one fixed repository/provider context without approval or mutation. */
|
|
61
|
+
export const readExternalContext = async (
|
|
62
|
+
root: string,
|
|
63
|
+
payload: ContextReadPayload,
|
|
64
|
+
): Promise<Result<unknown>> => {
|
|
65
|
+
if (payload.range !== undefined && !isSafeContextRange(payload.range))
|
|
66
|
+
return failure("invalid_input", "revision range is invalid", {
|
|
67
|
+
fields: [{ path: "range", reason: "option-like or control characters are not allowed" }],
|
|
68
|
+
});
|
|
69
|
+
const render = (result: { stdout: string; stderr: string; exitCode: number; cwd: string }) =>
|
|
70
|
+
result.exitCode === 0
|
|
71
|
+
? success(null, null, { kind: payload.kind, context: result.stdout })
|
|
72
|
+
: failure("capability_unavailable", "requested context is unavailable", {
|
|
73
|
+
capability: payload.kind,
|
|
74
|
+
});
|
|
75
|
+
switch (payload.kind) {
|
|
76
|
+
case "git": {
|
|
77
|
+
const context = gitContext(root);
|
|
78
|
+
return typeof context.exitCode === "number" && context.exitCode !== 0
|
|
79
|
+
? failure("capability_unavailable", "Git context is unavailable", { capability: "git" })
|
|
80
|
+
: success(null, null, { kind: payload.kind, context });
|
|
81
|
+
}
|
|
82
|
+
case "pr":
|
|
83
|
+
return render(prReadyContext(root, payload.range));
|
|
84
|
+
case "changelog":
|
|
85
|
+
return render(changelogContext(root, payload.range));
|
|
86
|
+
case "release":
|
|
87
|
+
return render(releaseNotesContext(root, payload.range ?? "HEAD~1...HEAD"));
|
|
88
|
+
case "affected":
|
|
89
|
+
return render(docsRefreshContext(root, payload.range));
|
|
90
|
+
case "youtrack": {
|
|
91
|
+
const value = youTrackContext({
|
|
92
|
+
workspace_root: root,
|
|
93
|
+
...(payload.specPath ? { spec_path: payload.specPath } : {}),
|
|
94
|
+
...(payload.planPath ? { plan_path: payload.planPath } : {}),
|
|
95
|
+
...(payload.issueId ? { issue_id: payload.issueId } : {}),
|
|
96
|
+
...(payload.issueUrl ? { issue_url: payload.issueUrl } : {}),
|
|
97
|
+
...(payload.issueRef ? { issue_ref: payload.issueRef } : {}),
|
|
98
|
+
...(payload.mode ? { mode: payload.mode } : {}),
|
|
99
|
+
});
|
|
100
|
+
if (value && typeof value === "object" && "error" in value)
|
|
101
|
+
return failure("capability_unavailable", "YouTrack context is unavailable", {
|
|
102
|
+
capability: "youtrack",
|
|
103
|
+
});
|
|
104
|
+
if (value.requiresMeetingChoice || !value.issueId)
|
|
105
|
+
return success(null, null, { kind: payload.kind, context: value });
|
|
106
|
+
const body = await fetchYouTrackIssueBody(value.issueId);
|
|
107
|
+
if ("error" in body && body.kind === "request")
|
|
108
|
+
return failure("capability_unavailable", "YouTrack issue body is unavailable", {
|
|
109
|
+
capability: "youtrack",
|
|
110
|
+
});
|
|
111
|
+
return success(null, null, {
|
|
112
|
+
kind: payload.kind,
|
|
113
|
+
context: {
|
|
114
|
+
...value,
|
|
115
|
+
...("data" in body
|
|
116
|
+
? { issueBody: body.data }
|
|
117
|
+
: { issueBody: null, issueBodyError: body.error }),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
case "github_issue": {
|
|
122
|
+
const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
|
|
123
|
+
if (!ref)
|
|
124
|
+
return failure("invalid_input", "github_issue needs issueId, issueUrl, or issueRef");
|
|
125
|
+
const body = await fetchGitHubIssueBody(ref, root);
|
|
126
|
+
if ("error" in body)
|
|
127
|
+
return failure("capability_unavailable", `GitHub issue is unavailable: ${body.error}`, {
|
|
128
|
+
capability: "github_issue",
|
|
129
|
+
});
|
|
130
|
+
return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
|
|
131
|
+
}
|
|
132
|
+
case "gitlab_issue": {
|
|
133
|
+
const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
|
|
134
|
+
if (!ref)
|
|
135
|
+
return failure("invalid_input", "gitlab_issue needs issueId, issueUrl, or issueRef");
|
|
136
|
+
const body = await fetchGitLabIssueBody(ref, root);
|
|
137
|
+
if ("error" in body)
|
|
138
|
+
return failure("capability_unavailable", `GitLab issue is unavailable: ${body.error}`, {
|
|
139
|
+
capability: "gitlab_issue",
|
|
140
|
+
});
|
|
141
|
+
return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The docs check `workit pr create` runs when a workspace sets
|
|
2
|
+
// `docs: when-user-facing`. It never blocks: the docs pass is the ship skill's
|
|
3
|
+
// step, and this check records whether the PR shows it (a doc file in the
|
|
4
|
+
// diff, or a "Docs" line in the body the skill asks for when nothing changed).
|
|
5
|
+
import { changedSince, nonUserFacingType, type ForkPoint } from "./changelog-gate";
|
|
6
|
+
import { appendObserved, type LedgerActor } from "../ledger";
|
|
7
|
+
import type { DocsMode } from "./workspaces";
|
|
8
|
+
|
|
9
|
+
export type DocsGate =
|
|
10
|
+
| { status: "off" }
|
|
11
|
+
| { status: "not-user-facing"; type: string }
|
|
12
|
+
| { status: "updated"; files: string[] }
|
|
13
|
+
| { status: "reported" }
|
|
14
|
+
| { status: "unconfirmed" }
|
|
15
|
+
| { status: "base-unknown"; base: string; remote: string };
|
|
16
|
+
|
|
17
|
+
const DOC_FILE = /\.(?:md|mdx|rst|adoc)$/iu;
|
|
18
|
+
const CHANGELOG_FILE = /(?:^|\/)(?:changelog|changes|history)(?:\.[a-z]+)?$/iu;
|
|
19
|
+
const DOCS_LINE = /^\s*(?:#{1,6}\s*)?docs\b/imu;
|
|
20
|
+
|
|
21
|
+
/** Whether the PR titled `title` shows its docs pass under `mode`. */
|
|
22
|
+
export function docsGate(input: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
mode: DocsMode;
|
|
25
|
+
title: string;
|
|
26
|
+
body: string | null;
|
|
27
|
+
forkPoint: () => ForkPoint;
|
|
28
|
+
}): DocsGate {
|
|
29
|
+
if (input.mode === "off") return { status: "off" };
|
|
30
|
+
const type = nonUserFacingType(input.title);
|
|
31
|
+
if (type) return { status: "not-user-facing", type };
|
|
32
|
+
const point = input.forkPoint();
|
|
33
|
+
if (!point.fork) return { status: "base-unknown", base: point.base, remote: point.remote };
|
|
34
|
+
const files = changedSince(input.cwd, point.fork).filter(
|
|
35
|
+
(file) => DOC_FILE.test(file) && !CHANGELOG_FILE.test(file),
|
|
36
|
+
);
|
|
37
|
+
if (files.length) return { status: "updated", files };
|
|
38
|
+
return DOCS_LINE.test(input.body ?? "") ? { status: "reported" } : { status: "unconfirmed" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One `pr create` check outcome as it is printed and recorded. */
|
|
42
|
+
export type PrCheckOutcome = { check: "changelog" | "docs"; status: string; line: string };
|
|
43
|
+
|
|
44
|
+
const LINE_LIMIT = 1000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record each outcome as an observed `pr.check` row on `branch` at `head`,
|
|
48
|
+
* so the ledger shows whether the step ran, passed, was skipped or blocked.
|
|
49
|
+
*/
|
|
50
|
+
export function recordPrChecks(
|
|
51
|
+
cwd: string,
|
|
52
|
+
context: { actor: LedgerActor; branch: string | null; head: string | null; base: string },
|
|
53
|
+
outcomes: readonly PrCheckOutcome[],
|
|
54
|
+
): string | null {
|
|
55
|
+
for (const outcome of outcomes) {
|
|
56
|
+
const row = appendObserved(cwd, {
|
|
57
|
+
type: "pr.check",
|
|
58
|
+
actor: context.actor,
|
|
59
|
+
branch: context.branch,
|
|
60
|
+
head: context.head,
|
|
61
|
+
base: context.base,
|
|
62
|
+
check: outcome.check,
|
|
63
|
+
status: outcome.status,
|
|
64
|
+
line: outcome.line.slice(0, LINE_LIMIT),
|
|
65
|
+
});
|
|
66
|
+
if (!row.ok) return row.error;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Which evidence resolves a finding (#317), so a refused `finding resolve`
|
|
2
|
+
// names what would work instead of only "unrelated" or "requires evidence".
|
|
3
|
+
// It mirrors the engine's rules (task-engine.ts findingOnce) as a read-only
|
|
4
|
+
// explanation; the engine stays the judge.
|
|
5
|
+
//
|
|
6
|
+
// fixed: check or review evidence, result passed, recorded after the
|
|
7
|
+
// finding, still current on the tree (bound to a candidate when
|
|
8
|
+
// the finding names one).
|
|
9
|
+
// dismissed: evidence of any kind with result passed or failed, on the
|
|
10
|
+
// finding's candidate (both none, or the same one), that shares a
|
|
11
|
+
// ref with the finding or makes the same claim.
|
|
12
|
+
// deferred: an approved limitation decision covering the finding's scope.
|
|
13
|
+
import {
|
|
14
|
+
canonicalJson,
|
|
15
|
+
type Entry,
|
|
16
|
+
type Evidence,
|
|
17
|
+
type Finding,
|
|
18
|
+
type TaskRecord,
|
|
19
|
+
} from "./task-contract";
|
|
20
|
+
|
|
21
|
+
export type ResolveDisposition = "fixed" | "dismissed" | "deferred";
|
|
22
|
+
|
|
23
|
+
const RULES: Record<ResolveDisposition, string> = {
|
|
24
|
+
fixed:
|
|
25
|
+
"check or review evidence with result passed, recorded after the finding and still current on this tree",
|
|
26
|
+
dismissed:
|
|
27
|
+
"evidence (check, review, investigation or artifact) with result passed or failed, on the finding's candidate, that shares a ref with the finding or repeats its claim",
|
|
28
|
+
deferred:
|
|
29
|
+
"an approved limitation decision (decisionIds) that references an acceptanceAllowed requirement and covers the finding's scope",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const sameRef = (left: unknown, right: unknown): boolean => {
|
|
33
|
+
try {
|
|
34
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sameCandidate = (finding: Finding, evidence: Evidence): boolean =>
|
|
41
|
+
finding.candidateId === null
|
|
42
|
+
? (evidence.candidateId ?? null) === null
|
|
43
|
+
: evidence.candidateId === finding.candidateId ||
|
|
44
|
+
evidence.beforeCandidateId === finding.candidateId;
|
|
45
|
+
|
|
46
|
+
/** Evidence ids in the task that meet the disposition's recorded-shape rule. */
|
|
47
|
+
export function matchingEvidence(
|
|
48
|
+
task: Pick<TaskRecord, "evidence">,
|
|
49
|
+
finding: Entry<Finding>,
|
|
50
|
+
disposition: ResolveDisposition,
|
|
51
|
+
): string[] {
|
|
52
|
+
if (disposition === "deferred") return [];
|
|
53
|
+
return task.evidence
|
|
54
|
+
.filter((entry) => {
|
|
55
|
+
const data = entry.data;
|
|
56
|
+
if (disposition === "fixed")
|
|
57
|
+
return (
|
|
58
|
+
entry.recordedAt >= finding.recordedAt &&
|
|
59
|
+
data.result === "passed" &&
|
|
60
|
+
(data.kind === "check" || data.kind === "review") &&
|
|
61
|
+
(finding.data.candidateId === null || data.candidateId != null)
|
|
62
|
+
);
|
|
63
|
+
return (
|
|
64
|
+
(data.result === "passed" || data.result === "failed") &&
|
|
65
|
+
sameCandidate(finding.data, data) &&
|
|
66
|
+
(finding.data.refs.some((left) => data.refs.some((right) => sameRef(left, right))) ||
|
|
67
|
+
data.claim === finding.data.claim)
|
|
68
|
+
);
|
|
69
|
+
})
|
|
70
|
+
.map((entry) => entry.id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The sentence a refused resolve gains: the evidence the disposition
|
|
75
|
+
* accepts, the ids in this task that fit it, or how to record one.
|
|
76
|
+
*/
|
|
77
|
+
export function acceptedEvidenceText(
|
|
78
|
+
task: Pick<TaskRecord, "evidence">,
|
|
79
|
+
finding: Entry<Finding>,
|
|
80
|
+
disposition: ResolveDisposition,
|
|
81
|
+
): string {
|
|
82
|
+
const accepted = `accepted for ${disposition}: ${RULES[disposition]}`;
|
|
83
|
+
if (disposition === "deferred") return accepted;
|
|
84
|
+
const ids = matchingEvidence(task, finding, disposition);
|
|
85
|
+
if (ids.length) return `${accepted}; evidence in this task that fits: ${ids.join(", ")}`;
|
|
86
|
+
const ref = finding.data.refs[0];
|
|
87
|
+
const refs = ref ? JSON.stringify([ref]) : "[]";
|
|
88
|
+
return `${accepted}; none recorded in this task yet: workit evidence record --payload '{"kind":"${disposition === "fixed" ? "check" : "review"}","claim":${JSON.stringify(finding.data.claim)},"result":"passed","refs":${refs},…}' (or workit check <name> for a check)`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `workit finding record --queue [<issue>]` (#439): a finding reaches the
|
|
2
|
+
// tracker as a new issue, or as a comment on an open one, so it is not left
|
|
3
|
+
// in a ledger only the recording session reads.
|
|
4
|
+
import type { Ref } from "./task-contract";
|
|
5
|
+
|
|
6
|
+
const TITLE_MAX = 120;
|
|
7
|
+
|
|
8
|
+
const refLine = (ref: Ref): string =>
|
|
9
|
+
ref.kind === "file"
|
|
10
|
+
? `\`${ref.path}\``
|
|
11
|
+
: ref.kind === "external"
|
|
12
|
+
? ref.url
|
|
13
|
+
: ref.kind === "record"
|
|
14
|
+
? `${ref.collection}/${ref.id}`
|
|
15
|
+
: `${ref.host}:${ref.handle}`;
|
|
16
|
+
|
|
17
|
+
/** The issue title and body (or comment body) for a finding. */
|
|
18
|
+
export function findingIssueText(finding: {
|
|
19
|
+
claim: string;
|
|
20
|
+
consequence: string;
|
|
21
|
+
refs: readonly Ref[];
|
|
22
|
+
}): { title: string; body: string } {
|
|
23
|
+
const claim = finding.claim.trim().replace(/\s+/gu, " ");
|
|
24
|
+
const title = claim.length > TITLE_MAX ? `${claim.slice(0, TITLE_MAX - 1)}…` : claim;
|
|
25
|
+
const body = [
|
|
26
|
+
finding.claim.trim(),
|
|
27
|
+
"",
|
|
28
|
+
`Consequence: ${finding.consequence.trim()}`,
|
|
29
|
+
...(finding.refs.length
|
|
30
|
+
? ["", "Refs:", ...finding.refs.map((ref) => `- ${refLine(ref)}`)]
|
|
31
|
+
: []),
|
|
32
|
+
"",
|
|
33
|
+
"Queued by `workit finding record --queue`.",
|
|
34
|
+
].join("\n");
|
|
35
|
+
return { title, body };
|
|
36
|
+
}
|